Skip to content

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 clients39
ESI endpoints wired235
Generated operations (src/generated/operations.generated.ts)235
EARS requirements in the specification664 (77 feature files)
Gherkin scenarios863
Test files matched by the nine Jest configurations and tsd369
Statement coverage97.8% (npm run coverage on master 114a485a, 2026-09-28; floor 90%)
OpenSSF Scorecard8.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. 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.
  • 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). 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). 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). 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, 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 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). 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). 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). 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). 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). 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).

  • 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).

  • 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). SEC-02 gains log lines to its evidence: URLs are redacted with sanitizeUrl at the logger boundary (#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).

  • 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). 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). 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). 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). 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, #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, #246) are settings only the maintainer can change, listed in guides/SECURITY.md §5.

  • 2026-09-27, knip blocks the release (#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). 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). 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). 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; 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. 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 ​

PrefixGovernsDerived guide
ARCHLayering, request pipeline, module boundaries, public surfaceguides/ARCHITECTURE.md
DESNaming, schema conventions, endpoint definitions, extension seamsguides/DESIGN-RULES.md (new)
TESTTest tiers, specification discipline, coverage and mutation floorsguides/TESTING.md + tests/bdd/GUIDE.md
GATEWhat runs at commit, push, PR, nightly and releaseguides/QUALITY-GATES.md (new)
SECRuntime defences, secrets, supply chainSECURITY.md + guides/SECURITY.md
DOCWhere documentation lives, what is canonical, how it is publishedguides/DOCUMENTATION.md
RELVersioning, changelog, publishing, support windowguides/RELEASE.md (new)
PROCIssue tracking, agent conduct, branch rulesguides/BEADS.md + CONTRIBUTING.md

Status legend ​

StatusMeaning
EnforcedA script or CI job fails on violation
PractisedTrue in the code, but not machine-checked
PartialHolds in some places
GapStated intent, not yet true

Evidence ​

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

LabelThe 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 39 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, decided 2026-10-17 in #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.

LayerWhereRole
Constructionsrc/EsiClient.ts, src/EsiClientBuilder.tsThe 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 clientssrc/clients (39 + BaseEsiClient)One class per ESI domain. Adds ergonomics only: named methods, stream* and fetchAll* wrappers, withSafeMode() envelopes. No HTTP knowledge.
Endpoint definitionssrc/core/endpoints (39 files, 235 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 pipelinesrc/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.
TransportFetchLike, globalThis.fetchInjectable 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.
Portssrc/core/portsType-only interfaces (CacheStore, Clock, HttpTransport, Identity, Logger, OperationTransport, TokenProvider) that import nothing. Exported from ./client.
Generated operationssrc/generated/operations.generated.tsOne function per spec operation, importing only ports. Checked by spec:generate:check and spec:coverage in CI; called through src/client.
Clientsrc/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.
Clocksrc/core/clock.ts, src/sde/clock.tssystemClock 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.
Authsrc/authEVE 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/memoryThe 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 ​

ConcernInterfaceDefaultKey defaults
Rate limiterIRateLimiteron, mandatoryBuckets 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 breakerICircuitBreakeroff, opt-in5 failures, 30 s reset, 1 half-open probe, key by resolved path; stale-record cleanup off unless cleanupIntervalMs > 0
DeduplicatorIDeduplicatoronGET without body only
RetryIRetryStrategyon3 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 cacheICacheon1000 entries, 5 min default TTL, 60 s sweep
InterceptorsRequestInterceptor / ResponseInterceptornoneSequential, unsubscribe closure returned
TransportFetchLikeglobal fetch30 s timeout
LoggerILoggerpino, level warnPer-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). 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 Errors 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 235 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).
  • 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 Errors and the missing isCircuitOpen export on the subpath broke that; both are fixed (#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). EsiApiFactory reaches all 39 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); 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). 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). 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 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 39-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.

ThingConventionExample
InterfaceI prefixICache, IRetryStrategy
Zod schemaSchema suffix, z.looseObjectMarketOrderSchema
Endpoint mapEndpoints suffix, as const satisfies EndpointMapallianceEndpoints
Domain clientClient suffix, extends BaseEsiClientAllianceClient
Generated file.generated.ts suffix, header comment, never hand-editedesi-scopes.generated.ts
Config objectConfig suffix, all fields optional, readonly on the classCircuitBreakerConfig
Wire formatsnake_case in types and schemas; camelCase in method parametersalliance_id vs allianceId
Enum from ESIesiEnum([...]): known members plus open stringesiEnum(['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).
  • 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.

TierNameWhereFilesRunnerRuns on
1Unit (TDD)tests/tdd240jest.unitpush (Node 22), PR (Node 22/24)
2Specification (EARS/BDD)tests/bdd77 features · 664 rules · 863 scenariosjest.unit + jest-cucumberpush (npm test), PR (bdd-tests), plus spec:audit
3Type teststests/typetests13tsdPR (full suite)
4Property fuzztests/fuzz15jest.fuzz + fast-checkPR, nightly properties
5Contracttests/contract6 (2 live + 4 replay) + snapshotjest.contract(.replay)PR (contract-replay), soft-skip on ESI 503
6Fault cataloguetests/faults3jest.faultsPR (fault-catalogue), nightly
7Integration, mocked full stacktests/integration6jest.integrationPR (full suite)
8Integration, livesame, ESI_LIVE_TESTS~50jest.integration.livemanual
9Integration, gated authsame, ESI_GATED_TESTS + .env30+jest.integration.livemanual
10Benchmark and heap soaktests/benchmark24 tasks (18 client, 6 SDE) + client soak + SDE soakmitata + soak driversPR (A/B on hot paths), nightly; SDE nightly on the real export
11Mutationsrc/**—StrykerPR (changed files), nightly sharded unit + BDD + type mutation
12API fuzzPrism mock + Schemathesis—Dockernightly

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

Coverage metricFloorLast measured (v9.8.0)MutationFloor
Statements90%98.17%UnitPer directory, config/mutation/unit-thresholds.json, ratcheted
Branches80%95.14%BDDPer directory, config/mutation/bdd-thresholds.json, ratcheted
Functions75%96.09%PRChanged files, gated by the same thresholds
Lines90%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); 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, planPrRun in scripts/mutation/mutation-ratchet-core.ts). Open: flip-flopping mutants (#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).

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.

CheckCommitPushPRNightlyRelease
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 (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) 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, 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). 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 ​

SurfaceWhat it isPublished?Problem
README.md (1037 lines)The de facto manualnpm + GitHubBanner says v9.5.2; restates six guides; 39-row client table hand-maintained
guides/Maintainer guides, best depthGitHub onlyOrphaned: nothing links to four of them; two say "37 clients"
docs/ (5 files)Examples catalogue, OKF, drift, two strategy papersGitHub onlyWas the TypeDoc output dir; nothing links to any of them
docs-site/ (VitePress, 21 pages)A hand-written fork of the READMENever built or deployedVersion dropdown says 9.6.1; two commits ever; duplicates everything
TypeDoc on gh-pagesAPI reference from JSDocgh-pages on releaseThe only published site, and the least readable one
guides/SDE.md + guides/sde/Complete SDE doc setSite (docs:sync)Resolved 2026-09-28 (Track S Run 11): moved out of src/, published with the guides
tests/bdd/README.md + GUIDE.mdEARS specification rulesGitHub onlyNewest and best docs in the repo; not referenced from the testing guide
Root TESTING.mdOlder copy of guides/TESTING.mdGitHub onlyDifferent 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 guideActionStatus (rev 2)Sources to fold inCharter partsTracking
CHARTER.mdnewDoneThis documentall—
ARCHITECTURE.mdmergeDoneREADME caching, rate limiting, streaming, error handling sections; docs-site interceptors, circuit-breaker pages2esi-07y · #286
DESIGN-RULES.mdnewDoneCLAUDE.md key patterns; generate:endpoints scaffold usage; naming table above3esi-lvi · #279
TESTING.mdmergeRoot copy folded in 2026-09-27, MUTATION-TESTING.md 2026-09-28; both deletedRoot TESTING.md, MUTATION-TESTING.md, README testing section, ARCHITECTURE §84esi-b4a · #273
SPECIFICATION.mdmoveOpentests/bdd/GUIDE.md + README.md; ears-gherkin-dev skill steps4—
QUALITY-GATES.mdnewDone.github/workflows/README.md, docs/nightly-spec-drift.md, scripts table5esi-30o · #280
SECURITY.md (guide)mergeDone; CODEOWNERS text staleRemove policy duplication; add SBOM and CODEOWNERS plan6esi-p0s · #285
ERRORS.mdnewDonedocs-site reference/errors.md; src/core/util/error.ts2esi-rt2 · #281
LOGGING.mdnewDoneWrite from the per-client logging refactor2, 3esi-5zs · #282
PAGINATION.mdnewDoneREADME streaming and cursor sections; docs-site pagination.md; 9.7.0 changelog for fetchAll*2esi-358 · #283
RUNTIME-VALIDATION.mdkeepKept; no Implements: line yetDelete README and docs-site copies, link instead3—
SDE.md + guides/sde/moveDone 2026-09-28 (Track S Run 11)src/sde/README.md, src/sde/docs/*, docs-site guide/sde.md2—
OKF.mdmoveDonedocs/okf-guide.md; link from README and DESIGN-RULES3—
RELEASE.mdnewDonerelease-please config, CHANGELOG conventions, release.yml jobs, SECURITY.md support table8esi-38g · #284
DOCUMENTATION.mdmergeSite build documented 2026-09-27; metrics generation openRewrite around the target tree; document the site build and metrics7—
BEADS.mdkeepOpen: AGENTS.md still has three Beads blocksCollapse AGENTS.md's three blocks and CLAUDE.md's copy to pointers9#276
Root TESTING.md, docs/examples.md, docs-site duplicatesretireDone 2026-09-27: root TESTING.md retired; docs-site pages generated, hand-written copies removedExamples catalogue becomes docs-site/examples generated from examples/*.ts headers7#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); 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. The guide roadmap in Part 7 is tracked the same way. Severity is about consumer impact, not effort.

#SevFindingRequirementFixBeadIssue
1HIGHBranch failed tsc --noEmit from the logging refactor (two import paths, one stale call)ARCH-09, GATE-02Done in revision 1: paths fixed, parseJsonBody receives the client, pagination logging migrated, logFatal/logTrace exported, toPinoLogger keeps pino's this bindingesi-d3o#262
2HIGHnpm run docs / clean deleted five committed files in docs/DOC-02Done: TypeDoc moved in revision 1; the five markdown files moved, folded or retiredesi-a2k#263
3HIGHdocs-site never built or deployed; duplicates README at two-version lagDOC-03Done 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 removedesi-06b#264
4MEDlogFatal/logTrace were not exported; global-logger fallback needs a lint gateARCH-09Done: exports in revision 1; no-restricted-imports gate in npm run lint and lint:layersesi-772#265
5MEDisCircuitOpen missing from ./errors; plumbing errors are string-typedARCH-07Export guard; introduce EsiConfigurationError familyesi-gyh#266
6MEDCustomEsiClient missing four gettersARCH-08Done: getters added, compile-time check in customClientGetters.test.ts; EsiApiFactory reaches all 39 through createClient(type), with named methods for 9esi-eqq#267
7MEDNo sideEffects: false; two pino instances built at importARCH-06Done: default logger built on first use; "sideEffects": false; importSideEffects.test.ts spies on pino and bundles import { EsiError }esi-piw#268
8MEDhandleSinglePageRequest hardcodes GET; cursor fetchAll swallows failuresDES-08Done: rules in 0051-resilience.feature; the stream helpers pass the real method and the cursor fetchAll rejectsesi-dwi#269
9MEDNo SBOM; no CODEOWNERS; admins exempt from protectionSEC-06, SEC-07SBOM done (SEC-06); CODEOWNERS done; admins-included is a maintainer setting, steps in SECURITY.md §5esi-wze#270
10MEDTests not linted; knip non-blocking; mutation never gatesTEST-09, GATE-04, TEST-07Incremental 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
11MEDClient count quoted as 33, 35, 36, 37 and 39; BDD counted as 41 features; README banner 9.5.2DOC-04, REL-03Done 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 lineesi-j03#272
12MEDTwo testing guides with different tier numbers; root TESTING.md unlinked (root folded into guides/TESTING.md 2026-09-27)DOC-01Done 2026-09-28: one testing guide; MUTATION-TESTING.md folded in; links checked by sync-docs.test.tsesi-b4a#273
13LOWsde:seed points at a missing scriptGATE-06Done: removed; script targets checked by package-scripts.test.tsesi-x3z#274
14LOWChangelog skips 9.2 to 9.6REL-04Done 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
15LOWBeads quick reference duplicated five times; persona files untracked in the repo rootPROC-05, PROC-04Persona files gone; pointers still to doesi-udr#276
16LOWNightly mutation and Schemathesis upload artifacts but file no issueGATE-05Done: each ends in a report job that opens, comments on or closes one fixed-title issue (mutation, api-fuzz)esi-mbr#277
17LOWCONTRIBUTING.md says Node 18 while .nvmrc says 20; populated .env in working treesSEC-08, REL-05Node 22 floor done (CONTRIBUTING, .nvmrc, engines); .env housekeeping still to doesi-wc6#278
18HIGHAuthenticated paginated results cached under an unhashed key; repeat calls returned page 1DES-08, SEC-02Done: the combined body is cached with requiresAuth, so it lands under the token-hashed keyesi-l38.1#290
19HIGHDefault all-pages call silently returned page 1 when a later page kept failingDES-08Done: surfaces PAGINATION_INCOMPLETE (#413)esi-l38.2#291
20MEDstream*/fetchAll* threw EsiError 304 after a normal call to the same endpointDES-08Done: the cached body is served on a 304esi-l38.3#292
21MEDMutations returning 201/204 did not invalidate the cache path prefixPart 2 step 7Doneesi-l38.4#293
22HIGHRelease pipeline broken: release-please GraphQL failure, cosign --bundle, token trigger, version markerREL-02, SEC-04Done: cosign 3 bundle, x-release-please-version marker; the remaining trigger problems are row 30esi-l38.5#294
23MEDValidation errors retryable, network faults untyped, safe mode and token refresh collapse typesARCH-07Done: typed classes (#470)esi-l38.6#295
24MEDLog lines carry unsanitised URLs; several call sites bypass the per-client loggerSEC-02, ARCH-09Done: sanitizeUrl at the logger boundary; rate limiter, cache, batch and token manager migratedesi-l38.7#296
25MEDCI does not match the gate matrix: release gate, never-run validators, dead docs job, freshness diffGATE-01..06, REL-02Done: release gate runs the charter's validators; validate:esi and validate:spec wired; freshness diff covers every esi-*.generated.tsesi-l38.8#297
26MEDEndpoints returning a body lack responseSchemaDES-02Done: spec:response-schemas (#471)esi-l38.9#298
27LOWCharter revision 1 carried factual errors found while writing the guidesDOC-01Done in revision 2esi-l38.10#299
28LOWMinor defects: schema:drift pairing, half-open extra probe, unused clientId, TEST-03 spyseveralDoneesi-l38.11#300
29MEDA path parameter of exactly .. passes validation and URL parsing collapses the segment (characters/../assets/ → /assets/)Part 6 step 2, SEC-01Reject . and .. as path parameters; add the case to security.test.ts—#430
30HIGHReleases need a manual dispatch of release.yml, and CI on the release-please PR waits for manual approvalREL-02A GitHub App token for release-please; waiting on the maintainer to create it—#378, #383
31MEDNo check that every public client method or provider method has a RuleTEST-10Done 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——
32LOWExclusions (what the client ignores) stated in prose, not as executable unwanted-behaviour Rules; one such Rule existsTEST-11Done: every stated exclusion is a Rule, and ears lists the 29 shall not Rules with their verdicts (2026-09-28)——
33LOWThe charter is EARS by convention; no audit parses its requirement blocks or checks Enforced rows name a mechanismPROC-06Done: npm run charter:audit (Phase 5 item 7)——
34MEDTwo SDE features (errors, ingestion) verified no charter requirement; they sat on a shrink-only exception listARCH-11..13Done 2026-10-12: ARCH-11, ARCH-12 and ARCH-13 added; both features tagged; unchartedFeatures empty—#640
35HIGH12.1.0 deprecated every custom-client surface for removal in 13.0.0; no requirement said the custom clients are the productARCH-14, ARCH-08Done 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

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).
  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).
  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); 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).

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.

Released under the GPL-3.0-or-later license.