contract generation graph
situation
several apps consumed the same API contract, updated three different ways:
- app A: local code generator run after every route change.
- app B: hand-written
CreateWorkflowInputtype updated by hand. - app C: global root script that only worked after
npm generatethennpm build, in that order.
when the API route changed:
- app B's type staled immediately; one developer knew to regenerate.
- app A's client sometimes regenerated at the wrong time, skewing against app C.
- the root sequence broke silently when generate was skipped or CI ran build first — no declared dependency, so either order was "valid" to the task system. the error surfaced in staging after the mistake was committed.
why
undeclared build order is invisible authority: whoever ran the commands last decides what fresh means. generated contracts are derived output whose freshness the build itself must establish, not a discipline each developer remembers. declared producer/consumer edges move that discipline into the graph — a wrong order fails the build instead of surfacing later as a staging type mismatch.
invariants
- an authored route/schema is the only hand-edited contract authority.
- generated API descriptions and clients are derived output, never repair targets.
- every consumer reads the generated package's public surface, not its source internals.
- a clean checkout produces the same build without relying on command history.
decision process
| candidate | reasoning | decision |
|---|---|---|
| generator command in every consumer build | duplicates ownership; consumers can generate incompatible output | rejected |
| manually repaired client beside generated output | unblocks one error; next generation reintroduces it | rejected |
| opaque root script with fixed sequence | hides real edges; breaks incremental/cached execution | rejected |
| declared producer/consumer tasks + public package boundaries | makes authority, freshness, and cache visible to people and tools | selected |
resulting graph
- the contract package owns
generateand its declared output; itsbuilddepends on that local generation. - consumers depend on the contract package's public build output, then import its generated client through the package name.
- the root invokes the graph; it owns neither a second client nor a special generation path.
- repair path: stale client → inspect authored route/schema and generator config → regenerate at the producer → rebuild consumers. never edit a type where a consumer observes the mismatch.
verification
- remove derived client output in a disposable clean state.
- run the repository build graph without a prior manual generation command.
- confirm the producer generates before consumers typecheck or build.
- inspect a second unchanged run for the intended cache behavior.
- search consumers for deep imports and handwritten copies of the generated contract; neither should remain.
good and bad
bad
// apps/app-b/src/api.ts — written by hand, must be kept in syncexport type CreateWorkflowInput = {channelHandle: string;title: string;description?: string;};// apps/backend/src/routes/workflows.ts — source of truthexport const createWorkflow = route.post("/workflows").input(z.object({channelHandle: z.string(),title: z.string(),description: z.string().optional(),scheduledAt: z.date().optional(), // ADDED — app B's type now stales}),).handler((req) => {// ...});
when the route adds scheduledAt, app B's type is obsolete. no build-time
verification exists — the compile succeeds and only a live test reveals the
mismatch. version skew causes silent corruption: the field is sent but the
type claims it is absent.
good
{"tasks": {"backend#generate": {"outputs": ["generated/openapi.json"],"cache": false},"client#generate": {"dependsOn": ["backend#generate"],"inputs": ["generated/openapi.json"],"outputs": ["src/api.generated.ts"]},"app-a#build": {"dependsOn": ["client#generate"],"inputs": ["src/**/*"]},"app-b#build": {"dependsOn": ["client#generate"],"inputs": ["src/**/*"]}}}
the task system enforces the sequence: build cannot run before generate.
a route change reruns the generate task automatically; every consumer's build
depends on that fresh output. version skew is impossible — all apps consume
the same generated client from the same client#generate output.