Skip to content

Type-safety strategy research — how Knowledge Hub should make type safety the default

Type-safety strategy research — how Knowledge Hub should make type safety the default

Section titled “Type-safety strategy research — how Knowledge Hub should make type safety the default”

Status: RESEARCH-S262. Decision-informing only; no production code. Audience: Liam (product owner) + Claude/agents (implementation). Decision it informs: whether the in-flight OPS-T1 defineRoute() work ships as a runtime validator (fork option 1), is re-scoped to an incremental manual migration (fork option 2), or is redirected to a third path. Companions: ../ops-t1-codemod/{PRODUCT,TECH,PLAN}.md, ../type-safety-pipeline/{PRODUCT,TECH}.md, R-WP12-type-safety-pipeline.md, docs/plans/phase-0-investigation/trpc-evaluation.md.


Liam wants type safety to be the default for a two-person team (Liam + Claude/agents). The request decomposes into two aspects, and three priorities that order every trade-off.

Aspect 1 — Implementation-time accuracy. Whoever builds (human or agent) needs an accurate, single, trustworthy source of truth for types — DB shapes and API request/response shapes — that they consult while writing code, so type safety is built in from the first keystroke rather than retrofitted. The enemy here is a manually-maintained reference doc that silently drifts from reality (e.g. docs/reference/SCHEMA-QUICK-REFERENCE.md).

Aspect 2 — Catch-what’s-missed. An automated safety net for anything missed at implementation time. The load-bearing constraint: this team has had repeated incidents of tests “built to pass” rather than testing real behaviour, which hides bugs. So the safety net must catch real shape/behaviour drift even when the tests are weak — it must not be defeatable by a mock-only test.

Priorities, in strict order:

  1. Maximise automation (the team is two; manual sweeps don’t get done).
  2. Minimise error likelihood (catch the bug before it ships).
  3. Minimise maintenance burden (no new framework tax the team can’t pay).

A note on bias, stated up front: for a two-person team, leveraging existing tooling at low maintenance beats a shiny new framework unless the payoff is large and durable. This document says so explicitly per option, with reasoning.


2. Current state — what KH already has (file:line evidence)

Section titled “2. Current state — what KH already has (file:line evidence)”

KH has, by accident of disciplined incremental work, most of the primitives of a typed boundary. The gap is that they are not wired into a single source of truth, and the one structural enforcement attempt (OPS-T1) has a weak runtime guarantee. Inventory:

2.1 The DB→type layer is already generated and trustworthy (mostly)

Section titled “2.1 The DB→type layer is already generated and trustworthy (mostly)”
  • supabase/types/database.types.ts (4,540 LOC) is auto-generated from the live Supabase schema via supabase gen types. Every Supabase client is constructed <Database>-typed (lib/supabase/server.ts, lib/mcp/auth.ts), so .from('workspaces').select(...) returns typed rows with zero app-level config (R-WP12 §“What IS well-typed”, credit 1-2).
  • This is already a generated single source of truth for DB shapes. It is the KH analog of warp’s schema.rs (see §3). It drifts only if a migration lands without re-running gen types — which the proposed supabase-types-parity CI job (type-safety-pipeline TECH §“Supabase types CI plan”) closes.
  • Where it breaks down: two documented holes. (a) 14 opaque-Json RPCs (grep -c 'Returns: Json' = 14, confirmed) type their return as Json (database.types.ts:1 defines Json = string | number | boolean | null | {...} | Json[] — i.e. unknown-equivalent). Call sites cast manually (app/api/review/stats/route.ts:76). (b) JSONB columns hold domain-typed payloads the generated types see as Json (20 columns / 14 tables per type-safety-pipeline TECH §“JSONB inventory”).

2.2 Zod is a partial, request-side source of truth — not response-side

Section titled “2.2 Zod is a partial, request-side source of truth — not response-side”
  • lib/validation/schemas.ts is 3,335 LOC, 161 export const …Schema constants. But the split matters: 97 are request-side (…Body|Input|Params|Query Schema); only 26 are response/result-ish, and 23 of those 26 are machine-generated (see §2.4). Request validation is enforced everywhere — 108 route files call parseBody( (confirmed count), backed by __tests__/validation/validation-sweep.test.ts + an ESLint rule.
  • Zod is NOT the source of truth for types. Only 3 z.infer derivations exist in the entire schemas file (schemas.ts:1667, :2309, :964). The flow is the reverse of a Zod-first model: TypeScript interfaces in types/*.ts are hand-authored and canonical; Zod schemas are written (or generated) to mirror them. So today Zod adds runtime request-validation but does not produce the types developers use at implementation time.

2.3 The route↔fetcher boundary is the unguarded edge (Gap 1)

Section titled “2.3 The route↔fetcher boundary is the unguarded edge (Gap 1)”
  • lib/query/fetchers.ts:29fetchJson<T> returns res.json() as Promise<T>: a structurally unchecked cast. mutationFetchJson<T> mirrors it.
  • Reality-check on scale: the headline “100+ fetchers” overstates the typed boundary. The file is 758 LOC with 18 exported fetch functions; 11 direct fetchJson<…> generic uses + 4 mutationFetchJson<…> in the file. Many fetchers wrap a typed return (e.g. fetchTaxonomySyncStatus(): Promise<TaxonomySyncStatus> at :70). The drift surface is real but concentrated in tens, not hundreds, of interfaces — the R-WP17 detector found exactly 37 fetcher-only interfaces (docs/generated/type-drift-baseline.json, confirmed count = 37).
  • The canonical drift example is honest and present: TaxonomySyncStatus (fetchers.ts:62-71) is declared in the fetcher file itself, not imported from the route — a route change cannot produce a fetcher error.

2.4 The shipped detector (R-WP17) and the in-flight OPS-T1 codemod

Section titled “2.4 The shipped detector (R-WP17) and the in-flight OPS-T1 codemod”
  • R-WP17 type-drift-detect is shipped (lib/ast-dataflow/queries/type-drift-detect.ts, 27.5 KB) and gating in CI (.github/workflows/ci.yml:870 type-drift-parity job runs type-drift-detect --ci). It classifies every response interface as enforced | fetcher-only | route-only | unused and fails the build on a new fetcher-only row vs the 37-row baseline. This is a working aspect-2 net for the route/fetcher axis — and notably it is not defeatable by a mock test (it reads the real source AST, not test doubles).
  • OPS-T1 is already substantially built, and it is already the runtime path. scripts/codemods/wrap-define-route.ts is 56.6 KB and the S262 git log shows subtasks 32.5→32.22 landed (scaffold, classifier, Source A/B inference, single+multi-method rewrite, apply mode, verifier integration). lib/api/define-route.ts exists and its contract is runtime safeParse() → 500 envelope on failure (define-route.ts:97-110). The compile-time clause (handler: (...) => Promise<z.infer<S>>) only binds if the handler returns a raw payload — which the codemod cannot mechanically guarantee (see §2.5).

2.5 Why the “compile-time” promise doesn’t hold today — and the .loose() trap

Section titled “2.5 Why the “compile-time” promise doesn’t hold today — and the .loose() trap”

Two findings here are the crux of the fork:

(a) The throw-for-errors obstacle is real. app/api/review/stats/route.ts is the representative shape: a single GET with two inline NextResponse.json({ error }, { status: 500 }) error returns plus a success NextResponse.json(response). defineRoute(schema, handler) expects the handler to return a raw payload of z.infer<S>; but real handlers return NextResponse objects for errors. To get compile-time success-shape enforcement, every handler must be rewritten to a throw-for-errors convention (errors throw, success return payload) — a semantic refactor that is not mechanical and touches ~178 of 193 routes. This is exactly the obstacle that forked the work.

(b) Even the runtime guarantee is currently near-zero for the 37 routes. The generated ResponseSchemas (schemas.ts:2314-3334, “BEGIN generated: R-WP17 ResponseSchema constants”) are produced by scripts/codemods/generate-response-schemas.ts, which walks each TS interface and deliberately errs permissive (its own docstring: “this generator errs PERMISSIVE … every emitted z.object({...}) is .passthrough()-equivalent via .loose()”). The 1,020-line block contains 84 .loose() and 10 z.unknown(). A .loose() object with z.unknown() members accepts almost any payload — so defineRoute(ChangeReportGenerateResponseSchema, …) would catch a grossly malformed response (wrong top-level type) but not a renamed/removed/retyped field. The shipped Option-1 path, as generated, is a very weak runtime net.

This is the single most consequential finding and it reframes the fork (see §10).


Liam pointed to the local warp repo (/Users/liamj/Documents/development/warp) as a schema-as-single-source-of-truth exemplar. I read the two files plus the codegen wiring. Here is the actual pattern (not the imagined one).

3.1 What warp actually does — two independent introspect-then-codegen pipelines

Section titled “3.1 What warp actually does — two independent introspect-then-codegen pipelines”

DB layer (crates/persistence):

  • Source of truth = the live Postgres database (migrations in crates/persistence/migrations/).
  • diesel print-schema introspects the DB → emits crates/persistence/src/schema.rs (530 LOC, header // @generated automatically by Diesel CLI). Config in diesel.toml.
  • Type-override mechanism: crates/persistence/schema.patch (a committed git diff) patches generated types the introspection got wrong — e.g. revision_ts: Nullable<Integer>Nullable<BigInt>. This is exactly the Supabase MergeDeep override pattern that type-safety-pipeline TECH already recommends for JSONB columns.

API layer (crates/warp_graphql_schema):

  • Source of truth = the running GraphQL server’s introspected schema (graphql.config.js points at https://staging.warp.dev/graphql/v2).
  • graphql-codegen (the generate script) downloads + introspects the server, filters via api/client-schema.ts, and emits the committed api/schema.graphql SDL (104 KB, 4,053 lines).
  • From that committed SDL, two generators run: cynic_codegen for Rust (build.rs: register_schema(...).from_sdl_file("api/schema.graphql")) and graphql-codegen for the TypeScript frontend.

The pattern, distilled: introspect a single live source (DB / GraphQL server) → emit a committed, generated artefact → generate language-specific types from that artefact → patch the rare mismatches in a committed override. The source of truth is the running system, never a hand-authored doc. Generation is wired into the build (Rust build.rs) and a dev script (yarn generate); drift is prevented by the committed artefacts being diffable in PR review.

3.2 Is a KH analog viable? — Partly, and KH already has half of it

Section titled “3.2 Is a KH analog viable? — Partly, and KH already has half of it”

KH is Next.js App Router REST + TanStack Query + Supabase — not Rust/GraphQL. But the warp pattern maps cleanly onto the DB layer and poorly onto the API layer:

  • DB layer — KH already has the warp model. supabase gen types is diesel print-schema. database.types.ts is schema.rs. The missing piece is two-fold: (a) the CI parity gate (warp relies on PR-diff of the committed artefact; KH should add supabase-types-parity — already specced) and (b) the override file for JSONB/opaque-Json columns (warp’s schema.patch = KH’s MergeDeep override in a separate database-overrides.ts, already specced in type-safety-pipeline TECH). KH’s DB-side warp-analog is ~1-2 days of already-specced work, not a new framework.

  • API layer — there is no equivalent live introspectable source. warp’s API source of truth is a running GraphQL server with a typed schema. KH’s API is 193 hand-written Next.js route handlers that return NextResponse.json(...) with no machine-readable contract to introspect. There is nothing to print-schema against. To build a warp-analog for the API, KH would first have to create the introspectable contract — i.e. adopt OpenAPI/TypeSpec (Option B) or tRPC (Option C). So “be like warp” on the API side is not a separate option; it collapses into Options B or C. This is the honest reading of the warp reference.

Verdict on warp-analog: viable and recommended for the DB layer (KH is 80% there); not directly transplantable for the API layer because KH lacks a live typed-API source to introspect — that half reduces to “adopt a contract framework”, evaluated below.


Each option is assessed against Aspect 1 (implementation-time source of truth), Aspect 2 (catch-what’s-missed, mock-proof), the three priorities, migration cost, new dependencies, tooling-leverage, and residual risk.

Option A — Zod-first single source of truth

Section titled “Option A — Zod-first single source of truth”

Zod schemas in lib/validation/schemas.ts become canonical; z.infer<> produces the TS types for server and client; runtime validation at both request and response boundaries; type-drift-detect enforces in CI.

  • Aspect 1: Strong in principle — one z.object per shape, z.infer for the type, used at author time. But KH is the opposite of this today: TS interfaces are canonical, Zod mirrors them (only 3 z.infer in 3,335 LOC, §2.2). Becoming Zod-first means inverting ~37+ response interfaces (delete the hand-authored interface, replace with z.infer<typeof XSchema>) and hand-authoring strict schemas (not the .loose() generated ones).
  • Aspect 2 (mock-proof): Strong, and this is Zod’s killer property for this team. A real Schema.parse(payload) at a real boundary runs in production regardless of test quality — a mock-only test cannot defeat it because the check executes on the real wire payload, not the mock. This is the single most important property given the “tests built to pass” problem.
  • Automation: Medium. Request side is already automated (parseBody + sweep). Response side needs the defineRoute runtime check — which already exists. The gap is schema strictness, which is a human/authoring task.
  • Maintenance: Medium. One artefact (the schema) per shape instead of interface+schema. Lower long-term than the status quo’s interface+mirror duplication.
  • Migration cost: Medium-high. ~37 response interfaces to invert + author strict; request side already done.
  • New deps: None. Zod is already a core dependency.
  • Tooling leverage: Maximal — reuses Zod, parseBody, defineRoute, type-drift-detect. Nothing new.
  • Residual risk: A Zod schema can still be written .loose()/z.any() and defeat itself (the generated ones already do). Mitigation: a lint/CI check that bans .loose()/z.unknown() in response schemas, or a “strictness budget”. Also: Zod runtime cost on hot paths (negligible for KH’s request volumes).

Option B — Contract-first codegen (OpenAPI / TypeSpec)

Section titled “Option B — Contract-first codegen (OpenAPI / TypeSpec)”

*A single API contract (OpenAPI 3 or TypeSpec) generates request/response types

  • a typed client + server validators.*
  • Aspect 1: Strong — one contract file is the source; types generate from it. This is the closest literal warp-analog for the API layer.
  • Aspect 2: Medium-strong only if generated server validators are wired in (otherwise the contract is documentation, defeatable by a non-conforming handler). The generated validators are mock-proof like Zod.
  • Automation: Medium. Codegen is a build step. But KH’s handlers are hand-written REST — OpenAPI would either (a) be generated from the handlers (needs annotations on all 193 → same manual cost as Option 2’s migration) or (b) be authored first and handlers conformed to it (a contract-first rewrite of 193 routes).
  • Maintenance: High for a two-person team. New toolchain (@openapitools/openapi-typescript/TypeSpec compiler), a contract file to keep in sync, a generation step in CI, and a second type-source competing with database.types.ts. This is a framework tax.
  • Migration cost: High (annotate or rewrite 193 routes + stand up codegen).
  • New deps: Yes — OpenAPI/TypeSpec toolchain + a generator.
  • Tooling leverage: Low — mostly net-new; would duplicate what type-drift-detect + Zod already give at the route axis.
  • Residual risk: Two sources of truth (DB types vs API contract) that themselves can drift; contract↔handler conformance still needs enforcement.
  • Verdict: Not worth it for a two-person team. The payoff (a typed external client) only matters if KH publishes its REST API to third parties. It does not (the tRPC eval §4.2.5 confirms external callers are cron/MCP/health, not a public REST surface). The warp-analog appeal is real but the maintenance cost is disproportionate to a private, agent+UI-only API.

End-to-end inference; drift impossible by construction.

  • Aspect 1: Strongest possible — the client imports the router type; there is no separate source of truth to drift. This is genuinely the best implementation-time story.
  • Aspect 2: Compile-time inference is not mock-proof on its own (a cast defeats it, and types don’t run), but tRPC’s .output(Schema) adds runtime validation that is. Combined, very strong.
  • Automation: Highest (no codegen, no detector needed — the type system is the check).
  • Maintenance: High one-time, low steady-state — but the bet is large. The original eval (trpc-evaluation.md §4.4) estimated 22-35 days full migration; hybrid adoption (§5.3 Option β) creates “two boundary patterns … doubled cognitive load for the small team”. Re-examined with fresh eyes and current state: nothing has changed the eval’s core objections — MCP (58 tools), cron (9), upload, SSE streaming all stay REST; external callers can’t consume tRPC; the TS-inference memory risk (schemas.ts already 3,335 LOC) is worse now than at eval time. The eval’s verdict (recommend Option α = defineRoute, not tRPC) still holds.
  • Migration cost: Very high (22-35 days).
  • New deps: @trpc/server + @trpc/react-query + @trpc/client.
  • Tooling leverage: Low — replaces the boundary wholesale; type-drift-detect becomes moot (a fine outcome) but so does the investment in it.
  • Residual risk: Framework lock-in; hybrid entrenchment; the half-migrated state is the worst state.
  • Verdict: Defer, as before. The right call for new greenfield apps (Sales Proposals) — OPS-T2 decision gate — but not a retrofit for the existing 193 routes. Confidence unchanged from the 2026-05-07 eval.

Extend the autogenerated DB types toward API response composition.

  • Aspect 1: Strong for the DB-shaped half of every response, which is most of it. Tables<'x'>, TablesInsert<>, QueryData<typeof query> (for nested joins) and MergeDeep overrides (for JSONB) give accurate, generated, zero-maintenance types for anything that is a row or a query result. This is the warp DB-analog and KH is 80% there.
  • Aspect 2: The generated types are trustworthy because they come from the live schema; the supabase-types-parity CI job (specced) makes them mock-proof at the schema-drift axis.
  • Where it breaks down — honestly: API responses are app-level compositions, not raw rows. ReviewStatsResponse is RPC result + a computed unverified+ a separateawaiting_publication count (review/stats/route.ts:84-89). No amount of Supabase type extension produces that shape — it is assembled in the handler. Supabase types cover the ingredients, never the dish. The 14 opaque-Json RPCs are the sharpest case: they need a PL/pgSQL RETURNS TABLE(...) migration (a DB fix, not a TS fix) before gen types can type them.
  • Automation/maintenance/deps: Excellent — pure leverage of existing gen types; no new deps; near-zero maintenance once the parity CI lands.
  • Verdict: Adopt as the DB-layer foundation, but it is necessary-not- sufficient. It solves Aspect 1 for row/query shapes and nothing for composed responses. It is a component of the recommendation, not a standalone answer.

Option E — Status quo + OPS-T1 runtime validator + shipped CI detector (fork option 1)

Section titled “Option E — Status quo + OPS-T1 runtime validator + shipped CI detector (fork option 1)”

Ship the runtime-validating defineRoute, lean on type-drift-detect --ci.

  • Aspect 1: Weak. Adds no new source of truth; TS interfaces stay canonical and the implementer still consults them. The defineRoute wrapper doesn’t help authoring accuracy.
  • Aspect 2: This is the crux. The CI detector (type-drift-detect) is a genuinely good, mock-proof net for the route/fetcher axis — it reads real source AST and fails the build on new drift. BUT the runtime defineRoute validation, as currently generated (§2.5b), is near-worthless: .loose() + z.unknown() schemas pass renamed/retyped/removed fields. So the “runtime net” the fork option 1 sells is mostly illusory until the schemas are tightened.
  • Automation: High (codemod is built; detector is gating).
  • Maintenance: Low.
  • Migration cost: Near-zero (already built).
  • New deps: None.
  • Tooling leverage: Maximal (it is the existing tooling).
  • Residual risk: The runtime validator gives false confidence. A reviewer sees defineRoute(XSchema, …) and assumes the response is checked, when XSchema is .loose(). The honest version of fork option 1 is “ship the detector as the net; treat defineRoute runtime validation as a bonus only where the schema is strict.”

Option F — Hybrid (the one the evidence actually supports)

Section titled “Option F — Hybrid (the one the evidence actually supports)”

Zod-first strict schemas at the request+response boundary (Option A) for the concentrated set of response interfaces that matter, layered on a Supabase-generated DB-type foundation with MergeDeep overrides (Option D), with type-drift-detect --ci + supabase-types-parity as the two mock-proof CI nets. tRPC reserved for greenfield apps (Option C, deferred).

  • Aspect 1: Strong. DB shapes come from gen types (generated, accurate, zero-maintenance). Composed API response shapes come from strict Zod schemas with z.infer as the authored type — one artefact per shape, used at author time. The manual SCHEMA-QUICK-REFERENCE.md is demoted to prose/navigation; the generated types + schemas are the truth.
  • Aspect 2: Strongest achievable without a framework bet. Two mock-proof CI nets (drift detector + types parity) plus real runtime Schema.parse at boundaries where the schema is strict. None defeatable by a mock test.
  • Automation/maintenance/deps: All favourable — reuses Zod, parseBody, defineRoute, type-drift-detect, gen types. The only new maintained artefact is the MergeDeep override file and a “no .loose() in response schemas” lint.
  • Migration cost: Medium, incremental and already 60% underway (the OPS-T1 machinery exists; what’s missing is schema strictness + the DB-side parity/override).
  • Residual risk: Schema strictness is a discipline the team must hold; mitigated by a CI lint banning .loose()/z.unknown() in the response-schema block.

5. Can GitNexus / cocoindex (ccc) contribute to generation or enforcement?

Section titled “5. Can GitNexus / cocoindex (ccc) contribute to generation or enforcement?”

Honest answer: No, not as type-safety primitives. Both are detection/retrieval tools, valuable in the pipeline but not on the generation or enforcement path.

  • GitNexus is an execution-flow graph (HANDLES_ROUTE, FETCHES, QUERIES edges; gitnexus_impact, route_map, shape_check). It answers “what is the blast radius of renaming ReviewQueueResponse?” and “which routes touch this table?” — excellent for scoping a migration and pre-change impact analysis. It cannot generate a type or fail a build on shape drift. Its shape_check is a graph-consistency check, not a payload validator.
  • cocoindex (ccc) is a text-embedding search engine. Its role (R-WP12 §“Where cocoindex-code contributes”) is finding prose contracts — a docstring describing an opaque-Json RPC shape — that the type checker can’t see. Useful for the investigation of the 14 opaque RPCs; irrelevant to generation or enforcement.
  • ast-dataflow type-drift-detect is the one tool in this family that enforces — and it is already shipped and gating. It is a detector, not a generator: it tells you the route/fetcher link is unenforced; it does not create the type. That distinction is exactly why OPS-T1 was scoped as the fix to the detector’s finding.

So in the recommended hybrid: GitNexus = migration-scoping/impact, ccc = opaque-RPC prose archaeology, ast-dataflow = the route-axis CI net, Supabase gen types = the DB-axis generator, Zod = the runtime enforcer + (via z.infer) the composed-shape source of truth. Each does the one thing it is good at.


6. The “tests built to pass” angle (addressed directly)

Section titled “6. The “tests built to pass” angle (addressed directly)”

This is the decisive lens, and it cleanly separates the options.

The insight to evaluate (and it holds): runtime validation at real boundaries + CI drift detection on real source catch shape errors regardless of test quality, because they execute against the real wire payload / real AST — a mock-only test cannot reach them. By contrast, pure compile-time types are defeatable by as any / casts (fetchJson<T> is itself a cast, fetchers.ts:44) and don’t run — they protect nothing at runtime and nothing a weak test mocked away.

Ranked by resistance to the “tests built to pass” failure mode:

MechanismMock-proof?Why
Runtime Schema.parse at real boundary (strict Zod)YesRuns in prod on real payload; mock can’t intercept
type-drift-detect --ciYesReads real source AST in CI; mock irrelevant
supabase-types-parity --ciYesDiffs generated-from-live-DB vs committed; mock irrelevant
OPS-T1 runtime defineRoute with .loose() schemaBarelyRuns, but passes drifted fields (§2.5b)
Compile-time z.infer / interface typesNoDefeatable by cast; doesn’t run
tRPC inference (no .output)NoSame — types don’t run

Conclusion for a team that can’t rely on test discipline: the protection must come from mechanisms that run on real data in CI/prod, not from types or tests. That is precisely strict Zod at boundaries + the two CI parity/drift gates — i.e. Option F. It is the only combination that is fully mock-proof and low-maintenance and reuses existing tooling.


Primary: Option F — strict-Zod boundary + Supabase-generated DB foundation + two mock-proof CI nets, with the OPS-T1 fork resolved as a re-scope (a new Option 4).

Section titled “Primary: Option F — strict-Zod boundary + Supabase-generated DB foundation + two mock-proof CI nets, with the OPS-T1 fork resolved as a re-scope (a new Option 4).”

Ranking:

  1. F (hybrid) — recommended.
  2. D (Supabase-types-centric) — adopt as F’s DB-layer component (not standalone).
  3. A (Zod-first) — adopt as F’s boundary component (not standalone; the “invert everything to Zod-first” maximal form is more than needed).
  4. E (status quo + runtime OPS-T1) — acceptable only if reframed honestly (detector is the net; runtime validation is bonus-where-strict).
  5. C (tRPC) — deferred to greenfield apps (OPS-T2), unchanged.
  6. B (OpenAPI/TypeSpec) — rejected for a private two-person API; cost ≫ payoff.

KH already has the generated DB source of truth (warp’s schema.rs analog) and already has the mock-proof route-axis CI net (type-drift-detect). The only genuinely missing pieces for “type safety as the default” are (a) a generated, accurate type for composed API responses and (b) runtime validation strong enough to actually catch drift. Both are solved by tightening the Zod schemas KH already generates — not by a new framework. tRPC and OpenAPI both pay a framework tax a two-person team shouldn’t pay for a private API, and both duplicate the DB-axis truth KH gets free from gen types.

Incremental adoption plan (each step independently shippable)

Section titled “Incremental adoption plan (each step independently shippable)”
  1. Land the DB-axis warp-analog (highest ROI, lowest risk, ~1-2 days).
    • Add supabase-types-parity CI job (already specced, type-safety-pipeline TECH §“Supabase types CI plan”).
    • Add a database-overrides.ts MergeDeep layer for the 3-4 high-value JSONB columns (workspaces.domain_metadataBidMetadata, etc.) — the direct schema.patch analog.
    • Outcome: DB shapes are a trustworthy, drift-gated, generated source of truth. Aspect 1 solved for ~most of every response.
  2. Fix the runtime-validation strength (the real fork resolution).
    • Change generate-response-schemas.ts to emit strict schemas (drop .loose(), resolve z.unknown() to real shapes) for the 37 baseline interfaces — or hand-tighten them. Add a CI lint banning .loose()/ z.unknown() in the generated response-schema block.
    • Outcome: defineRoute(XSchema, …) becomes a real mock-proof runtime net, not a false-confidence wrapper.
  3. Ship OPS-T1 as the runtime wrapper for the routes where it’s mechanical and the schema is strict — do NOT pursue the throw-for-errors compile-time rewrite.
    • Keep the existing codemod output; the compile-time z.infer constraint is a bonus the few naturally-conforming routes get, not a goal to force on 178 routes via a semantic refactor.
  4. Make type-drift-detect --ci the durable aspect-2 net (already gating); drive the 37-row baseline toward zero as routes get strict schemas.
  5. Demote SCHEMA-QUICK-REFERENCE.md to navigation/prose; point all “what’s the type?” questions at database.types.ts + the strict schemas. Add a CLAUDE.md “TypeScript conventions” note: DB shapes from Tables<>/ QueryData<>; composed responses from z.infer<typeof XSchema>.
  6. (Deferred) tRPC for greenfield apps only (OPS-T2), if/when Sales Proposals is designed.

The fork was runtime defineRoute (option 1) vs manual compile-time migration (option 2). The evidence says neither, exactly — choose a new Option 4: ship the runtime wrapper (option-1 mechanics, already built) but make it actually protective by tightening the schemas, and explicitly abandon the compile-time throw-for-errors rewrite. The compile-time guarantee (option 2) is not worth 178 hand-migrated handlers when (a) the route-axis is already caught mock-proof by type-drift-detect --ci and (b) a strict runtime schema catches more (real payload drift) than a compile-time type (which a cast defeats and which doesn’t run). The single change that makes option 1 worth shipping is schema strictness — without it, option 1 is false confidence.


  1. Schema strictness budget. Are you willing to accept that tightening the 37 generated response schemas may surface real current drift (a route that already returns the wrong shape would now 500)? That’s the net working — but it means the strict-schema rollout must be staged route-by-route with monitoring, not flipped on at once. Acceptable?
  2. defineRoute compile-time clause — keep or drop? The handler: () => Promise<z.infer<S>> constraint only binds for the handful of routes that return raw payloads. Keep it as a free bonus, or remove it to avoid implying a guarantee that doesn’t generalise?
  3. Opaque-Json RPC migration appetite. 14 RPCs need PL/pgSQL RETURNS TABLE(...) work to become typed. WP-C (the inventory) gates this. Schedule the inventory now, or leave the 14 as accepted runtime-cast debt?
  4. DB-overrides scope. Which JSONB columns get MergeDeep overrides first? The TECH inventory flags workspaces.domain_metadata, content_items.summary_data, feed_prompts.performance_snapshot as high-value. Confirm the first 3.
  5. Is the API ever external? The whole “reject OpenAPI/tRPC” reasoning rests on KH’s REST API being private (UI + agents only). If a public REST API is on the roadmap, Option B re-enters contention.

OptionAspect 1 (author-time truth)Aspect 2 (mock-proof net)AutomationMaint.MigrationNew depsVerdict
A Zod-firstStrong (after invert)Strong (runtime)MedMedMed-highNoneComponent of F
B OpenAPI/TypeSpecStrongMed (if validators wired)MedHighHighYesReject (private API)
C tRPCStrongestMed→Strong (+.output)HighestHigh one-timeVery highYesDefer (greenfield)
D Supabase-typesStrong (rows only)Strong (parity CI)ExcellentV.lowLowNoneComponent of F
E Status-quo + OPS-T1 runtimeWeakWeak as-generated (.loose())HighLow~0NoneOnly if reframed
F HybridStrongStrongest achievableHighLowMed (60% done)NoneRECOMMENDED