ADR 0116 — ActionType (and future types) generated from the OpenAPI contract, not hand-copied
- Status: Accepted
- Date: 2026-08-28
- Context: pays down the tech debt named in
docs/architecture.md("TheActionTypeunion is a copy, and a copy ages") and tracked indocs/explanation/backlog.md; the union has already diverged from the backend twice in production - Does not replace:
apps/web/src/lib/api-types.ts— most of it stays hand-written; see Decision, item 3
Context
apps/web/src/lib/api-types.ts (~1600 lines) mirrors entities from
apps/api/src/domain/** by hand, on purpose: apps/web does not depend on
server-side code, and the file's own header says so. That has been the right
call for most of the file — it is small, stable, UI-shaped types. It has been
the WRONG call for one type in particular: ActionType.
ActionType (apps/api/src/domain/actions/decide.ts, ACTION_TYPES) is not
UI-shaped — it is the exhaustive list decide() switches on, and every
consumer downstream (ApprovalCard, lib/aprovacoes.ts's
VERBO_DA_ACAO/FRASE_DA_ACAO records) needs it complete or it silently
degrades. It diverged from the hand-copied union in api-types.ts TWICE in
production, both times without the compiler noticing, because apps/api is
not a dependency of apps/web:
- The three Gitflow bootstrap types (
git_repo_create,git_branch_create,git_branch_protect) were missing from the web union.ApprovalCard'sACTION_ICON[actionType]returnedundefinedfor them and crashed the whole session screen — every project bootstrapped against a real provider had an unopenable session. parallelize/raise_max_parallelshipped in FASE 14d and nobody remembered to add them to the web copy.
The only thing catching this today is apps/web/src/lib/aprovacoes.test.ts,
which reads decide.ts's source text at test time and fails a type with no
sentence. That is a real safety net, but it is a runtime one, discovered at
pnpm test, not a structural one the compiler enforces on every edit.
Separately, apps/api/src/scripts/export-openapi.ts already exports the
full OpenAPI document, and docs/reference/openapi.json is already a
generated, drift-checked artifact (docs/.docmap.yml, rule
referencia-openapi; docs:generate/docs:check). ACTION_TYPES is
already exposed there as a real JSON Schema enum — three DTOs
(ProposeActionDto, CreateActionInternalDto, ProposedActionResponseDto)
declare @ApiProperty({ enum: ACTION_TYPES }) on their actionType field,
so the 17 values already round-trip through the contract the web already
has to trust for everything else.
Decision
1. openapi-typescript (^7.13.0) generates apps/web/src/lib/api-types.generated.ts from docs/reference/openapi.json
Chosen over a hand-rolled generator because turning a JSON Schema enum
(and, if the file ever grows, whole request/response shapes) into a
TypeScript union is exactly the problem the library solves, and it is the
de facto standard for it — this is the "dependência pesada" the
CLAUDE.md rule asks to be justified, and the justification is the two
production incidents above, not convenience.
The input is the COMMITTED docs/reference/openapi.json, not a fresh
export-openapi.ts run piped in. That file already has an enforced
freshness invariant (docs:generate --check, run in
.github/workflows/docs-check.yml's guardiao job) — reading the live
export from apps/web would mean spinning up the whole AppModule from
a package that has no NestJS dependency and no reason to gain one, to
re-derive a guarantee that already exists one door down.
Two npm scripts, in apps/web/package.json (not apps/api): the CONSUMER
decides when to regenerate, and the source is a file already sitting in
the repo, not a live api process — apps/web needing the api's dev server
running to get its own types would be a worse dependency than the one this
ADR removes.
openapi:types— writessrc/lib/api-types.generated.ts.openapi:types:check— the library's native--checkmode: recomputes and compares, never writes. Wired intodocs-check.ymlright after the existingopenapi.jsonfreshness step, and depending on it running first —--checkhere can only be trusted onceopenapi.jsonitself is known fresh.
2. docs/reference/openapi.json freshness gate: reused, not duplicated
docs/.docmap.yml's referencia-openapi rule already watches every DTO
and controller and requires openapi.json/the MDX manifest to move with
them. apps/web/src/lib/api-types.generated.ts was added to the SAME
rule's docs: list instead of a new rule with a copy of the same watch:
— it is generated FROM openapi.json, by the same trigger, and a second
rule would only ever drift from the first one's glob list.
3. Only ActionType migrates in this change — not the other ~1600 lines
api-types.ts's own header already draws the real line: types genuinely
shared between api and web (HealthStatus, GitProviderName) live in
packages/shared, not here; everything else in the file is a
domain-entity mirror maintained by hand. Regenerating the whole file in
one pass, without going consumer-by-consumer, would be exactly the kind
of change CLAUDE.md warns against ("Não refatorar código de fase
concluída sem pedido explícito") applied to 1600 lines nobody re-read for
this PR. ActionType is the one type with a demonstrated, repeated,
production cost to being hand-copied; it moves. The remaining lines of
api-types.ts are untouched and stay hand-written, re-evaluated
type-by-type in future PRs the same way this one was.
api-types.ts now does:
import type { components } from './api-types.generated';
export type ActionType =
components['schemas']['ProposedActionResponseDto']['actionType'];
ProposedActionResponseDto was picked over ProposeActionDto /
CreateActionInternalDto (which declare the identical 17-value enum)
because it is the response shape the web actually reads on every
approvals screen; the three are the same union today, and NestJS Swagger
does not emit a single named ActionType schema to point at instead — it
inlines the enum per-DTO, since none of the three uses @ApiExtraModels/
a shared enum name. Naming the enum globally in the OpenAPI document is
future work, not required for this fix: ProposedActionResponseDto's
actionType is a plain string-literal union, structurally identical to
the type it replaces.
4. aprovacoes.test.ts (FASE 19) stays — its job changed, it didn't disappear
The test used to do two things: prove every backend ActionType has a
verb/sentence entry (EXISTENCE), and prove that entry is non-empty,
in Portuguese, and correctly punctuated (CONTENT). Existence is now also
caught by the compiler — Record<ActionType, string> in
lib/aprovacoes.ts fails to compile the moment a value from
api-types.generated.ts has no key, before pnpm test ever runs. That
makes the test's existence check REDUNDANT with a stronger, earlier
guarantee — but its content assertions (frase length, trailing period, no
raw snake_case leaking into Portuguese prose) are not something a type
system checks, and stay exactly as valuable as before. Removing the test
would trade a belt-and-suspenders situation for no suspenders; it stays,
documented here as partially superseded rather than quietly kept for
inertia.
Consequences
For
- The class of bug that has hit production twice (
ApprovalCardcrashing on anActionTypethe web didn't know about) is now caught bytsc, on every PR that touchesdecide.tsand regeneratesopenapi.json— not discovered live. docs-check.ymlfails LOUDLY (::error::) with the exact command to run, the same UX as every other generated artifact in the repo.- No change to any HTTP contract, DTO, or runtime behavior — this is a types-only, dev-time change.
Against
- A second devDependency surface for
apps/web(openapi-typescript) purely for codegen; it never ships to the browser bundle (api-types.generated.tsis source, not the library itself). api-types.generated.tsis ~18k lines (the FULL OpenAPI surface, every path and DTO) even though only one type is consumed from it today —--root-types/schema filtering could shrink this later if it becomes a real cost (it is not loaded at runtime; it is a.d.ts-shaped source filetscreads and vite never bundles).api-types.tsis now, for one type, a re-export rather than a definition — a reader has to follow one more hop (api-types.ts→api-types.generated.ts→openapi.json→decide.ts) to find the source of truth. The comment at the import site inapi-types.tsnames every link in that chain for exactly this reason.
Alternatives considered
Regenerate all of api-types.ts from the OpenAPI document in one PR.
Rejected — the file is not a pure entity mirror (packages/shared already
carves out what's genuinely shared), and a wholesale replacement without
tracing each of ~1600 lines to its consumers would risk silent shape
changes nobody reviewed for. Migrating type-by-type, starting with the one
with a proven cost, is slower but each step is checkable.
Generate from a live export-openapi.ts run instead of the committed
openapi.json. Rejected in item 1 — would require apps/web to either
depend on apps/api's NestJS runtime or shell out to it, to re-derive a
freshness guarantee docs:check already provides for free.
Drop aprovacoes.test.ts now that the compiler covers exhaustiveness.
Rejected in item 4 — the content assertions (frase quality) are not
type-level properties, and removing the file would also remove the
governing comment explaining WHY the sentence-per-type rule exists
(RN-096).
References
apps/api/src/domain/actions/decide.ts—ACTION_TYPES, the canonical sourceapps/web/src/lib/api-types.ts— the re-export, and the comment naming the full chainapps/web/src/lib/aprovacoes.ts/aprovacoes.test.ts— the consumer whose exhaustiveness is now compiler-checkeddocs/.docmap.yml, rulereferencia-openapi— the shared freshness gate.github/workflows/docs-check.yml—openapi:types:check, wired after theopenapi.jsonfreshness stepdocs/architecture.md— "TheActionTypeunion is a copy, and a copy ages"- RN-096 — every
ActionTypeneeds a Portuguese sentence