ESI.ts Engineering Charter
Package: @lgriffin/esi.ts · Charter revision: 3 (2026-09-28) · Status: adopted
The governing statement of how ESI.ts is designed, built, tested, secured, documented and released. Every guide in this folder derives from a numbered requirement here, and every requirement is written in the same EARS form the test suite already uses, so the charter can be audited the way the specification is.
Measured from the source (npm run docs:metrics) | |
|---|---|
| Domain clients | 39 |
| ESI endpoints wired | 235 |
Generated operations (src/generated/operations.generated.ts) | 235 |
| EARS requirements in the specification | 664 (77 feature files) |
| Gherkin scenarios | 863 |
| Test files matched by the nine Jest configurations and tsd | 369 |
| Statement coverage | 97.8% (npm run coverage on master 114a485a, 2026-09-28; floor 90%) |
| OpenSSF Scorecard | 8.3 / 10 (scorecard.yml run 36409177565 on master 9486fe2c, 2026-09-28; 8.2 on 2026-09-21, 6.7 on 2026-08-24) |
What changed in revision 2
- Corrections from #299. 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).
SdeDatabaseBuilderwas 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 thebetter-sqlite3optional 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).SdeDatabaseErrorandisSdeDatabaseErrorstay exported from./sdeand./sde/memorywith the same shape, marked@deprecatedbecause 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
@deprecatedfor removal in 13.0.0 (EsiClient,EsiClientBuilderandCustomEsiClient,EsiApiFactory,BaseEsiClient, the 39 domain clients, 194 hand-written response types,TestDataFactory, the./schemasentry and 25 root pipeline exports), following a plan that made the generatedcreateEsi()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 callscreateEsithe spec-shaped low-level surface and future engine. Its requirement no longer asks every surface,createEsiincluded, for the same set of operations: it asks all four to configure the pipeline throughconfigureApiClientand 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 ofEsiApiFactory's nine named factories andEsiTokenManager.createClientstay, now pointing atcreateClient(type)and the builder, with removal left to a future major that has no date.guides/LEGACY-REMOVAL.mdis 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*andstream*did the same, and the cursor handler'sfetchAllreturned what it had when it reached its page limit with anaftercursor 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 newincompletePages: 'throw'option oncreateEsiandEsiClientConfig, every collecting path returns every page or throwsEsiPaginationError, which gainsreason(page-cap,empty-page,cursor-cap,fetch-failed),endpoint,reportedPages,fetchedPages,itemCountandpartial. 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 derivesX-Pagesfrom 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.featureandsde/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-onlyunchartedFeatureslist inscripts/spec/spec-audit-exceptions.jsonis 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:orTo 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 adescribe,itortesttitle: 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:auditchecks 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-10on the domain-client, integration, performance and SDE provider features,@TEST-11on every exclusion Rule,@SEC-10on every@sec-<id>Rule,@ARCH-04on 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:auditfails 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 inunchartedFeatures.npm run bdd:reportwrites the tags with each result toreports/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-05tests/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: thebranch-protectionjob inmaintenance.ymlnow reads it through the API weekly and fails whenmasteris unprotected orci-successis not required, and the requirement no longer namesLint, Build & Test, which protection stopped requiring whenci-successtook 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.mdnames 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.yamlindexes it andguides/security-triage-log.mdlogs closed reports (#628). Reading master against it found three gaps, fixed as hardening: the async page iterators readX-Pagesuncapped, 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:modelruns in thesecurity-modeljob ofci-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.ymlrunsnpm run quality:trendevery Monday. It measures the last twelve weekly commits of master in worktrees: hand-writtensrc/lines, total and per-1,000-line cognitive complexity, functions over 20,anyidentifiers,eslint-disablecomments 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 whilesrc/doubled, andcreateClientas 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:
IRetryStrategytookRetryContextfromRetryStrategy, andICircuitBreakertookCircuitStatefromCircuitBreaker, which importApiClient, 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 importApiClientwithimport type.npm run lint:cycles(scripts/quality/import-cycles.ts) builds the runtime import graph ofsrc/with the TypeScript parser and fails on any cycle; it runs inlint-and-buildandcheck:local.lint:layersstill 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 lintfailed on errors only, so warnings grew unseen (258 insrc/andtests/, 470 withscripts/).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 toconfig/eslint/warning-baseline.jsonunder 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 inlint-and-buildand thelinttier ofcheck: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 lintis noweslint src tests scripts, with the script relaxations declared in their own block ofeslint.config.mjs, andnpm run typecheck:scriptscompilestsconfig.scripts.jsoninlint-and-buildandcheck:local. Turning them on found six entry points whose top-levelmain()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.ymldrops--no-exit-codeandcheck:localgains akniptier.codeql.ymlruns thesecurity-extendedquery suite.lint-and-buildrunsnpm audit signaturesafternpm 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.mdis folded intoguides/TESTING.mdas its "Mutation testing" section and deleted, after rootTESTING.mdthe day before; every link now points at the section. DOC-01 moves from Gap to Enforced:tests/tdd/scripts/sync-docs.test.tskeeps 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 earsalready runs in CI (ears.yml, on every pull request that touchessrc/,tests/bdd/or the EARS scripts;bdd-testsandspec-auditgate the same ground insideci-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:auditholds every requirement block of this document to the rulesspec:auditapplies to aRule: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 oneshalleach 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:cicounts everyIStaticDataProvidermethod as covered when aRule: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:ciextends the provider check to every public method the 39 domain clients declare, counting one as covered when aRule:names it or a bound step calls it on that client, legacydefineFeaturescenarios 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 toguides/sde/(REFERENCE, USAGE, ARCHITECTURE, DEVELOPER_GUIDE, API_CONTRACTS) withguides/SDE.mdas the index andsrc/sde/README.mda pointer, sodocs:syncpublishes the set under its own sidebar section andtest:docs-exampleschecks 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.ymldownloads CCP's current Static Data Export (cached per build), loads it, runs the real-data integration suite withSDE_REQUIRE_DATA=1, runsnpm run sde:drift(the export's file list againstSDE_FILE_REGISTRY, each file's keys against the table's Zod schema) and the four@nightly sdeexamples, 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-prtoci-successout 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-prout ofci-successand 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-importsblock innpm run lintandlint:layerskeeps the globalloggerUtilout ofsrc/core/requestPipeline/andsrc/clients/(#265). SEC-02 gains log lines to its evidence: URLs are redacted withsanitizeUrlat 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.jsondeclares"sideEffects": false, andtests/tdd/core/importSideEffects.test.tsholds both (#268).2026-09-28, every nightly files an issue. GATE-05 moves from Partial to Enforced:
nightly-no-retry.yml,nightly-interleave.ymlandconsumer-matrix-nightly.ymlgain the samereportjob 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.ymlandnightly-schemathesis.ymlnow open or comment on one fixed-title issue on failure and close it on the next green night, the patternnightly-spec-drift.ymluses; 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:versionsalso reads the docs-site version selector, which now carries thex-release-please-versionmarker and sits in release-please'sextra-files, and runs instatic-analysisas well as at release. GATE-06 moves to Enforced:package-scripts.test.tsalso fails when a document names annpm runthatpackage.jsondoes not define.validate:esifails on a definition the spec does not list unlessscripts/spec/esi-endpoint-exceptions.jsongives a reason, andvalidate:esi:vendoredruns it offline against the vendored snapshot incheck:localand afterspec-refresh.ymlregenerates.2026-09-27, CI matches the gate matrix (Phase 5 item 1, #297).
validate-releasenow runsspec: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:esiandvalidate:specrun instatic-analysison every pull request, blocking when the pull request touches their inputs;validate:versionsalready ran in the release gate. The generated-freshness diff covers everysrc/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-scopesfails both directions of DES-04 and on a stale exception; the fourteen stale entries inscripts/spec/auth-scope-exceptions.jsonare removed. The pull-request contract step gainspipefail, without which itsifreadtee's status and a failing live suite passed. The documentation job andexample:sde-cross-refwere 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:prre-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 lintiseslint src tests, parsingtests/againsttsconfig.test.json, with the test relaxations declared one by one ineslint.config.mjsandno-floating-promises,no-misused-promisesandawait-thenablekept as errors;lint-stagedlints staged test files too. Turning it on found three floating promises and anawaitof a synchronousshutdown(), and three tests that could not fail: two fuzz properties that caught every throw and asserted nothing (they hid thatvalidatePathParam([])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.tsnow holds that, and lists every job-level write scope so a new one is a reviewed edit (SEC-03).sign-and-publish-assetsattests SLSA build provenance for the tarball, SBOM and docs archive, verifies it withgh attestation verify, and attaches it aslgriffin-esi.ts-X.Y.Z.intoto.jsonlbeside the cosign bundles, the file Scorecard's Signed-Releases check scores highest (SEC-04). The SBOM (SEC-06) and.github/CODEOWNERShad 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 inguides/SECURITY.md§5.2026-09-27, knip blocks the release (#271). GATE-04 moves from Gap to Enforced: the baseline is clean and
release.ymlrunsnpx knipwithout--no-exit-code.knip.jsonc(renamed fromknip.jsonto carry comments) makes tests, scripts and examples entry points, which cleared the findings that were only used there, and keeps four reasoned exceptions. The deprecatedsrc/core/logger/logger.ts, reachable from no entry point, the unused re-exports in the internalrequestPipelinebarrel, and the unusedjest-junit,@types/adm-zipand@types/js-yamldevDependencies are removed; the public API is unchanged.ci.ymlstays non-blocking;validateandcheck:allnow 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.nodeis>=22.12.0and.nvmrcis 22, in afeat!: require Node 22commit whoseBREAKING CHANGE:footer sends Node 18 and 20 consumers to 10.x; the unit matrix and the consumer contract inci.ymlandrelease.ymlrun 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:sitebuilds TypeDoc, generates the guide pages from README.md andguides/and one page perexamples/*.ts(scripts/docs/sync-docs.ts), and builds VitePress; the generated pages are git-ignored, and the hand-written copies indocs-site/guide/,reference/,examples/andexplorer/are removed.ci.yml'sdocumentationjob builds the whole site on every pull request, so it stays one of the 23 jobsci-successneeds;release.yml'sdeploy-docscalls the newdocs-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 readspackage.jsonat build time instead of being bumped by release-please, andvalidate:versions -- --sitechecks 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:metricscomputes the counts the documentation quotes from the source, with no test run and no network, writes them toetc/doc-metrics.json, and rewrites each number marked<!-- metric:name -->in README.md, the guides and the site's home page.validate:versionsruns the same check, so a stale count, release line or JSON file failsstatic-analysison the pull request andvalidate-releaseat 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
| Prefix | Governs | Derived guide |
|---|---|---|
ARCH | Layering, request pipeline, module boundaries, public surface | guides/ARCHITECTURE.md |
DES | Naming, schema conventions, endpoint definitions, extension seams | guides/DESIGN-RULES.md (new) |
TEST | Test tiers, specification discipline, coverage and mutation floors | guides/TESTING.md + tests/bdd/GUIDE.md |
GATE | What runs at commit, push, PR, nightly and release | guides/QUALITY-GATES.md (new) |
SEC | Runtime defences, secrets, supply chain | SECURITY.md + guides/SECURITY.md |
DOC | Where documentation lives, what is canonical, how it is published | guides/DOCUMENTATION.md |
REL | Versioning, changelog, publishing, support window | guides/RELEASE.md (new) |
PROC | Issue tracking, agent conduct, branch rules | guides/BEADS.md + CONTRIBUTING.md |
Status legend
| Status | Meaning |
|---|---|
| Enforced | A script or CI job fails on violation |
| Practised | True in the code, but not machine-checked |
| Partial | Holds in some places |
| Gap | Stated intent, not yet true |
Evidence
The "Verified by" bullet of a requirement is one or more clauses, and each clause opens with a label:
| Label | The clause names |
|---|---|
Test: | A test that exercises the requirement: a test file or directory under tests/ |
Gate: | A script, workflow job, tool or setting that fails on a violation |
Review: | What only a person checks |
To add: | Evidence that does not exist yet |
A row that cites both a test and a gate carries both clauses. An Enforced row has at least one Test: or Gate: clause; a row with Gate: and no Test: is checked by its gate, and no test is expected. Every Jest test file a "Verified by" cites, as a path or a bare file name, names the requirement's ID in a describe, it or test title, as in describe('SEC-03: workflow token permissions', ...), so a test run shows which requirement each test backs. npm run charter:audit fails a row that breaks any of these (PROC-06).
Form and precedence
Each requirement uses one of the five EARS patterns already enforced on the feature files by npm run spec:audit: ubiquitous, event-driven (When), state-driven (While), optional (Where) and unwanted (If … then). One shall per requirement, the system named, no vague quantifiers.
Precedence when documents disagree: the running code and its CI results are the fact. This charter is the intent. A guide explains how to meet the intent. The README sells and orients. If code and charter diverge, that is a bead to file, not a sentence to soften.
Part 1 · Posture
Seven positions that explain most of the individual choices below. A proposal that contradicts one of these needs to argue with the principle, not just with the rule. The first is the identity of the project and is not traded away for any of the others.
- 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./clientis 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). - 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.
- Tolerate additive change.
z.looseObjecteverywhere andesiEnumunions mean a new field or enum member from CCP never breaks a consumer at runtime. Removal is a breaking change; addition is not. - 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.
- 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.
- 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. - Verifiable supply chain. Every action SHA-pinned, least-privilege tokens, npm provenance on both registries, keyless cosign signatures and checksums on release assets, advisories accepted only with an expiry date.
Part 2 · Architecture
Five layers, one request path, and side modules that deliberately share nothing with the HTTP pipeline. Phase 2 of the 11.0 plan adds ports and generated operations beneath them; the layer rule is enforced by npm run lint:layers.
| Layer | Where | Role |
|---|---|---|
| Construction | src/EsiClient.ts, src/EsiClientBuilder.ts | The product's front door (posture 1): EsiClient (lazy getters for every domain), EsiClientBuilder → CustomEsiClient (subset), EsiApiFactory (one client). All three feed configureApiClient, the single place config becomes middleware. ARCH-14 keeps them. |
| Domain clients | src/clients (39 + BaseEsiClient) | One class per ESI domain. Adds ergonomics only: named methods, stream* and fetchAll* wrappers, withSafeMode() envelopes. No HTTP knowledge. |
| Endpoint definitions | src/core/endpoints (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 pipeline | src/core, src/core/requestPipeline/* | Pure functions receiving their dependencies as parameters (dependencies.ts is the only resolver). Cache policy, headers, fetch execution, status handling, pagination orchestration, middleware bridge. |
| Transport | FetchLike, globalThis.fetch | Injectable via setFetch(). Timeout by AbortController. The Node floor (engines.node >=22.12.0 since 11.0.0, REL-05) exists because this layer relies on the global fetch. |
| Ports | src/core/ports | Type-only interfaces (CacheStore, Clock, HttpTransport, Identity, Logger, OperationTransport, TokenProvider) that import nothing. Exported from ./client. |
| Generated operations | src/generated/operations.generated.ts | One function per spec operation, importing only ports. Checked by spec:generate:check and spec:coverage in CI; called through src/client. |
| Client | src/client (./client) | createEsi: the spec-shaped low-level surface, one runtime over configureApiClient, esi.public (a PublicScopeTree) and esi.as(identity) (a ScopeTree sharing the runtime's budgets and cache). Imports core, adapters and generated code, never the custom clients; lint:layers enforces it. |
| Clock | src/core/clock.ts, src/sde/clock.ts | systemClock is where wall-clock time, timers and Math.random belong (the SDE keeps its own copy, since it may import only the ports). npm run lint:determinism blocks new direct reads; the existing sites (rate limiter, cache, circuit breaker, request handler and others) sit in scripts/quality/determinism-baseline.json, which may only shrink. |
| Auth | src/auth | EVE SSO (PKCE), token manager and storage, with its own error subtree. Reached from the root and ./errors; there is no ./auth sub-path. |
| Side modules | ./schemas, ./errors, ./testing, ./client, ./sde, ./sde/memory | The SDE module shares no code with the pipeline, and lint:layers holds that in both directions (ARCH-10). It is an offline lookup layer for enriching ESI responses, with its own error hierarchy and its own docs. |
lint:layers (config/eslint/layers.rules.cjs) holds the direction: ports import nothing, generated code imports only ports, src/core imports no domain client, entry point, generated operation, auth, SDE or testing module, and src/sde imports nothing from src/ but the ports while nothing outside it imports the SDE. The baseline of exempt files is empty, and tests/tdd/layers/layers-lint.test.ts keeps it so.
The request path
Every domain method takes this route. Order is the information: a stage cannot see the effect of a later one.
- Domain method →
createClientclosure. Deprecation warning if declared.buildEndpointPathassembles path, query and body (fromhasBodyorbodyBuilder). Request body validated againstrequestSchemaonly whenvalidateRequestis on. - Spec-aware cache check.
trySpecAwareCacheHitmay 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'sx-cache-agefield, generated intoesi-cache-ttls.generated.ts.Cache-Control: max-agenever avoids a network call on its own. 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 unlessretryMutations.CircuitOpenErroris rethrown immediately. Each attempt re-checks the spec-TTL cache first.- Deduplication. Inside each attempt, identical in-flight GETs without a body coalesce onto one promise, keyed by URL and caller identity. On by default.
executeSingleFetch. Build headers (User-Agent, compatibility date,If-None-Match, bearer only ifrequiresAuth) → request interceptors → circuit breaker check → rate limiter check → timed fetch → parse headers → rate limiter learns from response → breaker records outcome.- 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 withoutIf-None-Match, and only then does it throw. 5xx with a cached copy serves stale. 401/403 get remediation text appended. - 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 followsx-pages. Cursor routes takebefore/afterquery parameters;x-cursor-before/x-cursor-afterheaders come back on the result ascursorsand are never followed automatically, so callers page withfetchAllCursorPages(). - Response interceptors → Zod validation → envelope. Body replaced by
safeParse().datawhenvalidateResponseis on (default). Failure throwsEsiValidationError.withMetadataandwithSafeModewrap the result last.
Middleware inventory
| Concern | Interface | Default | Key defaults |
|---|---|---|---|
| Rate limiter | IRateLimiter | on, mandatory | Buckets from generated groups; adopts x-ratelimit-* from responses; honours Retry-After; 420/429 → 60 s block; tracks x-esi-error-limit. It does not charge token costs itself (getTokenCost() is a static helper only) |
| Circuit breaker | ICircuitBreaker | off, opt-in | 5 failures, 30 s reset, 1 half-open probe, key by resolved path; stale-record cleanup off unless cleanupIntervalMs > 0 |
| Deduplicator | IDeduplicator | on | GET without body only |
| Retry | IRetryStrategy | on | 3 retries, 1 s base, 30 s cap (set by configureApiClient; a bare RetryStrategy defaults to 0 retries) |
| Pagination | — | — | fetchAll* concurrency 8; batch concurrency 20; batchPost chunk size 1000 |
| ETag cache | ICache | on | 1000 entries, 5 min default TTL, 60 s sweep |
| Interceptors | RequestInterceptor / ResponseInterceptor | none | Sequential, unsubscribe closure returned |
| Transport | FetchLike | global fetch | 30 s timeout |
| Logger | ILogger | pino, level warn | Per-client, falls back to global, then default |
Error taxonomy
Error
├── EsiError (statusCode, sanitised url, requestId) .retryable ⇐ {0, 420, 429, 502, 503, 504}
│ ├── TimeoutError (+ timeoutMs)
│ ├── EsiNetworkError (+ cause)
│ ├── EsiValidationError (+ ZodError, direction: request | response)
│ ├── CircuitOpenError (endpoint, failures, retryAfterMs) statusCode 0, never retryable
│ └── EsiFaultError (+ code, cause) raised by the library, not ESI
│ ├── EsiConfigurationError VALIDATION_ERROR, NO_AUTH_TOKEN, CONFIGURATION_ERROR
│ ├── EsiParseError JSON_PARSE_ERROR
│ ├── EsiPaginationError PAGINATION_INCOMPLETE
│ └── EsiTokenRefreshError TOKEN_REFRESH_FAILED
├── AuthError
│ ├── SsoError (+ statusCode) .retryable ⇐ 429 or ≥ 500
│ ├── TokenRevokedError
│ ├── TokenDecodeError no type guard yet
│ ├── TokenVerificationError
│ └── CharacterNotFoundError
└── SdeError → SdeValidationError | SdeVersionMismatchErrorEvery 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:typesthengit diff --exit-codeonsrc/types/generated/and everysrc/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:checkdoes 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:esiandnpm run validate:auth-scopesin the CI static-analysis job and the release gate (a definition the spec does not list fails unlessscripts/spec/esi-endpoint-exceptions.jsongives a reason;validate:esi:vendoredruns the same check offline incheck: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
ApiClientand keepsdependencies.tsthe one place resolution happens. - Verified by: Test:
tests/tdd/core/pipelineDependencyParameters.test.tsfails when a module insrc/core/requestPipelineother thandependencies.tsmakes a value import of a cache, rate limiter or circuit breaker implementation (theI*interfaces andcacheKeyhelpers 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, thepackage-lintjob inci.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 everyexportsentry with pino replaced by a spy and fails if the spy was called, asserts the manifest flag, and bundlesimport { EsiError }from the root entry with esbuild (ESM and CJS) and fails if the bundle reaches pino. Gate:npm run sizeholds 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 missingisCircuitOpenexport on the subpath broke that; both are fixed (#266). The missingTokenDecodeErrorguard 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:
CustomEsiClientnow has a getter for every registered client (#267).EsiApiFactoryreaches all 39 through the genericcreateClient(type); its nine namedcreate*Clientmethods are deprecated since 11.0.0 in favour ofcreateClient(type)and the builder, and removing them needs a future major release that is not scheduled.createEsiis 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.tsforCustomEsiClient(a missing getter fails to compile);tests/tdd/scripts/custom-client-identity.test.tsbuilds every registered domain client throughEsiApiFactory.createClient(type), anEsiClientBuilder→CustomEsiClientandEsiClient's domain properties and fails unless each is the registered class;constructionParity.test.tschecks that the three custom-client surfaces configure middleware the same way;tests/tdd/core/customClientRuntime.test.tsand the supplied-middleware, diagnostics and metadata Rules of0060-custom-client.featurecheck that the middleware instances, diagnostics and metadata and safe-mode viewscreateEsihas reachEsiClient,CustomEsiClientandEsiApiFactorythroughconfigureApiClient;tests/tdd/core/endpointOperationParity.test.tsfails when an endpoint definition and its generated operation disagree about deprecation, a text response or an empty answer, anddeprecationWarning.test.tsandrequestPipeline/noContent.test.tscover the helpers both surfaces share for them. Gate:createEsi(src/client/runtime.ts) callsconfigureApiClientand exposes every generated operation throughScopeTree, whichspec:coveragekeeps 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 lintandnpm run lint:layers(--no-inline-config) forbid the globalloggerUtilinsrc/core/requestPipeline/andsrc/clients/(config/eslint/logger-imports.rules.cjs, proved bytests/tdd/layers/logger-imports-lint.test.ts, #265). Test: the rate limiter, the ETag cache's startup line,EsiClient.batchand the token manager's fallback are covered bytests/tdd/core/loggerThreading.test.ts(#296). The global logger serves only the standalone batch exports andEsiTokenManager, 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/portsfor 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(thesdeandsideModulemessages oflayers/inward-imports, covered in both directions bytests/tdd/layers/layers-lint.test.ts). Test:tests/tdd/sde/memory-entry-bundle.test.ts, which bundlessrc/sde/memory.tsand the builtdist/sde/memory.{mjs,js}and fails onnode:fs,js-yaml,adm-ziporbetter-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.
SdeDatabaseErroris 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) andtests/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.tsandtests/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 catchingisSdeErrorwould miss it. - Verified by: Test: the
If …Rules oftests/bdd/features/sde/0023-sde-ingestion.feature,tests/tdd/sde/ingestion/SdeDownloader.test.tsandtests/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.tsfails when any of those surfaces carries@deprecatedin its source or inetc/esi.ts.api.md, when one leaves the root entry or stops being a class (a domain client must stayextends BaseEsiClient), and when a registered domain client disappears from the registry; it also holds the nine namedEsiApiFactoryfactories andEsiTokenManager.createClientdeprecated, and nothing else. Gate:npm testin theunit-testsjob ofci.yml, whichci-successneeds.
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.
| Thing | Convention | Example |
|---|---|---|
| Interface | I prefix | ICache, IRetryStrategy |
| Zod schema | Schema suffix, z.looseObject | MarketOrderSchema |
| Endpoint map | Endpoints suffix, as const satisfies EndpointMap | allianceEndpoints |
| Domain client | Client suffix, extends BaseEsiClient | AllianceClient |
| Generated file | .generated.ts suffix, header comment, never hand-edited | esi-scopes.generated.ts |
| Config object | Config suffix, all fields optional, readonly on the class | CircuitBreakerConfig |
| Wire format | snake_case in types and schemas; camelCase in method parameters | alliance_id vs allianceId |
| Enum from ESI | esiEnum([...]): known members plus open string | esiEnum(['buy','sell']) |
DES-01 · Ubiquitous · Enforced
Every hand-written response schema shall use z.looseObject so that fields ESI adds later are preserved in the validated body.
- Why: Validation replaces the body with
safeParse().data. A strict object would silently strip new fields from consumers. - Verified by: Gate:
npm run schema:driftagainst the spec; 398 uses oflooseObjectversus 2 ofz.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-schemasinci.ymllint-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:typesthengit diff --exit-code) inci.yml; theapi-surfacejob 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
requiresAuthis set. A mismatch either leaks a token or breaks a call. - Verified by: Gate:
npm run validate:auth-scopesin the CI static-analysis job and the release gate; both directions exit 1, and so does an entry inscripts/spec/auth-scope-exceptions.jsonthat 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(theDES-05block: 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 issuenightly-spec-drift.ymlfiles.
DES-06 · Ubiquitous · Practised
Configuration and error objects shall be immutable after construction, with merges copying rather than mutating.
- Why:
readonlyfields 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
noUncheckedIndexedAccessand nothing else. - Verified by: Gate:
tsconfig.json;npm run typecheckon 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 withincompletePages: 'throw', every collecting path (the eager method,fetchAll*,stream*, the cursor handler) returns every page or throwsEsiPaginationErrorwith areasonand thepartialresult. 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; withincompletePages: '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 anaftercursor completes the walk). Test:tests/fuzz/pagination-assembly.property.test.ts(withincompletePages: 'throw', every offset collector returns the whole dataset or throws). Gate:npm run faults(x-pages-beyond-the-cap,x-pages-beyond-the-cap-refused,x-pages-shrinks-to-empty-page,x-pages-shrinks-to-empty-page-refused,empty-page-mid-run).
Part 4 · Testing
This table is the canonical tier order. Both testing guides merge into one and cite it.
| Tier | Name | Where | Files | Runner | Runs on |
|---|---|---|---|---|---|
| 1 | Unit (TDD) | tests/tdd | 240 | jest.unit | push (Node 22), PR (Node 22/24) |
| 2 | Specification (EARS/BDD) | tests/bdd | 77 features · 664 rules · 863 scenarios | jest.unit + jest-cucumber | push (npm test), PR (bdd-tests), plus spec:audit |
| 3 | Type tests | tests/typetests | 13 | tsd | PR (full suite) |
| 4 | Property fuzz | tests/fuzz | 15 | jest.fuzz + fast-check | PR, nightly properties |
| 5 | Contract | tests/contract | 6 (2 live + 4 replay) + snapshot | jest.contract(.replay) | PR (contract-replay), soft-skip on ESI 503 |
| 6 | Fault catalogue | tests/faults | 3 | jest.faults | PR (fault-catalogue), nightly |
| 7 | Integration, mocked full stack | tests/integration | 6 | jest.integration | PR (full suite) |
| 8 | Integration, live | same, ESI_LIVE_TESTS | ~50 | jest.integration.live | manual |
| 9 | Integration, gated auth | same, ESI_GATED_TESTS + .env | 30+ | jest.integration.live | manual |
| 10 | Benchmark and heap soak | tests/benchmark | 24 tasks (18 client, 6 SDE) + client soak + SDE soak | mitata + soak drivers | PR (A/B on hot paths), nightly; SDE nightly on the real export |
| 11 | Mutation | src/** | — | Stryker | PR (changed files), nightly sharded unit + BDD + type mutation |
| 12 | API fuzz | Prism mock + Schemathesis | — | Docker | nightly |
Other nightlies: interleave, no-retry, recorded payloads, consumer matrix, examples against live ESI, and a post-publish canary.
| Coverage metric | Floor | Last measured (v9.8.0) | Mutation | Floor | |
|---|---|---|---|---|---|
| Statements | 90% | 98.17% | Unit | Per directory, config/mutation/unit-thresholds.json, ratcheted | |
| Branches | 80% | 95.14% | BDD | Per directory, config/mutation/bdd-thresholds.json, ratcheted | |
| Functions | 75% | 96.09% | PR | Changed files, gated by the same thresholds | |
| Lines | 90% | 98.37% | The global break is off (config/mutation/stryker.config.mjs, break: null) |
TEST-01 · Event-driven · Practised
When observable client behaviour changes, the change shall be preceded by an EARS requirement in a Rule: block and a scenario that fails before the implementation exists.
- Why: The most common defect in the suite has been scenarios that cannot fail. Red before green is the only defence. The form of the requirement is machine-checked; that the scenario failed first is a step in a workflow, and no check can establish it after the fact, so the status is Practised (moved from Enforced on 2026-09-27 for that reason, not because the code regressed).
- Verified by: Review: the
ears-gherkin-devworkflow for the RED step. Gate:npm run spec:auditholds the form on PR; the ratchet filescripts/spec/spec-audit-exceptions.jsonhas an emptyunconvertedlist and alegacyStepFileslist, 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-onlyunchartedFeatureslist inscripts/spec/spec-audit-exceptions.json.npm run bdd:reportcopies each scenario's charter tags intoreports/bdd/scenarios.jsonbeside 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.cjsthresholds; 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:driftinnightly-sde.ymlcompares CCP's current export withSDE_FILE_REGISTRYand 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:typesovertests/typetests/. SDE:tests/typetests/sde.test-d.tsandsde-memory.test-d.tspin every provider method, entity and error of./sdeand./sde/memory. Gate: type-mutation floors of 32 and 25 for them inconfig/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.jsonat 60 or above, every directory inconfig/mutation/bdd-thresholds.jsonat 20 or above, and every SDE directory at 90 or with each survivor carrying an equivalence reason (Track S Run M).src/schemasis measured by the unit tier andschema:driftonly: 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-prjob ("Mutation (changed files)") inmutation-pr.yml, advisory on every pull request since 2026-09-27, back insideci-successonce the target floors hold (after 11.0.0); nightly ratchets innightly-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,planPrRuninscripts/mutation/mutation-ratchet-core.ts). Open: flip-flopping mutants (#382). SDE:src/sdeandsrc/sde/ingestionare their own directories in both floor files andsrc/sdeits own shard inconfig/mutation/unit-shards.jsonandbdd-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.cjsandconfig/jest/contract.live.config.cjsrefuse to run withoutESI_LIVE_TESTS, and the 503 soft-skip sits in the CI jobs that run those tiers. Test:tests/integration/gated-auth.test.tsreadsESI_GATED_TESTS. SDE:tests/integration/sde/sde-real-data.test.tsskips without an export on disk and fails instead whenSDE_REQUIRE_DATA=1, whichnightly-sde.ymlsets.
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 lintoversrcandtests, on push (ci-fast.yml) and PR (ci.ymllint-and-build), with the relaxations declared, each with its reason, in thetests/**block ofeslint.config.mjs;no-floating-promises,no-misused-promisesandawait-thenablestay errors there.tests/is parsed againsttsconfig.test.json; fixture trees and the packed-tarball consumer intests/consumer/are ignored.lint-stagedrunseslint --fixon staged test files. Alongside it,lint:suite-healthovertests/(no.only/.skip/.todo, no assertion-free tests or Then steps, no swallowed assertions) andlint:bdd-seamovertests/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 insde-spec-coverage-core.ts, tested undertests/tdd/scripts/) parsesIStaticDataProvider.tsfor its methods and counts one as covered when aRule:intests/bdd/features/sde/names it or the step function a scenario binds calls it on the provider, directly or through anytests/bddfunction, with the TypeScript type checker deciding what the receiver is; the uncovered list, grouped by entity family, isscripts/sde/sde-spec-coverage-baseline.json, which may only shrink againstorigin/master's copy (23 of 101 covered at the first commit). Incheck:allandci.yml'sspec-auditjob;verify-localrunsspec: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 inclient-spec-coverage-core.ts, sharing the call graph, baseline and command line with the provider check throughscripts/spec/method-coverage-core.ts, tested undertests/tdd/scripts/) reads the public instance methods each class extendingBaseEsiClientdeclares and counts one as covered when aRule:names it (qualified asClient.method, or bare when one client declares the name) or a bound step calls it on that client, legacydefineFeaturescenarios bound by title included; the uncovered list, grouped by client, isscripts/spec/client-spec-coverage-baseline.json, which may only shrink againstorigin/master's copy (173 of 384 covered at the first commit). Incheck:allandci.yml'sspec-auditjob;verify-localrunsspec:coverage:clients. Test:tests/tdd/scripts/sde-spec-coverage.test.tsandtests/tdd/scripts/client-spec-coverage.test.tsdrive 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.mdandguides/sde/) is a Rule on 2026-09-28, in the feature for its area: the headers not honoured in0052-response-headersand0055-request-headers, the statuses not retried in0051-resilience, the cache features skipped in0050-etag-caching, the values not validated in0053-runtime-validation, the token not logged in0058-logging, the signature not verified in0054-token-management, the files and peers not read insde/0020andsde/0021. The client's own configuration and extension points (which interceptors run on which path,circuitBreakerConfigwithoutenableCircuitBreaker, which logger a component reads) are not ESI behaviours and are documented, not registered. - Verified by: Gate: the form:
npm run spec:auditaccepts the unwanted pattern with a negated response. Gate: the register:npm run earslists everyshall notRule with its verdict and scenarios in the "Exclusion register" section ofreports/ears/ears-report.mdand the job summary (scripts/quality/ears-core.ts,isExclusion), 29 Rules, all verified, on 2026-09-28; TESTING.md documents it andtests/bdd/GUIDE.mdshows how to write one. Test:tests/tdd/spec-audit/ears-report.test.ts(isExclusionand the register). Review: a new exclusion: the AGENTS.md reviewer checklist item "Exclusions stated (R2a)", at major severity, requires it as a Rule rather than prose or a code comment. No script can know what the client should ignore, so completeness stays a review question; what the check guarantees is that every stated exclusion executes on every run.
Part 5 · Quality gates
What runs where. ● blocks; ◐ runs but does not block; · does not run.
| Check | Commit | Push | PR | Nightly | Release |
|---|---|---|---|---|---|
| lint-staged: ESLint fix + Prettier | ● | · | · | · | · |
| commitlint (conventional commits) | ● | · | · | · | · |
| ESLint, Prettier check, build, typecheck, examples typecheck | · | ● | ● | · | ● |
| lint:layers, lint:bdd-seam, lint:suite-health | · | ● | ● | · | · |
| lint:determinism | · | · | ● | · | · |
Unit tests + BDD (npm test) | · | ● 20 | ● 18/20/22 | · | ● |
| Coverage thresholds + PR comment | · | · | ● | · | · |
| EARS spec audit, BDD report | · | · | ● | · | ● |
| Generated types and operations fresh, schema drift, auth scopes | · | · | ● if inputs touched, else ◐ | ◐ files issue | ● |
Endpoint definitions against the spec (validate:esi) | · | · | ● if inputs touched, else ◐ | · | ● |
Redocly lint of the ESI spec (validate:spec) | · | · | ● if inputs touched, else ◐ | · | · |
Version consistency (validate:versions) | · | · | · | · | ● |
| Live contract tests | · | · | ● (503 ◐) | ◐ weekly | ● (503 ◐) |
| Contract replay, fault catalogue, fuzz, integration, type tests | · | · | ● | ◐ | ● |
| API surface diff (api-extractor) and api-semver | · | · | ● | · | · |
| Export coverage, package lint (publint, attw), size-limit | · | · | ● | · | · |
| Consumer contract (Node 22/24), doc examples | · | · | ● | ◐ consumer matrix | · |
| TypeDoc build | · | · | ● | · | · |
| Benchmarks A/B | · | · | ● | ◐ | · |
| Dependency audit (diff-aware / allowlist) | · | · | ● new advisories | ◐ files issue | ● ≥ high |
| knip dead-code | · | · | ◐ | · | ● |
| CodeQL | · | ● | ● | ● weekly | · |
| zizmor (workflow security), workflow lint | · | · | ● | · | · |
| Stryker mutation | · | · | ● changed files | ◐ ratchet, files issue | · |
| Schemathesis API fuzz | · | · | · | ◐ files issue | · |
| Spec drift, faults, properties, examples, recorded payloads | · | · | · | ◐ files issue | · |
| Real SDE export: real-data suite, registry drift, SDE examples | · | · | · | ◐ files issue | · |
| OpenSSF Scorecard | · | · | · | ◐ weekly | · |
GATE-01 · Ubiquitous · Enforced
A pull request to master shall merge only when the aggregate ci-success job reports every blocking job green.
- Why: One required check that fans in every PR job keeps branch protection simple and complete.
- Verified by: Gate:
ci.ymlci-successwithif: always(), needing 23 jobs, plus a self-check that fails if a job is missing from itsneeds; branch protection requires it, and it alone (Lint, Build & Testwas required untilci-successreplacedQuality Gate). The pull-request mutation job is inmutation-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:examplesandnpm 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-surfacejob ("API Surface Check") inci.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-releaseinrelease.ymlrunsnpx knipwithout--no-exit-code, so an unused file, export or dependency fails the release. The ignore list isknip.jsonc, each entry with its reason. Since 2026-10-08ci.yml(static-analysis) blocks on it too (npm run knip), andcheck:localruns it;npm run validateandnpm run check:allblock 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.ymlandconsumer-matrix-nightly.ymleach end in areportjob that opens or comments on one fixed-title issue (labelsmutation,api-fuzz,no-retry,interleaveandconsumer-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.tslists every job allowedissues: 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, innpm test: every script target exists, and everynpm run <name>inREADME.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_REGISTRYand 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, andsde-real-data.test.tsskipping whereversde-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-zipextracts it, the real-data suite runs withSDE_REQUIRE_DATA=1so a missing export fails,npm run sde:driftwritesreports/sde-drift.jsonand exits 1 on drift,npm run examples:nightly -- --tier sderuns the SDE examples, and thereportjob keeps one issue titled "sde: nightly real-data run failed or the export drifted". Test:tests/tdd/scripts/sde-drift.test.tsproves 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
- HTTPS enforced, host allowlisted.
http://rejected at construction, always. Onlyesi.evetech.netunlessunsafeAllowCustomHost: trueis named explicitly; the flag lifts the host check only. - Path parameters validated. Rejects
/,\,?,#,@and the other URL delimiters, empty values and non-finite numbers, so../xis refused. Everything else, including%and null bytes, is percent-encoded byencodeURIComponent, not rejected. A bare..currently passes and is collapsed by URL parsing (Part 10, row 29). - Query parameters bounded. Length limits; NaN, Infinity and null rejected.
- Token gated by declared scope. Bearer header attached only when the definition says
requiresAuth; missing token fails before the network. - Cache isolated per token. Authenticated cache keys prefix a truncated SHA-256 of the auth header, so two characters never share a cached body.
- URLs sanitised in errors and logs. Eleven sensitive query names redacted; unparsable URLs truncated.
- 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. - Remote counts bounded. Every pagination path caps
X-Pagesat 1000 pages; withincompletePages: '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.
unsafeAllowCustomHostexists for proxies and mocks, and lifts only the host check; there is no way to turn HTTPS off. - Verified by: Test:
security.test.tshost 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.tstoken leakage group;cacheKey.ts; for log lines,sanitizeUrlat the logger boundary (src/core/logger/redactLog.ts), covered byredactLog.test.ts,clientLog.test.tsandtests/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-assetsjob, which also attests SLSA build provenance for the tarball, SBOM and docs archive (actions/attest-build-provenance), checks it withgh attestation verifyand attaches it as a.intoto.jsonlasset. Test:tests/tdd/workflows/workflow-permissions.test.tsfails 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.tswithscripts/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.ymlcreate-assetsrunsnpm 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 tochecksums.txt;sign-and-publish-assetssigns it. Test:tests/tdd/release-sbom/release-sbom.test.tschecks 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/CODEOWNERSexists 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 inguides/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:modelin thesecurity-modeljob ofci.yml. Test:tests/tdd/scripts/security-model-audit.test.tsdrives 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:modelwith--resultsin thesecurity-modeljob ofci.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 cleanandnpm run docsno longer touchdocs/.State at revision 2: fourteen guides exist in
guides/, plusAUDIT.mdfrom Phase 0. The five originaldocs/files are gone, butdocs/itself now holdsknowledge-graph/,spikes/generator/andesi-evolution/. RootTESTING.mdandguides/MUTATION-TESTING.mdare still separate. The docs-site is still unbuilt and still says v9.6.1, and the README banner still says v9.5.2. The full README, docs and examples rewrite is scheduled last in the 11.0 plan, so it documents the finished API.
Surfaces at the time of the survey
| Surface | What it is | Published? | Problem |
|---|---|---|---|
README.md (1037 lines) | The de facto manual | npm + GitHub | Banner says v9.5.2; restates six guides; 39-row client table hand-maintained |
guides/ | Maintainer guides, best depth | GitHub only | Orphaned: nothing links to four of them; two say "37 clients" |
docs/ (5 files) | Examples catalogue, OKF, drift, two strategy papers | GitHub only | Was the TypeDoc output dir; nothing links to any of them |
docs-site/ (VitePress, 21 pages) | A hand-written fork of the README | Never built or deployed | Version dropdown says 9.6.1; two commits ever; duplicates everything |
| TypeDoc on gh-pages | API reference from JSDoc | gh-pages on release | The only published site, and the least readable one |
guides/SDE.md + guides/sde/ | Complete SDE doc set | Site (docs:sync) | Resolved 2026-09-28 (Track S Run 11): moved out of src/, published with the guides |
tests/bdd/README.md + GUIDE.md | EARS specification rules | GitHub only | Newest and best docs in the repo; not referenced from the testing guide |
Root TESTING.md | Older copy of guides/TESTING.md | GitHub only | Different tier numbering from the guide; unlinked |
Target shape
One canonical tree, one published site built from it, counts generated rather than typed. The README shrinks to an orientation page and links out.
README.md pitch · install · quick start · links only
CONTRIBUTING.md · SECURITY.md SECURITY.md becomes the short policy, GitHub-surfaced
guides/ canonical, and the only source the site builds from
├── CHARTER.md this document
├── ARCHITECTURE.md absorbs README caching/rate-limit/streaming prose, interceptors, circuit breaker
├── DESIGN-RULES.md NEW: conventions + "add an endpoint" + "add a client" walkthroughs
├── TESTING.md merges root TESTING.md + MUTATION-TESTING.md; canonical tier table
├── SPECIFICATION.md moved from tests/bdd/GUIDE.md; README.md there becomes a pointer
├── QUALITY-GATES.md NEW: gate matrix, every workflow, every script, nightly-spec-drift.md folded in
├── SECURITY.md controls + supply chain; policy lines removed (they live in root)
├── ERRORS.md NEW: taxonomy, guards, retryability, safe mode
├── LOGGING.md NEW: ILogger, per-client loggers, pino adapter, levels
├── PAGINATION.md NEW: offset, cursor, stream*, fetchAll*, batch, concurrency defaults
├── RUNTIME-VALIDATION.md keep
├── SDE.md index; guides/sde/* hold the module reference, usage, architecture, developer guide and contracts
├── OKF.md moved from docs/okf-guide.md
├── RELEASE.md NEW: release-please, changelog, provenance, support window
├── SEMVER.md NEW: what the public contract is, major/minor/patch decisions, commit markers, merge buttons
├── DOCUMENTATION.md rewritten: surfaces, site build, TypeDoc, metrics generation
├── BEADS.md keep; AGENTS.md and CLAUDE.md shrink to pointers
└── rfcs/ future design papers (the jitaspace mapping and streaming-websocket strategy were retired as dated)
docs-site/ VitePress; guide/ and examples/ generated by scripts/docs/sync-docs.ts from guides/ and examples/; public/api = TypeDoc
docs/ hand-written guides retired (okf-guide → guides/OKF.md, nightly-spec-drift → QUALITY-GATES); now holds only spikes and generated artefacts
TESTING.md (root) deleted after merge
etc/doc-metrics.json generated counts (npm run docs:metrics): clients, endpoints, rules, scenarios, test filesGuide roadmap
| Target guide | Action | Status (rev 2) | Sources to fold in | Charter parts | Tracking |
|---|---|---|---|---|---|
| CHARTER.md | new | Done | This document | all | — |
| ARCHITECTURE.md | merge | Done | README caching, rate limiting, streaming, error handling sections; docs-site interceptors, circuit-breaker pages | 2 | esi-07y · #286 |
| DESIGN-RULES.md | new | Done | CLAUDE.md key patterns; generate:endpoints scaffold usage; naming table above | 3 | esi-lvi · #279 |
| TESTING.md | merge | Root copy folded in 2026-09-27, MUTATION-TESTING.md 2026-09-28; both deleted | Root TESTING.md, MUTATION-TESTING.md, README testing section, ARCHITECTURE §8 | 4 | esi-b4a · #273 |
| SPECIFICATION.md | move | Open | tests/bdd/GUIDE.md + README.md; ears-gherkin-dev skill steps | 4 | — |
| QUALITY-GATES.md | new | Done | .github/workflows/README.md, docs/nightly-spec-drift.md, scripts table | 5 | esi-30o · #280 |
| SECURITY.md (guide) | merge | Done; CODEOWNERS text stale | Remove policy duplication; add SBOM and CODEOWNERS plan | 6 | esi-p0s · #285 |
| ERRORS.md | new | Done | docs-site reference/errors.md; src/core/util/error.ts | 2 | esi-rt2 · #281 |
| LOGGING.md | new | Done | Write from the per-client logging refactor | 2, 3 | esi-5zs · #282 |
| PAGINATION.md | new | Done | README streaming and cursor sections; docs-site pagination.md; 9.7.0 changelog for fetchAll* | 2 | esi-358 · #283 |
| RUNTIME-VALIDATION.md | keep | Kept; no Implements: line yet | Delete README and docs-site copies, link instead | 3 | — |
| SDE.md + guides/sde/ | move | Done 2026-09-28 (Track S Run 11) | src/sde/README.md, src/sde/docs/*, docs-site guide/sde.md | 2 | — |
| OKF.md | move | Done | docs/okf-guide.md; link from README and DESIGN-RULES | 3 | — |
| RELEASE.md | new | Done | release-please config, CHANGELOG conventions, release.yml jobs, SECURITY.md support table | 8 | esi-38g · #284 |
| DOCUMENTATION.md | merge | Site build documented 2026-09-27; metrics generation open | Rewrite around the target tree; document the site build and metrics | 7 | — |
| BEADS.md | keep | Open: AGENTS.md still has three Beads blocks | Collapse AGENTS.md's three blocks and CLAUDE.md's copy to pointers | 9 | #276 |
| Root TESTING.md, docs/examples.md, docs-site duplicates | retire | Done 2026-09-27: root TESTING.md retired; docs-site pages generated, hand-written copies removed | Examples catalogue becomes docs-site/examples generated from examples/*.ts headers | 7 | #264 |
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 (rootTESTING.md,guides/MUTATION-TESTING.md) stay deleted, the test tier table is stated inguides/TESTING.mdonly, 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.jsonout: docs-site/public/api;cleanandclean:docsscripts updated;.gitignoreentry.
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:sitebuilds TypeDoc, thenscripts/docs/sync-docs.tsgenerates the git-ignored guide pages from README.md andguides/and the example pages fromexamples/(logic inscripts/docs/sync-docs-core.tsandscripts/docs/examples-showcase-core.ts, tested bytests/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'sdocumentationjob builds it on every pull request;release.yml'sdeploy-docscalls.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 inscripts/docs/doc-metrics-core.ts, tested bytests/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 toetc/doc-metrics.jsonand rewrites every<!-- metric:name -->marker in README.md,guides/*.mdanddocs-site/index.md.npm run validate:versions, inci.ymlstatic-analysisandrelease.ymlvalidate-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'sversionthroughextra-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 exceptAUDIT.mdis linked from the README. - Verified by: To add: link-check script asserting each
guides/*.mdis linked from README and carries anImplements: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),InMemoryFetchandcreateNoopLoggerexist 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, andtest: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:
featbumps minor,fixbumps 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.ymlvalidate-releasejob, which also runsvalidate: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) inci.ymlstatic-analysisandrelease.ymlvalidate-release; release-please bumpssrc/core/constants.tsthroughextra-files, anddocs-site/.vitepress/config.tsreads the selector frompackage.jsonat build time, which the script checks it still does.npm run validate:versions -- --sitechecks the built page beforedocs-site.ymldeploys it and inci.yml'sdocumentationjob. The README's release line carries the version in ametric:versionmarker, which release-please bumps throughextra-filesand 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.tskeeps 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 aBREAKING CHANGE:footer, not a silent engines bump; decided for 11.0.0 on 2026-09-26. - Verified by: Gate:
enginessays>=22.12.0and.nvmrc22; theunit-testsjob inci.ymlruns on Node 22 and 24, andconsumer-contractinci.ymlandrelease.ymlruns 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.xin 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.mdclassification and the Reviewer Checklist. Gate: theapi-semverjob inci.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(thePROC-01block):CLAUDE.mdandAGENTS.mdnamebdas the tracker and rule out markdown TODO lists, andguides/BEADS.mdtreats.beads/issues.jsonlas 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(thePROC-02block):guides/BEADS.mdstates 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 protectiononmaster, a repository setting, which requiresci-success(it gates every other job, Lint & Build included). Thebranch-protectionjob inmaintenance.ymlreads the setting through the API each week (scripts/quality/branch-protection.ts, tested intests/tdd/scripts/branch-protection.test.ts) and fails whenmasteris unprotected orci-successis 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-msghook, 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.mdand once inCLAUDE.md; both now point atguides/BEADS.mdandbd prime. The persona files are gone from the repository root. - Verified by: Test:
tests/tdd/scripts/agent-process.test.ts(thePROC-05block) 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 inscripts/quality/charter-audit-core.ts, tested bytests/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 noTest:orGate:clause, a "Verified by" that opens without a label, and a cited test file whosedescribe,itandtesttitles do not name the row. Runs incheck:all,scripts/quality/verify-local-core.tsandci.yml'sspec-auditjob.
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.
| # | Sev | Finding | Requirement | Fix | Bead | Issue |
|---|---|---|---|---|---|---|
| 1 | HIGH | Branch failed tsc --noEmit from the logging refactor (two import paths, one stale call) | ARCH-09, GATE-02 | Done in revision 1: paths fixed, parseJsonBody receives the client, pagination logging migrated, logFatal/logTrace exported, toPinoLogger keeps pino's this binding | esi-d3o | #262 |
| 2 | HIGH | npm run docs / clean deleted five committed files in docs/ | DOC-02 | Done: TypeDoc moved in revision 1; the five markdown files moved, folded or retired | esi-a2k | #263 |
| 3 | HIGH | docs-site never built or deployed; duplicates README at two-version lag | DOC-03 | Done 2026-09-27: npm run docs:site generates the site from guides/ and examples/; docs-site.yml deploys it from the release workflow; hand-written copies removed | esi-06b | #264 |
| 4 | MED | logFatal/logTrace were not exported; global-logger fallback needs a lint gate | ARCH-09 | Done: exports in revision 1; no-restricted-imports gate in npm run lint and lint:layers | esi-772 | #265 |
| 5 | MED | isCircuitOpen missing from ./errors; plumbing errors are string-typed | ARCH-07 | Export guard; introduce EsiConfigurationError family | esi-gyh | #266 |
| 6 | MED | CustomEsiClient missing four getters | ARCH-08 | Done: getters added, compile-time check in customClientGetters.test.ts; EsiApiFactory reaches all 39 through createClient(type), with named methods for 9 | esi-eqq | #267 |
| 7 | MED | No sideEffects: false; two pino instances built at import | ARCH-06 | Done: default logger built on first use; "sideEffects": false; importSideEffects.test.ts spies on pino and bundles import { EsiError } | esi-piw | #268 |
| 8 | MED | handleSinglePageRequest hardcodes GET; cursor fetchAll swallows failures | DES-08 | Done: rules in 0051-resilience.feature; the stream helpers pass the real method and the cursor fetchAll rejects | esi-dwi | #269 |
| 9 | MED | No SBOM; no CODEOWNERS; admins exempt from protection | SEC-06, SEC-07 | SBOM done (SEC-06); CODEOWNERS done; admins-included is a maintainer setting, steps in SECURITY.md §5 | esi-wze | #270 |
| 10 | MED | Tests not linted; knip non-blocking; mutation never gates | TEST-09, GATE-04, TEST-07 | Incremental Stryker on PR done (TEST-07); suite-health and seam lint added; npm run lint covers tests/ done (TEST-09); knip blocks at release done (GATE-04) | esi-p56 | #271 |
| 11 | MED | Client count quoted as 33, 35, 36, 37 and 39; BDD counted as 41 features; README banner 9.5.2 | DOC-04, REL-03 | Done 2026-09-28: npm run docs:metrics generates etc/doc-metrics.json and rewrites the marked counts; validate:versions fails on a stale count or README release line | esi-j03 | #272 |
| 12 | MED | Two testing guides with different tier numbers; root TESTING.md unlinked (root folded into guides/TESTING.md 2026-09-27) | DOC-01 | Done 2026-09-28: one testing guide; MUTATION-TESTING.md folded in; links checked by sync-docs.test.ts | esi-b4a | #273 |
| 13 | LOW | sde:seed points at a missing script | GATE-06 | Done: removed; script targets checked by package-scripts.test.ts | esi-x3z | #274 |
| 14 | LOW | Changelog skips 9.2 to 9.6 | REL-04 | Done 2026-09-27: 8.0.0, 9.4.0 and 9.6.0 backfilled from their tags; every unpublished number from 9.1.1 to 9.9.0 headed (not published) | esi-5fu | #275 |
| 15 | LOW | Beads quick reference duplicated five times; persona files untracked in the repo root | PROC-05, PROC-04 | Persona files gone; pointers still to do | esi-udr | #276 |
| 16 | LOW | Nightly mutation and Schemathesis upload artifacts but file no issue | GATE-05 | Done: each ends in a report job that opens, comments on or closes one fixed-title issue (mutation, api-fuzz) | esi-mbr | #277 |
| 17 | LOW | CONTRIBUTING.md says Node 18 while .nvmrc says 20; populated .env in working trees | SEC-08, REL-05 | Node 22 floor done (CONTRIBUTING, .nvmrc, engines); .env housekeeping still to do | esi-wc6 | #278 |
| 18 | HIGH | Authenticated paginated results cached under an unhashed key; repeat calls returned page 1 | DES-08, SEC-02 | Done: the combined body is cached with requiresAuth, so it lands under the token-hashed key | esi-l38.1 | #290 |
| 19 | HIGH | Default all-pages call silently returned page 1 when a later page kept failing | DES-08 | Done: surfaces PAGINATION_INCOMPLETE (#413) | esi-l38.2 | #291 |
| 20 | MED | stream*/fetchAll* threw EsiError 304 after a normal call to the same endpoint | DES-08 | Done: the cached body is served on a 304 | esi-l38.3 | #292 |
| 21 | MED | Mutations returning 201/204 did not invalidate the cache path prefix | Part 2 step 7 | Done | esi-l38.4 | #293 |
| 22 | HIGH | Release pipeline broken: release-please GraphQL failure, cosign --bundle, token trigger, version marker | REL-02, SEC-04 | Done: cosign 3 bundle, x-release-please-version marker; the remaining trigger problems are row 30 | esi-l38.5 | #294 |
| 23 | MED | Validation errors retryable, network faults untyped, safe mode and token refresh collapse types | ARCH-07 | Done: typed classes (#470) | esi-l38.6 | #295 |
| 24 | MED | Log lines carry unsanitised URLs; several call sites bypass the per-client logger | SEC-02, ARCH-09 | Done: sanitizeUrl at the logger boundary; rate limiter, cache, batch and token manager migrated | esi-l38.7 | #296 |
| 25 | MED | CI does not match the gate matrix: release gate, never-run validators, dead docs job, freshness diff | GATE-01..06, REL-02 | Done: release gate runs the charter's validators; validate:esi and validate:spec wired; freshness diff covers every esi-*.generated.ts | esi-l38.8 | #297 |
| 26 | MED | Endpoints returning a body lack responseSchema | DES-02 | Done: spec:response-schemas (#471) | esi-l38.9 | #298 |
| 27 | LOW | Charter revision 1 carried factual errors found while writing the guides | DOC-01 | Done in revision 2 | esi-l38.10 | #299 |
| 28 | LOW | Minor defects: schema:drift pairing, half-open extra probe, unused clientId, TEST-03 spy | several | Done | esi-l38.11 | #300 |
| 29 | MED | A path parameter of exactly .. passes validation and URL parsing collapses the segment (characters/../assets/ → /assets/) | Part 6 step 2, SEC-01 | Reject . and .. as path parameters; add the case to security.test.ts | — | #430 |
| 30 | HIGH | Releases need a manual dispatch of release.yml, and CI on the release-please PR waits for manual approval | REL-02 | A GitHub App token for release-please; waiting on the maintainer to create it | — | #378, #383 |
| 31 | MED | No check that every public client method or provider method has a Rule | TEST-10 | Done 2026-09-28: spec:coverage:sde:ci (Track S Run 4, baseline empty since Run 6) and spec:coverage:clients:ci (Phase 5 item 8), each with a shrink-only baseline | — | — |
| 32 | LOW | Exclusions (what the client ignores) stated in prose, not as executable unwanted-behaviour Rules; one such Rule exists | TEST-11 | Done: every stated exclusion is a Rule, and ears lists the 29 shall not Rules with their verdicts (2026-09-28) | — | — |
| 33 | LOW | The charter is EARS by convention; no audit parses its requirement blocks or checks Enforced rows name a mechanism | PROC-06 | Done: npm run charter:audit (Phase 5 item 7) | — | — |
| 34 | MED | Two SDE features (errors, ingestion) verified no charter requirement; they sat on a shrink-only exception list | ARCH-11..13 | Done 2026-10-12: ARCH-11, ARCH-12 and ARCH-13 added; both features tagged; unchartedFeatures empty | — | #640 |
| 35 | HIGH | 12.1.0 deprecated every custom-client surface for removal in 13.0.0; no requirement said the custom clients are the product | ARCH-14, ARCH-08 | Done 2026-10-17: posture 1 and ARCH-14 added; 12.2.0 lifts the 12.1.0 deprecations; custom-client-identity.test.ts gates a new one | — | #664 |
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.
- Make the branch compile. ✅ Revision 1.
- Move TypeDoc output to
docs-site/public/apiand updateclean. ✅ Revision 1. The five hand-writtendocs/files are moved, folded or retired;docs/now holds only spikes and generated artefacts. - Commit this charter as
guides/CHARTER.mdand file one bead per row of the gap register, tagged with the requirement ID. ✅ Revision 1. - 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.md2026-09-27,guides/MUTATION-TESTING.md2026-09-28, #273). - Write the new guides: DESIGN-RULES, QUALITY-GATES, ERRORS, LOGGING, PAGINATION, RELEASE. Each opens with
Implements: ARCH-03, DES-01 …. ✅ All six exist withImplements:lines. - Generate the numbers.
scripts/docs/doc-metrics.tswritesetc/doc-metrics.json; a small template step stamps the README and site. Extendvalidate:versionsto fail on stale banners. ✅ 2026-09-28:npm run docs:metricsrewrites the marked counts andvalidate:versionsfails on a stale one (#272). - Publish the site.
scripts/docs/sync-docs.tscopiesguides/intodocs-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. - 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.
