M2-23: Generated TypeScript and Python clients as a gate (G4, G5)¶
Status: ready | Depends on: M1-17 | Milestone: M2
Goal¶
After this ticket sdk/python and sdk/typescript are real components with a generated core. openapi-python-client 0.29.1 and openapi-typescript 7.13.0 (with openapi-fetch as the runtime client), pinned inside each component, generate the clients from docs/api/openapi.yaml; the generated code is checked in; make -C sdk/python generate-check and make -C sdk/typescript generate-check regenerate it, fail on any drift and compile it; and make api-clients runs both in every make check (gate G4). A small hand-written tolerance layer per SDK makes both clients accept what a newer server may send: an unknown field is ignored, an unknown enum value is kept as its raw string, an event of an unknown type is skipped while the cursor still advances, and a received JSON null counts as absent; one test per SDK proves this on shared fixtures (gate G5, ADR 0053). Why: a new enum value or event type is only an additive change (06 "Versioning, deprecation, compatibility") if every client tolerates it, and openapi-python-client throws on unknown values (06 13 point 3, RK-15). Without this gate a spec change would break the SDKs, the adapters and apps built on them without anyone noticing, and M2-29 and M2-30 would build their hand layers on an unchecked core.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Contract": G1 to G6 as own gates inmake check); section 8 "M2 Agent platform, extensions and terminal": "Goal" (apps and webhooks use the same public API, P-18), "Exit criteria" item 4 (G4 and G5 for TypeScript and Python throughmake api-clients, M2-23), "Test strategy" ("contracts G4, G5, G10"), "Risks" ("the Python generator throws on unknown values (hand-written layer, G5)"); block "agents and extensions" rows M2-23, M2-29, M2-30, M2-31, M2-37; section 10 row M4-02 (the Dart client, same gate); section 7 "Test strategy" ("G4 and G5 follow with the SDK clients"); section 19 P-04 (the enable list governs the server only), P-09, P-10, P-11.docs/spec/06-api.md: 2 "Basic rules" (rows "Nonull", "Unknown values", "Language"); 11 "Real-time gateway" point 3 (Eventis the union of all durable types with discriminatortype); 13 "Versioning, deprecation, compatibility" point 3 (Python only with a hand layer; openapi-python-client 0.29.1 throws on unknown values, also withliteral_enums); 14 "Code generation" (generator table, points 1 to 4); 15 "Contract tests and gate" (rows G4 and G5, point 3); 19 "Decisions of this chapter" (R-API-04, R-API-05, R-API-06, R-API-18).docs/spec/11-extensions.md: 11 "SDK design" (11.1 "Generated core": the TypeScript runtime client is "choice in the build ticket"; 11.2 "Hand-written layer"; 11.3 "Runtimes and dependencies"); 12 "Versioning and compatibility"; 14 "Decisions of this chapter" (R-EX-10, R-EX-11); 16 "Configuration values of this chapter" (sdk.*, used by M2-29 and M2-30, not here).docs/spec/04-sync-protocol.md(core contract, read only): 6.1 "Endpoint and version" ("Clients ignore what they do not know"); 7.1 "Cursor"; 7.4 "REST variant" ({events, next_cursor, head_seq, sync_epoch}).docs/spec/23-quality.md: 1 "Principles" (points 3 to 5); 2 "Gates"; 3 "Component contract" (rows "Python project", "TypeScript project", "Generated client") with "Documentation in code"; 10 "Rulings" (R-QA-02, R-QA-14); 11 "Configuration values of this chapter".docs/spec/02-architecture.md: 6 "Code layout", the list "Places that carry the product name" (the only list of places where the working name may appear in code).docs/spec/18-release.md: 2 "Components" (rowssdk-python,sdk-typescript; rule 1:VERSIONandCHANGELOG.mdper component).docs/spec/19-security.md: SEC-SC-1.docs/security/threat-model.md: T-SC-01.docs/api/openapi.yaml: operationslistEventsandpollAgentEvents(both answerEventPage),getThread; schemasEvent(itsdiscriminator.mapping),EventPage,EventEnvelope,EventActor,ActorKind,EventScope,EventResource,ThreadUpdatedEvent,ThreadPayload,Thread(with the inline enumkindandstate),ThreadState,ThreadMovedOutEvent,ThreadMovedOutPayload,Problem(codeis a string, unknown codes are handled by status).docs/api/asyncapi.yaml(references the schemas of openapi.yaml; no generator, 06 14 point 3).docs/api/README.md("Change").- ADRs: 0013 (spec first, generated code checked in), 0014 (versioning; "Confirmation": G5), 0026 (generated core plus hand layer; "Confirmation"), 0038 (one local gate command identical to CI), 0041 (Renovate), 0044 (working name only at listed places), 0052 (one event envelope), 0053 (no JSON
null; "Confirmation": G5 skips a receivednull). docs/DECISIONS.md: D-R-CLIENTS, D-API-13, D-QA-22, D-QA-25, D-W-54, D-W-100, section 10 "Rulings of the build milestones" (row formatD-M2-23-<n>).docs/RISKS.md: RK-15, RK-21.docs/OPEN-QUESTIONS.md: OQ-17 (package names and accounts at PyPI and npm; default: reserve only after the final name; the plan builds the SDKs without publishing).docs/quality/gates.md(rowsapi-clients,coverage,lint,typecheck,licenses; section "Skipping and failing": a missing part of the component contract is an error),docs/quality/toolchain.md("Gate tools"),docs/quality/licenses.md("License gate (make licenses)", point 3:PackageOverridesonly per package with reason),docs/quality/public-switch.md(step 18 "Trial run").sdk/README.md,sdk/python/README.md,sdk/typescript/README.md(status "planned" today).- M1 briefs and merged code this ticket builds on (read the merged versions on the base branch):
- M1-01:
tools/ci/codegen_check.py(--dir <path> -- <command>, snapshot of every file git would see before and after the generator; exit 1 withcodegen-check: generated code in <path> was stale; commit the regenerated files), the rootMakefile(variablesCODEGEN_CHECK,PY_PROJECTS,TS_PROJECTS,PY_ON,TS_ON, functionwant, targetsapi-clients,bootstrap-ci,fmt-check,lint,typecheck,test,coverage,licenses),tools/ci/tests/test_make_server_gates.py(pattern for testing make recipes with fakes). - M1-17: the Go server stubs generated from the same spec (the precedent for generator pins inside the component: oapi-codegen as a
tooldirective inservices/server/go.mod),services/server/api/rest/rest_test.go(TestEventUnion_UnknownTypeDecodes: the Go side of the same tolerance), the coverage exclusion of*.gen.go,tools/ci/components.py(GATE_COMPONENTS,GATE_COMPONENT_INPUTS,API_PREFIX),tools/ci/license_check.py(GENERATEDpatterns,*/generated/*is exempt from the SPDX header),tools/docs/tests/test_toolchain_doc.py(pinned_tools,go_tools),sonar-project.properties(**/generated/**excluded, coverage report pathssdk/python/coverage.xmlandsdk/typescript/coverage/lcov.info, test inclusions**/tests/**,**/test/**,**/*.test.ts). - M1-03:
make api-checks(G2) andmake api-tables(G3), which keepopenapi.yamlconsistent before any client is generated from it. - M1-74 (merged on the base branch, not in the dependency closure):
.github/workflows/codeql.ymlandtools/ci/tests/test_codeql_workflow.py, which require a matrix entry for every tracked component language and name the first TypeScript project as the one that addsjavascript-typescript; the runbook check of step "Trial run".
- M1-01:
- Also read:
tools/docs/tests/test_config_values.py,tools/ci/tests/test_components.py,.pre-commit-config.yaml(hygiene hooks run over all tracked files,check-added-large-fileswithrepo.max_file_kb,text-rules),.gitignore,renovate.json,.github/workflows/ci.yml(jobsgatesandsonarrunbootstrap-ciand thenmake gatesormake sonar),quality/licenses/allowed.txt. - Implied dependencies: the header names only M1-17; M1-03, M1-05, M1-02 and M1-01 are in its closure and therefore merged before this brief starts.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- G4: both clients are generated in the gate from
docs/api/openapi.yamlwith the pinned generators of 06 14 (Python: openapi-python-client 0.29.1; TypeScript: openapi-typescript 7.13.0 with openapi-fetch), compiled, and the gate fails on any difference to the checked-in code (06 15 row G4, 06 14 point 1, R-API-18, ADR 0013). The generated core is never changed by hand (11 11.1, R-EX-10). - Component contract (23 3): each client directory has a
Makefilewith the targetgenerate-check("generate fromdocs/api/, compile; a deviation from the checked-in state reports an error"); the Python project haspyproject.tomlwith a uv lock and ruff, mypy and pytest in the dev group and its coverage threshold in its own pytest configuration; the TypeScript project haspackage.jsonwith the scriptsformat,format:check,lint,typecheck,testand the threshold in its test runner. A missing part fails instead of skipping (23 1 point 3, R-QA-02, D-QA-22, gates.md "Skipping and failing"). - G5: one test per SDK with fixtures: an unknown field, an unknown enum value and an unknown event type decode without an exception (06 15 row G5; 06 2 row "Unknown values"; R-API-04; 04 6.1; ADR 0014 "Confirmation"), and a received JSON
nullis treated as an absent field (06 2 row "Nonull"; ADR 0053 point 5 and "Confirmation"). - Python tolerates unknown values only through the hand layer; the generator runs with its standard settings (06 13 point 3, 06 14 row "Python SDK and Hermes adapter", ADR 0014 "Consequences", ADR 0026).
- A client skips an event of unknown type and still advances its cursor (
Eventdescription in openapi.yaml; 04 7.1 and 7.4). - Dependencies are locked with checksums and pass the license and vulnerability gates (SEC-SC-1, T-SC-01, licenses.md "License gate (
make licenses)" point 3). - Each new component has
VERSIONandCHANGELOG.md(18 2 rule 1). - Doc comments on every public symbol of the hand-written SDK code; for Python ruff
Dwith conventiongoogle, tests excluded (23 "Documentation in code", R-QA-14). The Python API reference in MkDocs (rule 3) and the typedoc gate come with M2-29 and M2-30.
Scope¶
Paths are relative to the repository root. This ticket creates no new top-level make target: api-clients exists; the new targets generate and generate-check live in the component Makefiles.
Create:
sdk/python/pyproject.toml,sdk/python/uv.lock: projectbothy-sdk,version = "0.0.0",license = "Apache-2.0", src layout, the runtime dependencies the generated code imports (Design note 11), dev group with the exact pinopenapi-python-client==0.29.1and ruff, mypy, pytest, pytest-cov, PyYAML (for the spec tests) and type stubs;[tool.ruff],[tool.mypy],[tool.pytest.ini_options],[tool.coverage.*]per Design notes 8 and 10.sdk/python/openapi-python-client.yaml: generator configuration (Design note 3).sdk/python/Makefile: targetsgenerateandgenerate-check(Design note 3), header comment liketools/design-tokens/Makefile.sdk/python/src/bothy_sdk/__init__.py,sdk/python/src/bothy_sdk/py.typed,sdk/python/src/bothy_sdk/tolerance.py(Design note 5).sdk/python/src/bothy_sdk/generated/: generated core, written only bymake -C sdk/python generate.sdk/python/tests/conftest.py,sdk/python/tests/test_tolerance.py,sdk/python/tests/test_generated_core.py.sdk/python/VERSION(0.0.0),sdk/python/CHANGELOG.md(Keep a Changelog, likeservices/server/CHANGELOG.md).sdk/typescript/package.json,sdk/typescript/package-lock.json,sdk/typescript/tsconfig.json,sdk/typescript/eslint.config.mjs,sdk/typescript/.prettierignore,sdk/typescript/vitest.config.ts(Design notes 6, 10 and 12).sdk/typescript/Makefile: targetsgenerateandgenerate-check.sdk/typescript/src/index.ts,sdk/typescript/src/tolerance.ts(Design note 6).sdk/typescript/src/generated/openapi.ts: generated types, written only bymake -C sdk/typescript generate.sdk/typescript/test/tolerance.test.ts,sdk/typescript/test/generated.test.ts.sdk/typescript/VERSION(0.0.0),sdk/typescript/CHANGELOG.md.sdk/testdata/tolerance/unknown_field.json,unknown_enum_value.json,unknown_event_type.json,null_member.jsonandREADME.md(English, like the other READMEs undersdk/: what each fixture holds and which test reads it) (Design note 7).tools/ci/tests/test_make_api_clients.py(Design note 9 and the tests below).sdk/python/osv-scanner.tomlorsdk/typescript/osv-scanner.tomlonly if OSV-Scanner reports a license asUNKNOWNor non-standard (Design note 13).
Change:
Makefile(root):api-clientsfails closed and respectsCOMPONENTS;bootstrap-ciinstalls the SDK dependencies; the Python lines oftypecheckandlint(Design note 9).tools/ci/components.pyandtools/ci/tests/test_components.py:sdk/testdata/selects both SDK components (Design note 7).tools/docs/tests/test_config_values.py: sources of the two new coverage keys (Design note 10).tools/docs/tests/test_toolchain_doc.py: generator pins read fromsdk/python/uv.lockandsdk/typescript/package-lock.json, and 06 14 names the pinned versions (Design note 2)..github/workflows/codeql.yml: matrix entrylanguage: javascript-typescript,build-mode: none; header comment updated (the first TypeScript project is now tracked).docs/quality/public-switch.mdstep 18 "Trial run": nameanalyze (javascript-typescript)..gitignore:sdk/**/coverage/.renovate.json: a package rule for the generator pins (Design note 13).docs/spec/06-api.md14 (table cell of the TypeScript row with the openapi-fetch pin; where the generated code lives; the Python tolerance hook) and 15 (row G5, Design note 14);docs/spec/11-extensions.md11.1 (runtime client openapi-fetch; gateway types from the schemas of openapi.yaml that asyncapi.yaml references, as 06 14 point 3 says);docs/spec/23-quality.md3 (rows "Python project" and "TypeScript project": coverage keys, generated code outside coverage and style lint) and 11 (two keys);docs/spec/02-architecture.md6 (list "Places that carry the product name": the SDK package names).docs/quality/gates.md(rowsapi-clients,coverage,lint,typecheck),docs/quality/toolchain.md("Gate tools": rowsopenapi-python-clientandopenapi-typescript; the intro sentence names the SDK locks as pin sources),docs/api/README.md("Change" point 5: regenerate both SDK cores withmake -C sdk/python generateandmake -C sdk/typescript generate).sdk/README.md(table "State", section "Package names"),sdk/python/README.md,sdk/typescript/README.md(status, layout, how to regenerate, what the tolerance layer guarantees and what it does not), all in English like today.docs/DECISIONS.mdsection 10: rowsD-M2-23-1and following (Design note 15).CHANGELOG.md(root) under "Unreleased": the tool chain changes (api-clientsfails closed, SDK dependencies inbootstrap-ci, CI path filter forsdk/testdata/, CodeQL for TypeScript).- If
tools/ci/tests/test_product_name_places.py(M1-46) exists on the base branch: allow the SDK package names as the new kind from 02 6 there.
Out of scope¶
- The hand layer modules
auth,gateway(frame router, ephemeral events and gateway ops),invocation,runs,components,commands,outbox,retry,heartbeat,webhooks,testingwith the FakeServer, the client defaults fromsdk.http_timeout,sdk.retry_max_attempts,sdk.retry_backoff_baseandsdk.retry_backoff_cap, the deprecation warning of 11 12, the MkDocs API reference (mkdocstrings) and the gate test "a public function without docstring failsmake lint": M2-29 (Python). - The same for TypeScript plus the webhook signature verifier, the typedoc gate, the typedoc reference and the build output (
dist/): M2-30. - The Dart client
packages/api_clientwith its G5 test: M4-02. The Go server stubs andmake codegen-check: M1-17. - The breaking-change check against the last release (G6): M7-11. Publishing to PyPI or npm and the final package names: OQ-17, V1-06; the release chain of the SDK components: the release milestone (M7).
- Tolerance of unknown gateway ops and close codes: M2-29 and M2-30 (their gateway modules).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-23-generated-clients; one writer per worktree. - Conventional Commits with the component as scope (for example
feat(sdk-python): ...,feat(sdk-typescript): ...,build(gates): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a package to PyPI or npm and never reserve a name there (OQ-17); both packages stay private (Design note 11). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests open no listener (if one ever becomes necessary, only
127.0.0.1and port 0, P-10) and call no server: REST calls in the SDK tests go through an in-process fake transport (httpx.MockTransport, a fakefetch) with base URLhttps://example.com/api/v1; no real third-party service and no real server of this product is contacted. No PostgreSQL is needed. - Tests never modify the working tree: the drift tests run on temporary copies (Design note 9).
- Stop only processes you started, by PID. Temporary directories only via
mktemp -d(or the test framework's temporary directory), removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools, Python packages and npm packages; npm installs run with
--ignore-scriptsliketools/node(T-SC-01). - Generators and dev tools are pinned to exact versions in the component manifests and locks; no
latest, no unpinnednpxoruvxcall in a Makefile or script. - Never edit generated files by hand; regenerate. Do not exclude generated files from the pre-commit hygiene hooks and do not raise
repo.max_file_kb; if the pinned generator's output cannot pass them after the deterministic normalization of Design note 3, stop withVerdict: BLOCKED <reason>. - Do not extend
quality/licenses/allowed.txt; choose dependencies whose whole tree passes it. If a pinned generator or required tool cannot, stop withVerdict: BLOCKED <package and license>. - Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>.
Design notes¶
-
Layout and names. Ruling for docs/DECISIONS.md: Python distribution
bothy-sdk, import packagebothy_sdkinsdk/python/src/bothy_sdk/, generated core in the subpackagebothy_sdk.generated(sdk/python/src/bothy_sdk/generated/); npm packagebothy-sdkwith"private": true,"type": "module", generated types insdk/typescript/src/generated/openapi.ts. Reasons: the directory namegeneratedis already excluded by Sonar (**/generated/**) and exempt from the SPDX header check (*/generated/*intools/ci/license_check.py), so no gate configuration changes for it; the names follow the Dart packagesbothy_vizandbothy_noiseand become a listed kind in 02 6 "Places that carry the product name" (ADR 0044), so V1-06 renames them with the other name-bound places; they are working names until OQ-17 is decided. Add the classifierPrivate :: Do Not Uploadtopyproject.tomlso an accidental upload is refused. -
Generator pins. Ruling for docs/DECISIONS.md: each generator is pinned inside its component,
openapi-python-client==0.29.1in the dev group ofsdk/python/pyproject.toml(locked insdk/python/uv.lock) andopenapi-typescript7.13.0exactly indevDependenciesofsdk/typescript/package.json(locked inpackage-lock.json), like oapi-codegen inservices/server/go.mod(M1-17). Reason: the generator version belongs to the code it produced, Renovate updates it next to that code, and the license and OSV gates see it. Fallback, only if the license gate rejects a dependency that only the Python generator pulls in: pin it intools/pyproject.tomlinstead (development tools are outside the allowlist,quality/licenses/allowed.txtheader) and record the reason in the report.docs/quality/toolchain.mdgets one row per generator (version, purpose "core of the Python SDK or types of the TypeScript SDK, respectively, fromdocs/api/openapi.yaml, runs inmake api-clients", license, reason "06 14, ADR 0026"); extendpinned_tools()andtest_every_pinned_gate_tool_is_documentedintools/docs/tests/test_toolchain_doc.pyto read these two pins from the SDK locks, and add a test that the TypeScript and Python rows of the table in 06 14 name exactly the pinned versions of openapi-typescript, openapi-fetch and openapi-python-client. -
Targets
generateandgenerate-check(per SDK Makefile;SHELL := bash,.SHELLFLAGS := -euo pipefail -clike the root Makefile).- Variables with defaults that a test can override:
SPEC ?= ../../docs/api/openapi.yamlandTOOLS ?= ../../tools. generateregenerates in place from$(SPEC): Python withuv run --project . --locked openapi-python-client generatewith--meta none,--overwrite,--config openapi-python-client.yamland the output path of the generated subpackage (check the flags against--helpof the pinned version); TypeScript with the localopenapi-typescriptbinary fromnode_modules/.binand outputsrc/generated/openapi.ts. Standard generator settings (06 14 column "Settings"): in particularliteral_enumsstays off for Python, because the tolerance of Design note 5 needs enum classes, and openapi-typescript runs without flags that change the output form.- Determinism: two runs on the same spec give byte-identical files, wherever they run. Set the post hooks of openapi-python-client explicitly in its configuration (an explicit ruff configuration path, or none) so that the output does not depend on the working directory. If the raw output of a pinned generator does not pass the pre-commit hygiene hooks (trailing whitespace, final newline, line endings,
text-rules),generatenormalizes it as its last step with a deterministic rewrite;make lintmust find nothing to fix in generated files. generate-check(G4): first the drift check with the M1-01 tool,uv run --project $(TOOLS) --locked --quiet python $(TOOLS)/ci/codegen_check.py --dir . -- $(MAKE) generate, so it reportschanged:,added:andremoved:lines and fails with exit 1 when the checked-in code differs from a fresh generation; then compile: Pythonpython -m compileall -qover the package andmypywith the project configuration (generated code included); TypeScriptnpm run typecheck(tsc --noEmit,strict: true, generated file included). Reason for reusingcodegen_check.py: one drift rule in the repository, and an uncommitted hand edit outside the generator's output is not drift (M1-01 Design note 4).- Size: a generated file larger than
repo.max_file_kbfails the hook; see Boundaries.
- Variables with defaults that a test can override:
-
The SDKs are generated from the full spec, all operations under
paths, with no filter byservices/server/api/operations.yaml: P-04 governs what the server registers, while 11 11.1 asks for "models and calls of all REST operations". A test (test_g4_every_operation_is_generated) fails if the Python generator skipped an operation (it can skip with only a warning); for TypeScript thepathsinterface holds every path by construction. -
Python tolerance,
bothy_sdk.tolerance. Ruling for docs/DECISIONS.md: tolerance through a hook that the hand layer installs on import, no custom generator templates (06 14 "default", 06 13 point 3 "only with the hand-written layer", R-EX-10 "the core is never changed by hand"). Public API, each with a docstring (google convention):install() -> None: idempotent;bothy_sdk/__init__.pycalls it, so importing any part of the SDK makes the generated core tolerant.KNOWN_EVENT_TYPES: frozenset[str]: the discriminator values of theEventunion in the generated core. Derive it from the generated code if possible, otherwise list it; either way a test holds it equal to the keys ofcomponents.schemas.Event.discriminator.mappingindocs/api/openapi.yaml.UnknownEvent: frozen dataclass withtype: str,seq: int | Noneandraw: Mapping[str, Any](a new hand-layer type; the core has no type for it, ADR 0026 "Confirmation").decode_event(data: Mapping[str, Any]) -> <generated event model> | UnknownEvent: classifies bytypeagainstKNOWN_EVENT_TYPESbefore any variant is tried; an unknown or missingtypegivesUnknownEvent, a known one the generated model of exactly that type.is_unknown(value: Enum) -> bool: true for a value that a generated enum did not know.
Behaviour that
install()establishes, with the recommended mechanism (verify against the pinned generator's output):- Unknown enum value: every enum class of
bothy_sdk.generated.modelsgets a_missing_hook that returns a pseudo-member carrying the raw value (.valueequals the received string,to_dict()sends it back unchanged, it equals no known member). Exception, and the reason for the tolerance rule's only gap: the single-valuetypeenum of every event schema composed fromEventEnvelope(durable and ephemeral) stays strict. A toleranttypewould let the union parser accept an unknown event as the first variant whose payload happens to fit, for example{thread_id, channel_id}asthread.moved_out; the fixture of Design note 7 contains exactly this case. - JSON
null: thefrom_dictof every generated model drops members whose value isNonebefore it delegates to the generated code (06 2 "Nonull", ADR 0053 point 5). Nested models are covered because the generated code calls theirfrom_dict. - Unknown event type:
EventPage.from_dictdrops every item ofeventswhosetypeis not inKNOWN_EVENT_TYPES(also an item withouttypeor that is not an object) before it delegates, solistEventsandpollAgentEventsthrough the generated functions never raise, and the caller advances withnext_cursor(04 7.4), as theEventdescription in openapi.yaml says ("Clients skip unknown types and still advance their cursor"). - Unknown field: nothing to install; the generated models keep unknown members in
additional_propertiesand ignore them otherwise (no response schema in openapi.yaml setsadditionalProperties: false). The test proves it.
The hook must not change behaviour for known values: a known enum value decodes to the generated member itself (identity), and a fully known page decodes as without the hook.
-
TypeScript runtime client and tolerance. Ruling for docs/DECISIONS.md: the runtime client is openapi-fetch (11 11.1 leaves the choice to this ticket; 06 14 probed openapi-typescript "with openapi-fetch"), a runtime
dependencylocked inpackage-lock.json; record its version in the TypeScript row of 06 14. TypeScript has no runtime validation (06 13 point 3), so unknown fields and enum values pass by construction; the hand layersrc/tolerance.tsadds, each export with a TSDoc comment:KNOWN_EVENT_TYPES: areadonlytuple of the discriminator values, checked both ways at compile time againstcomponents["schemas"]["Event"]["type"](satisfiesfor "every listed value exists" and anevercheck of theExcludefor "every spec value is listed"), sotscingenerate-checkfails when the spec gains or loses an event type and the list is not updated.type UnknownEvent = { readonly kind: "unknown"; readonly type: string; readonly seq?: number; readonly raw: unknown }anddecodeEvent(raw: unknown), returning{ kind: "known"; event: Event }or anUnknownEvent, classified bytypeonly.knownEvents(page: EventPage): Event[]: the known events in order; unknown ones are skipped.toleranceMiddleware: an openapi-fetch middleware that, forapplication/jsonandapplication/problem+jsonresponses, removes object members whose value isnull(JSON reviver) and returns an equivalentResponse.src/index.tsexports the generated types (paths,components,operations,webhooksas openapi-typescript names them), the tolerance API andcreateApiClient(options), which creates the openapi-fetch client forpathsand always registerstoleranceMiddleware. Authentication, DPoP, timeouts and retries are M2-30.
The difference to Python is deliberate and goes into the README: in Python the generated page already skips unknown events (otherwise it would raise); in TypeScript the page is passed through and
knownEventsordecodeEventskip them. -
Fixtures. Ruling for docs/DECISIONS.md: one shared set in
sdk/testdata/tolerance/, read by the tests of both SDKs, so both prove the same cases (06 15 G5 "one test with a fixture per SDK");tools/ci/components.pyadds the prefixsdk/testdata/to both entriessdk-pythonandsdk-typescriptofGATE_COMPONENTS, so a fixture change runs both SDK gates in CI (sonar-project.propertiesalready excludes**/testdata/**). Each fixture is a completeEventPagebody oflistEventsthat is valid against openapi.yaml except for the one deliberate unknown it is named after, with RFC 3339 times in UTC with milliseconds, lower-case UUIDs, no JSONnulloutsidenull_member.json, no names beyond the people of rule 8:unknown_field.json: onethread.updatedevent (payloadThreadPayloadwith a completeThread) with an extra member on the page, on the envelope, on the payload and onthread(one with an object value).unknown_enum_value.json: onethread.updatedevent with values outside the spec forthread.state(named schemaThreadState),thread.kind(inline enum) andactor.kind(ActorKindinsideEventActor).unknown_event_type.json: three events with ascendingseq:thread.updated, then typefuture.examplewhose payload is{thread_id, channel_id}(it fitsThreadMovedOutPayload), thenthread.updated;next_cursorandhead_seqequal the lastseq.null_member.json: onethread.updatedevent withnullfor the optionalcorrelation_idof the envelope and the optionalthread.closed_at.
The chosen unknown names and values must really be unknown: the tests check them against the spec (Python by reading
docs/api/openapi.yaml, TypeScript with// @ts-expect-errorassignments to the generated types), so the tests fail instead of silently passing when the spec later gains such a value. -
Gate scope of generated code. Ruling for docs/DECISIONS.md: generated code is excluded from style lint and formatting checks (ruff format and ruff check through
extend-exclude, Prettier through.prettierignore, ESLint through its ignore list), from bandit (its union parsers usetry/except/passby design), from coverage (pytest-covomit, Vitest coverageexclude) and from Sonar (already**/generated/**); it stays in the drift check, the compile and type checks (mypy, tsc), the import test, the pre-commit hygiene hooks, gitleaks, the denylist scan, the license and OSV gates of its dependencies and CodeQL. Reason: like*.gen.goin M1-17 and generated Dart code in 23 "Documentation in code", its correctness is held by its generator and G4, while hand rules (docstrings, style, coverage) would only measure the generator's templates. Hand-written code, including tests, follows every rule; ruffDwith conventiongoogleis active from this ticket for the hand-written SDK code (tests excluded, 23 "Documentation in code"), M2-29 adds its gate test. -
Root Makefile.
api-clients(contract of 23 3 and gates.md): for each client directory (sdk/pythonwith componentsdk-python,sdk/typescriptwithsdk-typescript,packages/api_clientwithapps; keep the list in one variable so a test can replace it): no tracked manifest (pyproject.toml,package.json,pubspec.yaml) gives aSKIPline; a component not selected byCOMPONENTSgives aSKIPline like the other component gates (23 4); a tracked manifest without aMakefilethat definesgenerate-checkfails with a message naming the directory and the contract (today it skips silently, which contradicts gates.md "Skipping and failing" and D-QA-22); otherwise$(MAKE) -C <dir> generate-check, and its failure fails the gate. Update the help text to "G4".bootstrap-ci: whensdk/typescript/package-lock.jsonis tracked,npm ci --prefix sdk/typescript --ignore-scripts --no-audit --no-fund --loglevel=error; whensdk/python/uv.lockis tracked,uv sync --project sdk/python --locked --quiet. CI runsbootstrap-ciin the jobsgatesandsonar, so no workflow change is needed.- The first Python project exercises the Python lines of the root Makefile for the first time. Run each of
fmt-check,lint,typecheck,testandcoverageagainst it and fix what does not work; two defects are known:typecheckrunsuv run --project <p> --locked mypyfrom the repository root, where mypy finds no configuration and no target (run it inside the project, astestdoes), andlintrunsbandit -r <p> -x <p>/tests, which descends into<p>/.venv(exclude the project's.venvandgenerateddirectories, Design note 8). Same check for the TypeScript lines.
-
Coverage thresholds. 23 3 asks for the threshold in each project's own configuration, but 23 11 names no key for it, and thresholds exist only as named values (23 1 point 5). Ruling for docs/DECISIONS.md: add
quality.coverage.python_min_percent("Minimum coverage per Python project undersdk/andplugins/", held byfail_underin[tool.coverage.report]of each project'spyproject.toml) andquality.coverage.typescript_min_percent("Minimum coverage per TypeScript project", held bycoverage.thresholdsinvitest.config.ts) to 23 11, each with the same standard value asquality.coverage.go_min_percent; extendSOURCESintools/docs/tests/test_config_values.pyso each tracked project's value equals the key; name the keys in gates.md rowcoverageand in a comment next to each value. pytest-cov enforcesfail_underduringpytest --cov; verify that a run below the threshold fails before you rely on it. Coverage counts hand-written code only (Design note 8). Chapter 23 keys are not part ofdocs/operations/configuration.md(tools/docs/config_reference.pyexcludes chapter 23 on purpose). -
Versions, runtimes and dependencies.
VERSIONis0.0.0in both SDKs, likeservices/server/VERSION; a unit test per SDK holds the manifest version equal toVERSION. Pythonrequires-pythonstarts at the oldest CPython line that python.org lists as maintained on the build day, andengines.nodeat the oldest Node.js line in active LTS on the build day (11 11.3); record both lines and the date in the READMEs. Runtime dependencies are exactly those the generated code and the hand layer import (for the Python core typically httpx, attrs and python-dateutil; check the generated imports), declared as compatible ranges from the tested version as a library must, with the exact tested versions in the locks; every dev tool is pinned exactly. Ruling for docs/DECISIONS.md: use the same versions of ruff, mypy, pytest, pytest-cov and PyYAML astools/pyproject.toml, so one rule set formats and checks all Python of the repository. -
TypeScript tool chain. Ruling for docs/DECISIONS.md: TypeScript compiler with
strict: true(06 14 probedtsc --strict), Prettier forformatandformat:check, ESLint with typescript-eslint (type-checked recommended rules) forlint, Vitest with the v8 coverage provider fortest, writingcoverage/lcov.info(the pathsonar-project.propertiesreads); all exact pins, all with licenses on the allowlist. Test files are named*.test.tsundertest/(Sonar test inclusions). Hand-written.tsand.mjsfiles carry the SPDX header (tools/ci/license_check.py). If Sonar counts the configuration filesvitest.config.tsandeslint.config.mjsas uncovered product code, exclude exactly these two from coverage insonar-project.propertieswith a comment giving the reason, and nothing else. -
CI and supply chain.
- CodeQL: with
sdk/typescript/package.jsontracked,tools/ci/tests/test_codeql_workflow.py(M1-74) requires the matrix entryjavascript-typescriptwithbuild-mode: none; add it, update the header comment, and nameanalyze (javascript-typescript)in step 18 "Trial run" ofdocs/quality/public-switch.md. Python is already in the matrix. - Renovate: a package rule in
renovate.jsonthat groupsopenapi-python-clientandopenapi-typescriptas "SDK code generators" with aprBodyNotesentry: regenerate withmake -C sdk/python generateandmake -C sdk/typescript generateand update the versions in 06 14 before merging; the pull request failsmake api-clientsuntil then, on purpose (the pattern of the release binaries rule). - Licenses: OSV-Scanner checks all lock files of both components (licenses.md point 3). A license reported as
UNKNOWNor non-standard is checked in the package's LICENSE file and recorded as aPackageOverridesentry with reason inosv-scanner.tomlof that component, never as a blanket rule.
- CodeQL: with
-
Specification and documentation text (spec first, rule 10). 06 15 row G5 becomes: unknown field ignored, unknown enum value kept as its raw value, event of unknown type skipped while the cursor advances, received
nulltreated as absent (ADR 0053 "Confirmation" already demands the last part; the row omits it today). 06 14: TypeScript row names openapi-fetch with its pinned version; a sentence that the Python hand layer installs the tolerance on import and the generator keeps its standard settings; where the generated code of each SDK lives. 11 11.1: the TypeScript row says "runtime client openapi-fetch" instead of "choice in the build ticket"; the sentence on gateway types says they come from the schemas of openapi.yaml that asyncapi.yaml references (06 14 point 3; 11.1 says "fromdocs/api/asyncapi.yaml" today). 02 6: a new kind "SDKs: Python distributionbothy-sdk, import packagebothy_sdk, npm packagebothy-sdk(not published until OQ-17)". 23 3: Python and TypeScript rows name their coverage key and that generated code counts toward neither coverage nor style lint. All text is English, like every file of the repository (chapters, READMEs, quality pages and CHANGELOGs; D-W-94,AGENTS.md). -
Rulings for docs/DECISIONS.md, one row each with date, decision, reason and place: layout and names (note 1); generator pins in the component with the fallback (note 2); full spec without enable list (note 4); Python tolerance by an import hook without custom templates, with the strict
typeenums (note 5); openapi-fetch and the TypeScript tolerance API (note 6); shared fixtures and the CI path filter (note 7); gate scope of generated code and ruffDfrom this ticket (note 8);api-clientsfails closed and respectsCOMPONENTS(note 9); the two coverage keys and their value (note 10); runtime lines, dependency ranges and shared tool versions (note 11); the TypeScript tool chain (note 12).
Tests (write them first)¶
Python, sdk/python/tests/ (pytest, run by the root make test; the generated functions are called with a generated client on https://example.com/api/v1 whose httpx transport is an httpx.MockTransport answering with a fixture):
test_tolerance.py:test_g5_unknown_field_is_ignored:unknown_field.jsonthrough the generatedlistEventsfunction gives anEventPagewith one typedthread.updatedevent; every known field equals the fixture; the unknown members appear only inadditional_properties; no exception.test_g5_unknown_enum_value_keeps_raw_value:unknown_enum_value.jsondecodes without exception;thread.state,thread.kindandactor.kindcarry the raw strings as.value, equal no known member andis_unknownis true for them;to_dict()returns the raw strings unchanged; a known value in the same run decodes to the generated member itself.test_g5_unknown_event_type_is_skipped:unknown_event_type.jsonthrough the generatedlistEventsfunction gives exactly the twothread.updatedevents in order;next_cursoris not lower than theseqof the unknown event;decode_eventon the raw middle event returnsUnknownEventwithtype == "future.example"and itsseq, and never athread.moved_outmodel.test_g5_null_member_is_treated_as_absent:null_member.jsondecodes without exception, andcorrelation_idandthread.closed_atare unset, exactly as if the members were missing.test_g5_poll_agent_events_is_tolerant: the generatedpollAgentEventsfunction withunknown_event_type.jsonbehaves likelistEvents.test_known_event_types_match_spec:KNOWN_EVENT_TYPESequals the keys ofcomponents.schemas.Event.discriminator.mappingindocs/api/openapi.yaml.test_event_type_enums_stay_strict: constructing thetypeenum of any event schema with an unknown value raises; every other generated enum accepts it.test_fixture_unknowns_are_unknown_to_the_spec: the unknown event type, the three unknown enum values and the unknown member names of the fixtures do not occur in the corresponding places ofdocs/api/openapi.yaml.test_install_is_idempotentandtest_known_page_is_unchanged_by_the_hook.
test_generated_core.py:test_g4_every_generated_module_imports: walksbothy_sdk.generatedand imports every module.test_g4_every_operation_is_generated: everyoperationIdunderpathsofdocs/api/openapi.yamlhas its generated function.test_version_matches_version_file.
TypeScript, sdk/typescript/test/ (Vitest, run by the root make test; createApiClient gets baseUrl: "https://example.com/api/v1" and a fake fetch answering GET /events with a fixture):
tolerance.test.ts:G5: unknown field is ignored:unknown_field.jsonthroughcreateApiClientreturns the page without error;decodeEventon its event giveskind: "known"with typethread.updated, and the known fields equal the fixture.G5: unknown enum value keeps its raw value: the three values arrive unchanged as strings; no exception anywhere on the path.G5: unknown event type is skipped:knownEventsreturns the twothread.updatedevents in order;decodeEventon the middle event returnskind: "unknown",type: "future.example"and itsseq, neverthread.moved_out;next_cursoris not lower than thatseq.G5: null member is treated as absent: after the client,"correlation_id" in eventand"closed_at" in threadare false.known event types are unique(the compile-time checks of Design note 6 cover equality with the spec).fixture unknowns are unknown to the spec:// @ts-expect-errorassignments of the fixture's unknown event type toEvent["type"], of each unknown enum value to its generated type and of each unknown member name tokeyofits schema, sotscfails when the spec gains one of them.
generated.test.ts:G4: generated file carries the generator headerandversion matches VERSION file.
Repository tools, tools/ci/tests/:
test_make_api_clients.py(make recipes run against throwaway client directories, the pattern oftest_make_server_gates.py):test_client_without_manifest_is_skipped:SKIP api-clientsnaming the directory, exit 0.test_client_with_manifest_but_without_generate_check_fails: non-zero exit, message names the directory and the missing target.test_failing_generate_check_fails_the_gateandtest_passing_generate_check_passes.test_unselected_component_is_skipped: withCOMPONENTS=serverboth SDK directories printSKIP, nogenerate-checkruns.test_g4_spec_change_without_regeneration_fails[python]and[typescript]: copy the SDK directory (without.venv,node_modulesand coverage output; TypeScript gets a link to the realnode_modules) into a temporary git repository with the repository.gitignore, write a copy ofdocs/api/openapi.yamlwith one new optional property on schemaThread, run the realmake -C <copy> generate-check SPEC=<copy of the spec> TOOLS=<repository tools>; assert a non-zero exit, thecodegen-check: generated code inmessage and achanged:line naming the generated file ofThread(Python: the module of theThreadmodel; TypeScript:src/generated/openapi.ts). The real working tree stays untouched (assert it withgit status --porcelainbefore and after).
test_components.py: new casesdk/testdata/tolerance/unknown_field.jsonselectssdk-pythonandsdk-typescript.test_codeql_workflow.py(M1-74, unchanged): passes with the new matrix entry and the runbook line; run it red first by trackingsdk/typescript/package.jsonbefore the workflow change.tools/docs/tests/test_config_values.py: the two new keys equalfail_underinsdk/python/pyproject.tomland the thresholds insdk/typescript/vitest.config.ts.tools/docs/tests/test_toolchain_doc.py: the generator rows match the SDK locks; the 06 14 versions match the pins.
Test IDs of the plan row:
- G4 (06 15): both generators produce, both outputs compile, and there is no difference to the checked-in code; proven by
make api-clients(bothgenerate-checktargets),test_g4_every_generated_module_imports,test_g4_every_operation_is_generatedandtest_g4_spec_change_without_regeneration_failsfor both SDKs. - G5 (06 15, ADR 0053): per SDK a test with fixture shows that an unknown field, an unknown enum value and an unknown event type decode without an exception and that a received
nullcounts as absent; proven by thetest_g5_*tests in Python and theG5:tests in TypeScript.
Commands that must pass, from the repository root:
make -C sdk/python generate-checkandmake -C sdk/typescript generate-checkmake api-clientscd sdk/python && uv run --locked pytestnpm --prefix sdk/typescript testcd tools && uv run --locked --quiet pytest ci/tests/test_make_api_clients.py ci/tests/test_components.py ci/tests/test_codeql_workflow.py docs/tests/test_config_values.py docs/tests/test_toolchain_doc.pymake fmt-check,make lint,make typecheck,make coverage,make licenses,make osv,make docs,make linkcheckmake check
Acceptance criteria¶
-
make api-clientsis green and runsgenerate-checkofsdk/pythonandsdk/typescript; each regenerates fromdocs/api/openapi.yaml, compiles (mypy and import for Python,tsc --noEmitwithstrictfor TypeScript) and finds no drift (G4). - A spec change without regeneration fails
generate-checkof each SDK with a drift message naming the generated file, proven by the end-to-end tests on temporary copies. - Per SDK a test decodes an unknown field, an unknown enum value and an unknown event type from the shared fixtures without an exception, keeps the raw enum value, skips the unknown event while the cursor advances, never classifies it as a known type, and treats a received
nullas absent (G5). - Generators are pinned exactly inside the components, documented in
docs/quality/toolchain.mdand 06 14, and held by tests; the generated code is checked in and never edited by hand. -
api-clientsfails for a client directory with a manifest but withoutgenerate-checkand respectsCOMPONENTS; the Python and TypeScript lines of the root Makefile work against the new projects. - Both SDKs meet the component contract of 23 3, with coverage thresholds under the new keys
quality.coverage.python_min_percentandquality.coverage.typescript_min_percent; the license and OSV gates pass without a change to the allowlist. - CodeQL covers
javascript-typescript; the CI path filter selects both SDKs forsdk/testdata/. - 06, 11, 23 and 02 are updated as in Design note 14;
VERSIONandCHANGELOG.mdexist in both SDKs; nothing is published. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
sdk,sdk/python,sdk/typescriptandsdk/testdata/tolerancewritten or updated (layout, regeneration, generator pins, tolerance guarantees and the Python and TypeScript difference of note 6, what M2-29 and M2-30 add);docs/api/README.md,docs/quality/gates.md,docs/quality/toolchain.mdanddocs/quality/public-switch.mdupdated. - Rulings of note 15 recorded in
docs/DECISIONS.md(one row each with reason). sdk/python/CHANGELOG.md,sdk/typescript/CHANGELOG.mdand the rootCHANGELOG.mdentries under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts (generated directories as one line each with their file count), test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; include the Python and Node.js lines chosen under note 11 and any
PackageOverridesentry with its reason.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.