# ESI.ts Engineering Charter

**Package:** `@lgriffin/esi.ts` · **Charter revision:** 3 (2026-09-28) · **Status:** adopted

The governing statement of how ESI.ts is designed, built, tested, secured, documented and released. Every guide in this folder derives from a numbered requirement here, and every requirement is written in the same EARS form the test suite already uses, so the charter can be audited the way the specification is.

| Measured from the source (`npm run docs:metrics`)              |                                                                                                                   |
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| Domain clients                                                 | <!-- metric:clients -->39<!-- /metric -->                                                                         |
| ESI endpoints wired                                            | <!-- metric:routes -->235<!-- /metric -->                                                                         |
| Generated operations (`src/generated/operations.generated.ts`) | <!-- metric:operations -->235<!-- /metric -->                                                                     |
| EARS requirements in the specification                         | <!-- metric:requirements -->664<!-- /metric --> (<!-- metric:featureFiles -->77<!-- /metric --> feature files)    |
| Gherkin scenarios                                              | <!-- metric:scenarios -->863<!-- /metric -->                                                                      |
| Test files matched by the nine Jest configurations and tsd     | <!-- metric:testFiles -->369<!-- /metric -->                                                                      |
| Statement coverage                                             | 97.8% (`npm run coverage` on master `114a485a`, 2026-09-28; floor 90%)                                            |
| OpenSSF Scorecard                                              | 8.3 / 10 (`scorecard.yml` run 36409177565 on master `9486fe2c`, 2026-09-28; 8.2 on 2026-09-21, 6.7 on 2026-08-24) |

### What changed in revision 2

- **Corrections from [#299](https://github.com/lgriffin/ESI.ts/issues/299).** Spec TTL source, the HTTPS rule in SEC-01, path-parameter handling, cursor pagination, the order of retry and deduplication, the 8 / 20 / 1000 defaults, rate-limiter token costs, circuit-breaker cleanup, per-call deprecation warnings, the auth error subtree, required check names and the scaffold path.
- **Statuses moved** to match the code at v10.2.3: TEST-03 and TEST-07 to Enforced; TEST-09, SEC-07 and DOC-05 to Partial; GATE-05 back to Partial, tracked by [#277](https://github.com/lgriffin/ESI.ts/issues/277).
- **Phase 0 to 2 of the 11.0 plan** are reflected in Part 2: ports, the generated operations, the clock module and the layer lint.
- **11.0.0 decisions** recorded below. REL-05 now states the Node 22 floor, so it reads Gap until the 11.0.0 engines bump lands; the requirement changed, the code did not regress.
- **Gap register** rows closed with evidence, the eleven findings filed after revision 1 (#290 to #300) added, and one new finding (a bare `..` path parameter) registered.

### What changed in revision 3

- **Header re-measured** on master `114a485a` (2026-09-28): 566 requirements in 73 feature files, 726 scenarios, 333 test files, statement coverage 97.8% (branches 95.9%, functions 93.6%, lines 97.9%). Revision 2 quoted 545 requirements and 703 scenarios, and a coverage figure from v9.8.0.
- **Statuses moved to Enforced** since revision 2, each by the amendment below it: ARCH-06, ARCH-09, ARCH-10 (new), DOC-01, DOC-03, DOC-04, GATE-04, GATE-05, GATE-06, GATE-07 (new), REL-03, REL-05, TEST-09, TEST-10, TEST-11 (new) and PROC-06 (new). TEST-07's stale-survivor gap is closed and its status is unchanged.
- **Still Partial or Gap:** ARCH-07, ARCH-08, DES-08, DOC-05, DOC-06, REL-04, REL-06, PROC-04, PROC-05, and SEC-07, which waits on branch protection that includes administrators, a setting only the maintainer can change ([#270](https://github.com/lgriffin/ESI.ts/issues/270)). The Scorecard number in the header is 8.3 (2026-09-28); Part 11 step 8 records the next one when the maintainer's items land.
- **11.0.0 decisions since revision 2** stand as recorded under Amendments: no bridge between the core and the SDE, mutation off the release path, and the exclusions stated as Rules.

### Amendments after revision 2 (the detail behind revision 3)

- **2026-10-17, the SQLite path removed (12.x, minor).** `SdeDatabaseBuilder` was exported by no entry point, used by no script, and replaced by the YAML provider by design. The maintainer ruled it removed, with the SQLite-only transforms and the `better-sqlite3` optional peer, and approved dropping the peer in a 12.x minor release: no public export loads it, so an install that has it keeps working ([SEMVER.md](SEMVER.md)). `SdeDatabaseError` and `isSdeDatabaseError` stay exported from `./sde` and `./sde/memory` with the same shape, marked `@deprecated` because nothing in the package throws the class any more, and leave in the next major. ARCH-12 and ARCH-13 keep their ids and now state what ingestion still does, download and extract; ARCH-10 loses the peer, and ARCH-11 keeps the class as deprecated. The security model retires P12 rather than renumbering.

- **2026-10-17, the custom clients are the product ([#664](https://github.com/lgriffin/ESI.ts/issues/664)).** 12.1.0 marked every custom-client surface `@deprecated` for removal in 13.0.0 (`EsiClient`, `EsiClientBuilder` and `CustomEsiClient`, `EsiApiFactory`, `BaseEsiClient`, the 39 domain clients, 194 hand-written response types, `TestDataFactory`, the `./schemas` entry and 25 root pipeline exports), following a plan that made the generated `createEsi()` surface the only one. #664 asked whether that was the project's direction, and the maintainer chose option D: "The core identity of the project is the custom approach and we should never give that up. This needs enshrined in the charter". The custom clients stay the public product, and the generated layer becomes the foundation under them, not a replacement. Posture 1 now says so first. ARCH-14 (new, Enforced) fails a change that deprecates, removes or replaces a custom-client construction surface. ARCH-08 no longer says 13.0.0 removes anything, and calls `createEsi` the spec-shaped low-level surface and future engine. Its requirement no longer asks every surface, `createEsi` included, for the same set of operations: it asks all four to configure the pipeline through `configureApiClient` and the three custom-client surfaces to hand out the same registered domain clients, which the identity test now checks through each public construction path, so ARCH-08 moves from Partial to Enforced (review of #666). 12.2.0 lifts the 12.1.0 deprecations, a compatible change, and exports the root pipeline declarations directly again instead of through deprecated re-exports, with every name and type unchanged. The 11.0.0 deprecations of `EsiApiFactory`'s nine named factories and `EsiTokenManager.createClient` stay, now pointing at `createClient(type)` and the builder, with removal left to a future major that has no date. `guides/LEGACY-REMOVAL.md` is replaced by [CLIENT-ARCHITECTURE.md](CLIENT-ARCHITECTURE.md), which records the decision and the plan. No 13.0 is planned.

- **2026-10-16, incomplete pagination can be an error (12.x, opt-in).** The eager call clamped a reported page count above 1000 and stopped at an empty page with a log line, `fetchAll*` and `stream*` did the same, and the cursor handler's `fetchAll` returned what it had when it reached its page limit with an `after` cursor still set: four ways to return a short list as if it were the whole collection. The maintainer chose not to ship a major release for it, so the error is opt-in: with the new `incompletePages: 'throw'` option on `createEsi` and `EsiClientConfig`, every collecting path returns every page or throws `EsiPaginationError`, which gains `reason` (`page-cap`, `empty-page`, `cursor-cap`, `fetch-failed`), `endpoint`, `reportedPages`, `fetchedPages`, `itemCount` and `partial`. Opted in, a count above 1000 is refused after page 1, and an empty page after the first is refused, the last page included, because ESI derives `X-Pages` from the item count and a shrunken collection skips items. The default, `'truncate'`, keeps the earlier behaviour and now logs a warning naming the option; it flips to `'throw'` in a future major. DES-08 stays Partial until then.

- **2026-10-12, the SDE errors and ingestion ([#640](https://github.com/lgriffin/ESI.ts/issues/640) follow-up).** Tagging the features with the charter showed two that no requirement covered: `sde/0007-sde-error-handling.feature` and `sde/0023-sde-ingestion.feature`. The maintainer ruled that a gap, not an exception. ARCH-11 states the SDE error family and its guards, ARCH-12 what ingestion writes, and ARCH-13 how it fails. Both features carry their tags, and the shrink-only `unchartedFeatures` list in `scripts/spec/spec-audit-exceptions.json` is empty. Gap register row 34 records it.

- **2026-10-11, tests name their requirement ([#639](https://github.com/lgriffin/ESI.ts/issues/639)).** A review joined a Jest run to this charter and found only nine requirement IDs in any test title; the rest were linked, if at all, by a file name in "Verified by". Every "Verified by" now opens each clause with `Test:`, `Gate:`, `Review:` or `To add:` (Part 0, Evidence), so a row whose evidence is a gate says so and no test is expected. Every Jest test file a row cites now carries the row's ID in a `describe`, `it` or `test` title: ARCH-08, ARCH-09, ARCH-10, TEST-08, TEST-10, TEST-11, GATE-05, GATE-07, SEC-01, SEC-02, SEC-03, SEC-04, SEC-06, DOC-03, DOC-04 and PROC-06 joined the rows that already did. `npm run charter:audit` checks both (PROC-06). No status changed.
- **2026-10-11, features name their requirement ([#640](https://github.com/lgriffin/ESI.ts/issues/640)).** No scenario cited a requirement of this charter, so a passing scenario was evidence for nothing here. Every feature now carries a charter tag on the Feature or on the Rule that backs it: `@TEST-10` on the domain-client, integration, performance and SDE provider features, `@TEST-11` on every exclusion Rule, `@SEC-10` on every `@sec-<id>` Rule, `@ARCH-04` on the resilience, caching, rate-limit and transport features, and the narrower ids (DES-01, DES-04, DES-08, SEC-02, ARCH-07 to ARCH-10) on the Rules whose statement matches. `npm run spec:audit` fails a feature with no tag, a tag naming no requirement, and a feature a "Verified by" line cites without its id (TEST-02). Two SDE features no requirement covers are listed in `unchartedFeatures`. `npm run bdd:report` writes the tags with each result to `reports/bdd/scenarios.json`.
- **2026-10-11, evidence for nine rows ([#634](https://github.com/lgriffin/ESI.ts/issues/634)).** A review found nine requirements whose "Verified by" named nothing in the repository. Each now names a file: ARCH-03 a unit test that fails when a request-pipeline stage imports a cache, rate limiter or circuit breaker implementation; DES-05 the deprecation-warning tests in `createClient.test.ts`, which now also check every call warns; PROC-01, PROC-02 and PROC-05 `tests/tdd/scripts/agent-process.test.ts`, which holds the instructions those habits come from; REL-04 a test that keeps the twelve `(not published)` changelog headings; PROC-04 and DOC-06 the files that do part of the job. PROC-03 named only branch protection, a setting: the `branch-protection` job in `maintenance.yml` now reads it through the API weekly and fails when `master` is unprotected or `ci-success` is not required, and the requirement no longer names `Lint, Build & Test`, which protection stopped requiring when `ci-success` took over; GATE-01 now says the same. No status changed.
- **2026-10-10, the security model.** From the maintainer's security model document, distilled from the Apache Camel security model. `guides/SECURITY-MODEL.md` names every role and its trust, the untrusted inputs, twelve claimed properties with severities and tests, the properties not provided, scope, nine closed triage dispositions and the known non-findings; `guides/security-model.yaml` indexes it and `guides/security-triage-log.md` logs closed reports ([#628](https://github.com/lgriffin/ESI.ts/pull/628)). Reading master against it found three gaps, fixed as hardening: the async page iterators read `X-Pages` uncapped, redirects were followed, and SDE archive entry names reached adm-zip unchecked ([#629](https://github.com/lgriffin/ESI.ts/pull/629)). SEC-09 to SEC-12 are new and Enforced: `npm run security:model` runs in the `security-model` job of `ci-success`, and the Rules that state each property carry `@sec-<id>` tags ([#627](https://github.com/lgriffin/ESI.ts/issues/627)).
- **2026-10-08, a quality trend.** From the static analysis audit of 2026-10-08, which found that each number the gates produce exists only inside one run. `quality-trend.yml` runs `npm run quality:trend` every Monday. It measures the last twelve weekly commits of master in worktrees: hand-written `src/` lines, total and per-1,000-line cognitive complexity, functions over 20, `any` identifiers, `eslint-disable` comments and the lint warning total. It also ranks hotspots by churn × complexity. The trend is recomputed from git, so it needs no stored state and no write scope. It is a report, not a gate. The first run measured complexity per 1,000 lines falling from 40.9 to 38.4 since 2026-07-21 while `src/` doubled, and `createClient` as the top hotspot.
- **2026-10-08, no import cycles.** From the static analysis audit of 2026-10-08, which found the core's interfaces importing their implementations: `IRetryStrategy` took `RetryContext` from `RetryStrategy`, and `ICircuitBreaker` took `CircuitState` from `CircuitBreaker`, which import `ApiClient`, which imports both interfaces. Each type now lives in its interface module and is re-exported from the old one, so every import path and the API report are unchanged, and the implementations import `ApiClient` with `import type`. `npm run lint:cycles` (`scripts/quality/import-cycles.ts`) builds the runtime import graph of `src/` with the TypeScript parser and fails on any cycle; it runs in `lint-and-build` and `check:local`. `lint:layers` still owns direction between layers; this covers cycles inside one.
- **2026-10-08, the warning ratchet.** From the static analysis audit of 2026-10-08. `npm run lint` failed on errors only, so warnings grew unseen (258 in `src/` and `tests/`, 470 with `scripts/`). `npm run lint:ratchet` (`scripts/quality/warning-ratchet.ts`) runs the same ESLint pass, fails on any error, and holds warnings per file and rule to `config/eslint/warning-baseline.json` under the determinism lint's shrink-only rules: a new warning fails, a fixed one fails until the baseline is lowered with `-- --update`, and the baseline may not grow against the pull request's target branch. It replaces the plain ESLint step in `lint-and-build` and the `lint` tier of `check:local`.
- **2026-10-08, scripts are linted and type-checked.** From the static analysis audit of 2026-10-08. `scripts/` (91 files, about 28,000 lines, holding the audit, ratchet, release and spec gates) was outside every tsconfig and ignored by ESLint. `npm run lint` is now `eslint src tests scripts`, with the script relaxations declared in their own block of `eslint.config.mjs`, and `npm run typecheck:scripts` compiles `tsconfig.scripts.json` in `lint-and-build` and `check:local`. Turning them on found six entry points whose top-level `main()` promise was never handled, fourteen in-place sorts and reverses of arrays the caller owns, and an unchecked array index in the BDD transport double.
- **2026-10-08, static analysis gates.** From the static analysis audit of 2026-10-08. knip blocks pull requests as well as the release (GATE-04): its baseline has been clean since #271, so `ci.yml` drops `--no-exit-code` and `check:local` gains a `knip` tier. `codeql.yml` runs the `security-extended` query suite. `lint-and-build` runs `npm audit signatures` after `npm ci`, so a package whose registry signature or provenance does not verify fails the pull request.
- **2026-09-28, one testing guide (DOC-01).** `guides/MUTATION-TESTING.md` is folded into `guides/TESTING.md` as its "Mutation testing" section and deleted, after root `TESTING.md` the day before; every link now points at the section. DOC-01 moves from Gap to Enforced: `tests/tdd/scripts/sync-docs.test.ts` keeps the retired copies deleted, keeps the tier table in TESTING.md alone, and fails on a guide link to a missing guide or heading ([#273](https://github.com/lgriffin/ESI.ts/issues/273)).
- **2026-09-27, EARS governance.** Six decisions from the review of the specification's reach, taken by the maintainer on the recommendations recorded in the roadmap: exclusions are stated as unwanted-behaviour Rules (TEST-11, new); the charter itself is audited like a feature file (PROC-06, new); every public client method traces to a Rule (TEST-10, new, shared with Track S Run 4); `npm run ears` already runs in CI (`ears.yml`, on every pull request that touches `src/`, `tests/bdd/` or the EARS scripts; `bdd-tests` and `spec-audit` gate the same ground inside `ci-success`), and making it a required check is a branch-protection setting for the maintainer that would first need the workflow's path filters removed, so a documentation-only pull request is not blocked by a check that never ran; TEST-01 moves to Practised because its RED step is a workflow, not a check; TEST-07 gains the 11.0.0 mutation floors. Statuses that moved down did so with the reason in the row.

- **2026-09-27, the charter audit.** `npm run charter:audit` holds every requirement block of this document to the rules `spec:audit` applies to a `Rule:` and fails an Enforced row that names no mechanism (PROC-06, Enforced). To pass it, twenty-three requirements that had stated two or three obligations were reworded to one `shall` each without changing what they require, three that said "it" now name the system, and DES-03, TEST-08 and GATE-03 name their mechanism in backticks.

- **2026-09-28, provider method coverage (Track S Run 4).** `npm run spec:coverage:sde:ci` counts every `IStaticDataProvider` method as covered when a `Rule:` names it or a bound step reaches it, and gates the uncovered list against a shrink-only baseline; TEST-10 moves from Gap to Partial, and stays there until Phase 5 item 8 does the same for the domain clients.

- **2026-09-28, client method coverage (Phase 5 item 8).** `npm run spec:coverage:clients:ci` extends the provider check to every public method the 39 domain clients declare, counting one as covered when a `Rule:` names it or a bound step calls it on that client, legacy `defineFeature` scenarios included; 173 of 384 methods are covered, and the 211 uncovered are committed as measured, by client, in a shrink-only baseline. No scenarios were added. TEST-10 moves from Partial to Enforced.

- **2026-09-28, the SDE doc set with the guides (Track S Run 11).** `src/sde/docs/*` and the module README moved to `guides/sde/` (REFERENCE, USAGE, ARCHITECTURE, DEVELOPER_GUIDE, API_CONTRACTS) with `guides/SDE.md` as the index and `src/sde/README.md` a pointer, so `docs:sync` publishes the set under its own sidebar section and `test:docs-examples` checks it as guides. The SDE lines of TEST-05 to TEST-08 and GATE-05 name their SDE mechanism; the "considered and cut" static-data port is recorded under ARCH-10; the gate matrix in QUALITY-GATES.md gained an SDE column.

- **2026-09-28, the real export every night (Track S Run 9).** `nightly-sde.yml` downloads CCP's current Static Data Export (cached per build), loads it, runs the real-data integration suite with `SDE_REQUIRE_DATA=1`, runs `npm run sde:drift` (the export's file list against `SDE_FILE_REGISTRY`, each file's keys against the table's Zod schema) and the four `@nightly sde` examples, and keeps one fixed-title issue open while the run fails or the export drifts. GATE-07 (new) is Enforced.

- **2026-09-28, mutation off the release path.** The maintainer took the mutation floors and the return of `mutation-pr` to `ci-success` out of the 11.0.0 release gate: the nightlies keep ratcheting and filing issues, and both follow the release in 11.x (ROADMAP.md, below the release gate). TEST-07's status does not change.

- **2026-09-28, the exclusions as Rules (Phase 5 item 6).** TEST-11 moves from Partial to Enforced. Every exclusion of an ESI behaviour the guides stated in prose is now an unwanted-behaviour Rule with a scenario that proves the absence, and each scenario was checked against a mutant of the source that acts on the behaviour: the headers not honoured (Content-Type, Expires, X-Pages on later pages or past page 1000, the bearer token on a public route or in a URL), the statuses not retried (400, 401, 403, 404 and 500, a 503 on a mutation, a stale 5xx answer, a body that fails to parse or to validate, the replay after a 401 refresh), the ETag and cache features the page and stream paths skip, the values and enum members not validated, the token never logged, the JWT signature not verified, and the SDE files and peers not read. The register lists 29 Rules, all verified. What the client does with its own configuration and extension points (interceptors, `circuitBreakerConfig`, loggers) is not an ESI behaviour and stays in prose.

- **2026-09-27, the pull-request mutation gate.** The maintainer took `mutation-pr` out of `ci-success` and into its own workflow, `mutation-pr.yml`, so a pull request no longer waits up to 37 minutes for it: it still runs and reports on every pull request, the nightly still holds every floor, and ROADMAP.md's release gate carries the row that puts it back before 11.0.0 ships. GATE-01 needs 23 jobs; TEST-07 stays Enforced on the nightly.

- **2026-09-27, logging at the boundary (Phase 4 items 1 and 2).** ARCH-09 moves from Partial to Enforced: every call site logs through the per-client logger, and a `no-restricted-imports` block in `npm run lint` and `lint:layers` keeps the global `loggerUtil` out of `src/core/requestPipeline/` and `src/clients/` ([#265](https://github.com/lgriffin/ESI.ts/issues/265)). SEC-02 gains log lines to its evidence: URLs are redacted with `sanitizeUrl` at the logger boundary ([#296](https://github.com/lgriffin/ESI.ts/issues/296)). ARCH-06 stays a Gap until item 3.

- **2026-09-27, nothing built at import (Phase 4 item 3).** ARCH-06 moves from Gap to Enforced: the default logger builds its pino instance on first use, `package.json` declares `"sideEffects": false`, and `tests/tdd/core/importSideEffects.test.ts` holds both ([#268](https://github.com/lgriffin/ESI.ts/issues/268)).
- **2026-09-28, every nightly files an issue.** GATE-05 moves from Partial to Enforced: `nightly-no-retry.yml`, `nightly-interleave.yml` and `consumer-matrix-nightly.yml` gain the same `report` job as the mutation and Schemathesis nightlies, one fixed-title issue each, closed on the next green night.

- **2026-09-27, nightlies file issues (Phase 5 item 3).** `nightly-mutation.yml` and `nightly-schemathesis.yml` now open or comment on one fixed-title issue on failure and close it on the next green night, the pattern `nightly-spec-drift.yml` uses; gap register row 16 is Done ([#277](https://github.com/lgriffin/ESI.ts/issues/277)). GATE-05 stays Partial: the no-retry, interleaving and consumer-matrix nightlies still only fail the run.
- **2026-09-27, the version selector, the documents and the offline endpoint check.** REL-03 moves to Enforced: `validate:versions` also reads the docs-site version selector, which now carries the `x-release-please-version` marker and sits in release-please's `extra-files`, and runs in `static-analysis` as well as at release. GATE-06 moves to Enforced: `package-scripts.test.ts` also fails when a document names an `npm run` that `package.json` does not define. `validate:esi` fails on a definition the spec does not list unless `scripts/spec/esi-endpoint-exceptions.json` gives a reason, and `validate:esi:vendored` runs it offline against the vendored snapshot in `check:local` and after `spec-refresh.yml` regenerates.

- **2026-09-27, CI matches the gate matrix (Phase 5 item 1, [#297](https://github.com/lgriffin/ESI.ts/issues/297)).** `validate-release` now runs `spec:generate:check`, `validate:auth-scopes`, `validate:esi`, `spec:audit`, `validate:spec-consistency`, `contract:replay`, the fault catalogue and the live contract tests (503 soft-skips), with the shrink-only ratchets compared against the previous release tag (the job fails when none resolves). `validate:esi` and `validate:spec` run in `static-analysis` on every pull request, blocking when the pull request touches their inputs; `validate:versions` already ran in the release gate. The generated-freshness diff covers every `src/core/endpoints/esi-*.generated.ts`, so the rate-limit-group and scope files are diffed in CI, at release and nightly (ARCH-01). `validate:auth-scopes` fails both directions of DES-04 and on a stale exception; the fourteen stale entries in `scripts/spec/auth-scope-exceptions.json` are removed. The pull-request contract step gains `pipefail`, without which its `if` read `tee`'s status and a failing live suite passed. The documentation job and `example:sde-cross-ref` were already fixed on master. Gap register row 25 is done.
- **2026-09-27, stale survivors (Phase 5 item 2, part; [#380](https://github.com/lgriffin/ESI.ts/issues/380)).** TEST-07's stale incremental results are closed: `mutation:pr` re-mutates, with `--force`, every reused file a changed or deleted test covered in the nightly report, and a test-only pull request runs over the directories its tests reach.
- **2026-09-27, tests are linted (Phase 5 item 2, part; [#271](https://github.com/lgriffin/ESI.ts/issues/271)).** TEST-09 moves from Partial to Enforced: `npm run lint` is `eslint src tests`, parsing `tests/` against `tsconfig.test.json`, with the test relaxations declared one by one in `eslint.config.mjs` and `no-floating-promises`, `no-misused-promises` and `await-thenable` kept as errors; `lint-staged` lints staged test files too. Turning it on found three floating promises and an `await` of a synchronous `shutdown()`, and three tests that could not fail: two fuzz properties that caught every throw and asserted nothing (they hid that `validatePathParam([])` returned an empty segment, now rejected) and a pagination concurrency test that never read what it recorded. Gap register row 10 keeps knip open.
- **2026-09-27, Scorecard's repository-side checks (Phase 6, code side; [#239](https://github.com/lgriffin/ESI.ts/issues/239), [#270](https://github.com/lgriffin/ESI.ts/issues/270)).** Every workflow already declared read-only top-level permissions; `tests/tdd/workflows/workflow-permissions.test.ts` now holds that, and lists every job-level write scope so a new one is a reviewed edit (SEC-03). `sign-and-publish-assets` attests SLSA build provenance for the tarball, SBOM and docs archive, verifies it with `gh attestation verify`, and attaches it as `lgriffin-esi.ts-X.Y.Z.intoto.jsonl` beside the cosign bundles, the file Scorecard's Signed-Releases check scores highest (SEC-04). The SBOM (SEC-06) and `.github/CODEOWNERS` had landed earlier. Signed-Releases reads the last five releases, and v10.2.2 and v10.2.3 have no assets attached, so the score rises only as signed releases ship. SEC-07 stays Partial: branch protection that includes administrators, required approvals and the Best Practices badge ([#243](https://github.com/lgriffin/ESI.ts/issues/243), [#246](https://github.com/lgriffin/ESI.ts/issues/246)) are settings only the maintainer can change, listed in `guides/SECURITY.md` §5.
- **2026-09-27, knip blocks the release ([#271](https://github.com/lgriffin/ESI.ts/issues/271)).** GATE-04 moves from Gap to Enforced: the baseline is clean and `release.yml` runs `npx knip` without `--no-exit-code`. `knip.jsonc` (renamed from `knip.json` to carry comments) makes tests, scripts and examples entry points, which cleared the findings that were only used there, and keeps four reasoned exceptions. The deprecated `src/core/logger/logger.ts`, reachable from no entry point, the unused re-exports in the internal `requestPipeline` barrel, and the unused `jest-junit`, `@types/adm-zip` and `@types/js-yaml` devDependencies are removed; the public API is unchanged. `ci.yml` stays non-blocking; `validate` and `check:all` now block. Gap register row 10's knip item is done.
- **2026-09-27, the Node 22 floor (Phase 7 item 1, [#278](https://github.com/lgriffin/ESI.ts/issues/278)).** REL-05 moves from Gap to Enforced: `engines.node` is `>=22.12.0` and `.nvmrc` is 22, in a `feat!: require Node 22` commit whose `BREAKING CHANGE:` footer sends Node 18 and 20 consumers to 10.x; the unit matrix and the consumer contract in `ci.yml` and `release.yml` run on Node 22 and 24, and every single-version job moved from Node 20 to 22. GATE-02's push run is now on Node 22. Gap register row 17's Node half is done.
- **2026-09-27, the documentation site (Phase 7 item 2, [#264](https://github.com/lgriffin/ESI.ts/issues/264)).** DOC-03 moves from Gap to Enforced: `npm run docs:site` builds TypeDoc, generates the guide pages from README.md and `guides/` and one page per `examples/*.ts` (`scripts/docs/sync-docs.ts`), and builds VitePress; the generated pages are git-ignored, and the hand-written copies in `docs-site/guide/`, `reference/`, `examples/` and `explorer/` are removed. `ci.yml`'s `documentation` job builds the whole site on every pull request, so it stays one of the 23 jobs `ci-success` needs; `release.yml`'s `deploy-docs` calls the new `docs-site.yml`, which publishes it to gh-pages with the API reference under `/api/` and can be dispatched on master between releases. REL-03's selector now reads `package.json` at build time instead of being bumped by release-please, and `validate:versions -- --site` checks the built page. Gap register row 3 is done.
- **2026-09-28, generated documentation counts (Phase 7 item 2, [#272](https://github.com/lgriffin/ESI.ts/issues/272)).** DOC-04 moves from Gap to Enforced: `npm run docs:metrics` computes the counts the documentation quotes from the source, with no test run and no network, writes them to `etc/doc-metrics.json`, and rewrites each number marked `<!-- metric:name -->` in README.md, the guides and the site's home page. `validate:versions` runs the same check, so a stale count, release line or JSON file fails `static-analysis` on the pull request and `validate-release` at release; release-please bumps the release line and the JSON's version. Running it corrected the counts that had drifted: 57 feature files, 432 requirements and 537 scenarios where the charter said 54, 402 and 490 and the README and TESTING.md 54, 405 and 496, and 58 examples where USAGE.md said 56. Test counts measured by a Jest run (the "7,198 tests" kind) and coverage stay unmarked and dated. Gap register row 11 is done.

### 11.0.0

The next major is 11.0.0, built in the phases of the Road to Done plan (Phase 0 audit in [AUDIT.md](AUDIT.md); Phase 1 generator done; Phase 2 architecture lock in progress). The phase schedule, each phase's definition of done, the SDE programme and the release gate are in [ROADMAP.md](ROADMAP.md). Decided on 2026-09-26:

- The target is 11.0.0, not 1.0.0: npm already carries 10.x.
- Node 22 becomes the floor in 11.0.0, released as a breaking change (REL-05).
- pino and zod stay runtime dependencies.
- Jest, npm, release-please and Dependabot stay the toolchain.

---

## Part 0 · How to read this

This is a control document, not a tutorial. It states what the project holds itself to and points at the mechanism that proves it. Where the mechanism does not exist yet, the requirement says so with a status rather than pretending.

### Requirement identifiers

| Prefix | Governs                                                            | Derived guide                              |
| ------ | ------------------------------------------------------------------ | ------------------------------------------ |
| `ARCH` | Layering, request pipeline, module boundaries, public surface      | `guides/ARCHITECTURE.md`                   |
| `DES`  | Naming, schema conventions, endpoint definitions, extension seams  | `guides/DESIGN-RULES.md` (new)             |
| `TEST` | Test tiers, specification discipline, coverage and mutation floors | `guides/TESTING.md` + `tests/bdd/GUIDE.md` |
| `GATE` | What runs at commit, push, PR, nightly and release                 | `guides/QUALITY-GATES.md` (new)            |
| `SEC`  | Runtime defences, secrets, supply chain                            | `SECURITY.md` + `guides/SECURITY.md`       |
| `DOC`  | Where documentation lives, what is canonical, how it is published  | `guides/DOCUMENTATION.md`                  |
| `REL`  | Versioning, changelog, publishing, support window                  | `guides/RELEASE.md` (new)                  |
| `PROC` | Issue tracking, agent conduct, branch rules                        | `guides/BEADS.md` + `CONTRIBUTING.md`      |

### Status legend

| Status        | Meaning                                   |
| ------------- | ----------------------------------------- |
| **Enforced**  | A script or CI job fails on violation     |
| **Practised** | True in the code, but not machine-checked |
| **Partial**   | Holds in some places                      |
| **Gap**       | Stated intent, not yet true               |

### Evidence

The "Verified by" bullet of a requirement is one or more clauses, and each clause opens with a label:

| Label     | The clause names                                                               |
| --------- | ------------------------------------------------------------------------------ |
| `Test:`   | A test that exercises the requirement: a test file or directory under `tests/` |
| `Gate:`   | A script, workflow job, tool or setting that fails on a violation              |
| `Review:` | What only a person checks                                                      |
| `To add:` | Evidence that does not exist yet                                               |

A row that cites both a test and a gate carries both clauses. An Enforced row has at least one `Test:` or `Gate:` clause; a row with `Gate:` and no `Test:` is checked by its gate, and no test is expected. Every Jest test file a "Verified by" cites, as a path or a bare file name, names the requirement's ID in a `describe`, `it` or `test` title, as in `describe('SEC-03: workflow token permissions', ...)`, so a test run shows which requirement each test backs. `npm run charter:audit` fails a row that breaks any of these (PROC-06).

### Form and precedence

Each requirement uses one of the five EARS patterns already enforced on the feature files by `npm run spec:audit`: ubiquitous, event-driven (_When_), state-driven (_While_), optional (_Where_) and unwanted (_If … then_). One _shall_ per requirement, the system named, no vague quantifiers.

Precedence when documents disagree: the running code and its CI results are the fact. This charter is the intent. A guide explains how to meet the intent. The README sells and orients. If code and charter diverge, that is a bead to file, not a sentence to soften.

---

## Part 1 · Posture

Seven positions that explain most of the individual choices below. A proposal that contradicts one of these needs to argue with the principle, not just with the rule. The first is the identity of the project and is not traded away for any of the others.

1. **The custom clients are the product.** ESI.ts is a library of hand-written, curated custom clients: `EsiClient`, `EsiClientBuilder` → `CustomEsiClient`, `EsiApiFactory.createClient(type)` and the <!-- metric:clients -->39<!-- /metric --> domain clients, over hand-written endpoint maps and Zod schemas. Method names, argument shapes, wire fixes and validation strictness are product decisions, so people make them. Generation serves the custom clients: it supplies paths, schemas and coverage, and it never replaces them. `createEsi()` from `./client` is the spec-shaped low-level surface and the engine underneath, not a successor. A change that deprecates, removes or replaces a custom client fails ARCH-14; making one starts with amending this posture ([CLIENT-ARCHITECTURE.md](CLIENT-ARCHITECTURE.md), decided 2026-10-17 in [#664](https://github.com/lgriffin/ESI.ts/issues/664)).
2. **The OpenAPI spec is upstream.** Types, cache TTLs, rate-limit groups and scopes are generated from the live ESI spec and CI fails if they go stale. Hand-written code is diffed against the spec, never trusted blindly, and drift reports keep it honest.
3. **Tolerate additive change.** `z.looseObject` everywhere and `esiEnum` unions mean a new field or enum member from CCP never breaks a consumer at runtime. Removal is a breaking change; addition is not.
4. **Resilience is pluggable.** Retry, rate limiting, circuit breaking, deduplication, caching and the transport itself are interfaces with default implementations and setters. Nothing in the pipeline imports a concrete middleware.
5. **Secure by construction.** HTTPS and a host allowlist at construction, tokens attached only where a scope is declared, URLs redacted in every error, cache keys scoped per character (or per hashed token). The controls are tests, not advice.
6. **The specification executes.** Behaviour is stated as EARS requirements in Gherkin, one per `Rule:`, verified by scenarios that mock the transport seam. The audit fails a PR that weakens the wording.
7. **Verifiable supply chain.** Every action SHA-pinned, least-privilege tokens, npm provenance on both registries, keyless cosign signatures and checksums on release assets, advisories accepted only with an expiry date.

---

## Part 2 · Architecture

Five layers, one request path, and side modules that deliberately share nothing with the HTTP pipeline. Phase 2 of the 11.0 plan adds ports and generated operations beneath them; the layer rule is enforced by `npm run lint:layers`.

| Layer                | Where                                                                                                                            | Role                                                                                                                                                                                                                                                                                                                                                           |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Construction         | `src/EsiClient.ts`, `src/EsiClientBuilder.ts`                                                                                    | The product's front door (posture 1): `EsiClient` (lazy getters for every domain), `EsiClientBuilder` → `CustomEsiClient` (subset), `EsiApiFactory` (one client). All three feed `configureApiClient`, the single place config becomes middleware. ARCH-14 keeps them.                                                                                         |
| Domain clients       | `src/clients` (<!-- metric:clients -->39<!-- /metric --> + `BaseEsiClient`)                                                      | One class per ESI domain. Adds ergonomics only: named methods, `stream*` and `fetchAll*` wrappers, `withSafeMode()` envelopes. No HTTP knowledge.                                                                                                                                                                                                              |
| Endpoint definitions | `src/core/endpoints` (<!-- metric:endpointMaps -->39<!-- /metric --> files, <!-- metric:routes -->235<!-- /metric --> endpoints) | Declarative maps (`as const satisfies EndpointMap`) wiring path, method, `requiresAuth`, pagination kind, `responseSchema`, `requestSchema`, deprecation. `createClient()` turns a map into typed methods; return types are inferred from the schema.                                                                                                          |
| Request pipeline     | `src/core`, `src/core/requestPipeline/*`                                                                                         | Pure functions receiving their dependencies as parameters (`dependencies.ts` is the only resolver). Cache policy, headers, fetch execution, status handling, pagination orchestration, middleware bridge.                                                                                                                                                      |
| Transport            | `FetchLike`, `globalThis.fetch`                                                                                                  | Injectable via `setFetch()`. Timeout by `AbortController`. The Node floor (`engines.node` `>=22.12.0` since 11.0.0, REL-05) exists because this layer relies on the global fetch.                                                                                                                                                                              |
| Ports                | `src/core/ports`                                                                                                                 | Type-only interfaces (`CacheStore`, `Clock`, `HttpTransport`, `Identity`, `Logger`, `OperationTransport`, `TokenProvider`) that import nothing. Exported from `./client`.                                                                                                                                                                                      |
| Generated operations | `src/generated/operations.generated.ts`                                                                                          | One function per spec operation, importing only ports. Checked by `spec:generate:check` and `spec:coverage` in CI; called through `src/client`.                                                                                                                                                                                                                |
| Client               | `src/client` (`./client`)                                                                                                        | `createEsi`: the spec-shaped low-level surface, one runtime over `configureApiClient`, `esi.public` (a `PublicScopeTree`) and `esi.as(identity)` (a `ScopeTree` sharing the runtime's budgets and cache). Imports core, adapters and generated code, never the custom clients; `lint:layers` enforces it.                                                      |
| Clock                | `src/core/clock.ts`, `src/sde/clock.ts`                                                                                          | `systemClock` is where wall-clock time, timers and `Math.random` belong (the SDE keeps its own copy, since it may import only the ports). `npm run lint:determinism` blocks new direct reads; the existing sites (rate limiter, cache, circuit breaker, request handler and others) sit in `scripts/quality/determinism-baseline.json`, which may only shrink. |
| Auth                 | `src/auth`                                                                                                                       | EVE SSO (PKCE), token manager and storage, with its own error subtree. Reached from the root and `./errors`; there is no `./auth` sub-path.                                                                                                                                                                                                                    |
| Side modules         | `./schemas`, `./errors`, `./testing`, `./client`, `./sde`, `./sde/memory`                                                        | The SDE module shares no code with the pipeline, and `lint:layers` holds that in both directions (ARCH-10). It is an offline lookup layer for enriching ESI responses, with its own error hierarchy and its own docs.                                                                                                                                          |

`lint:layers` (`config/eslint/layers.rules.cjs`) holds the direction: ports import nothing, generated code imports only ports, `src/core` imports no domain client, entry point, generated operation, auth, SDE or testing module, and `src/sde` imports nothing from `src/` but the ports while nothing outside it imports the SDE. The baseline of exempt files is empty, and `tests/tdd/layers/layers-lint.test.ts` keeps it so.

### The request path

Every domain method takes this route. Order is the information: a stage cannot see the effect of a later one.

1. **Domain method → `createClient` closure.** Deprecation warning if declared. `buildEndpointPath` assembles path, query and body (from `hasBody` or `bodyBuilder`). Request body validated against `requestSchema` only when `validateRequest` is on.
2. **Spec-aware cache check.** `trySpecAwareCacheHit` may answer a GET from cache with no network call, but only when the endpoint has a generated TTL. The spec TTL comes from the operation's `x-cache-age` field, generated into `esi-cache-ttls.generated.ts`. `Cache-Control: max-age` never avoids a network call on its own.
3. **`RetryStrategy.execute`.** Wraps deduplication and the fetch. Exponential backoff with 0.75–1.25× jitter. Retries only status 0, 420, 429, 502, 503, 504. One 401 token refresh per call when a provider exists. Mutations never retried unless `retryMutations`. `CircuitOpenError` is rethrown immediately. Each attempt re-checks the spec-TTL cache first.
4. **Deduplication.** Inside each attempt, identical in-flight GETs without a body coalesce onto one promise, keyed by URL and caller identity. On by default.
5. **`executeSingleFetch`.** Build headers (User-Agent, compatibility date, `If-None-Match`, bearer only if `requiresAuth`) → request interceptors → circuit breaker check → rate limiter check → timed fetch → parse headers → rate limiter learns from response → breaker records outcome.
6. **Status handling.** 201 returns the parsed body. 204 yields `undefined`. 304 serves the cached body; if the entry was evicted mid-flight the request is repeated without `If-None-Match`, and only then does it throw. 5xx with a cached copy serves stale. 401/403 get remediation text appended.
7. **Cache write and pagination.** Successful GETs that carry an ETag are cached under a key that hashes the auth header. Stored TTL: spec TTL, else `max-age`, else the cache default (5 min), plus 1 h stale retention. Non-GET success invalidates the path prefix. Offset pagination follows `x-pages`. Cursor routes take `before`/`after` query parameters; `x-cursor-before`/`x-cursor-after` headers come back on the result as `cursors` and are never followed automatically, so callers page with `fetchAllCursorPages()`.
8. **Response interceptors → Zod validation → envelope.** Body replaced by `safeParse().data` when `validateResponse` is on (default). Failure throws `EsiValidationError`. `withMetadata` and `withSafeMode` wrap the result last.

### Middleware inventory

| Concern         | Interface                                    | Default            | Key defaults                                                                                                                                                                                                                    |
| --------------- | -------------------------------------------- | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Rate limiter    | `IRateLimiter`                               | on, mandatory      | Buckets from generated groups; adopts `x-ratelimit-*` from responses; honours `Retry-After`; 420/429 → 60 s block; tracks `x-esi-error-limit`. It does not charge token costs itself (`getTokenCost()` is a static helper only) |
| Circuit breaker | `ICircuitBreaker`                            | off, opt-in        | 5 failures, 30 s reset, 1 half-open probe, key by resolved path; stale-record cleanup off unless `cleanupIntervalMs > 0`                                                                                                        |
| Deduplicator    | `IDeduplicator`                              | on                 | GET without body only                                                                                                                                                                                                           |
| Retry           | `IRetryStrategy`                             | on                 | 3 retries, 1 s base, 30 s cap (set by `configureApiClient`; a bare `RetryStrategy` defaults to 0 retries)                                                                                                                       |
| Pagination      | —                                            | —                  | `fetchAll*` concurrency 8; `batch` concurrency 20; `batchPost` chunk size 1000                                                                                                                                                  |
| ETag cache      | `ICache`                                     | on                 | 1000 entries, 5 min default TTL, 60 s sweep                                                                                                                                                                                     |
| Interceptors    | `RequestInterceptor` / `ResponseInterceptor` | none               | Sequential, unsubscribe closure returned                                                                                                                                                                                        |
| Transport       | `FetchLike`                                  | global fetch       | 30 s timeout                                                                                                                                                                                                                    |
| Logger          | `ILogger`                                    | pino, level `warn` | Per-client, falls back to global, then default                                                                                                                                                                                  |

### Error taxonomy

```
Error
├── EsiError (statusCode, sanitised url, requestId)      .retryable ⇐ {0, 420, 429, 502, 503, 504}
│   ├── TimeoutError (+ timeoutMs)
│   ├── EsiNetworkError (+ cause)
│   ├── EsiValidationError (+ ZodError, direction: request | response)
│   ├── CircuitOpenError (endpoint, failures, retryAfterMs)   statusCode 0, never retryable
│   └── EsiFaultError (+ code, cause)                     raised by the library, not ESI
│       ├── EsiConfigurationError                         VALIDATION_ERROR, NO_AUTH_TOKEN, CONFIGURATION_ERROR
│       ├── EsiParseError                                 JSON_PARSE_ERROR
│       ├── EsiPaginationError                            PAGINATION_INCOMPLETE
│       └── EsiTokenRefreshError                          TOKEN_REFRESH_FAILED
├── AuthError
│   ├── SsoError (+ statusCode)                           .retryable ⇐ 429 or ≥ 500
│   ├── TokenRevokedError
│   ├── TokenDecodeError                                  no type guard yet
│   ├── TokenVerificationError
│   └── CharacterNotFoundError
└── SdeError → SdeValidationError | SdeVersionMismatchError
```

Every failure the request pipeline raises is an `EsiError`, `CircuitOpenError` included, so `isEsiError(err)` is true for all of them ([ERRORS.md](ERRORS.md#hierarchy)). The auth subtree is exported from the root and from `./errors`, with guards `isAuthError`, `isSsoError`, `isTokenRevoked`, `isTokenVerificationError` and `isCharacterNotFound`.

Configuration and plumbing faults (`NO_AUTH_TOKEN`, `CONFIGURATION_ERROR`, `JSON_PARSE_ERROR`, `PAGINATION_INCOMPLETE`, `TOKEN_REFRESH_FAILED`, `VALIDATION_ERROR`) are `EsiFaultError` subclasses carrying the code. Until 11.0.0 they were plain `Error`s with a bracketed type prefix, built by `buildError`; Part 10 row 5 records that history.

### Requirements

#### ARCH-01 · Ubiquitous · Enforced

The library **shall** derive response types, cache TTLs, rate-limit groups and endpoint scopes from the live ESI OpenAPI specification and commit the generated output.

- **Why:** CCP changes ESI on its own schedule. Generated metadata is the only way to keep <!-- metric:routes -->235<!-- /metric --> endpoints honest.
- **Verified by:** Gate: `npm run generate:types` then `git diff --exit-code` on `src/types/generated/` and every `src/core/endpoints/esi-*.generated.ts` (cache TTLs, rate-limit groups, scopes) in CI, the release gate and the nightly spec-drift run. The PR check fails only when a PR touches the generator inputs and warns otherwise. `spec:generate:check` does the same for the generated operations, on pull requests and at release.

#### ARCH-02 · Ubiquitous · Enforced

Every endpoint exposed by a domain client **shall** be declared in exactly one `*Endpoints.ts` definition map that names its path, method, authentication requirement and response schema.

- **Why:** The definition is the contract. `createClient`, the contract tests, the scope validator and the OKF bundle all read from it.
- **Verified by:** Gate: `npm run validate:esi` and `npm run validate:auth-scopes` in the CI static-analysis job and the release gate (a definition the spec does not list fails unless `scripts/spec/esi-endpoint-exceptions.json` gives a reason; `validate:esi:vendored` runs the same check offline in `check:local`). Test: `tests/contract/`.

#### ARCH-03 · Ubiquitous · Practised

Request-pipeline modules under `src/core/requestPipeline` **shall** receive cache, rate limiter and circuit breaker as function parameters rather than importing a concrete implementation.

- **Why:** Keeps every stage unit-testable without an `ApiClient` and keeps `dependencies.ts` the one place resolution happens.
- **Verified by:** Test: `tests/tdd/core/pipelineDependencyParameters.test.ts` fails when a module in `src/core/requestPipeline` other than `dependencies.ts` makes a value import of a cache, rate limiter or circuit breaker implementation (the `I*` interfaces and `cacheKey` helpers are allowed). Review: code review covers the parameter shape itself.

#### ARCH-04 · Ubiquitous · Enforced

Each resilience concern (retry, rate limiting, circuit breaking, deduplication, caching, transport, logging) **shall** be exposed as an interface with a default implementation and a setter on `ApiClient`.

- **Why:** Consumers embed the client in very different runtimes. Swapping a strategy must not require a fork.
- **Verified by:** Test: `tests/typetests/`. Gate: `etc/esi.ts.api.md` (API surface check fails on removal).

#### ARCH-05 · Ubiquitous · Enforced

The package **shall** publish a dual CJS and ESM build with declaration files for the seven entry points: root, `client`, `schemas`, `errors`, `testing`, `sde` and `sde/memory`.

- **Why:** Subpath entries are the tree-shaking story while the root barrel stays wide.
- **Verified by:** Gate: `tsup.config.ts`, the `package-lint` job in `ci.yml` (publint and Are The Types Wrong on the packed tarball), and the consumer contract.

#### ARCH-06 · Ubiquitous · Enforced

The package **shall** declare `"sideEffects": false` in its manifest and construct no logger, timer or network client at import time in any module under `src/`.

- **Why:** Seven entry points are wasted if bundlers must assume side effects. The default logger builds its pino instance on first use, not on import ([#268](https://github.com/lgriffin/ESI.ts/issues/268)).
- **Verified by:** Test: `tests/tdd/core/importSideEffects.test.ts`: it imports the source of every `exports` entry with pino replaced by a spy and fails if the spy was called, asserts the manifest flag, and bundles `import { EsiError }` from the root entry with esbuild (ESM and CJS) and fails if the bundle reaches pino. Gate: `npm run size` holds every entry to its budget.

#### ARCH-07 · Ubiquitous · Partial

Every public error the pipeline can throw **shall** be an instance of a class exported from `@lgriffin/esi.ts/errors` with a matching type guard.

- **Why:** Consumers branch on errors. Before 11.0.0, string-prefixed plain `Error`s and the missing `isCircuitOpen` export on the subpath broke that; both are fixed ([#266](https://github.com/lgriffin/ESI.ts/issues/266)). The missing `TokenDecodeError` guard still does.
- **Verified by:** To add: a type test that the guard set on `.` equals the guard set on `./errors`.

#### ARCH-08 · Ubiquitous · Enforced

Every construction surface (`EsiClient`, `CustomEsiClient`, `EsiApiFactory` and `createEsi`) **shall** configure the pipeline through `configureApiClient`, with the three custom-client surfaces handing out the same set of registered domain clients.

- **Why:** `CustomEsiClient` now has a getter for every registered client ([#267](https://github.com/lgriffin/ESI.ts/issues/267)). `EsiApiFactory` reaches all <!-- metric:clients -->39<!-- /metric --> through the generic `createClient(type)`; its nine named `create*Client` methods are deprecated since 11.0.0 in favour of `createClient(type)` and the builder, and removing them needs a future major release that is not scheduled. `createEsi` is the spec-shaped low-level surface over the same pipeline and the future engine under the custom clients (posture 1, [CLIENT-ARCHITECTURE.md](CLIENT-ARCHITECTURE.md)); it shares the pipeline, not the custom clients' method set, and needs no feature parity with them. Until 2026-10-17 this requirement asked all four surfaces for the same set of operations, which posture 1 no longer wants.
- **Verified by:** Test: `tests/tdd/core/customClientGetters.test.ts` for `CustomEsiClient` (a missing getter fails to compile); `tests/tdd/scripts/custom-client-identity.test.ts` builds every registered domain client through `EsiApiFactory.createClient(type)`, an `EsiClientBuilder` → `CustomEsiClient` and `EsiClient`'s domain properties and fails unless each is the registered class; `constructionParity.test.ts` checks that the three custom-client surfaces configure middleware the same way; `tests/tdd/core/customClientRuntime.test.ts` and the supplied-middleware, diagnostics and metadata Rules of `0060-custom-client.feature` check that the middleware instances, diagnostics and metadata and safe-mode views `createEsi` has reach `EsiClient`, `CustomEsiClient` and `EsiApiFactory` through `configureApiClient`; `tests/tdd/core/endpointOperationParity.test.ts` fails when an endpoint definition and its generated operation disagree about deprecation, a text response or an empty answer, and `deprecationWarning.test.ts` and `requestPipeline/noContent.test.ts` cover the helpers both surfaces share for them. Gate: `createEsi` (`src/client/runtime.ts`) calls `configureApiClient` and exposes every generated operation through `ScopeTree`, which `spec:coverage` keeps complete.

#### ARCH-09 · Ubiquitous · Enforced

All pipeline logging **shall** go through the per-client logger with structured context, with the global logger existing only as a fallback for callers with no client handle.

- **Why:** Per-client logging makes log lines attributable when several clients share a process.
- **Verified by:** Gate: `npm run lint` and `npm run lint:layers` (`--no-inline-config`) forbid the global `loggerUtil` in `src/core/requestPipeline/` and `src/clients/` (`config/eslint/logger-imports.rules.cjs`, proved by `tests/tdd/layers/logger-imports-lint.test.ts`, [#265](https://github.com/lgriffin/ESI.ts/issues/265)). Test: the rate limiter, the ETag cache's startup line, `EsiClient.batch` and the token manager's fallback are covered by `tests/tdd/core/loggerThreading.test.ts` ([#296](https://github.com/lgriffin/ESI.ts/issues/296)). The global logger serves only the standalone batch exports and `EsiTokenManager`, which hold no client.

#### ARCH-10 · Ubiquitous · Enforced

`src/sde` **shall** import only Node built-ins, its own files, its peer packages (`zod`, `js-yaml`, `adm-zip`) and `src/core/ports`, be imported by no file under `src/` outside `src/sde`, and keep the `./sde/memory` bundle free of file-system, YAML, ZIP or SQLite code.

- **Why:** The SDE is a side module: an offline lookup layer that enriches ESI responses without a consumer in the pipeline. Holding the boundary in both directions keeps the SDE free to change without a release of the client, and keeps the client free of the SDE's optional peers. A bridge between the two was considered on 2026-09-27 and cut, and so was a static-data port under `src/core/ports` for the client to resolve names through; if either is ever wanted it is a separate package above both, so nothing here is added for it.
- **Verified by:** Gate: `npm run lint:layers` (the `sde` and `sideModule` messages of `layers/inward-imports`, covered in both directions by `tests/tdd/layers/layers-lint.test.ts`). Test: `tests/tdd/sde/memory-entry-bundle.test.ts`, which bundles `src/sde/memory.ts` and the built `dist/sde/memory.{mjs,js}` and fails on `node:fs`, `js-yaml`, `adm-zip` or `better-sqlite3` (no longer a peer, still forbidden).

#### ARCH-11 · Ubiquitous · Enforced

Every error class of the SDE module (`SdeError`, `SdeDatabaseError`, `SdeValidationError` and `SdeVersionMismatchError`) **shall** descend from `SdeError`, carry the context of its failure as fields as well as in its message, and be exported from `@lgriffin/esi.ts/sde` with a type guard that is true for its own class and its subclasses.

- **Why:** The SDE shares no code with the pipeline (ARCH-10), so ARCH-07 does not reach its errors, yet a consumer branches on them the same way: a validation failure names the entity type and ID, a version mismatch names both versions. `SdeDatabaseError` is deprecated since the SQLite builder was removed: nothing in the package throws it, and it stays exported with its guard until the next major. Until 2026-10-12 no requirement named them, and their feature carried no charter tag.
- **Verified by:** Test: `tests/bdd/features/sde/0007-sde-error-handling.feature`, `tests/tdd/sde/errors.test.ts` (the classes, their fields and the four guards) and `tests/tdd/sde/index.test.ts` (the exports of `./sde`).

#### ARCH-12 · Event-driven · Enforced

When an SDE export is ingested, the ingestion pipeline (`SdeDownloader`, `SdeExtractor` and the record transforms) **shall** read the build number and release date from the export, write the downloaded archive and every extracted file to the paths the caller names, and parse every registry file the archive holds into records keyed by ID.

- **Why:** The ingestion pipeline is the only path from CCP's published export to the files the provider loads. A file written short, a stale copy left in place, or a registry file skipped would surface later as missing records with no error to trace. Through 12.2.0 the pipeline also built a SQLite database; that builder was removed (the YAML provider replaced it), and this requirement now covers download and extraction only.
- **Verified by:** Test: `tests/bdd/features/sde/0023-sde-ingestion.feature`, `tests/tdd/sde/ingestion/SdeExtractor.test.ts` and `tests/tdd/sde/ingestion/transforms.test.ts`.

#### ARCH-13 · Unwanted · Enforced

If the SDE ingestion pipeline cannot fetch or write the export, find a file in the archive, or extract an entry inside the target directory, then the SDE ingestion pipeline **shall** throw an `SdeError` that names the failure: the HTTP status, the abort, the write failure, the byte limit, the missing file, or the refused path.

- **Why:** Ingestion runs unattended (`nightly-sde.yml`, a consumer's build step). An untyped or unnamed failure there leaves the reader of a log with nothing to act on, and a consumer catching `isSdeError` would miss it.
- **Verified by:** Test: the `If …` Rules of `tests/bdd/features/sde/0023-sde-ingestion.feature`, `tests/tdd/sde/ingestion/SdeDownloader.test.ts` and `tests/tdd/sde/ingestion/SdeExtractor.test.ts`.

#### ARCH-14 · Unwanted · Enforced

If a change marks a custom-client construction surface (`EsiClient`, `getDefaultClient`, `EsiClientBuilder`, `CustomEsiClient`, `EsiApiFactory`, `EsiApiFactory.createClient`, `BaseEsiClient`, `ApiClientType` or a registered domain client) `@deprecated`, removes it, or replaces it with the generated surface, then the custom-client identity test **shall** fail the pull request's `ci-success` check.

- **Why:** The custom clients are the product (posture 1). 12.1.0 deprecated every one of them for removal in 13.0.0 without anyone deciding that on its own merits; [#664](https://github.com/lgriffin/ESI.ts/issues/664) decided the opposite on 2026-10-17 and 12.2.0 lifted the tags. A gate keeps the next such change from merging by drift: changing the identity is a charter amendment first, not a pull request that adds tags. Building the custom clients on the generated layer underneath (endpoint maps from `*Meta`, generated baseline schemas, identity views) is the plan, and none of it trips the test, because the surfaces stay classes with the same names.
- **Verified by:** Test: `tests/tdd/scripts/custom-client-identity.test.ts` fails when any of those surfaces carries `@deprecated` in its source or in `etc/esi.ts.api.md`, when one leaves the root entry or stops being a class (a domain client must stay `extends BaseEsiClient`), and when a registered domain client disappears from the registry; it also holds the nine named `EsiApiFactory` factories and `EsiTokenManager.createClient` deprecated, and nothing else. Gate: `npm test` in the `unit-tests` job of `ci.yml`, which `ci-success` needs.

---

## Part 3 · Design rules

Conventions that make a <!-- metric:clients -->39<!-- /metric -->-client codebase read like one author wrote it. These are the seed of `guides/DESIGN-RULES.md`, which should also carry the walkthrough for adding an endpoint and adding a client.

| Thing          | Convention                                                      | Example                       |
| -------------- | --------------------------------------------------------------- | ----------------------------- |
| Interface      | `I` prefix                                                      | `ICache`, `IRetryStrategy`    |
| Zod schema     | `Schema` suffix, `z.looseObject`                                | `MarketOrderSchema`           |
| Endpoint map   | `Endpoints` suffix, `as const satisfies EndpointMap`            | `allianceEndpoints`           |
| Domain client  | `Client` suffix, extends `BaseEsiClient`                        | `AllianceClient`              |
| Generated file | `.generated.ts` suffix, header comment, never hand-edited       | `esi-scopes.generated.ts`     |
| Config object  | `Config` suffix, all fields optional, readonly on the class     | `CircuitBreakerConfig`        |
| Wire format    | snake_case in types and schemas; camelCase in method parameters | `alliance_id` vs `allianceId` |
| Enum from ESI  | `esiEnum([...])`: known members plus open string                | `esiEnum(['buy','sell'])`     |

#### DES-01 · Ubiquitous · Enforced

Every hand-written response schema **shall** use `z.looseObject` so that fields ESI adds later are preserved in the validated body.

- **Why:** Validation replaces the body with `safeParse().data`. A strict object would silently strip new fields from consumers.
- **Verified by:** Gate: `npm run schema:drift` against the spec; 398 uses of `looseObject` versus 2 of `z.object`, both internal.

#### DES-02 · Event-driven · Enforced

When an endpoint is added to a definition map, the library **shall** ship a `responseSchema` for it, since a missing schema degrades the inferred return type to `unknown`.

- **Why:** Types are inferred from the schema. No schema means no type, which is a silent regression for consumers.
- **Verified by:** Gate: `npm run spec:response-schemas` in `ci.yml` `lint-and-build` (every definition whose spec operation returns JSON declares a schema). Test: `tests/typetests/domain-responses.test-d.ts`.

#### DES-03 · Ubiquitous · Enforced

Files ending in `.generated.ts`, the `okf/` bundle and `etc/esi.ts.api.md` **shall** be regenerated by their script and never edited by hand.

- **Why:** A hand edit is overwritten on the next run and hides the real spec change.
- **Verified by:** Gate: the generated-types freshness diff (`npm run generate:types` then `git diff --exit-code`) in `ci.yml`; the `api-surface` job for the report; `.prettierignore`.

#### DES-04 · Ubiquitous · Enforced

An endpoint definition **shall** declare `requiresAuth: true` if and only if the generated scope map lists at least one scope for it.

- **Why:** The bearer token is attached only when `requiresAuth` is set. A mismatch either leaks a token or breaks a call.
- **Verified by:** Gate: `npm run validate:auth-scopes` in the CI static-analysis job and the release gate; both directions exit 1, and so does an entry in `scripts/spec/auth-scope-exceptions.json` that no longer excuses a mismatch.

#### DES-05 · Event-driven · Practised

When an endpoint is deprecated upstream, the client **shall** carry `DeprecationInfo` with a replacement and sunset date on the endpoint's definition and log a warning on every call to it.

- **Why:** Deprecation is a first-class field, not a comment. Consumers get a log line before the endpoint vanishes. The warning is not memoised (`createClient.ts`), so a hot loop logs on each call; revision 1 said "once", which was never true.
- **Verified by:** Test: `tests/tdd/core/createClient.test.ts` (the `DES-05` block: a call to a deprecated endpoint logs a warning naming its replacement and sunset date, on every call). Review: adding the field when ESI deprecates an endpoint is code review, prompted by the issue `nightly-spec-drift.yml` files.

#### DES-06 · Ubiquitous · Practised

Configuration and error objects **shall** be immutable after construction, with merges copying rather than mutating.

- **Why:** `readonly` fields on every error and every strategy config; copy-on-write interceptor lists. Bucket and circuit records are the intentional exception because they are counters.
- **Verified by:** Review: TypeScript `readonly`; code review.

#### DES-07 · Ubiquitous · Enforced

The compiler **shall** run with `strict`, `noUncheckedIndexedAccess`, `noImplicitReturns`, `noImplicitOverride` and `noFallthroughCasesInSwitch` for `src/`.

- **Why:** The type-level endpoint inference only holds under strict settings. Tests relax `noUncheckedIndexedAccess` and nothing else.
- **Verified by:** Gate: `tsconfig.json`; `npm run typecheck` on every push.

#### DES-08 · Ubiquitous · Partial

Pagination helpers **shall** propagate the caller's HTTP method into the retry context and surface partial results as an error rather than a silent truncation.

- **Why:** A helper that retries a mutation, or returns a short list as if it were complete, surprises the consumer. The retry method and the cursor `fetchAll`'s failure path meet it in every configuration. Where the client is configured with `incompletePages: 'throw'`, every collecting path (the eager method, `fetchAll*`, `stream*`, the cursor handler) returns every page or throws `EsiPaginationError` with a `reason` and the `partial` result. The default, `'truncate'`, still ends an incomplete walk with a warning, not an error, until a future major flips it ([PAGINATION.md](PAGINATION.md#known-gaps)).
- **Verified by:** Test: `tests/bdd/features/core/0051-resilience.feature`, the Rules tagged `@DES-08` (a streamed POST answered with 503 is sent once; three failed cursor pages reject; exactly the reported pages resolve; under the default an empty page truncates and warns naming the option; with `incompletePages: 'throw'` a count above 1000 is refused after page 1, an empty page after the first is refused and a cursor still set at the page limit is refused; an empty cursor page or one without an `after` cursor completes the walk). Test: `tests/fuzz/pagination-assembly.property.test.ts` (with `incompletePages: 'throw'`, every offset collector returns the whole dataset or throws). Gate: `npm run faults` (`x-pages-beyond-the-cap`, `x-pages-beyond-the-cap-refused`, `x-pages-shrinks-to-empty-page`, `x-pages-shrinks-to-empty-page-refused`, `empty-page-mid-run`).

---

## Part 4 · Testing

This table is the canonical tier order. Both testing guides merge into one and cite it.

| Tier | Name                           | Where                            | Files                                                                                                                                                                    | Runner                    | Runs on                                                        |
| ---- | ------------------------------ | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------- | -------------------------------------------------------------- |
| 1    | Unit (TDD)                     | `tests/tdd`                      | <!-- metric:unitSuites -->240<!-- /metric -->                                                                                                                            | jest.unit                 | push (Node 22), PR (Node 22/24)                                |
| 2    | Specification (EARS/BDD)       | `tests/bdd`                      | <!-- metric:featureFiles -->77<!-- /metric --> features · <!-- metric:requirements -->664<!-- /metric --> rules · <!-- metric:scenarios -->863<!-- /metric --> scenarios | jest.unit + jest-cucumber | push (`npm test`), PR (`bdd-tests`), plus `spec:audit`         |
| 3    | Type tests                     | `tests/typetests`                | <!-- metric:typeTestFiles -->13<!-- /metric -->                                                                                                                          | tsd                       | PR (full suite)                                                |
| 4    | Property fuzz                  | `tests/fuzz`                     | <!-- metric:fuzzSuites -->15<!-- /metric -->                                                                                                                             | jest.fuzz + fast-check    | PR, nightly properties                                         |
| 5    | Contract                       | `tests/contract`                 | 6 (2 live + 4 replay) + snapshot                                                                                                                                         | jest.contract(.replay)    | PR (`contract-replay`), soft-skip on ESI 503                   |
| 6    | Fault catalogue                | `tests/faults`                   | 3                                                                                                                                                                        | jest.faults               | PR (`fault-catalogue`), nightly                                |
| 7    | Integration, mocked full stack | `tests/integration`              | <!-- metric:integrationSuites -->6<!-- /metric -->                                                                                                                       | jest.integration          | PR (full suite)                                                |
| 8    | Integration, live              | same, `ESI_LIVE_TESTS`           | ~50                                                                                                                                                                      | jest.integration.live     | manual                                                         |
| 9    | Integration, gated auth        | same, `ESI_GATED_TESTS` + `.env` | 30+                                                                                                                                                                      | jest.integration.live     | manual                                                         |
| 10   | Benchmark and heap soak        | `tests/benchmark`                | 24 tasks (18 client, 6 SDE) + client soak + SDE soak                                                                                                                     | mitata + soak drivers     | PR (A/B on hot paths), nightly; SDE nightly on the real export |
| 11   | Mutation                       | `src/**`                         | —                                                                                                                                                                        | Stryker                   | PR (changed files), nightly sharded unit + BDD + type mutation |
| 12   | API fuzz                       | Prism mock + Schemathesis        | —                                                                                                                                                                        | Docker                    | nightly                                                        |

Other nightlies: interleave, no-retry, recorded payloads, consumer matrix, examples against live ESI, and a post-publish canary.

| Coverage metric | Floor | Last measured (v9.8.0) |     | Mutation | Floor                                                                           |
| --------------- | ----- | ---------------------- | --- | -------- | ------------------------------------------------------------------------------- |
| Statements      | 90%   | 98.17%                 |     | Unit     | Per directory, `config/mutation/unit-thresholds.json`, ratcheted                |
| Branches        | 80%   | 95.14%                 |     | BDD      | Per directory, `config/mutation/bdd-thresholds.json`, ratcheted                 |
| Functions       | 75%   | 96.09%                 |     | PR       | Changed files, gated by the same thresholds                                     |
| Lines           | 90%   | 98.37%                 |     |          | The global `break` is off (`config/mutation/stryker.config.mjs`, `break: null`) |

#### TEST-01 · Event-driven · Practised

When observable client behaviour changes, the change **shall** be preceded by an EARS requirement in a `Rule:` block and a scenario that fails before the implementation exists.

- **Why:** The most common defect in the suite has been scenarios that cannot fail. Red before green is the only defence. The form of the requirement is machine-checked; that the scenario failed first is a step in a workflow, and no check can establish it after the fact, so the status is Practised (moved from Enforced on 2026-09-27 for that reason, not because the code regressed).
- **Verified by:** Review: the `ears-gherkin-dev` workflow for the RED step. Gate: `npm run spec:audit` holds the form on PR; the ratchet file `scripts/spec/spec-audit-exceptions.json` has an empty `unconverted` list and a `legacyStepFiles` list, and both may only shrink.

#### TEST-02 · Ubiquitous · Enforced

Each `Rule:` block **shall** state exactly one requirement with one _shall_, name the system, use one of the five EARS patterns, and contain no vague language.

- **Why:** A requirement you cannot falsify is not a requirement.
- **Verified by:** Gate: `scripts/spec/spec-audit.ts`: eleven finding types, inline GitHub annotations, PR gate. The same audit holds every feature to a charter tag (`@DES-08`) naming the requirement it verifies, on the Feature or a Rule, fails a tag naming an id this charter does not define, and fails a feature a "Verified by" line cites that does not carry the citing id; the features no requirement covers are the shrink-only `unchartedFeatures` list in `scripts/spec/spec-audit-exceptions.json`. `npm run bdd:report` copies each scenario's charter tags into `reports/bdd/scenarios.json` beside its result.

#### TEST-03 · Ubiquitous · Enforced

Scenario steps **shall** mock at the transport seam with `jest-fetch-mock` rather than spying on the client method under test.

- **Why:** Spying on the method makes the pipeline invisible to the test. Reference implementations: `etag-caching.steps.ts`, `resilience.steps.ts`.
- **Verified by:** Gate: `npm run lint:bdd-seam` (`config/eslint/bdd-seam.config.mjs`) on every push (`ci-fast.yml`) and PR (`ci.yml`).

#### TEST-04 · Ubiquitous · Enforced

Unit coverage **shall** stay at or above 90% statements, 80% branches, 75% functions and 90% lines, measured on `src/**` excluding generated files.

- **Why:** Floors far below the current numbers stop a bad week becoming a broken gate, while still catching an untested module.
- **Verified by:** Gate: `config/jest/unit.config.cjs` thresholds; coverage job posts a PR comment.

#### TEST-05 · Ubiquitous · Enforced

Every endpoint definition **shall** be validated against the live ESI OpenAPI document for path, method, cache TTL and scopes, with a committed snapshot as fallback.

- **Why:** This is the only test that can tell the project CCP moved something.
- **Verified by:** Test: `tests/contract/`. Gate: `npm run contract:diff` (oasdiff, breaking changes only). SDE: `npm run sde:drift` in `nightly-sde.yml` compares CCP's current export with `SDE_FILE_REGISTRY` and the Zod schemas every night (GATE-07).

#### TEST-06 · Ubiquitous · Enforced

The consumer-facing type surface **shall** be asserted by tsd tests covering endpoint argument inference, branded IDs, error guards and the result envelope.

- **Why:** Half the value of the library is at the type level. A refactor can break inference without failing a runtime test.
- **Verified by:** Test: `npm run test:types` over `tests/typetests/`. SDE: `tests/typetests/sde.test-d.ts` and `sde-memory.test-d.ts` pin every provider method, entity and error of `./sde` and `./sde/memory`. Gate: type-mutation floors of 32 and 25 for them in `config/mutation/type-thresholds.json`.

#### TEST-07 · Ubiquitous · Enforced

Mutation testing **shall** hold each directory at or above its floor in `config/mutation/unit-thresholds.json` (unit) and `config/mutation/bdd-thresholds.json` (BDD), with a PR touching mutated source running Stryker on the changed files.

- **Why:** Nightly-only mutation means a weak test lands before anyone sees the score. Per-directory floors replace the single score of 65, which let a strong directory hide a weak one. Incremental Stryker on changed files keeps the PR cost bounded.
- **Target floors (decided 2026-09-27; off the 11.0.0 release path since 2026-09-28).** The ratchets only rise, and these are where they must stand, in 11.x: every directory in `config/mutation/unit-thresholds.json` at 60 or above, every directory in `config/mutation/bdd-thresholds.json` at 20 or above, and every SDE directory at 90 or with each survivor carrying an equivalence reason (Track S Run M). `src/schemas` is measured by the unit tier and `schema:drift` only: scenarios send valid ESI-shaped bodies through the transport seam, so a mutant that relaxes a field is invisible to them by design, and its BDD entry stays at 0 rather than pretending otherwise.
- **Verified by:** Gate: `mutation-pr` job ("Mutation (changed files)") in `mutation-pr.yml`, advisory on every pull request since 2026-09-27, back inside `ci-success` once the target floors hold (after 11.0.0); nightly ratchets in `nightly-mutation.yml`. A pull request that changes or deletes a test file re-runs every mutant whose nightly verdict rests on it (killed by it, or covered by it and survived or timed out) and reuses the rest ([#571](https://github.com/lgriffin/ESI.ts/issues/571)); one that changes a test helper, setup file or fixture re-mutates, with `--force`, every reused file a changed test covered (a test-only pull request, over the directories its tests reach). Either way a weakened or deleted test lowers a score from the pull request and a strengthened one raises it; a test the report never saw can only raise a score and waits for the next nightly ([#380](https://github.com/lgriffin/ESI.ts/issues/380), `planPrRun` in `scripts/mutation/mutation-ratchet-core.ts`). Open: flip-flopping mutants ([#382](https://github.com/lgriffin/ESI.ts/issues/382)). SDE: `src/sde` and `src/sde/ingestion` are their own directories in both floor files and `src/sde` its own shard in `config/mutation/unit-shards.json` and `bdd-shards.json` (Track S Run 2).

#### TEST-08 · Optional · Enforced

Where a test needs live ESI or a real token, the test **shall** be gated behind `ESI_LIVE_TESTS` or `ESI_GATED_TESTS` and soft-skip when ESI returns 503.

- **Why:** Tranquility downtime must not fail a PR that changed nothing about networking.
- **Verified by:** Gate: `config/jest/integration.live.config.cjs` and `config/jest/contract.live.config.cjs` refuse to run without `ESI_LIVE_TESTS`, and the 503 soft-skip sits in the CI jobs that run those tiers. Test: `tests/integration/gated-auth.test.ts` reads `ESI_GATED_TESTS`. SDE: `tests/integration/sde/sde-real-data.test.ts` skips without an export on disk and fails instead when `SDE_REQUIRE_DATA=1`, which `nightly-sde.yml` sets.

#### TEST-09 · Ubiquitous · Enforced

Test source under `tests/` **shall** be linted with the same ESLint configuration as `src/`, with test-specific relaxations declared explicitly.

- **Why:** Floating promises in a step file produce a scenario that passes without asserting.
- **Verified by:** Gate: `npm run lint` over `src` and `tests`, on push (`ci-fast.yml`) and PR (`ci.yml` `lint-and-build`), with the relaxations declared, each with its reason, in the `tests/**` block of `eslint.config.mjs`; `no-floating-promises`, `no-misused-promises` and `await-thenable` stay errors there. `tests/` is parsed against `tsconfig.test.json`; fixture trees and the packed-tarball consumer in `tests/consumer/` are ignored. `lint-staged` runs `eslint --fix` on staged test files. Alongside it, `lint:suite-health` over `tests/` (no `.only`/`.skip`/`.todo`, no assertion-free tests or Then steps, no swallowed assertions) and `lint:bdd-seam` over `tests/bdd` ([#271](https://github.com/lgriffin/ESI.ts/issues/271)).

#### TEST-10 · Ubiquitous · Enforced

Every public method of a domain client and of `IStaticDataProvider` **shall** be named by at least one `Rule:` block or bound step, with the list of methods without one only ever shrinking.

- **Why:** The audit proves every Rule has a scenario, but nothing proves every behaviour has a Rule. Two hundred and thirty-five wired endpoints and 101 provider methods can each lose their specification without a check noticing. The provider half is closed (Track S Runs 4 to 6: 101 of 101 methods covered, an empty baseline); the domain clients have the same gate since Phase 5 item 8, with 210 of 384 methods still in their baseline. A shrink-only baseline turns "specified" into a number that cannot go down.
- **Verified by:** Gate: the provider: `npm run spec:coverage:sde:ci` (`scripts/sde/sde-spec-coverage.ts`, checks in `sde-spec-coverage-core.ts`, tested under `tests/tdd/scripts/`) parses `IStaticDataProvider.ts` for its methods and counts one as covered when a `Rule:` in `tests/bdd/features/sde/` names it or the step function a scenario binds calls it on the provider, directly or through any `tests/bdd` function, with the TypeScript type checker deciding what the receiver is; the uncovered list, grouped by entity family, is `scripts/sde/sde-spec-coverage-baseline.json`, which may only shrink against `origin/master`'s copy (23 of 101 covered at the first commit). In `check:all` and `ci.yml`'s `spec-audit` job; `verify-local` runs `spec:coverage:sde`, which checks the committed baseline without a base ref. The clients: `npm run spec:coverage:clients:ci` (`scripts/spec/client-spec-coverage.ts`, checks in `client-spec-coverage-core.ts`, sharing the call graph, baseline and command line with the provider check through `scripts/spec/method-coverage-core.ts`, tested under `tests/tdd/scripts/`) reads the public instance methods each class extending `BaseEsiClient` declares and counts one as covered when a `Rule:` names it (qualified as `Client.method`, or bare when one client declares the name) or a bound step calls it on that client, legacy `defineFeature` scenarios bound by title included; the uncovered list, grouped by client, is `scripts/spec/client-spec-coverage-baseline.json`, which may only shrink against `origin/master`'s copy (173 of 384 covered at the first commit). In `check:all` and `ci.yml`'s `spec-audit` job; `verify-local` runs `spec:coverage:clients`. Test: `tests/tdd/scripts/sde-spec-coverage.test.ts` and `tests/tdd/scripts/client-spec-coverage.test.ts` drive both checks with fixtures and over this repository.

#### TEST-11 · Optional · Enforced

Where the client deliberately does not act on an ESI behaviour (a status, a header, a field, an endpoint feature), the exclusion **shall** be stated as an unwanted-behaviour Rule (`If <condition>, then the <system> shall not <response>.`) with a scenario that proves the absence.

- **Why:** The specification governs what the client does; what it ignores is a decision too, and an unstated exclusion reads as an omission the next contributor "fixes". Every exclusion the guides stated in prose (`SECURITY.md`, `AUTHENTICATION.md`, `LOGGING.md`, `ERRORS.md`, `PAGINATION.md`, `ARCHITECTURE.md`, `TESTING.md`'s fault-tier decisions, `SDE.md` and `guides/sde/`) is a Rule on 2026-09-28, in the feature for its area: the headers not honoured in `0052-response-headers` and `0055-request-headers`, the statuses not retried in `0051-resilience`, the cache features skipped in `0050-etag-caching`, the values not validated in `0053-runtime-validation`, the token not logged in `0058-logging`, the signature not verified in `0054-token-management`, the files and peers not read in `sde/0020` and `sde/0021`. The client's own configuration and extension points (which interceptors run on which path, `circuitBreakerConfig` without `enableCircuitBreaker`, which logger a component reads) are not ESI behaviours and are documented, not registered.
- **Verified by:** Gate: the form: `npm run spec:audit` accepts the unwanted pattern with a negated response. Gate: the register: `npm run ears` lists every `shall not` Rule with its verdict and scenarios in the "Exclusion register" section of `reports/ears/ears-report.md` and the job summary (`scripts/quality/ears-core.ts`, `isExclusion`), 29 Rules, all verified, on 2026-09-28; TESTING.md documents it and `tests/bdd/GUIDE.md` shows how to write one. Test: `tests/tdd/spec-audit/ears-report.test.ts` (`isExclusion` and the register). Review: a new exclusion: the AGENTS.md reviewer checklist item "Exclusions stated (R2a)", at major severity, requires it as a Rule rather than prose or a code comment. No script can know what the client should ignore, so completeness stays a review question; what the check guarantees is that every stated exclusion executes on every run.

---

## Part 5 · Quality gates

What runs where. ● blocks; ◐ runs but does not block; · does not run.

| Check                                                           | Commit | Push |             PR              |        Nightly         |  Release  |
| --------------------------------------------------------------- | :----: | :--: | :-------------------------: | :--------------------: | :-------: |
| lint-staged: ESLint fix + Prettier                              |   ●    |  ·   |              ·              |           ·            |     ·     |
| commitlint (conventional commits)                               |   ●    |  ·   |              ·              |           ·            |     ·     |
| ESLint, Prettier check, build, typecheck, examples typecheck    |   ·    |  ●   |              ●              |           ·            |     ●     |
| lint:layers, lint:bdd-seam, lint:suite-health                   |   ·    |  ●   |              ●              |           ·            |     ·     |
| lint:determinism                                                |   ·    |  ·   |              ●              |           ·            |     ·     |
| Unit tests + BDD (`npm test`)                                   |   ·    | ● 20 |         ● 18/20/22          |           ·            |     ●     |
| Coverage thresholds + PR comment                                |   ·    |  ·   |              ●              |           ·            |     ·     |
| EARS spec audit, BDD report                                     |   ·    |  ·   |              ●              |           ·            |     ●     |
| Generated types and operations fresh, schema drift, auth scopes |   ·    |  ·   | ● if inputs touched, else ◐ |     ◐ files issue      |     ●     |
| Endpoint definitions against the spec (`validate:esi`)          |   ·    |  ·   | ● if inputs touched, else ◐ |           ·            |     ●     |
| Redocly lint of the ESI spec (`validate:spec`)                  |   ·    |  ·   | ● if inputs touched, else ◐ |           ·            |     ·     |
| Version consistency (`validate:versions`)                       |   ·    |  ·   |              ·              |           ·            |     ●     |
| Live contract tests                                             |   ·    |  ·   |          ● (503 ◐)          |        ◐ weekly        | ● (503 ◐) |
| Contract replay, fault catalogue, fuzz, integration, type tests |   ·    |  ·   |              ●              |           ◐            |     ●     |
| API surface diff (api-extractor) and api-semver                 |   ·    |  ·   |              ●              |           ·            |     ·     |
| Export coverage, package lint (publint, attw), size-limit       |   ·    |  ·   |              ●              |           ·            |     ·     |
| Consumer contract (Node 22/24), doc examples                    |   ·    |  ·   |              ●              |   ◐ consumer matrix    |     ·     |
| TypeDoc build                                                   |   ·    |  ·   |              ●              |           ·            |     ·     |
| Benchmarks A/B                                                  |   ·    |  ·   |              ●              |           ◐            |     ·     |
| Dependency audit (diff-aware / allowlist)                       |   ·    |  ·   |      ● new advisories       |     ◐ files issue      | ● ≥ high  |
| knip dead-code                                                  |   ·    |  ·   |              ◐              |           ·            |     ●     |
| CodeQL                                                          |   ·    |  ●   |              ●              |        ● weekly        |     ·     |
| zizmor (workflow security), workflow lint                       |   ·    |  ·   |              ●              |           ·            |     ·     |
| Stryker mutation                                                |   ·    |  ·   |       ● changed files       | ◐ ratchet, files issue |     ·     |
| Schemathesis API fuzz                                           |   ·    |  ·   |              ·              |     ◐ files issue      |     ·     |
| Spec drift, faults, properties, examples, recorded payloads     |   ·    |  ·   |              ·              |     ◐ files issue      |     ·     |
| Real SDE export: real-data suite, registry drift, SDE examples  |   ·    |  ·   |              ·              |     ◐ files issue      |     ·     |
| OpenSSF Scorecard                                               |   ·    |  ·   |              ·              |        ◐ weekly        |     ·     |

#### GATE-01 · Ubiquitous · Enforced

A pull request to `master` **shall** merge only when the aggregate `ci-success` job reports every blocking job green.

- **Why:** One required check that fans in every PR job keeps branch protection simple and complete.
- **Verified by:** Gate: `ci.yml` `ci-success` with `if: always()`, needing 23 jobs, plus a self-check that fails if a job is missing from its `needs`; branch protection requires it, and it alone (`Lint, Build & Test` was required until `ci-success` replaced `Quality Gate`). The pull-request mutation job is in `mutation-pr.yml`, outside the gate until the release gate row in ROADMAP.md moves it back (decided 2026-09-27).

#### GATE-02 · Ubiquitous · Enforced

Every push on every branch **shall** run lint, format check, build, typecheck and the unit and BDD tests on Node 22.

- **Why:** Fast feedback before a PR exists.
- **Verified by:** Gate: `ci-fast.yml` ("Lint, Build & Test"): lint, `lint:layers`, `lint:bdd-seam`, `lint:suite-health`, format, build, typecheck, `typecheck:examples` and `npm test`, whose Jest config includes the BDD specs.

#### GATE-03 · Ubiquitous · Enforced

A change to the public API surface **shall** be visible as a diff to `etc/esi.ts.api.md` in the same pull request.

- **Why:** This is the breaking-change tripwire. Reviewers see the exported shape change, not just the implementation.
- **Verified by:** Gate: the `api-surface` job ("API Surface Check") in `ci.yml`, a CRLF-normalised diff against HEAD.

#### GATE-04 · Ubiquitous · Enforced

knip **shall** block the release gate on unused exports and dependencies, with an explicit ignore list for intentional public re-exports.

- **Why:** Non-blocking everywhere means the report is never read. Blocking only at release keeps PR friction low.
- **Verified by:** Gate: the "Dead code detection" step of `validate-release` in `release.yml` runs `npx knip` without `--no-exit-code`, so an unused file, export or dependency fails the release. The ignore list is `knip.jsonc`, each entry with its reason. Since 2026-10-08 `ci.yml` (`static-analysis`) blocks on it too (`npm run knip`), and `check:local` runs it; `npm run validate` and `npm run check:all` block on it.

#### GATE-05 · Ubiquitous · Enforced

Every nightly job that finds a problem **shall** file or update a labelled GitHub issue rather than only failing the run.

- **Why:** A red nightly nobody reads is the same as no nightly. Revision 1 marked this Enforced while two nightlies still filed nothing; revision 2 corrected it, and [#277](https://github.com/lgriffin/ESI.ts/issues/277) (bead `esi-mbr`) closed those two, and the last three followed on 2026-09-28.
- **Verified by:** Gate: audit, spec drift, faults, properties, benchmarks, examples, mutation, Schemathesis, the real SDE export (`nightly-sde.yml`), the Tranquility smoke (`nightly-live-health.yml`) and the post-publish canary file issues. `nightly-mutation.yml`, `nightly-schemathesis.yml`, `nightly-no-retry.yml`, `nightly-interleave.yml` and `consumer-matrix-nightly.yml` each end in a `report` job that opens or comments on one fixed-title issue (labels `mutation`, `api-fuzz`, `no-retry`, `interleave` and `consumer-matrix`) and closes it on the next green run. Recorded-payload drift opens a pull request instead, and only a failed run files an issue. Test: `tests/tdd/workflows/workflow-permissions.test.ts` lists every job allowed `issues: write`, so a new report job is a reviewed change.

#### GATE-06 · Ubiquitous · Enforced

`package.json` **shall** hold every npm script a document references, each resolving to an existing file.

- **Why:** A script that points at a missing file (`sde:seed`, fixed in [#274](https://github.com/lgriffin/ESI.ts/issues/274)) fails only when someone runs it, and a document that tells the reader to run a script that does not exist fails only the reader.
- **Verified by:** Test: `tests/tdd/scripts/package-scripts.test.ts`, in `npm test`: every script target exists, and every `npm run <name>` in `README.md`, `CLAUDE.md`, `AGENTS.md`, `guides/` (ROADMAP.md aside, which names scripts later items add) and the BDD README and GUIDE is a defined script.

#### GATE-07 · Ubiquitous · Enforced

The nightly SDE run **shall** load CCP's current Static Data Export and report every file, record key and failing lookup the module does not know about as one tracked issue.

- **Why:** `SDE_FILE_REGISTRY` and the Zod schemas are hand-written, and CCP publishes a new export without notice. Without a nightly against the real export, a new file or field is found by the first user who asks for it, and `sde-real-data.test.ts` skipping wherever `sde-data/` is absent meant the suite never ran in CI at all.
- **Verified by:** Gate: `.github/workflows/nightly-sde.yml` (Track S Run 9): the build is resolved from CCP's feed, the ZIP restored from the cache keyed on it, `npm run sde:ingest -- --from-zip` extracts it, the real-data suite runs with `SDE_REQUIRE_DATA=1` so a missing export fails, `npm run sde:drift` writes `reports/sde-drift.json` and exits 1 on drift, `npm run examples:nightly -- --tier sde` runs the SDE examples, and the `report` job keeps one issue titled "sde: nightly real-data run failed or the export drifted". Test: `tests/tdd/scripts/sde-drift.test.ts` proves the check names an unknown file and a new key over a fixture export, keeps a schema for every registered file, and pins the workflow to the module's download URLs.

---

## Part 6 · Security

Runtime defences live in the pipeline and are proven by `tests/tdd/core/security.test.ts`. Supply-chain defences live in the workflows and are scored weekly. The two are one posture and should be one guide.

### Defence chain on every request

1. **HTTPS enforced, host allowlisted.** `http://` rejected at construction, always. Only `esi.evetech.net` unless `unsafeAllowCustomHost: true` is named explicitly; the flag lifts the host check only.
2. **Path parameters validated.** Rejects `/`, `\`, `?`, `#`, `@` and the other URL delimiters, empty values and non-finite numbers, so `../x` is refused. Everything else, including `%` and null bytes, is percent-encoded by `encodeURIComponent`, not rejected. A bare `..` currently passes and is collapsed by URL parsing (Part 10, row 29).
3. **Query parameters bounded.** Length limits; NaN, Infinity and null rejected.
4. **Token gated by declared scope.** Bearer header attached only when the definition says `requiresAuth`; missing token fails before the network.
5. **Cache isolated per token.** Authenticated cache keys prefix a truncated SHA-256 of the auth header, so two characters never share a cached body.
6. **URLs sanitised in errors and logs.** Eleven sensitive query names redacted; unparsable URLs truncated.
7. **Redirects refused.** Requests go out with `redirect: 'manual'` and a 3xx is rejected naming only the target origin, so a redirect cannot carry a request off the allowlisted host.
8. **Remote counts bounded.** Every pagination path caps `X-Pages` at 1000 pages; with `incompletePages: 'throw'` it refuses a larger count after page 1 (`EsiPaginationError`, `page-cap`).

What the library claims, disclaims and who it trusts is the security model, [`guides/SECURITY-MODEL.md`](SECURITY-MODEL.md), indexed by `guides/security-model.yaml`; SEC-09 to SEC-12 hold the two to each other and to the code.

#### SEC-01 · Unwanted · Enforced

If a consumer supplies a base URL that is not HTTPS, or whose host is not on the allowlist while `unsafeAllowCustomHost` is unset, then the client **shall** refuse construction.

- **Why:** Server-side request forgery through a configurable base URL is the classic SDK hole. `unsafeAllowCustomHost` exists for proxies and mocks, and lifts only the host check; there is no way to turn HTTPS off.
- **Verified by:** Test: `security.test.ts` host allowlist and HTTPS groups; `src/core/util/validation.ts`.

#### SEC-02 · Ubiquitous · Enforced

The library **shall** never write an access token to a log line, an error message or a cache key in clear text.

- **Why:** Tokens end up in bug reports. Redaction and hashing make the report safe to paste.
- **Verified by:** Test: `security.test.ts` token leakage group; `cacheKey.ts`; for log lines, `sanitizeUrl` at the logger boundary (`src/core/logger/redactLog.ts`), covered by `redactLog.test.ts`, `clientLog.test.ts` and `tests/bdd/features/core/0058-logging.feature`.

#### SEC-03 · Ubiquitous · Enforced

Every GitHub Action **shall** be pinned to a full commit SHA with a version comment, inside a workflow that declares top-level read-only permissions with per-job escalation.

- **Why:** Tag pinning is mutable. Scorecard scores both dimensions and zizmor enforces them.
- **Verified by:** Gate: the zizmor job in `ci.yml` (config `.zizmor.yml`); `npm run lint:workflows` (`scripts/quality/workflow-lint.ts`); weekly Scorecard; Dependabot keeps the SHAs current. Test: `tests/tdd/workflows/workflow-permissions.test.ts`, which fails a workflow without read-only top-level permissions and any job-level write scope not on its list.

#### SEC-04 · Ubiquitous · Enforced

Published packages **shall** carry npm provenance, with release assets carrying keyless cosign signatures and SHA-256 checksums minted in an isolated job.

- **Why:** A consumer can verify that the tarball came from this repository's workflow, not from a laptop.
- **Verified by:** Gate: `release.yml`: `npm publish --provenance`, `sign-and-publish-assets` job, which also attests SLSA build provenance for the tarball, SBOM and docs archive (`actions/attest-build-provenance`), checks it with `gh attestation verify` and attaches it as a `.intoto.jsonl` asset. Test: `tests/tdd/workflows/workflow-permissions.test.ts` fails if the job stops signing, attesting or uploading any of them.

#### SEC-05 · Unwanted · Enforced

If a dependency advisory is accepted rather than fixed, then the acceptance **shall** carry a reason and an expiry date, after which the release fails.

- **Why:** Allowlists rot. An expiry forces the conversation again.
- **Verified by:** Gate: `scripts/quality/audit-check.ts` with `scripts/quality/audit-exceptions.json`.

#### SEC-06 · Ubiquitous · Enforced

Each release **shall** publish a CycloneDX or SPDX SBOM as a signed release asset.

- **Why:** Provenance says who built it. An SBOM says what is inside. Scorecard and downstream policy tooling look for both.
- **Verified by:** Gate: `release.yml` `create-assets` runs `npm run release:sbom`, which fails the release unless the CycloneDX SBOM names the package at the tagged version and lists every runtime dependency, and adds it to `checksums.txt`; `sign-and-publish-assets` signs it. Test: `tests/tdd/release-sbom/release-sbom.test.ts` checks the document and runs the generator against the repository.

#### SEC-07 · Ubiquitous · Partial

The repository **shall** carry a `CODEOWNERS` file and branch protection on `master` that applies to administrators.

- **Why:** Both are Scorecard code-review inputs. A solo maintainer can still enforce "admins included" and own every path.
- **Verified by:** Gate: `.github/CODEOWNERS` exists and assigns every path to `@lgriffin`. Review: admin enforcement lives in GitHub settings and cannot be seen from the repository; the steps the maintainer takes, and the API call that verifies them, are in `guides/SECURITY.md` §5 ([#270](https://github.com/lgriffin/ESI.ts/issues/270)). The row moves to Enforced when that call shows administrators included.

#### SEC-08 · Ubiquitous · Enforced

Local credentials **shall** be minted by the PKCE script into a git-ignored `.env`, never appearing in a committed file, fixture or example.

- **Why:** The example file is the only one that belongs in git.
- **Verified by:** Gate: `.gitignore`; `scripts/auth/create-token.ts`. To add: a secret-scanning pre-commit hook, under consideration.

#### SEC-09 · Ubiquitous · Enforced

The security model audit **shall** fail when a property, disclaimed property, non-finding or disposition appears in only one of `guides/SECURITY-MODEL.md` and `guides/security-model.yaml`, or when a property's severity differs between them.

- **Why:** The prose is canonical and the index is what scanners and agents read. Two copies that drift tell a reviewer two different things.
- **Verified by:** Gate: `npm run security:model` in the `security-model` job of `ci.yml`. Test: `tests/tdd/scripts/security-model-audit.test.ts` drives each rule with a fixture.

#### SEC-10 · Unwanted · Enforced

If a claimed security property names no existing test file, has no `@sec-<id>` Rule that ran and passed and no stated reason for having none, or is a gap without an issue and an unexpired date, then the security model audit **shall** fail.

- **Why:** A claim with no test is a hope. A gap with no owner or date never closes, so the count of gaps may only shrink against `master`.
- **Verified by:** Gate: `npm run security:model` with `--results` in the `security-model` job of `ci.yml`. Test: `tests/tdd/scripts/security-model-audit.test.ts`.

#### SEC-11 · Unwanted · Enforced

If a file under `src/` makes a network call or reads a response header that `guides/security-model.yaml` does not list, then the security model audit **shall** fail.

- **Why:** The list of untrusted inputs is only useful while it is complete. A new call site or header read is a new input the model must name before it ships.
- **Verified by:** Test: `tests/tdd/core/untrusted-input-sites.test.ts`. Gate: `npm run security:model`.

#### SEC-12 · Unwanted · Enforced

If a non-finding or disposition cites a section the security model does not have, or a line of `guides/security-triage-log.md` carries a disposition outside the closed set, then the security model audit **shall** fail.

- **Why:** The dispositions are a closed set so every report ends in a classified outcome; a report that fits none is a revision of the model, not an ad hoc call.
- **Verified by:** Gate: `npm run security:model`. Test: `tests/tdd/scripts/security-model-audit.test.ts`.

---

## Part 7 · Documentation

The repository has excellent individual documents and, until this charter, no documentation system: three publication surfaces, four copies of the testing story, five copies of the Beads quick reference, and a TypeDoc configuration that used to delete hand-written files.

> **Resolved in revision 1:** TypeDoc now writes to `docs-site/public/api/` (git-ignored). `npm run clean` and `npm run docs` no longer touch `docs/`.
>
> **State at revision 2:** fourteen guides exist in `guides/`, plus `AUDIT.md` from Phase 0. The five original `docs/` files are gone, but `docs/` itself now holds `knowledge-graph/`, `spikes/generator/` and `esi-evolution/`. Root `TESTING.md` and `guides/MUTATION-TESTING.md` are still separate. The docs-site is still unbuilt and still says v9.6.1, and the README banner still says v9.5.2. The full README, docs and examples rewrite is scheduled last in the 11.0 plan, so it documents the finished API.

### Surfaces at the time of the survey

| Surface                            | What it is                                          | Published?                  | Problem                                                                              |
| ---------------------------------- | --------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------ |
| `README.md` (1037 lines)           | The de facto manual                                 | npm + GitHub                | Banner says v9.5.2; restates six guides; 39-row client table hand-maintained         |
| `guides/`                          | Maintainer guides, best depth                       | GitHub only                 | Orphaned: nothing links to four of them; two say "37 clients"                        |
| `docs/` (5 files)                  | Examples catalogue, OKF, drift, two strategy papers | GitHub only                 | Was the TypeDoc output dir; nothing links to any of them                             |
| `docs-site/` (VitePress, 21 pages) | A hand-written fork of the README                   | **Never built or deployed** | Version dropdown says 9.6.1; two commits ever; duplicates everything                 |
| TypeDoc on gh-pages                | API reference from JSDoc                            | gh-pages on release         | The only published site, and the least readable one                                  |
| `guides/SDE.md` + `guides/sde/`    | Complete SDE doc set                                | Site (`docs:sync`)          | Resolved 2026-09-28 (Track S Run 11): moved out of `src/`, published with the guides |
| `tests/bdd/README.md` + `GUIDE.md` | EARS specification rules                            | GitHub only                 | Newest and best docs in the repo; not referenced from the testing guide              |
| Root `TESTING.md`                  | Older copy of `guides/TESTING.md`                   | GitHub only                 | Different tier numbering from the guide; unlinked                                    |

### Target shape

One canonical tree, one published site built from it, counts generated rather than typed. The README shrinks to an orientation page and links out.

```
README.md                       pitch · install · quick start · links only
CONTRIBUTING.md · SECURITY.md   SECURITY.md becomes the short policy, GitHub-surfaced
guides/                         canonical, and the only source the site builds from
├── CHARTER.md                  this document
├── ARCHITECTURE.md             absorbs README caching/rate-limit/streaming prose, interceptors, circuit breaker
├── DESIGN-RULES.md             NEW: conventions + "add an endpoint" + "add a client" walkthroughs
├── TESTING.md                  merges root TESTING.md + MUTATION-TESTING.md; canonical tier table
├── SPECIFICATION.md            moved from tests/bdd/GUIDE.md; README.md there becomes a pointer
├── QUALITY-GATES.md            NEW: gate matrix, every workflow, every script, nightly-spec-drift.md folded in
├── SECURITY.md                 controls + supply chain; policy lines removed (they live in root)
├── ERRORS.md                   NEW: taxonomy, guards, retryability, safe mode
├── LOGGING.md                  NEW: ILogger, per-client loggers, pino adapter, levels
├── PAGINATION.md               NEW: offset, cursor, stream*, fetchAll*, batch, concurrency defaults
├── RUNTIME-VALIDATION.md       keep
├── SDE.md                      index; guides/sde/* hold the module reference, usage, architecture, developer guide and contracts
├── OKF.md                      moved from docs/okf-guide.md
├── RELEASE.md                  NEW: release-please, changelog, provenance, support window
├── SEMVER.md                   NEW: what the public contract is, major/minor/patch decisions, commit markers, merge buttons
├── DOCUMENTATION.md            rewritten: surfaces, site build, TypeDoc, metrics generation
├── BEADS.md                    keep; AGENTS.md and CLAUDE.md shrink to pointers
└── rfcs/                       future design papers (the jitaspace mapping and streaming-websocket strategy were retired as dated)
docs-site/                      VitePress; guide/ and examples/ generated by scripts/docs/sync-docs.ts from guides/ and examples/; public/api = TypeDoc
docs/                           hand-written guides retired (okf-guide → guides/OKF.md, nightly-spec-drift → QUALITY-GATES); now holds only spikes and generated artefacts
TESTING.md (root)               deleted after merge
etc/doc-metrics.json            generated counts (npm run docs:metrics): clients, endpoints, rules, scenarios, test files
```

### Guide roadmap

| Target guide                                            | Action | Status (rev 2)                                                                                   | Sources to fold in                                                                                               | Charter parts | Tracking                                                          |
| ------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------- | ----------------------------------------------------------------- |
| CHARTER.md                                              | new    | Done                                                                                             | This document                                                                                                    | all           | —                                                                 |
| ARCHITECTURE.md                                         | merge  | Done                                                                                             | README caching, rate limiting, streaming, error handling sections; docs-site interceptors, circuit-breaker pages | 2             | `esi-07y` · [#286](https://github.com/lgriffin/ESI.ts/issues/286) |
| DESIGN-RULES.md                                         | new    | Done                                                                                             | CLAUDE.md key patterns; `generate:endpoints` scaffold usage; naming table above                                  | 3             | `esi-lvi` · [#279](https://github.com/lgriffin/ESI.ts/issues/279) |
| TESTING.md                                              | merge  | Root copy folded in 2026-09-27, MUTATION-TESTING.md 2026-09-28; both deleted                     | Root TESTING.md, MUTATION-TESTING.md, README testing section, ARCHITECTURE §8                                    | 4             | `esi-b4a` · [#273](https://github.com/lgriffin/ESI.ts/issues/273) |
| SPECIFICATION.md                                        | move   | Open                                                                                             | tests/bdd/GUIDE.md + README.md; ears-gherkin-dev skill steps                                                     | 4             | —                                                                 |
| QUALITY-GATES.md                                        | new    | Done                                                                                             | .github/workflows/README.md, docs/nightly-spec-drift.md, scripts table                                           | 5             | `esi-30o` · [#280](https://github.com/lgriffin/ESI.ts/issues/280) |
| SECURITY.md (guide)                                     | merge  | Done; CODEOWNERS text stale                                                                      | Remove policy duplication; add SBOM and CODEOWNERS plan                                                          | 6             | `esi-p0s` · [#285](https://github.com/lgriffin/ESI.ts/issues/285) |
| ERRORS.md                                               | new    | Done                                                                                             | docs-site reference/errors.md; `src/core/util/error.ts`                                                          | 2             | `esi-rt2` · [#281](https://github.com/lgriffin/ESI.ts/issues/281) |
| LOGGING.md                                              | new    | Done                                                                                             | Write from the per-client logging refactor                                                                       | 2, 3          | `esi-5zs` · [#282](https://github.com/lgriffin/ESI.ts/issues/282) |
| PAGINATION.md                                           | new    | Done                                                                                             | README streaming and cursor sections; docs-site pagination.md; 9.7.0 changelog for `fetchAll*`                   | 2             | `esi-358` · [#283](https://github.com/lgriffin/ESI.ts/issues/283) |
| RUNTIME-VALIDATION.md                                   | keep   | Kept; no `Implements:` line yet                                                                  | Delete README and docs-site copies, link instead                                                                 | 3             | —                                                                 |
| SDE.md + guides/sde/                                    | move   | Done 2026-09-28 (Track S Run 11)                                                                 | src/sde/README.md, src/sde/docs/\*, docs-site guide/sde.md                                                       | 2             | —                                                                 |
| OKF.md                                                  | move   | Done                                                                                             | docs/okf-guide.md; link from README and DESIGN-RULES                                                             | 3             | —                                                                 |
| RELEASE.md                                              | new    | Done                                                                                             | release-please config, CHANGELOG conventions, release.yml jobs, SECURITY.md support table                        | 8             | `esi-38g` · [#284](https://github.com/lgriffin/ESI.ts/issues/284) |
| DOCUMENTATION.md                                        | merge  | Site build documented 2026-09-27; metrics generation open                                        | Rewrite around the target tree; document the site build and metrics                                              | 7             | —                                                                 |
| BEADS.md                                                | keep   | Open: AGENTS.md still has three Beads blocks                                                     | Collapse AGENTS.md's three blocks and CLAUDE.md's copy to pointers                                               | 9             | [#276](https://github.com/lgriffin/ESI.ts/issues/276)             |
| Root TESTING.md, docs/examples.md, docs-site duplicates | retire | Done 2026-09-27: root TESTING.md retired; docs-site pages generated, hand-written copies removed | Examples catalogue becomes docs-site/examples generated from `examples/*.ts` headers                             | 7             | [#264](https://github.com/lgriffin/ESI.ts/issues/264)             |

#### DOC-01 · Ubiquitous · Enforced

Each documentation topic **shall** have exactly one canonical file under `guides/`, with every other mention linking to it rather than restating it.

- **Why:** Four copies of the testing story drifted to two different tier numberings within a month.
- **Verified by:** Test: `tests/tdd/scripts/sync-docs.test.ts` ("one canonical file per topic"): the retired copies of a folded guide (root `TESTING.md`, `guides/MUTATION-TESTING.md`) stay deleted, the test tier table is stated in `guides/TESTING.md` only, and every link from README.md or a guide to another guide lands on a file and a heading that exist. Gate: VitePress's dead-link check fails the site build on a missing page. The roadmap table above is the migration plan for the topics still to merge.

#### DOC-02 · Ubiquitous · Enforced

Generated API reference output **shall** be written to a git-ignored directory outside `docs/`, with no npm script deleting a directory containing committed markdown.

- **Why:** The previous configuration wiped five committed files on every `npm run docs`.
- **Verified by:** Gate: `typedoc.json` `out: docs-site/public/api`; `clean` and `clean:docs` scripts updated; `.gitignore` entry.

#### DOC-03 · Ubiquitous · Enforced

The published documentation site **shall** be built from `guides/` by a script in the repository and deployed by the release workflow alongside the API reference.

- **Why:** A site nobody can reach is a maintenance cost with no reader. A site built from the canonical files cannot drift.
- **Verified by:** Gate: `npm run docs:site` builds TypeDoc, then `scripts/docs/sync-docs.ts` generates the git-ignored guide pages from README.md and `guides/` and the example pages from `examples/` (logic in `scripts/docs/sync-docs-core.ts` and `scripts/docs/examples-showcase-core.ts`, tested by `tests/tdd/scripts/sync-docs.test.ts`, which also fails on a guide link to a repository file that does not exist), then VitePress, whose dead-link check fails the build. `ci.yml`'s `documentation` job builds it on every pull request; `release.yml`'s `deploy-docs` calls `.github/workflows/docs-site.yml`, which publishes it to gh-pages with TypeDoc under `/api/` and can be dispatched between releases.

#### DOC-04 · Ubiquitous · Enforced

Counts quoted in documentation (clients, endpoints, requirements, scenarios, test files, coverage) **shall** be generated into `etc/doc-metrics.json` and inserted by a script, never typed by hand.

- **Why:** At the time of the survey the docs carried 33, 35, 36, 37 and 39 as the number of clients. Only one is right.
- **Verified by:** Gate: `npm run docs:metrics` (`scripts/docs/doc-metrics.ts`, counting in `scripts/docs/doc-metrics-core.ts`, tested by `tests/tdd/scripts/doc-metrics.test.ts`) computes the counts from the source with no test run and no network: domain clients, endpoint maps and routes, generated operations, the compatibility date, `stream*` methods, schema and type modules, examples, feature files, `Rule:` requirements, scenarios, test files per tier, the charter's statuses and the package version. It writes them to `etc/doc-metrics.json` and rewrites every `<!-- metric:name -->` marker in README.md, `guides/*.md` and `docs-site/index.md`. `npm run validate:versions`, in `ci.yml` `static-analysis` and `release.yml` `validate-release`, fails on a stale marked count, a stale README release line, a stale JSON file, an unknown metric or a malformed marker; release-please bumps the release line and the JSON's `version` through `extra-files`. Test counts that only a Jest run measures, and coverage, stay unmarked prose with the date they were measured.

#### DOC-05 · Ubiquitous · Partial

Every guide **shall** open with the charter requirement identifiers it implements and be reachable from the README within one link.

- **Why:** Traceability both ways: from a rule to how it is met, and from a reader's landing page to the depth. At revision 2, ten of seventeen guides carried an `Implements:` line (TESTING, BEADS, DOCUMENTATION, RUNTIME-VALIDATION, AUDIT, MUTATION-TESTING and CHARTER did not; MUTATION-TESTING has since folded into TESTING), and every guide except `AUDIT.md` is linked from the README.
- **Verified by:** To add: link-check script asserting each `guides/*.md` is linked from README and carries an `Implements:` line.

#### DOC-06 · Event-driven · Gap

When a public export is added, the pull request **shall** include its JSDoc, its guide section and, where user-facing, an example under `examples/`.

- **Why:** `fetchAll*` (73 methods), `InMemoryFetch` and `createNoopLogger` exist only in the changelog.
- **Verified by:** To add: a PR template checklist (the repository has none). Gate: today the API surface diff (`etc/esi.ts.api.md`) makes the addition visible to the reviewer, and `test:export-coverage --ci` (`scripts/package/export-coverage-baseline.json`) fails a PR that adds an export no test references. Neither checks for JSDoc, a guide section or an example.

---

## Part 8 · Release

Releases are cut by release-please from conventional commits, validated by the full suite, published to two registries with provenance, and signed.

#### REL-01 · Ubiquitous · Enforced

Every commit on `master` **shall** follow the conventional-commit format so that release-please can derive the version bump and changelog section.

- **Why:** `feat` bumps minor, `fix` bumps patch, `!` bumps major. The changelog writes itself only if the commits are honest.
- **Verified by:** Gate: commitlint on the commit-msg hook; `release-please.yml`.

#### REL-02 · Ubiquitous · Enforced

A release **shall** publish only after lint, format, audit allowlist, changelog presence, build, schema drift, generated-type freshness and the full test suite pass on the tag.

- **Why:** The tag is the last place to stop a bad build.
- **Verified by:** Gate: `release.yml` `validate-release` job, which also runs `validate:versions`, `spec:generate:check`, `validate:auth-scopes`, `validate:esi`, `spec:audit`, `validate:spec-consistency`, `contract:replay`, the fault catalogue and the live contract tests.

#### REL-03 · Ubiquitous · Enforced

The version string **shall** be identical in `package.json`, `src/core/constants.ts` and the docs-site version selector.

- **Why:** The site selector had lagged the package by a major (v9.6.1 against 10.2.3) because nothing checked it and release-please did not bump it. The README's release line had lagged the same way (v9.5.2).
- **Verified by:** Gate: `scripts/package/validate-versions.ts` (`npm run validate:versions`) in `ci.yml` `static-analysis` and `release.yml` `validate-release`; release-please bumps `src/core/constants.ts` through `extra-files`, and `docs-site/.vitepress/config.ts` reads the selector from `package.json` at build time, which the script checks it still does. `npm run validate:versions -- --site` checks the built page before `docs-site.yml` deploys it and in `ci.yml`'s `documentation` job. The README's release line carries the version in a `metric:version` marker, which release-please bumps through `extra-files` and the same script checks (DOC-04).

#### REL-04 · Unwanted · Partial

If a version is bumped but not published, then the changelog **shall** record it as unreleased rather than skipping the number.

- **Why:** The changelog jumped from 9.1.0 to 9.7.0, so consumers could not tell whether 9.2 to 9.6 existed. Every number from 7.4.0 through 9.9.0 now has an entry ([#275](https://github.com/lgriffin/ESI.ts/issues/275)); numbers before 7.4.0 and the 10.x releases that never reached npm are not yet marked.
- **Verified by:** Review: `guides/RELEASE.md` ("Bumped but not published") is the procedure. Test: `tests/tdd/scripts/changelog-unpublished.test.ts` keeps the twelve 9.x numbers already marked `(not published)`. Nothing checks the changelog against the registry, so the 10.x numbers that never reached npm are still unmarked.

#### REL-05 · Ubiquitous · Enforced

From 11.0.0 the package **shall** support Node 22 or newer, tested on every supported Node line before merge.

- **Why:** Node 18 and 20 have reached end of life (20 in April 2026). Raising the floor is a major version and needs a `feat!:` commit with a `BREAKING CHANGE:` footer, not a silent engines bump; decided for 11.0.0 on 2026-09-26.
- **Verified by:** Gate: `engines` says `>=22.12.0` and `.nvmrc` 22; the `unit-tests` job in `ci.yml` runs on Node 22 and 24, and `consumer-contract` in `ci.yml` and `release.yml` runs the packed tarball on Node 22.12 (the floor, pinned), the latest 22 and 24, both inside the gates that block a merge and a publish.

#### REL-06 · Unwanted · Partial

If a change can make code that works against the previous release fail to compile, throw, or return a different result, then the commit introducing that change **shall** be marked breaking (`type!:` and a `BREAKING CHANGE:` footer with the migration), with anything the change removes deprecated in an earlier minor release unless ESI has already removed it.

- **Why:** `^9.x` in a consumer's manifest installs every minor and patch automatically. A break released as a minor breaks those installs silently.
- **Verified by:** Review: `guides/SEMVER.md` classification and the Reviewer Checklist. Gate: the `api-semver` job in `ci.yml` (`npm run api-report:semver`) for the root entry point's type surface. Runtime behaviour, schema tightening, sub-path exports and required members on implemented interfaces are review-only.

---

## Part 9 · Process

Agents are contributors here and are bound by the same rules as people, with less authority by default.

#### PROC-01 · Ubiquitous · Practised

All task tracking **shall** live in the Beads tracker (`bd`), with `.beads/issues.jsonl` treated as a passive export and never as the source of truth.

- **Why:** One tracker, shared by people and agents. Markdown TODO lists are forbidden by AGENTS.md.
- **Verified by:** Test: `tests/tdd/scripts/agent-process.test.ts` (the `PROC-01` block): `CLAUDE.md` and `AGENTS.md` name `bd` as the tracker and rule out markdown TODO lists, and `guides/BEADS.md` treats `.beads/issues.jsonl` as an exported snapshot. It checks the instruction, not that every task is in Beads.

#### PROC-02 · State-driven · Practised

While an agent operates under the default conservative profile, the agent **shall not** commit, push or sync the tracker without an explicit instruction.

- **Why:** Autonomy is earned per session. The handoff reports changed files and proposed commands instead.
- **Verified by:** Test: `tests/tdd/scripts/agent-process.test.ts` (the `PROC-02` block): `guides/BEADS.md` states the conservative policy as the default, and both agent files point at it. It checks the instruction, not what an agent does.

#### PROC-03 · Ubiquitous · Enforced

`master` **shall** accept changes only through a pull request that is up to date with the base and passes the `ci-success` check.

- **Why:** Recorded in bead `esi-8we`. Force-push is disabled.
- **Verified by:** Gate: GitHub `branch protection` on `master`, a repository setting, which requires `ci-success` (it gates every other job, Lint & Build included). The `branch-protection` job in `maintenance.yml` reads the setting through the API each week (`scripts/quality/branch-protection.ts`, tested in `tests/tdd/scripts/branch-protection.test.ts`) and fails when `master` is unprotected or `ci-success` is no longer required. The pull-request requirement, strict status checks and the force-push block need an admin token to read; the job's token cannot, so it prints them as unchecked warnings (run with an admin token, it fails on them too). Administrators are exempt from the required checks, which SEC-07 tracks; the job prints that as a warning as well.

#### PROC-04 · Ubiquitous · Partial

A branch **shall** carry one concern, named by its conventional-commit type and scope.

- **Why:** Reviewers cannot review two things at once.
- **Verified by:** Gate: `commitlint.config.cjs`, run by the `.husky/commit-msg` hook, holds each commit to a conventional type. Review: nothing checks the branch name or that a branch carries one concern; that is code review, and there is no PR template yet (see DOC-06).

#### PROC-05 · Ubiquitous · Partial

Agent instruction files (`AGENTS.md`, `CLAUDE.md`) **shall** contain pointers to guides, not copies of them.

- **Why:** Pointers cannot drift. The Beads quick reference once appeared three times in `AGENTS.md` and once in `CLAUDE.md`; both now point at `guides/BEADS.md` and `bd prime`. The persona files are gone from the repository root.
- **Verified by:** Test: `tests/tdd/scripts/agent-process.test.ts` (the `PROC-05` block) holds the Beads section of each agent file to one pointer of at most twelve lines with no command reference. Other sections are not yet held to a ceiling.

#### PROC-06 · Ubiquitous · Enforced

Every requirement block in this charter **shall** satisfy the rules `spec:audit` applies to a `Rule:` block (one _shall_, a named system, one of the five patterns, no vague language) and, when its status is Enforced, name the script or job that proves it.

- **Why:** The charter claims to be auditable the way the specification is. Before the audit it was EARS by convention only: a row could say Enforced with a "Verified by" that named nothing, and nobody was told.
- **Verified by:** Gate: `npm run charter:audit` (`scripts/quality/charter-audit.ts`; the checks in `scripts/quality/charter-audit-core.ts`, tested by `tests/tdd/scripts/charter-audit.test.ts`) parses every `####` block with the spec-audit rules and fails an Enforced row that names no script, job or file that exists or has no `Test:` or `Gate:` clause, a "Verified by" that opens without a label, and a cited test file whose `describe`, `it` and `test` titles do not name the row. Runs in `check:all`, `scripts/quality/verify-local-core.ts` and `ci.yml`'s `spec-audit` job.

---

## Part 10 · Gap register

Everything found during the survey, and since, that contradicts a requirement above. Rows marked **Done** stay for traceability until the next revision. Each row is tracked as a bead under epic `esi-l38` and mirrored as a GitHub issue under [#287](https://github.com/lgriffin/ESI.ts/issues/287). The guide roadmap in Part 7 is tracked the same way. Severity is about consumer impact, not effort.

| #   | Sev  | Finding                                                                                                                         | Requirement               | Fix                                                                                                                                                                               | Bead         | Issue                                                                                                        |
| --- | ---- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------ |
| 1   | HIGH | Branch failed `tsc --noEmit` from the logging refactor (two import paths, one stale call)                                       | ARCH-09, GATE-02          | **Done in revision 1**: paths fixed, `parseJsonBody` receives the client, pagination logging migrated, `logFatal`/`logTrace` exported, `toPinoLogger` keeps pino's `this` binding | `esi-d3o`    | [#262](https://github.com/lgriffin/ESI.ts/issues/262)                                                        |
| 2   | HIGH | `npm run docs` / `clean` deleted five committed files in `docs/`                                                                | DOC-02                    | **Done**: TypeDoc moved in revision 1; the five markdown files moved, folded or retired                                                                                           | `esi-a2k`    | [#263](https://github.com/lgriffin/ESI.ts/issues/263)                                                        |
| 3   | HIGH | docs-site never built or deployed; duplicates README at two-version lag                                                         | DOC-03                    | **Done** 2026-09-27: `npm run docs:site` generates the site from `guides/` and `examples/`; `docs-site.yml` deploys it from the release workflow; hand-written copies removed     | `esi-06b`    | [#264](https://github.com/lgriffin/ESI.ts/issues/264)                                                        |
| 4   | MED  | `logFatal`/`logTrace` were not exported; global-logger fallback needs a lint gate                                               | ARCH-09                   | **Done**: exports in revision 1; `no-restricted-imports` gate in `npm run lint` and `lint:layers`                                                                                 | `esi-772`    | [#265](https://github.com/lgriffin/ESI.ts/issues/265)                                                        |
| 5   | MED  | `isCircuitOpen` missing from `./errors`; plumbing errors are string-typed                                                       | ARCH-07                   | Export guard; introduce `EsiConfigurationError` family                                                                                                                            | `esi-gyh`    | [#266](https://github.com/lgriffin/ESI.ts/issues/266)                                                        |
| 6   | MED  | `CustomEsiClient` missing four getters                                                                                          | ARCH-08                   | **Done**: getters added, compile-time check in `customClientGetters.test.ts`; `EsiApiFactory` reaches all 39 through `createClient(type)`, with named methods for 9               | `esi-eqq`    | [#267](https://github.com/lgriffin/ESI.ts/issues/267)                                                        |
| 7   | MED  | No `sideEffects: false`; two pino instances built at import                                                                     | ARCH-06                   | **Done**: default logger built on first use; `"sideEffects": false`; `importSideEffects.test.ts` spies on pino and bundles `import { EsiError }`                                  | `esi-piw`    | [#268](https://github.com/lgriffin/ESI.ts/issues/268)                                                        |
| 8   | MED  | `handleSinglePageRequest` hardcodes GET; cursor `fetchAll` swallows failures                                                    | DES-08                    | **Done**: rules in `0051-resilience.feature`; the stream helpers pass the real method and the cursor `fetchAll` rejects                                                           | `esi-dwi`    | [#269](https://github.com/lgriffin/ESI.ts/issues/269)                                                        |
| 9   | MED  | No SBOM; no CODEOWNERS; admins exempt from protection                                                                           | SEC-06, SEC-07            | SBOM **done** (SEC-06); CODEOWNERS **done**; admins-included is a maintainer setting, steps in SECURITY.md §5                                                                     | `esi-wze`    | [#270](https://github.com/lgriffin/ESI.ts/issues/270)                                                        |
| 10  | MED  | Tests not linted; knip non-blocking; mutation never gates                                                                       | TEST-09, GATE-04, TEST-07 | Incremental Stryker on PR **done** (TEST-07); suite-health and seam lint added; `npm run lint` covers `tests/` **done** (TEST-09); knip blocks at release **done** (GATE-04)      | `esi-p56`    | [#271](https://github.com/lgriffin/ESI.ts/issues/271)                                                        |
| 11  | MED  | Client count quoted as 33, 35, 36, 37 and 39; BDD counted as 41 features; README banner 9.5.2                                   | DOC-04, REL-03            | **Done** 2026-09-28: `npm run docs:metrics` generates `etc/doc-metrics.json` and rewrites the marked counts; `validate:versions` fails on a stale count or README release line    | `esi-j03`    | [#272](https://github.com/lgriffin/ESI.ts/issues/272)                                                        |
| 12  | MED  | Two testing guides with different tier numbers; root `TESTING.md` unlinked (root folded into guides/TESTING.md 2026-09-27)      | DOC-01                    | **Done** 2026-09-28: one testing guide; MUTATION-TESTING.md folded in; links checked by `sync-docs.test.ts`                                                                       | `esi-b4a`    | [#273](https://github.com/lgriffin/ESI.ts/issues/273)                                                        |
| 13  | LOW  | `sde:seed` points at a missing script                                                                                           | GATE-06                   | **Done**: removed; script targets checked by `package-scripts.test.ts`                                                                                                            | `esi-x3z`    | [#274](https://github.com/lgriffin/ESI.ts/issues/274)                                                        |
| 14  | LOW  | Changelog skips 9.2 to 9.6                                                                                                      | REL-04                    | **Done** 2026-09-27: 8.0.0, 9.4.0 and 9.6.0 backfilled from their tags; every unpublished number from 9.1.1 to 9.9.0 headed `(not published)`                                     | `esi-5fu`    | [#275](https://github.com/lgriffin/ESI.ts/issues/275)                                                        |
| 15  | LOW  | Beads quick reference duplicated five times; persona files untracked in the repo root                                           | PROC-05, PROC-04          | Persona files **gone**; pointers still to do                                                                                                                                      | `esi-udr`    | [#276](https://github.com/lgriffin/ESI.ts/issues/276)                                                        |
| 16  | LOW  | Nightly mutation and Schemathesis upload artifacts but file no issue                                                            | GATE-05                   | **Done**: each ends in a `report` job that opens, comments on or closes one fixed-title issue (`mutation`, `api-fuzz`)                                                            | `esi-mbr`    | [#277](https://github.com/lgriffin/ESI.ts/issues/277)                                                        |
| 17  | LOW  | `CONTRIBUTING.md` says Node 18 while `.nvmrc` says 20; populated `.env` in working trees                                        | SEC-08, REL-05            | Node 22 floor **done** (CONTRIBUTING, `.nvmrc`, `engines`); `.env` housekeeping still to do                                                                                       | `esi-wc6`    | [#278](https://github.com/lgriffin/ESI.ts/issues/278)                                                        |
| 18  | HIGH | Authenticated paginated results cached under an unhashed key; repeat calls returned page 1                                      | DES-08, SEC-02            | **Done**: the combined body is cached with `requiresAuth`, so it lands under the token-hashed key                                                                                 | `esi-l38.1`  | [#290](https://github.com/lgriffin/ESI.ts/issues/290)                                                        |
| 19  | HIGH | Default all-pages call silently returned page 1 when a later page kept failing                                                  | DES-08                    | **Done**: surfaces `PAGINATION_INCOMPLETE` ([#413](https://github.com/lgriffin/ESI.ts/pull/413))                                                                                  | `esi-l38.2`  | [#291](https://github.com/lgriffin/ESI.ts/issues/291)                                                        |
| 20  | MED  | `stream*`/`fetchAll*` threw `EsiError` 304 after a normal call to the same endpoint                                             | DES-08                    | **Done**: the cached body is served on a 304                                                                                                                                      | `esi-l38.3`  | [#292](https://github.com/lgriffin/ESI.ts/issues/292)                                                        |
| 21  | MED  | Mutations returning 201/204 did not invalidate the cache path prefix                                                            | Part 2 step 7             | **Done**                                                                                                                                                                          | `esi-l38.4`  | [#293](https://github.com/lgriffin/ESI.ts/issues/293)                                                        |
| 22  | HIGH | Release pipeline broken: release-please GraphQL failure, cosign `--bundle`, token trigger, version marker                       | REL-02, SEC-04            | **Done**: cosign 3 bundle, `x-release-please-version` marker; the remaining trigger problems are row 30                                                                           | `esi-l38.5`  | [#294](https://github.com/lgriffin/ESI.ts/issues/294)                                                        |
| 23  | MED  | Validation errors retryable, network faults untyped, safe mode and token refresh collapse types                                 | ARCH-07                   | **Done**: typed classes ([#470](https://github.com/lgriffin/ESI.ts/pull/470))                                                                                                     | `esi-l38.6`  | [#295](https://github.com/lgriffin/ESI.ts/issues/295)                                                        |
| 24  | MED  | Log lines carry unsanitised URLs; several call sites bypass the per-client logger                                               | SEC-02, ARCH-09           | **Done**: `sanitizeUrl` at the logger boundary; rate limiter, cache, batch and token manager migrated                                                                             | `esi-l38.7`  | [#296](https://github.com/lgriffin/ESI.ts/issues/296)                                                        |
| 25  | MED  | CI does not match the gate matrix: release gate, never-run validators, dead docs job, freshness diff                            | GATE-01..06, REL-02       | **Done**: release gate runs the charter's validators; `validate:esi` and `validate:spec` wired; freshness diff covers every `esi-*.generated.ts`                                  | `esi-l38.8`  | [#297](https://github.com/lgriffin/ESI.ts/issues/297)                                                        |
| 26  | MED  | Endpoints returning a body lack `responseSchema`                                                                                | DES-02                    | **Done**: `spec:response-schemas` ([#471](https://github.com/lgriffin/ESI.ts/pull/471))                                                                                           | `esi-l38.9`  | [#298](https://github.com/lgriffin/ESI.ts/issues/298)                                                        |
| 27  | LOW  | Charter revision 1 carried factual errors found while writing the guides                                                        | DOC-01                    | **Done in revision 2**                                                                                                                                                            | `esi-l38.10` | [#299](https://github.com/lgriffin/ESI.ts/issues/299)                                                        |
| 28  | LOW  | Minor defects: `schema:drift` pairing, half-open extra probe, unused `clientId`, TEST-03 spy                                    | several                   | **Done**                                                                                                                                                                          | `esi-l38.11` | [#300](https://github.com/lgriffin/ESI.ts/issues/300)                                                        |
| 29  | MED  | A path parameter of exactly `..` passes validation and URL parsing collapses the segment (`characters/../assets/` → `/assets/`) | Part 6 step 2, SEC-01     | Reject `.` and `..` as path parameters; add the case to `security.test.ts`                                                                                                        | —            | [#430](https://github.com/lgriffin/ESI.ts/pull/430)                                                          |
| 30  | HIGH | Releases need a manual dispatch of `release.yml`, and CI on the release-please PR waits for manual approval                     | REL-02                    | A GitHub App token for release-please; waiting on the maintainer to create it                                                                                                     | —            | [#378](https://github.com/lgriffin/ESI.ts/issues/378), [#383](https://github.com/lgriffin/ESI.ts/issues/383) |
| 31  | MED  | No check that every public client method or provider method has a Rule                                                          | TEST-10                   | **Done** 2026-09-28: `spec:coverage:sde:ci` (Track S Run 4, baseline empty since Run 6) and `spec:coverage:clients:ci` (Phase 5 item 8), each with a shrink-only baseline         | —            | —                                                                                                            |
| 32  | LOW  | Exclusions (what the client ignores) stated in prose, not as executable unwanted-behaviour Rules; one such Rule exists          | TEST-11                   | **Done**: every stated exclusion is a Rule, and `ears` lists the 29 `shall not` Rules with their verdicts (2026-09-28)                                                            | —            | —                                                                                                            |
| 33  | LOW  | The charter is EARS by convention; no audit parses its requirement blocks or checks Enforced rows name a mechanism              | PROC-06                   | **Done**: `npm run charter:audit` (Phase 5 item 7)                                                                                                                                | —            | —                                                                                                            |
| 34  | MED  | Two SDE features (errors, ingestion) verified no charter requirement; they sat on a shrink-only exception list                  | ARCH-11..13               | **Done** 2026-10-12: ARCH-11, ARCH-12 and ARCH-13 added; both features tagged; `unchartedFeatures` empty                                                                          | —            | [#640](https://github.com/lgriffin/ESI.ts/issues/640)                                                        |
| 35  | HIGH | 12.1.0 deprecated every custom-client surface for removal in 13.0.0; no requirement said the custom clients are the product     | ARCH-14, ARCH-08          | **Done** 2026-10-17: posture 1 and ARCH-14 added; 12.2.0 lifts the 12.1.0 deprecations; `custom-client-identity.test.ts` gates a new one                                          | —            | [#664](https://github.com/lgriffin/ESI.ts/issues/664)                                                        |

---

## Part 11 · Adoption

The order matters: nothing in the documentation work is safe until step 2 is done, and nothing is trustworthy until step 1 is done.

1. **Make the branch compile.** ✅ Revision 1.
2. **Move TypeDoc output** to `docs-site/public/api` and update `clean`. ✅ Revision 1. The five hand-written `docs/` files are moved, folded or retired; `docs/` now holds only spikes and generated artefacts.
3. **Commit this charter** as `guides/CHARTER.md` and file one bead per row of the gap register, tagged with the requirement ID. ✅ Revision 1.
4. **Merge the duplicates** in the roadmap order: TESTING first (it has the tier conflict), then SECURITY, then ARCHITECTURE. Delete the root copies as each merge lands. ✅ SECURITY, ARCHITECTURE and TESTING merged (root `TESTING.md` 2026-09-27, `guides/MUTATION-TESTING.md` 2026-09-28, [#273](https://github.com/lgriffin/ESI.ts/issues/273)).
5. **Write the new guides**: DESIGN-RULES, QUALITY-GATES, ERRORS, LOGGING, PAGINATION, RELEASE. Each opens with `Implements: ARCH-03, DES-01 …`. ✅ All six exist with `Implements:` lines.
6. **Generate the numbers.** `scripts/docs/doc-metrics.ts` writes `etc/doc-metrics.json`; a small template step stamps the README and site. Extend `validate:versions` to fail on stale banners. ✅ 2026-09-28: `npm run docs:metrics` rewrites the marked counts and `validate:versions` fails on a stale one ([#272](https://github.com/lgriffin/ESI.ts/issues/272)).
7. **Publish the site.** `scripts/docs/sync-docs.ts` copies `guides/` into `docs-site/guide/`; release workflow builds VitePress and deploys it with the API reference under `/api/`. Shrink the README to an orientation page. Open ([#264](https://github.com/lgriffin/ESI.ts/issues/264)); lands with the 11.0 docs rewrite.
8. **Close the security gaps**: CODEOWNERS, SBOM asset, admins in branch protection. Re-run Scorecard and record the new score in the charter's next revision. ◐ SBOM and CODEOWNERS done; admins-included and the new score still to record ([#239](https://github.com/lgriffin/ESI.ts/issues/239)).

> **Revision rule.** This charter is revised by pull request like any other file. A requirement's status may only move toward Enforced by citing the script or job that proves it. Moving it the other way needs a bead explaining why.
