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.
1. Frame & the two aspects
Section titled “1. Frame & the two aspects”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:
- Maximise automation (the team is two; manual sweeps don’t get done).
- Minimise error likelihood (catch the bug before it ships).
- 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 viasupabase 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-runninggen types— which the proposedsupabase-types-parityCI job (type-safety-pipeline TECH §“Supabase types CI plan”) closes. - Where it breaks down: two documented holes. (a) 14 opaque-
JsonRPCs (grep -c 'Returns: Json'= 14, confirmed) type their return asJson(database.types.ts:1definesJson=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 asJson(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.tsis 3,335 LOC, 161export const …Schemaconstants. 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 callparseBody((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.inferderivations exist in the entire schemas file (schemas.ts:1667,:2309,:964). The flow is the reverse of a Zod-first model: TypeScript interfaces intypes/*.tsare 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:29—fetchJson<T>returnsres.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 + 4mutationFetchJson<…>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-detectis shipped (lib/ast-dataflow/queries/type-drift-detect.ts, 27.5 KB) and gating in CI (.github/workflows/ci.yml:870type-drift-parityjob runstype-drift-detect --ci). It classifies every response interface asenforced | fetcher-only | route-only | unusedand fails the build on a newfetcher-onlyrow 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.tsis 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.tsexists and its contract is runtimesafeParse()→ 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).
3. The warp model & KH-analog feasibility
Section titled “3. The warp model & KH-analog feasibility”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-schemaintrospects the DB → emitscrates/persistence/src/schema.rs(530 LOC, header// @generated automatically by Diesel CLI). Config indiesel.toml.- Type-override mechanism:
crates/persistence/schema.patch(a committedgit diff) patches generated types the introspection got wrong — e.g.revision_ts: Nullable<Integer>→Nullable<BigInt>. This is exactly the SupabaseMergeDeepoverride 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.jspoints athttps://staging.warp.dev/graphql/v2). graphql-codegen(thegeneratescript) downloads + introspects the server, filters viaapi/client-schema.ts, and emits the committedapi/schema.graphqlSDL (104 KB, 4,053 lines).- From that committed SDL, two generators run:
cynic_codegenfor Rust (build.rs:register_schema(...).from_sdl_file("api/schema.graphql")) andgraphql-codegenfor 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 typesisdiesel print-schema.database.types.tsisschema.rs. The missing piece is two-fold: (a) the CI parity gate (warp relies on PR-diff of the committed artefact; KH should addsupabase-types-parity— already specced) and (b) the override file for JSONB/opaque-Jsoncolumns (warp’sschema.patch= KH’sMergeDeepoverride in a separatedatabase-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 toprint-schemaagainst. 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.
4. Options A–F
Section titled “4. Options A–F”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.objectper shape,z.inferfor the type, used at author time. But KH is the opposite of this today: TS interfaces are canonical, Zod mirrors them (only 3z.inferin 3,335 LOC, §2.2). Becoming Zod-first means inverting ~37+ response interfaces (delete the hand-authoredinterface, replace withz.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 thedefineRouteruntime 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 withdatabase.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.
Option C — tRPC (revisit)
Section titled “Option C — tRPC (revisit)”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.tsalready 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-detectbecomes 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.
Option D — Supabase-types-centric
Section titled “Option D — Supabase-types-centric”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) andMergeDeepoverrides (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-parityCI 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.
ReviewStatsResponseisRPC result + a computedunverified+ a separateawaiting_publicationcount(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-JsonRPCs are the sharpest case: they need a PL/pgSQLRETURNS TABLE(...)migration (a DB fix, not a TS fix) beforegen typescan 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
defineRoutewrapper 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 runtimedefineRoutevalidation, 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, whenXSchemais.loose(). The honest version of fork option 1 is “ship the detector as the net; treatdefineRouteruntime 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 withz.inferas the authored type — one artefact per shape, used at author time. The manualSCHEMA-QUICK-REFERENCE.mdis 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.parseat 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 theMergeDeepoverride 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,QUERIESedges;gitnexus_impact,route_map,shape_check). It answers “what is the blast radius of renamingReviewQueueResponse?” 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. Itsshape_checkis 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-
JsonRPC 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-detectis 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:
| Mechanism | Mock-proof? | Why |
|---|---|---|
Runtime Schema.parse at real boundary (strict Zod) | Yes | Runs in prod on real payload; mock can’t intercept |
type-drift-detect --ci | Yes | Reads real source AST in CI; mock irrelevant |
supabase-types-parity --ci | Yes | Diffs generated-from-live-DB vs committed; mock irrelevant |
OPS-T1 runtime defineRoute with .loose() schema | Barely | Runs, but passes drifted fields (§2.5b) |
Compile-time z.infer / interface types | No | Defeatable by cast; doesn’t run |
tRPC inference (no .output) | No | Same — 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.
7. Ranked recommendation
Section titled “7. Ranked recommendation”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:
- F (hybrid) — recommended.
- D (Supabase-types-centric) — adopt as F’s DB-layer component (not standalone).
- A (Zod-first) — adopt as F’s boundary component (not standalone; the “invert everything to Zod-first” maximal form is more than needed).
- E (status quo + runtime OPS-T1) — acceptable only if reframed honestly (detector is the net; runtime validation is bonus-where-strict).
- C (tRPC) — deferred to greenfield apps (OPS-T2), unchanged.
- B (OpenAPI/TypeSpec) — rejected for a private two-person API; cost ≫ payoff.
Why F over the others, in one paragraph
Section titled “Why F over the others, in one paragraph”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)”- Land the DB-axis warp-analog (highest ROI, lowest risk, ~1-2 days).
- Add
supabase-types-parityCI job (already specced, type-safety-pipeline TECH §“Supabase types CI plan”). - Add a
database-overrides.tsMergeDeeplayer for the 3-4 high-value JSONB columns (workspaces.domain_metadata→BidMetadata, etc.) — the directschema.patchanalog. - Outcome: DB shapes are a trustworthy, drift-gated, generated source of truth. Aspect 1 solved for ~most of every response.
- Add
- Fix the runtime-validation strength (the real fork resolution).
- Change
generate-response-schemas.tsto emit strict schemas (drop.loose(), resolvez.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.
- Change
- 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.inferconstraint is a bonus the few naturally-conforming routes get, not a goal to force on 178 routes via a semantic refactor.
- Keep the existing codemod output; the compile-time
- Make
type-drift-detect --cithe durable aspect-2 net (already gating); drive the 37-row baseline toward zero as routes get strict schemas. - Demote
SCHEMA-QUICK-REFERENCE.mdto navigation/prose; point all “what’s the type?” questions atdatabase.types.ts+ the strict schemas. Add a CLAUDE.md “TypeScript conventions” note: DB shapes fromTables<>/QueryData<>; composed responses fromz.infer<typeof XSchema>. - (Deferred) tRPC for greenfield apps only (OPS-T2), if/when Sales Proposals is designed.
How this resolves the OPS-T1 fork
Section titled “How this resolves the OPS-T1 fork”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.
8. Open questions for Liam
Section titled “8. Open questions for Liam”- 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?
defineRoutecompile-time clause — keep or drop? Thehandler: () => 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?- Opaque-
JsonRPC migration appetite. 14 RPCs need PL/pgSQLRETURNS TABLE(...)work to become typed. WP-C (the inventory) gates this. Schedule the inventory now, or leave the 14 as accepted runtime-cast debt? - DB-overrides scope. Which JSONB columns get
MergeDeepoverrides first? The TECH inventory flagsworkspaces.domain_metadata,content_items.summary_data,feed_prompts.performance_snapshotas high-value. Confirm the first 3. - 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.
9. Summary table
Section titled “9. Summary table”| Option | Aspect 1 (author-time truth) | Aspect 2 (mock-proof net) | Automation | Maint. | Migration | New deps | Verdict |
|---|---|---|---|---|---|---|---|
| A Zod-first | Strong (after invert) | Strong (runtime) | Med | Med | Med-high | None | Component of F |
| B OpenAPI/TypeSpec | Strong | Med (if validators wired) | Med | High | High | Yes | Reject (private API) |
| C tRPC | Strongest | Med→Strong (+.output) | Highest | High one-time | Very high | Yes | Defer (greenfield) |
| D Supabase-types | Strong (rows only) | Strong (parity CI) | Excellent | V.low | Low | None | Component of F |
| E Status-quo + OPS-T1 runtime | Weak | Weak as-generated (.loose()) | High | Low | ~0 | None | Only if reframed |
| F Hybrid | Strong | Strongest achievable | High | Low | Med (60% done) | None | RECOMMENDED |