Skip to content

R-WP12 — Type-safety pipeline feasibility brief

R-WP12 — Type-safety pipeline feasibility brief

Section titled “R-WP12 — Type-safety pipeline feasibility brief”

Companions: ROADMAP.md (Wave 3, R-WP12 entry), TECH.md (current tool surface), PRODUCT.md (12-query inventory).

S8 decision gate: This brief surfaces the gap inventory and proposes an MVP WP decomposition. Whether to build is a Liam call at S8 review.


R-WP12 in the roadmap asks: can we use ast-dataflow + cocoindex-code + GitNexus + Supabase auto-generated types to implement and then automate full codebase type-safety — coming at the problem from a different angle to tRPC?

This brief answers that question at feasibility grade. It maps the current gaps, identifies which existing tool fills each gap, and proposes a ≤ 2-week MVP decomposition for S8 review.


Full evaluation: docs/plans/phase-0-investigation/trpc-evaluation.md (2026-05-07).

Key findings that inform this investigation:

  1. The type-sharing gap is real and named. Every fetcher in lib/query/fetchers.ts carries a <T> generic that names the expected response shape. There is no compile-time check that NextResponse.json({ ... }) in the matching route actually produces T. The evaluation calls this the “manual type-sync drift” class of bug and cites concrete instances (Phase 0.6 §3.1).

  2. tRPC was deferred as too expensive. Full migration: 22–35 days. Hybrid adoption: cognitive overhead of two boundary patterns. Option α — a typed defineRoute() wrapper — was recommended as the highest-ROI alternative (~2–3 days, no new framework). Backlog item OPS-T1. As of S7, OPS-T1 has not been implemented; no defineRoute or withRoute wrapper exists in lib/.

  3. DW.14 (Supabase typegen pattern) remains PENDING-INVESTIGATION. Decision graph docs/plans/phase-0-investigation/0.9-decision-graph.md line 279 records this decision as unresolved. The current investigation provides the evidence needed to close it.

  4. The codebase structurally resembles tRPC already. The evaluation’s net observation: “The codebase has spent significant effort building a discriminated, Zod-first, TanStack-Query-exclusive client/server boundary that structurally resembles tRPC, with the type-sharing piece being the one piece left manual.” This is the gap the current investigation targets.


Sampled five representative routes + MCP tool layer + internal lib layer. Routes examined:

  • app/api/items/route.ts — highest-volume write route (POST)
  • app/api/bids/[id]/route.ts — composite GET + PATCH with RPC and storage
  • app/api/coverage/route.ts — pure RPC route, simple shape
  • app/api/review/stats/route.ts — opaque-Json RPC route with manual cast

MCP tools examined:

  • lib/mcp/tools/search.ts — vector RPC tools
  • lib/mcp/tools/content.ts — primary CRUD tools (9 tools)
  • lib/mcp/tools/shared.tsdefineTool wrapper, toStructuredContent

Internal lib sampled:

  • lib/bid/bid-queries.ts — shared RPC aggregation
  • lib/query/fetchers.ts — all ~100+ named fetchers
  • lib/supabase/server.ts — client factory

Gap 1: Route response types — manual <T> on every fetcher

Section titled “Gap 1: Route response types — manual <T> on every fetcher”

Current state: Routes return NextResponse.json(payload) with no static output type annotation. The compiler infers Promise<NextResponse<unknown>> for every handler. Client-side fetchers in lib/query/fetchers.ts carry the expected type as a generic parameter:

// lib/query/fetchers.ts:29
export async function fetchJson<T>(url: string, ...): Promise<T> {
...
return res.json() as Promise<T>; // unchecked cast
}
// Call site example
return fetchJson<ReviewQueueResponse>('/api/review/queue', ...);

There is no structural link between the type T the fetcher declares and what the route actually sends. Renaming, removing, or adding a field in the route response does not surface a type error at the fetcher call site.

Risk level: HIGH. The tRPC evaluation cites concrete prior instances of drift (Phase 0.6 §3.1 metadata.thumbnail_url drift; fictional coverage in lib/queue/handlers/batch-reclassify.ts:309). With 193 routes and 100+ fetchers, the surface area is large.

Evidence (file:line):

  • lib/query/fetchers.ts:29fetchJson<T> returns res.json() as Promise<T>
  • lib/query/fetchers.ts:356mutationFetchJson<T> same pattern
  • app/api/bids/[id]/route.ts:105const responseBody: Record<string, unknown> = { ...bid, ... } — response assembled as untyped record
  • app/api/review/stats/route.ts:76statsResult.data as Omit<ReviewStatsResponse, ...> & { total: number; verified: number } — manual cast over opaque RPC result to satisfy response type
  • lib/query/fetchers.ts:71fetchJson<TaxonomySyncStatus>TaxonomySyncStatus is defined in the same file, NOT imported from the route. A route change would not be detected.

Scope: All 193 routes + their matching fetchers (100+ in lib/query/fetchers.ts).


Gap 2: RPC return types — opaque Json for 14 functions

Section titled “Gap 2: RPC return types — opaque Json for 14 functions”

Current state: Supabase’s auto-generated database.types.ts types RPC return values in two tiers:

  • Structured RPCs (e.g. get_coverage_matrix, get_bid_question_stats): fully typed row shapes — the type-checker knows column names and types.
  • Opaque-Json RPCs (14 functions): declared Returns: Json in database.types.ts. The type-checker sees Json (defined as string | number | boolean | null | { [key: string]: Json } | Json[]), which is structurally equivalent to unknown for most practical purposes.

Risk level: MEDIUM. Callers must cast to use the data. Mistakes in the cast shape go undetected until runtime.

Evidence (file:line):

  • supabase/types/database.types.ts:3227get_author_analysis: { Returns: Json }
  • supabase/types/database.types.ts:3255get_bid_summary: { Returns: Json }
  • supabase/types/database.types.ts:3263get_content_gaps: { Returns: Json }
  • supabase/types/database.types.ts:3419get_filter_counts: { Returns: Json }
  • supabase/types/database.types.ts:3533get_reading_patterns: { Returns: Json }
  • supabase/types/database.types.ts:3534get_review_breakdown_stats: { Returns: Json }
  • app/api/review/stats/route.ts:76 — route must manually cast statsResult.data to Omit<ReviewStatsResponse, ...> because get_review_breakdown_stats returns opaque Json
  • app/api/insights/route.ts:84get_content_gaps RPC data used without typed access

Full list: get_author_analysis, get_bid_summary, get_content_gaps, get_coverage_entity_data, get_freshness_breakdown, get_filter_counts, get_reading_patterns, get_review_breakdown_stats, get_scoring_breakdown, get_tag_overlap, get_topic_deep_dive, get_user_tag_counts, plus two others. (Count: 14 Returns: Json entries from grep -c "Returns: Json" database.types.ts.)


Gap 3: RPC return columns not fully narrowed — type-correct but structurally loose

Section titled “Gap 3: RPC return columns not fully narrowed — type-correct but structurally loose”

Current state: For structured RPCs (the majority), database.types.ts provides the full column list with precise types. However, call sites in the codebase sometimes cast the returned rows to Record<string, unknown> before accessing fields, bypassing the narrowing entirely:

// lib/bid/bid-queries.ts:83-86
needs_sme_count: (row as Record<string, unknown>).needs_sme_count as
| number
| undefined,
no_content_count: (row as Record<string, unknown>).no_content_count as
| number
| undefined,

Yet database.types.ts:3246-3247 declares both needs_sme_count: number and no_content_count: number as non-optional on get_bid_question_stats_batch. The cast discards the available narrowing and adds | undefined unnecessarily.

Similarly in MCP tools:

// lib/mcp/tools/search.ts:152-179 (multiple instances)
filtered = filtered.filter((r: Record<string, unknown>) => {
const domain = r.primary_domain as string | null;
...
});

hybrid_search returns a fully typed row shape (documented at database.types.ts:3638). The Record<string, unknown> cast is not needed.

Risk level: MEDIUM. These are correctness-reducing patterns: they convert compile-time guarantees to runtime assumptions. A column rename in the PL/pgSQL function would not surface an error here.

Evidence (file:line):

  • lib/bid/bid-queries.ts:83-86 — unnecessary cast on structured RPC return
  • lib/mcp/tools/search.ts:152(r: Record<string, unknown>) on hybrid_search result
  • lib/mcp/tools/search.ts:178 — same pattern
  • lib/mcp/tools/search.ts:191-199 — manual field extraction from typed result
  • lib/mcp/tools/content.ts:84(rows ?? []) as Record<string, unknown>[] on structured table query
  • lib/mcp/tools/content.ts:2165-2166 — cast on structured query result

Count: 13 as Record<string, unknown> casts in lib/mcp/tools/ and 34 in app/api/ per grep. Not all are RPC-related — some are domain_metadata JSONB columns that are genuinely Json — but the pattern is widespread.


Gap 4: MCP tool output schemas — structured content without machine-verified contract

Section titled “Gap 4: MCP tool output schemas — structured content without machine-verified contract”

Current state: MCP tools return dual content:

  • content: [{ type: 'text', text: markdown }] — human-readable output.
  • structuredContent: toStructuredContent(dataObject) — machine-readable JSON.

toStructuredContent (defined at lib/mcp/tools/shared.ts:213) performs JSON.parse(JSON.stringify(data)) as Record<string, unknown> — a JSON round-trip cast. This satisfies the MCP SDK’s index-signature requirement but erases the type. There is no outputSchema registered on any of the 58 MCP tools (confirmed by grep -rn "outputSchema:" lib/mcp/tools/ returning empty).

The DefineToolConfig interface at lib/mcp/tools/shared.ts:128 declares outputSchema?: OutputArgs as optional. The intent to support output schemas exists at the type level, but no tool uses it.

Risk level: LOW-MEDIUM. MCP tools are invoked by agents (Claude) and are not part of the same client/server type boundary as the REST routes. However, absence of output schemas means:

  1. MCP clients cannot validate responses at the protocol level.
  2. There is no mechanism for detecting when a formatter’s interface drifts from the tool’s actual return shape.

The existing mcp-app-contracts.test.ts partially fills this gap for MCP App tools specifically, but covers only 4 of 58 tools.

Evidence (file:line):

  • lib/mcp/tools/shared.ts:135outputSchema?: OutputArgs — optional, never used
  • lib/mcp/tools/shared.ts:213toStructuredContent erases type to Record<string, unknown>
  • lib/mcp/tools/search.tsdefineTool(server, 'search_knowledge_base', { ... }, ...) — no outputSchema
  • lib/mcp/tools/content.ts — 9 tools, none register outputSchema
  • grep -rn "outputSchema:" lib/mcp/tools/ returns empty

Gap 5: Domain-typed JSONB columns — structurally Json but semantically richer

Section titled “Gap 5: Domain-typed JSONB columns — structurally Json but semantically richer”

Current state: Several Supabase columns are stored as JSONB and typed as Json in database.types.ts. The application has richer domain types for these in TypeScript — but the mapping from DB Json to domain type is manual and unchecked:

  • workspaces.domain_metadata — typed Json | null in DB, domain type is BidMetadata (parsed via parseBidMetadata() in lib/validation/schemas.ts). Evidence: app/api/bids/[id]/route.ts:160 casts current.domain_metadata as Record<string, unknown>.
  • content_items.metadata — typed Json | null, domain meaning varies by content type; callers cast directly.
  • Various metadata columns across bid_responses, feed_articles, pipeline_runs — all Json, all hand-cast at each call site.
  • MCP tool writes: lib/mcp/tools/content.ts:520 uses metadata as unknown as Json to satisfy Supabase insert type.

Risk level: LOW-MEDIUM. The JSONB pattern is intentional — schema-less flexibility for domain_metadata is by design. The risk is that no validation enforces that a write to these columns produces a shape the downstream reader can parse. parseBidMetadata() provides runtime validation at read time, but callers can write any shape.

Evidence (file:line):

  • app/api/bids/[id]/route.ts:160current.domain_metadata as Record<string, unknown>
  • lib/mcp/tools/content.ts:388} as Json cast on structured insert payload
  • lib/mcp/tools/content.ts:520metadata as unknown as Json (double-cast)
  • app/api/bids/[id]/responses/draft/route.ts:216draftResult.metadata as unknown as Json

Before the gap-closing analysis, it is important to record what is already working well:

  1. Supabase client creation is fully typed. lib/supabase/server.ts and lib/mcp/auth.ts both use createSupabaseClient<Database>(...) or createSupabaseServerClient<Database>(...). All Supabase clients carry the full Database type parameter — .from('workspaces').select(...) returns typed rows without any application-level configuration required.

  2. Structured RPCs are typed through. 46+ RPC functions (those not in the 14 opaque-Json group) return fully typed row arrays. Call sites that use the return value directly — without the Record<string, unknown> cast antipattern — get full column-level type checking.

  3. Input validation is exhaustive. lib/validation/schemas.ts (2,297 LOC, ~50 Zod schemas) covers every route’s input. parseBody(Schema, raw) discriminated-union enforces this. A validation-sweep test (__tests__/validation/validation-sweep.test.ts) asserts all routes call it. The ESLint rule DW.13 (mirroring tRPC’s .input() convention) further enforces this at author time.

  4. Auth pattern is typed. getAuthorisedClient() returns a discriminated union { success: boolean } checked before any handler logic. The authFailureResponse(auth) helper is typed correctly. No routes bypass this (per audit in trpc-evaluation §2).

  5. Response type definitions exist in types/*.ts. types/review.ts, types/bid.ts, and others export named interfaces that fetchers reference. The types exist — the gap is that routes do not declare they produce them.


GapDescriptionPrimary closerSupporting toolRationale
Gap 1 (route/fetcher <T> drift)Manual type sync between route response and fetcher genericast-dataflow type-evolution queryGitNexus gitnexus_impacttype-evolution traces where a response-type interface is used: route (definition/write) and fetcher (read). Drift = interface used in fetcher but not constrained in route. GitNexus finds blast radius for rename.
Gap 1 (longer term)Compile-time enforcementOPS-T1 defineRoute() wrapper (no new tool)NoneThe tRPC evaluation already scoped this: ~2-3 days, closes the root cause. ast-dataflow can detect the gap; OPS-T1 closes it structurally.
Gap 2 (opaque Json RPCs)14 RPC functions return Json not typed rowsSupabase gen types re-run + PL/pgSQL function signaturesast-dataflow column-reads/string-literal-usesRoot cause: the Postgres functions return json/jsonb scalars, not RETURNS TABLE(...). Adding RETURNS TABLE to those 14 functions would cause gen types to emit typed row shapes. ast-dataflow can find all call sites that need cast removal after the migration.
Gap 3 (unnecessary casts on typed RPCs)as Record<string, unknown> discarding structured typeast-dataflow references + type-evolutionNonetype-evolution('HybridSearchResult') or equivalent would show call sites that cast away the type. The query surfaces every place the typed result is downcasted. Fixable by mechanical refactor.
Gap 4 (MCP output schemas)No outputSchema on 58 toolsast-dataflow callers('defineTool') + dead-exportsNonecallers('defineTool') enumerates all 58 call sites. dead-exports on the formatter interfaces shows whether any formatter type has no typed consumer. For enforcement: register outputSchema on each defineTool call.
Gap 5 (JSONB domain types)Json columns holding domain-typed payloadsast-dataflow column-writes + string-literal-usescocoindex-codecolumn-writes('workspaces', 'domain_metadata') finds every write site. string-literal-uses('domain_metadata') finds fixture and test references. cocoindex-code finds prose mentions in docs. Together: complete inventory of write sites to validate.

Gap 1 in detail: ast-dataflow as a type-drift detector

Section titled “Gap 1 in detail: ast-dataflow as a type-drift detector”

The core mechanism:

  1. For each named response interface in types/ (e.g. ReviewQueueResponse), run references('ReviewQueueResponse').
  2. The result set should include:
    • At least one route file where the interface is used as a type annotation on the response (or the response assembly function’s return type).
    • At least one fetcher in lib/query/fetchers.ts where it appears as the fetchJson<T> generic.
  3. If the route appears in references but with no typeReference kind at the route handler’s return position — only at the interface definition and the fetcher import — that is a structural signal of the gap: the type is declared but not enforced at the route boundary.

This is a detection mechanism, not a fix. The fix is OPS-T1 (defineRoute()). But the detection mechanism has value independently: it can surface every instance of the pattern across all 193 routes without manual audit.

ast-dataflow queries involved:

  • references(type-name) — to find all usages of a response interface
  • type-evolution(type-name) — to trace re-exports and intersections of the type
  • callers('fetchJson') — to enumerate all fetcher call sites that carry a <T> generic

Gap 2 in detail: Supabase typegen + ast-dataflow for cast removal

Section titled “Gap 2 in detail: Supabase typegen + ast-dataflow for cast removal”

The 14 opaque-Json RPCs are not an ast-dataflow problem — they are a database schema problem. The fix is to alter those 14 PL/pgSQL functions from returning json to returning TABLE(col1 type1, col2 type2, ...), then regenerate types. After regeneration:

  • database.types.ts emits typed row shapes for those functions.
  • Call sites that were previously statsResult.data as Omit<...> can use the typed result directly.
  • ast-dataflow string-literal-uses + references can find every downstream cast that needs removal — producing the PR change list.

The investigation needed before this work: which of the 14 are actually called from TS (not only from Python or from other SQL functions)?

ast-dataflow query:

bun run ast-dataflow string-literal-uses 'get_review_breakdown_stats' --scope app/ lib/

Run for each of the 14 function names to produce the call inventory.

Gap 3 in detail: mechanical de-casting via ast-dataflow

Section titled “Gap 3 in detail: mechanical de-casting via ast-dataflow”

The as Record<string, unknown> cast pattern on structured RPC results is the most automatable gap. The process:

  1. bun run ast-dataflow references 'HybridSearchResult' (or the correct return type name from database.types.ts) — finds where the type flows.
  2. Any result in the references output that appears inside a as Record<string, unknown> node is a candidate for mechanical removal.
  3. The type-evolution query on the RPC’s return type traces whether the full type is being used downstream or whether it is being truncated at the cast site.

After de-casting, lib/mcp/tools/search.ts line 190 changes from:

(r: Record<string, unknown>) => ({
id: r.id as string,
title: r.title as string | null,
...

to:

(r) => ({
id: r.id, // string — already typed
title: r.title, // string | null — already typed
...

This is a pure mechanical refactor with no behaviour change. The typed client already does the narrowing; the cast is just noise.

defineTool at lib/mcp/tools/shared.ts already supports outputSchema as an optional field. The work is:

  1. For each of the 58 tools, identify the formatter interface that toStructuredContent(...) receives (e.g. SearchResult[], ContentItemDetail).
  2. Express that interface as a Zod schema (the formatters in lib/mcp/formatters/ already define TypeScript interfaces — converting to Zod is the work).
  3. Register the Zod schema as outputSchema in the defineTool call.

ast-dataflow involvement: callers('defineTool') produces the complete list of 58 tool registration sites. For each site, callers('toStructuredContent') finds which formatter interface is the argument — scoping the Zod conversion.

The immediate payback: the MCP SDK validates structured content against the registered schema at runtime, surfacing formatter regressions before they reach agents.

cocoindex-code is a text-embedding search engine, not a type resolver. Its role in the type-safety pipeline is narrow but real:

  • Finding prose documentation that describes the expected shape of an opaque-Json RPC return. If get_bid_summary is not typed but a docstring or adjacent comment describes its shape, cocoindex finds it. ast-dataflow does not index comments.
  • Discovering ad-hoc type contracts in markdown docs, runbooks, or continuation prompts. Example: if docs/specs/ somewhere documents “the get_filter_counts RPC returns { domain: string, count: number }[]”, cocoindex surfaces it; ast-dataflow does not.
  • Cross-tool verification sweep: after a typing PR, cocoindex can search for lingering as string or as Record patterns that the type-checker did not catch because they are inside a JSON assertion.

cocoindex is complementary at the boundary between typed and untyped code; ast-dataflow handles the typed interior.

GitNexus operates at the execution-flow graph level: HANDLES_ROUTE, FETCHES, QUERIES edge types connect routes to their callers and the Supabase tables/functions they touch.

In the type-safety pipeline:

  • Blast radius before a response type change. gitnexus_impact on a response interface name (ReviewQueueResponse) surfaces which routes and which hooks depend on it — before running ast-dataflow’s finer-grained type-evolution query.
  • Route coverage for the defineRoute() migration. GitNexus’s HANDLES_ROUTE edges enumerate all 193 routes; this is the source list for any OPS-T1 migration sweep.
  • Execution-flow gap detection. A route that has a FETCHES edge to a table but no corresponding type-evolution evidence that the row type flows to the client is a candidate for the Gap 1 pattern.

GitNexus and ast-dataflow are complementary at the process/flow level vs. the per-symbol level respectively.


MVP proposal (≤ 2 weeks of incremental work)

Section titled “MVP proposal (≤ 2 weeks of incremental work)”

This MVP targets the three highest-leverage gaps: Gap 3 (mechanical de-casting), Gap 2 (opaque-Json RPCs — investigation phase only), and Gap 1 (type-drift detection tooling). It does not implement OPS-T1 (defineRoute()) — that warrants its own WP outside the ast-dataflow track.

MVP scope: Build type-safety detection and mechanical de-casting tooling using the existing ast-dataflow queries. Leave structural fixes (OPS-T1, PL/pgSQL function signature changes) scoped as separate subsequent WPs.

WP A — Structured-RPC de-cast audit (Gap 3)

Section titled “WP A — Structured-RPC de-cast audit (Gap 3)”

What: Run references and type-evolution queries on each structured RPC return type (the ~46 non-opaque-Json functions) to enumerate every as Record<string, unknown> cast site. Produce a JSONL report of cast sites with their enclosing function, file, line, and the inferred type at that point.

Files touched:

  • scripts/type-safety-audit.ts (new — CLI wrapper over existing ast-dataflow queries)
  • docs/generated/type-safety-audit.md (new — human-readable report output)

Acceptance criteria:

  • Script runs in < 2 minutes on KH corpus.
  • Report lists every as Record<string, unknown> cast site on a structured RPC return, with file + line + inferred type available.
  • Report has zero false-negatives for the three known sites (bid-queries.ts:83, search.ts:152, search.ts:191) confirmed by manual review.

Effort estimate: ~4h. Builds on existing references query; new work is the CLI wrapper and report formatter.


WP B — Structured-RPC cast removal PR (Gap 3, execution)

Section titled “WP B — Structured-RPC cast removal PR (Gap 3, execution)”

What: Using the WP A report, mechanically remove the as Record<string, unknown> casts from structured RPC return sites. Replace with direct property access on the typed result. Each removal is verified by bun run test on the affected file.

Files touched (illustrative from WP A report):

  • lib/bid/bid-queries.ts — remove casts on get_bid_question_stats_batch rows
  • lib/mcp/tools/search.ts — remove casts on hybrid_search rows (~8 sites)
  • lib/mcp/tools/content.ts — remove casts on structured table query rows (~4 sites)
  • Additional sites from the WP A report

Acceptance criteria:

  • All removed casts replaced with direct property access.
  • bun run test lib/bid/ lib/mcp/tools/ passes after changes.
  • No as Record<string, unknown> casts remain on structured (non-Json) RPC returns (enforced by a new ESLint rule or by WP A report re-run showing empty output).

Effort estimate: ~3h. Mostly mechanical given WP A’s report. ESLint rule authorship adds ~1h if included.


WP C — Opaque-Json RPC inventory and fix-path (Gap 2)

Section titled “WP C — Opaque-Json RPC inventory and fix-path (Gap 2)”

What: For each of the 14 opaque-Json RPCs, determine:

  1. Is it actually called from TS (not only from Python/SQL)?
  2. What does it actually return? (Read the PL/pgSQL function body.)
  3. Is the return shape stable enough to model as RETURNS TABLE(...)?

Use bun run ast-dataflow string-literal-uses '<function_name>' --scope app/ lib/ for each of the 14 function names to produce the TS call inventory.

Deliverable: A markdown table listing each opaque-Json RPC with:

  • TS call sites (from ast-dataflow)
  • Current return shape (from PL/pgSQL function inspection)
  • Feasibility verdict: convertible | too-dynamic | no-ts-callers
  • Estimated migration effort if convertible

Files touched:

  • docs/specs/id-16-ast-dataflow-tool/investigations/R-WP12-opaque-json-rpcs.md (new)

Acceptance criteria:

  • All 14 RPC functions inventoried.
  • At least one convertible verdict with a migration draft migration script sketched (not executed — feasibility only).
  • Functions with no TS callers (Python-only) documented as no-ts-callers.

Effort estimate: ~4h. Primarily reading PL/pgSQL function bodies via Supabase CLI and cross-referencing with ast-dataflow output.


WP D — Route/fetcher type-drift detector (Gap 1)

Section titled “WP D — Route/fetcher type-drift detector (Gap 1)”

What: Build a script that, for each named response interface in types/*.ts and lib/query/fetchers.ts, runs references(interface-name) and checks whether the interface appears in both a route file AND a fetcher file. If it appears only in the fetcher (and not as a return type annotation in any route), flag it as a potential gap.

This is the detection mechanism for the manual-<T>-sync class. It does not fix the pattern — fixing requires OPS-T1 — but it provides an auditable gap list that OPS-T1 can work against.

Files touched:

  • scripts/type-drift-detector.ts (new — CLI wrapper)
  • docs/generated/type-drift-report.md (new — gap list output)

Acceptance criteria:

  • Script runs in < 3 minutes on KH corpus.
  • Report correctly identifies at least 5 response interfaces that are used in fetchers but not annotated in any matching route handler return type.
  • Report is structured as JSONL with one row per gap: { interface, usedInFetcher, usedInRoute, evidence[] }.
  • Known non-gap interfaces (e.g. TaxonomySyncStatus — directly typed in fetcher and route both) do not appear in the report.

Effort estimate: ~5h. The most novel piece — requires combining references output across files and classifying route vs. fetcher context.


WP E — MCP outputSchema registration scaffold (Gap 4, partial)

Section titled “WP E — MCP outputSchema registration scaffold (Gap 4, partial)”

What: For the 5 highest-usage MCP tools (determined by bun run test:mcp-eval:fc hit count and by Liam’s session usage), register a Zod outputSchema in the defineTool call. Use the corresponding formatter interface as the schema source. This is a scaffold — not all 58 tools — to validate that the pattern works before full rollout.

Files touched (illustrative):

  • lib/mcp/tools/search.tsoutputSchema for search_knowledge_base
  • lib/mcp/formatters/search.ts — Zod schema derived from SearchResult interface
  • __tests__/mcp/output-schema-smoke.test.ts (new — verify MCP SDK validates output)

Acceptance criteria:

  • At least 5 tools have outputSchema registered.
  • bun run test:mcp-eval (L1 protocol compliance) continues to pass.
  • New output-schema-smoke.test.ts verifies the MCP SDK validates a known-good and a known-bad structured content object against the registered schema.
  • No regression in existing formatter tests.

Effort estimate: ~4h. Pattern work — first tool is ~2h, subsequent tools ~30m each.


WPTarget gapEffortDeliverable
WP AGap 3 (detection)~4hAudit report of cast sites
WP BGap 3 (execution)~3hMechanical cast removal
WP CGap 2 (inventory)~4hOpaque-Json RPC fix-path doc
WP DGap 1 (detection)~5hType-drift gap list
WP EGap 4 (scaffold)~4houtputSchema on 5 tools
Total~20h

20 hours ≈ 10 S7-S9 working sessions at ~2h per session, or ~2 weeks of incremental parallel-agent work with the orchestrator sequencing WPs.

WP A and WP C can run in parallel (no dependency). WP B depends on WP A. WP D and WP E are independent of A/B/C. Recommended sequence:

S8: WP A + WP C (parallel)
S8: WP B (after WP A)
S9: WP D + WP E (parallel)
S9: OPS-T1 assessment (uses WP D report as input — Liam decision gate)

Build the detection tooling (WP A, WP D) first — before any structural fix.

The tRPC evaluation correctly identified that the type-sync gap exists and that OPS-T1 (defineRoute()) would close it at the root. But that decision (whether to implement OPS-T1) is better made with empirical evidence of gap prevalence: how many fetchers are drifted, how badly, which routes are the riskiest. WP D produces that evidence.

Similarly, WP C determines whether the 14 opaque-Json RPCs are worth the PL/pgSQL migration effort. If 10 of the 14 have no TS callers (Python-only or admin-only SQL), the effort is much smaller than the headline number suggests.

The mechanical de-casting (WP B) has zero risk and immediate payback. Removing as Record<string, unknown> casts on structured RPCs makes the code more correct without changing behaviour. It should be shipped regardless of what Liam decides about OPS-T1.

Key decision for S8: After WP D produces the gap list, Liam decides:

  • Is the gap large enough to justify OPS-T1 (defineRoute() wrapper, ~2–3 days)?
  • Or is the gap small/concentrated enough that targeted per-route annotations are sufficient?

The DW.14 (Supabase typegen) investigation is now effectively answered by this brief: the Supabase client factory already uses <Database> and structured RPCs are already typed. The remaining issue is (a) the 14 opaque-Json RPCs (needs PL/pgSQL schema work, not a client-side fix) and (b) the route→fetcher gap (needs OPS-T1 or manual per-route annotations). gen types CI automation (generating types on every migration) remains valuable but is a hygiene item, not the primary gap closer.


  1. OPS-T1 priority. Given that WP D will produce a gap list, does the prevalence justify scheduling OPS-T1 as a Wave 4 WP? The tRPC evaluation estimated 2–3 days; this is now the main candidate for closing Gap 1 structurally. The choice: (a) OPS-T1 now, (b) per-route annotations incrementally, (c) accept the gap and rely on the WP D detector as a lint-like CI check.

  2. Opaque-Json RPCs: migration appetite. If WP C shows 8 of 14 functions are straightforwardly convertible to RETURNS TABLE(...), is that worth a migration sprint? Each conversion is a new Supabase migration

    • gen types re-run + cast removal. Estimated: ~1h per function for those with clear, stable schemas.
  3. Automated CI integration. Should the WP D type-drift detector run in CI (as a check that fails on new drift)? This requires deciding whether the current gap list is a baseline-to-maintain or a list-to-fix. If baseline-to-maintain: the CI check gates on “no new gaps added”. If list-to-fix: CI gates once the list hits zero. The two approaches have different implementation shapes.

  4. MCP outputSchema full rollout. WP E scaffolds 5 tools. Full rollout to all 58 tools is ~58 × 30m ≈ ~29h (assuming the scaffold WP establishes the pattern cleanly). Is this a Wave 4 multi-agent parallelisation target?

  5. domain_metadata and metadata JSONB columns (Gap 5). The gap analysis here is: write-side validation is absent. Adding Zod validation at write time (in each route that writes domain_metadata) would close this at the application layer without schema changes. Is this worth a separate WP, or is it covered by the existing parseBody() convention? Quick answer: parseBody() already validates the HTTP request body, so if the domain_metadata field is included in the Zod schema for that route, it is effectively covered. The question is whether the current BidUpdateBodySchema in lib/validation/schemas.ts includes a typed shape for domain_metadata or accepts it as z.any().

  6. bun run gen:types in CI. CLAUDE.md documents the manual supabase gen types typescript ... command. Adding this to CI (on every merge that touches supabase/migrations/) would prevent type drift when migrations add new RPCs or tables. This is a production-readiness track item, but it is closely related to Gap 2.


Key files cited in this brief:

# Supabase types (auto-generated, never edit manually)
supabase/types/database.types.ts
# Supabase client factories
lib/supabase/server.ts
lib/mcp/auth.ts
# Fetchers (Gap 1 primary surface)
lib/query/fetchers.ts
# Canonical Zod schemas (Gap 5 check: does metadata have a Zod shape?)
lib/validation/schemas.ts
# API routes sampled
app/api/items/route.ts
app/api/bids/[id]/route.ts
app/api/coverage/route.ts
app/api/review/stats/route.ts
# MCP tool files (Gap 3, Gap 4)
lib/mcp/tools/search.ts
lib/mcp/tools/content.ts
lib/mcp/tools/shared.ts
# Internal lib sampled (Gap 3)
lib/bid/bid-queries.ts
# tRPC evaluation (prior art)
docs/plans/phase-0-investigation/trpc-evaluation.md
# Decision graph (DW.14, OPS-T1)
docs/plans/phase-0-investigation/0.9-decision-graph.md

Authored kh-ast-S7. S7 scope: feasibility only. No implementation. 2h cap reached after completing all five gap analyses, the gap-closing analysis, and the MVP WP decomposition. Open questions for S8 recorded above.