11.5.1 Decision — the CLI's v1 client contract: the type generator, where runtime validation happens, how the generated artifact stays fresh, the skew comparison, and the adapter boundary
Five questions the rest of this story would otherwise answer five times, inconsistently. Deliverable: a new ADR at docs/decisions/cli-v1-client.md — its own file, in the shape docs/decisions/cli-login.md already established for a CLI-side contract, CITING public-api-conventions.md rather than amending it (the server contract is not what changes here).
⚠️ On the
likely-missing-edgeadvisory this card carries. Its criteria name 11.7, which is notdone, and the detector reads that as a missingblocked_by. It is not one, in BOTH directions: this card does not CONSUME anything 11.7 produces — it WRITES ONTO 11.7 (Q3's discharge amends that story's acceptance criteria), so an edge would invert the real order; and 11.7 is a STORY while this is a subtask under a different story, so ablocked_bybetween them is structurally illegal anyway — the dependency that does exist lives at the story level, where 11.5 is alreadyblocked_by11.7. Recorded here so the next reader does not re-litigate it.
Q1 — what generates the types, and where does RUNTIME validation come from?
The story requires both: generated types replacing the ~20 hand-written mirrors, AND "parse instead of cast" — a malformed payload must produce an error naming the field, not undefined in a rendered cell. Those are two artifacts, and the question is whether they come from one source.
Leaning, to verify rather than adopt on sight: generate TYPES with openapi-typescript (the dominant TS ecosystem choice, and what openapi-fetch is built around) and compile RUNTIME VALIDATORS from the same document with Ajv in standalone mode — precompiled to plain JS at build time, so a published CLI ships no validator library and no schema blob. The document is already JSON-Schema-2020-12 (ADR Amendment 4 Q1), which Ajv supports natively, and Ajv's instancePath + keyword is literally the "names the field" error the acceptance criterion asks for. The alternative — regenerating zod from the document (openapi-zod-client, orval) — round-trips zod → JSON Schema → zod, a lossy translation of shapes z.toJSONSchema() produced in the first place, and it puts a second dialect between the server's schema and the client's.
Record why the rejected options were rejected, including the honest cost of the recommendation (bundle size, build-step complexity).
Q2 — where does the generated artifact live, and how is it kept fresh? (and it contradicts a shipped amendment)
ADR Amendment 4 Q3 says the spec is fetched from /api/openapi/v1.json and "point the type-generation step at that path". That instruction was written for an EXTERNAL generator and is wrong for this one, and the contradiction has to be settled rather than silently resolved: packages/cli lives in the same repo as the emitter, so generating over HTTP would make the build depend on a running server — in CI, and for anyone who checks out the repo.
Settle: (a) generate from emitOpenApiDocument() directly, in-process, no network; (b) COMMIT the generated module under packages/cli/src/api/ so npm install of the published tarball needs nothing; (c) a CI guard regenerates and fails on a diff (git diff --exit-code), which is what makes the committed artifact trustworthy; and (d) a second, separate guard asserts the SERVED route emits the same bytes as the emitter — the route is force-static and the emitter is documented deterministic, so this is cheap and it is what keeps Amendment 4 Q3's public URL honest for everyone else.
Q3 — what exactly does the skew gate COMPARE, and what does it read it from?
Amendment 4 Q6 pins the MEANING of info.version (the contract's MAJOR.MINOR.PATCH, not a release number) and that a v2 is a second document. It does not say how a running CLI learns the server's number.
⚠️ Verify, do not assume, that a cheap surface advertises it. V1_CONTRACT_VERSION is a compile-time constant in lib/api/v1/openapi/emit.ts; grep whether any /api/v1 RESPONSE carries it — a header set by withV1Route, a field on GET /api/v1/me — before designing around one. Fetching /api/openapi/v1.json on every invocation to read one string is a wasted round-trip on a spec document, so the likely shapes are: the CLI pins the major it generated against and compares against a version the API advertises cheaply, or it compares only on a mismatch it already detected. If nothing advertises it today, that surface is 11.7's to add — update_work_item 11.7's acceptance criteria to carry it in THIS pass, because a card is closed against its own criteria by someone who never read this ADR. Do not invent a server change here.
Also pin: what "incompatible" means (major mismatch only, per §8's additive-only promise within a major), that the message is emitted ONCE per invocation, and its exact wording.
Q4 — the ADAPTER boundary
render.ts must not change, and the v1 shapes are not the MCP shapes — the detail read alone goes from { item, ancestors, parent, children, blockedBy, blocks, relatesTo, readiness } to a flat item plus ancestorKeys and a five-group links object. Pin: the client owns a named adapter layer; wire types never escape it; the renderers' input types become the CLI's OWN view models rather than mirrors of anything. State the rule that makes the byte-identical property auditable — a renderer that imports a generated type is the defect.
Q5 — the error taxonomy
One row per status: 401 → AuthError (replacing isUnauthorized's regex over an error message, an artefact of MCP reporting auth failures in band), 403 → a scope-hint error naming the missing scope, 429 → a rate-limit error reporting X-RateLimit-Reset, 404 → not-found, 5xx and network failure → the generic CliError. Pin the exact sentences and the hint on each, so five cards do not each invent one.
Acceptance criteria
docs/decisions/cli-v1-client.mdexists, follows the shape of the repo's other decision records (context · decision per question · rejected alternatives · consequences), and answers all five questions above with a single chosen option each.- Q1's answer names the exact packages and the exact build step, and states the recommendation's cost as well as its benefit.
- Q2's answer explicitly reconciles itself with ADR Amendment 4 Q3 — quoting it, and saying why the in-repo generator reads the emitter while the public URL remains the contract for everyone else.
- Q3's answer cites a GREP of
lib/api/v1/route.tsand the shipped response envelopes for an advertised contract version, and states which of the two outcomes it found. If nothing advertises it, this card ALSO leaves 11.7's acceptance criteria carrying that surface — an edit made BY this card TO that one (see the advisory note above), not a dependency on it, and not a sentence in this document. - Q5's answer is a table of status → error class → message → hint, complete over the statuses
/api/v1can return. - Every rejected option carries the reason it lost, in one line — a decision with no rejected alternatives is a preference.
- No code changes: this card ships a document.
Context refs
docs/decisions/public-api-conventions.md— the server contract this cites, especially §5 (pagination), §6 (rate-limit headers), §8 (additive-only) and Amendment 4 Q1/Q3/Q6.docs/decisions/cli-login.md— the precedent for a CLI-side ADR in its own file.lib/api/v1/openapi/emit.ts—emitOpenApiDocument(),V1_CONTRACT_VERSION(1.0.0),V1_API_MAJOR, and the determinism claim Q2(d) leans on.lib/api/v1/route.ts—withV1Route, where a version header would live if one exists.packages/cli/src/errors.ts—CliError/AuthError/NotLinkedError, the taxonomy Q5 extends.packages/cli/src/mcpClient.ts—isUnauthorized's regex andcallStructured'sas T, the two things Q1 and Q5 exist to delete.- Gates: 11.5.2 (the generator), 11.5.3 (the transport core). Story: 11.5.