Skip to content

ID-144 {144.3} TECH — hybrid_search RPC completion (scope_tag/source_url projection, owner_kind, server-side filters)

ID-144 {144.3} TECH — hybrid_search RPC completion

Section titled “ID-144 {144.3} TECH — hybrid_search RPC completion”

Status: TECH artefact — not self-ratified. Authored 10/07/2026 by the Task Planner (fresh dispatch). British English throughout; dates DD/MM/YYYY. Tier TECH+PLAN — the behaviour is already product-specified in id-135 PRODUCT BI-10/BI-14/BI-15/BI-16/BI-20 and id-135 TECH §5 (the CorpusSearchResult contract); this Task completes designed behaviour on the hybrid_search RPC, it does not introduce new product surface.

Invariant-tag convention: a bare BI-N here refers to an id-135 invariant (the consuming spec); any other Task’s invariant is Task-qualified. This spec maps one proposed change per consumed id-135 invariant in §2.

Owner decisions (ratified S460, binding — reproduced for the Executor, not re-litigated): OD-1 EXTEND the RPC to project scope_tag (q_a_pair arm) + source_url (reference_item arm). OD-2 resolve the sd-arm content_type/owner_kind conflation NOW (not deferred) — resolution in §1.4. OD-3 server-side filter wiring approved: extend SearchBodySchema (kind/domain/subtopic/dateFrom/dateTo), add matching RPC params, thread through route.ts; per-grain date-column mapping proposed in §2.4 with the genuinely-ambiguous piece flagged as OQ-144-1. OBS-3 (ORDER BY tie-breaker) is OUT of scope — it ships as the parallel fast-path migration 20260710190000; this migration builds ON TOP of it (see the §5 sequencing hazard).

Task 144 completes four confirmed gaps in public.hybrid_search that leave the id-135 Surface-A corpus-search contract (types/corpus-search.ts) permanently degraded. The evidence base is specs/id-144-hybrid-search-completion/notes/bl-431-434-investigation.md (OBS-1/OBS-2/OBS-4; OBS-3 is split out). The canonical RPC on disk is supabase/migrations/20260702120000_id131_search_rpcs.sql — a 4-arm UNION ALL (source_documents / content_chunks→SD / q_a_pairs / reference_items) with a 21-column RETURNS TABLE (signature at :65, body :114–350, dedup :336–349, grants :356–358).

1.1 Code-intelligence orientation (cited verbatim, not paraphrased)

Section titled “1.1 Code-intelligence orientation (cited verbatim, not paraphrased)”

GitNexus (repo: 'canonical') — not a greenfield surface; the consuming symbols are indexed.

  • gitnexus_query({query: 'hybrid search corpus results'}) → top process proc_132_corpussearchcontent (CorpusSearchContent → CorpusSearchBox → useDebouncedPreview). definitions name the RPC consumers directly: Function:lib/mcp/tools/search.ts:runItemSearch (86–243) and registerSearchTools (73–737); Function:hooks/corpus-search/use-corpus-search.ts:resolveCorpusKind (87–94) and queryFn (232–267); Function:scripts/eval-search.ts:executeSearch (144–165); Function:scripts/kb-search.ts:main (198–390).
  • gitnexus_context({name: 'useCorpusSearch'})Function:hooks/corpus-search/use-corpus-search.ts:useCorpusSearch (195–366). incoming.calls: exactly ONE — CorpusSearchContent (app/search/search-content.tsx). outgoing: {}. processes: []. Single-consumer hook — the read-boundary mapping layer (RawCorpusSearchRow / toCorpusSearchResult) is the only place the new columns are consumed on the web path.
  • gitnexus_context({name: 'SearchBodySchema'})Const:lib/validation/schemas.ts:SearchBodySchema (131–144). incoming: {}, outgoing: {}, processes: [] (Zod const — GitNexus does not index its .parse consumer edge; the consumer is app/api/search/route.ts:42 via parseBody, confirmed on disk).

ccc fallback not required (GitNexus returned the consuming symbols — surface is not greenfield).

Call-site sweep — every .rpc('hybrid_search') consumer (grep -rnE "hybrid_search" app/ lib/ hooks/ scripts/): five call sites, all using the named-param object form, all backward-compatible with new trailing DEFAULT params (a caller that omits them keeps the current behaviour):

#Call siteRoleChange in this Task?
1app/api/search/route.ts:91Human /api/search (Surface-A)YES — threads the 5 new filter params (§2.5).
2lib/mcp/tools/search.ts:139MCP find (item granularity, runItemSearch)No — see §3 MCP impact map.
3lib/mcp/tools/search.ts:337MCP find/find_duplicates (findSimilarItemsImpl)No — see §3.
4scripts/kb-search.ts:258CLI search toolNo (unchanged; benefits from cols passively).
5scripts/eval-search.ts:150Eval harnessNo (unchanged).

1.2 The two projection gaps (OBS-1 → OD-1)

Section titled “1.2 The two projection gaps (OBS-1 → OD-1)”

The source tables carry the data one projection away:

  • q_a_pairs.scope_tagtext[] NOT NULL DEFAULT ARRAY[]::text[], a NATIVE T6 WP1 baseline column (ontology CV 21-scope-tag.md; GIN index idx_q_a_pairs_scope_tag). The q_a_pair arm (:231–263) never projects it → the hook hard-codes scopeTags: [] (use-corpus-search.ts:112).
  • reference_items.source_urltext NOT NULL (supabase/migrations/20260617130000_squash_baseline.sql:7226; the 19-migration reference_items sweep shows no ALTER … DROP NOT NULL ever targeting it — the investigation had it right). The reference_item arm (:291–319) never projects it → the hook hard-codes sourceUrl: null (:123). This is the more serious gap: sourceUrl is the reference card’s entire link target (BI-13 → /reference/[id] and the external source link). No design change follows from the NOT-NULL correction: arm 4 projects ri.source_url directly, and the id-135 target reference.sourceUrl: string | null stays a safe superset of a NOT-NULL column.

Provenance (OD-1): scope tags are a critical platform-safety function — the initial driver (kh-client-content-archive client-briefs/kh-client-feedback.md §“Item 3”) was the AI pulling correct content from the wrong scope (Bitdefender/Internal-IT surfaced against production- infrastructure principles). “A high-confidence match in the wrong scope is much more dangerous than a low-confidence match in the right scope.” The current ontology CV (21-scope-tag.md) is the binding vocabulary; the client brief is provenance only. Surfacing scope_tag on every answer result is the projection that lets a human (BI-13 scope/domain badges) see scope at a glance.

1.3 The read-boundary contract this feeds (id-135 TECH §5)

Section titled “1.3 The read-boundary contract this feeds (id-135 TECH §5)”

types/corpus-search.tsCorpusSearchResult is a discriminated union keyed on kind (answer | document | reference). The gaps land on two variants: answer.scopeTags: string[], answer.primaryDomain/primarySubtopic, and reference.sourceUrl: string | null. BI-3 (AI-invisible) forbids any score/similarity/model field on the display shape — the RPC keeps emitting similarity (needed for ranking) but the hook never maps it onto the union (modelled on RawCorpusSearchRow.similarity for audit only, use-corpus-search.ts:78–84).

1.4 The content_type / owner_kind conflation (OBS-2 → OD-2, resolved here)

Section titled “1.4 The content_type / owner_kind conflation (OBS-2 → OD-2, resolved here)”

Do CVs 04-content-type.md + 22-origin-kind.md give the required approach? — Yes, and they rule out both alternatives:

  • 04-content-type.md: content_type is a closed 7-value editorial-shape enum (article/blog/pdf/note/research/other/document) that lives only on source_documents. “Neither q_a_pairs nor feed_articles carries a content_type column.” So content_type is not a grain discriminator — it is genuine descriptive metadata, and only arm 1 emits a real value (sd.content_type::text, e.g. 'guidance', :128). Arms 2/3/4 emit the synthetic literals 'content_chunk'/'q_a_pair'/'reference_item' (:184/:238/:298) — overloading the column as a pseudo-owner_kind for those three arms only.
  • 22-origin-kind.md: origin_kind is planned, not yet in schema, and is a q_a_pairs provenance-route enum (extracted_from_corpus/curated_explicit/…) — not a grain discriminator either. It is the wrong tool.
  • The real grain discriminator already exists in the schema: record_embeddings.owner_kind / record_lifecycle.owner_kind ∈ {source_document, content_chunk, q_a_pair, reference_item} — the canonical vocabulary the RPC arms already JOIN on (:155/:207/:266/:321).

Resolution (best-practice + future-extensible, satisfies “a 4th kind must not misclassify”): ADD a dedicated owner_kind text return column that always carries the grain discriminator, mirroring record_embeddings.owner_kind. Leave content_type unchanged (arm 1 = real editorial value; arms 2/3/4 = the existing literal). This is purely additive:

  • Arm 1 is the only arm where content_type ≠ owner_kind today; it gains owner_kind = 'source_document' while keeping content_type = sd.content_type (‘guidance’ etc.). The conflation is closed because the honest signal now exists in its own column.
  • kind derivation re-points to read owner_kind (unambiguous) instead of content_type (overloaded) — see §2.2. A future 4th grain extends the owner_kind vocabulary (precedent: the record_embeddings.owner_kind CHECK extension, DR-036) and gets an explicit, non-colliding literal instead of silently collapsing to document.
  • Why not “document the exception” (the cheap alternative)? It does not stop a 4th kind misclassifying — the owner ruled for the durable fix. Why not repurpose content_type? The MCP find tool’s type filter keys off content_type === 'q_a_pair' (search.ts:168–172); repurposing it would break that filter. Keeping content_type intact and adding owner_kind is non-breaking to MCP and resolves the conflation.

2. Proposed changes — 1:1 mapping against the consumed id-135 invariants

Section titled “2. Proposed changes — 1:1 mapping against the consumed id-135 invariants”
id-135 invariantProposed change
BI-10 (default scope = ALL grains)No behavioural change to the default: with all 5 new RPC filter params NULL, the 4-arm UNION returns exactly as today. Preserved by trailing DEFAULT NULL params (§2.4).
BI-13/BI-14 (per-kind card content + owner_kind-derived routing)RPC adds owner_kind (§2.2); hook resolveCorpusKind reads owner_kind not content_type (§2.3). Reference variant gains a real sourceUrl (§2.1) — the BI-13 link target stops degrading to null. Answer variant gains real scopeTags (§2.1).
BI-15 (kind-narrow narrows only, server-authoritative)RPC filter_kind param narrows before the LIMIT (§2.4), fixing the OBS-4 ranking/pagination corruption (client .filter() after top-N). Client .filter() at use-corpus-search.ts:257 becomes redundant (§2.3).
BI-16 (domain / subtopic / date metadata filters)RPC gains filter_domain, filter_subtopic, filter_date_from, filter_date_to (§2.4); schema + route thread them (§2.5). Today these 4 are silently stripped by Zod and never reach the server — a dead feature.
BI-20 (stable pagination / incremental load)Server-side narrowing keeps the limit-raising load-more correct (a full page of matching rows rather than a thin post-LIMIT slice). Relies on the OBS-3 tie-breaker ORDER BY deduped.similarity DESC, deduped.id being carried forward (§5 hazard).
id-135 TECH §5 (CorpusSearchResult union)scope_tag/source_url/owner_kind reconcile the two documented gap-notes; the union shape is unchanged (the hook maps new columns onto existing fields).

2.1 Projections (OD-1) — per-arm, with explicit NULL typing

Section titled “2.1 Projections (OD-1) — per-arm, with explicit NULL typing”

Return-column count 21 → 24 (+scope_tag text[], +source_url text, +owner_kind text). Per arm (add to each arm’s SELECT list, and carry through the deduped final SELECT):

Armscope_tagsource_urlowner_kind
1 source_documents (:121)NULL::text[]NULL::text'source_document'::text
2 content_chunks (:177)NULL::text[]NULL::text'content_chunk'::text
3 q_a_pairs (:231)qa.scope_tagNULL::text'q_a_pair'::text
4 reference_items (:291)NULL::text[]ri.source_url'reference_item'::text

Carry-through: the deduped CTE is SELECT DISTINCT ON (arms.id) arms.* (:337) so the new columns flow automatically; the final explicit SELECT (:341–347) MUST add deduped.scope_tag, deduped.source_url, deduped.owner_kind. Column order in RETURNS TABLE is free but MUST match the SELECT order — recommend appending the three at the end (after verified_by) to minimise diff.

2.2 owner_kind column (OD-2) — see §1.4 for the rationale

Section titled “2.2 owner_kind column (OD-2) — see §1.4 for the rationale”

owner_kind is a non-null literal per arm (table above). Note arm 2 (content_chunk) collapses to sd.id and is provenance-deduped against arm 1; whichever row wins the DISTINCT ON keeps its own owner_kind (content_chunk or source_document) — both map to document at the read boundary, so the collapse is UI-consistent either way.

2.3 App-side read boundary (hooks/corpus-search/use-corpus-search.ts)

Section titled “2.3 App-side read boundary (hooks/corpus-search/use-corpus-search.ts)”
  • Extend RawCorpusSearchRow (:60) with scope_tag: string[] | null, source_url: string | null, owner_kind: string.
  • Re-point resolveCorpusKind (:88–94) to switch on owner_kind: 'q_a_pair'→answer, 'reference_item'→reference, 'source_document'|'content_chunk'→document. Keep an explicit default→document but this is now the honest-signal path (a 4th owner_kind would fall through to document — acceptable until CorpusKind itself gains a member, an id-135-contract change out of scope here). Update the :67–76 gap-note comment (the “content_type carries owner_kind” drift is resolved).
  • In toCorpusSearchResult (:104–125): replace scopeTags: [] with row.scope_tag ?? []; replace sourceUrl: null with row.source_url ?? null. Delete the two gap-note comments (:51–57, :112, :123).
  • The client-side narrow .filter((r) => r.kind === kind) (:257) becomes redundant now the server narrows authoritatively — remove it and set narrowed = mapped (the reachedEnd = mapped.length < pageParam heuristic stays correct against the server-narrowed row set). Removing it is the fix for the OBS-4 pagination corruption; leaving it would be a harmless no-op but misleading.

2.4 RPC filter params (OD-3, OBS-4) — trailing, DEFAULT NULL, consumer vocabulary

Section titled “2.4 RPC filter params (OD-3, OBS-4) — trailing, DEFAULT NULL, consumer vocabulary”

Add five trailing params to hybrid_search (naming follows the filter_* convention already used by the sibling search_content_chunks RPC — filter_source_document_id etc.):

filter_kind text DEFAULT NULL, -- 'answer' | 'document' | 'reference' (the consumer/display vocabulary)
filter_domain text DEFAULT NULL,
filter_subtopic text DEFAULT NULL,
filter_date_from timestamptz DEFAULT NULL,
filter_date_to timestamptz DEFAULT NULL

filter_kind is expressed in the display vocabulary the hook already sends (CorpusKind), and maps to arms inside the RPC — no translation in the route. Add one guard clause to each arm’s WHERE:

  • Arm 1 (SD): AND (filter_kind IS NULL OR filter_kind = 'document')
  • Arm 2 (chunk): AND (filter_kind IS NULL OR filter_kind = 'document')
  • Arm 3 (qa): AND (filter_kind IS NULL OR filter_kind = 'answer')
  • Arm 4 (ref): AND (filter_kind IS NULL OR filter_kind = 'reference')

document deliberately spans arms 1+2 (a document hit can arrive via the SD text arm or the chunk vector arm; both collapse to the SD identity). This narrows before LIMIT limit_count, which is the whole point (fixes the OBS-4 “kind filter returns < a page even when more match below the limit”).

filter_domain / filter_subtopic — per-arm domain/subtopic expression differs:

Armdomain exprsubtopic expr
1 SDsd.primary_domain::textsd.primary_subtopic::text
2 chunksd.primary_domain::textsd.primary_subtopic::text
3 qaCOALESCE(rl.domain, 'unclassified')::text(none — arm emits NULL::text)
4 refri.primary_domain::textri.primary_subtopic::text

Guard: AND (filter_domain IS NULL OR <domain_expr> = filter_domain) and likewise for subtopic. The q_a_pair arm has no subtopic, so a non-null filter_subtopic naturally excludes answers (AND (filter_subtopic IS NULL OR NULL = filter_subtopic) → excluded) — correct: a subtopic filter scopes to grains that carry subtopics. Document this in the arm comment.

Per-grain date-range mapping (OD-3 — RATIFIED, owner post-S460; STRICT-EXCLUDE NULL handling):

Armdate columnrationale
1 SDsd.captured_datealready the projected date — “when the content was captured/authored”.
2 chunksd.captured_dateinherits the parent SD (chunk carries only created_at/updated_at).
3 qaqa.valid_fromtemporal-validity start — the “as-of” date; created_at is an ingestion timestamp.
4 refri.published_atthe external source’s publication date — the natural date of the evidence.

Guard (strict — NULL-date rows excluded when a bound is set): AND (filter_date_from IS NULL OR <date_col> >= filter_date_from) AND (filter_date_to IS NULL OR <date_col> <= filter_date_to).

2.5 Schema + route threading (SearchBodySchema, route.ts)

Section titled “2.5 Schema + route threading (SearchBodySchema, route.ts)”
  • lib/validation/schemas.ts:132 — extend SearchBodySchema with: kind: z.enum(['answer','document','reference']).optional(), domain: z.string().optional(), subtopic: z.string().optional(), and dateFrom / dateTo accepting either a bare date YYYY-MM-DD OR a Z-suffixed ISO datetime, .optional(). (Keep .optional() — an empty filter set is the default; Zod currently strips these keys, which is the OBS-4 root cause.)

    S460 amendment (post-checker): the original .datetime()-only spec missed the shipped date-control’s output shape. The live consumer components/corpus-search/corpus-search-controls.tsx <input type="date"> (forwarded by use-corpus-search.ts:244–245) emits a bare YYYY-MM-DD, which z.string().datetime() rejects — as it also rejects offsetted datetimes; only Z-suffixed pass. Validate a UNION, e.g. z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.string().datetime()]) (bare date OR Z-suffixed ISO). Offsetted datetimes remain rejected (documented — a caller needing one normalises to Z first).

  • app/api/search/route.ts:44 — destructure the 5 new fields. The ROUTE normalises a bare dateFrom to the UTC start-of-day …T00:00:00.000Z and a bare dateTo to the UTC end-of-day …T23:59:59.999Z (inclusive) before binding; a Z-suffixed datetime passes through unchanged. Boundary normalisation at the API keeps the contract tolerant for every future caller rather than pushing date-to-timestamp semantics into each client. :91 — pass the 5 params to the RPC as filter_kind, filter_domain, filter_subtopic, filter_date_from, filter_date_to with the ?? undefined idiom already used for application_type (keeps the JSON-RPC payload free of null). No change to SearchResponseSchema (results: z.array(z.unknown()) stays opaque — the new columns flow through untyped, consumed by the hook’s RawCorpusSearchRow, per the {131.19} typed-envelope deferral).

All DDL via the Supabase CLI (supabase migration new + db push foreground) — never MCP execute_sql/apply_migration (supabase/CLAUDE.md). Two migrations in ONE push batch, api sorted after public (DR-032):

  1. <ts>_id144_hybrid_search_projection_filters.sqlDROP FUNCTION public.hybrid_search(<the exact 7-arg signature at :63>) then CREATE FUNCTION the 24-col / 12-arg version. Recreate the entire existing body verbatim (win_stats CTE, 4 arms, qa_profile_boost, win_stats boost, deduped) and layer ONLY these deltas: the 3 projections (§2.1), owner_kind (§2.2), the 5 filter params + per-arm guards (§2.4), and — critically — carry forward the OBS-3 tie-breaker ORDER BY deduped.similarity DESC, deduped.id (§5). Executor filename-sort check (load-bearing): supabase migration new auto-stamps by wall-clock, and the OBS-3 fast-path 20260710190000_bl431_obs3_hybrid_search_tiebreak.sql is stamped TODAY 19:00 — a migration new run earlier the same day sorts BEFORE it and silently reverts the tie-breaker. Before push, run ls supabase/migrations | tail -3 and confirm BOTH id144 migrations sort AFTER that filename; hand-bump the timestamp prefix if not. Keep LANGUAGE plpgsql STABLE, SET search_path = public, extensions. Re-establish grants exactly as :356–358: REVOKE ALL … FROM PUBLIC; GRANT … TO authenticated, service_role. REVOKE posture (DR-035): the migration uses DROP+CREATE, so dr035_born_locked_functions fires on the fresh CREATE and zero-anon-EXECUTE is guaranteed regardless of the {127.36} CREATE-OR-REPLACE question; still emit the explicit REVOKE-from-PUBLIC + GRANTs (defence-in-depth, matches the current migration). ALTER FUNCTION … OWNER TO postgres and refresh the COMMENT.
  2. <ts+1>_id144_hybrid_search_api_wrapper.sql — regenerate the api.hybrid_search wrapper (hybrid_search is in SURFACE_RPCS, scripts/generate-api-views.ts:215). Run bun scripts/generate-api-views.ts against the DB after migration 1 is applied so it introspects the new 24-col / 12-arg signature and emits the LANGUAGE sql SECURITY INVOKER passthrough (precedent 20260703210000_id138_api_rpc_wrappers.sql; emitFunction() filters anon out of the mirrored grants, DR-035). Supabase-js .rpc('hybrid_search', …) resolves to api.hybrid_search at runtime (config.toml schemas=["api"], DR-030) — without this the new params/cols are unreachable (PGRST202).
  3. Type regen (after BOTH migrations, supabase/CLAUDE.md recipe, both schemas, deterministic order): supabase gen types typescript --project-id <platform-ref> --schema public,api > supabase/types/database.types.ts. This lands the new Args/Returns on public.hybrid_search so route.ts type-checks the new params. Never hand-edit the types.

2.7 Keyword-facet param ({135.21}) — EXCLUDED from Task 144; deferred to its own design slice (OQ-144-2 RESOLVED, staff review)

Section titled “2.7 Keyword-facet param ({135.21}) — EXCLUDED from Task 144; deferred to its own design slice (OQ-144-2 RESOLVED, staff review)”

Context (Orchestrator addendum). Subtask {135.21} (“Surface A hybrid_search keyword-facet control”, pending, sibling deps ['6','8']) is the §9-dropped filter_by_keywords REPLACEMENT (intent (b) from {131.20} §9). Its contract: a control on corpus-search-controls.tsxuseCorpusSearch URL param (BI-9) → /api/search as a §9 NARROW (never widens). It carries a task-level dependency on a hybrid_search keyword-facet PARAM that {131.11} intended (“a keyword pre-filter becomes a BACKLOG facet-param on hybrid_search, owner_kind-scoped”) but never shipped as a param — and its own details say to “reconcile the param name/shape against the shipped RPC before impl; build against MOCKED emit.” Since Task 144 already DROP/recreates the signature, the Orchestrator asks whether this param should ride the same churn.

Decision: do NOT bake it into the default signature — the facet SEMANTICS are unratified. The narrow direction is clear, but the load-bearing shape questions are open, and per-arm keyword sources differ materially:

Armkeyword-like array available
1 SD / 2 chunksd.ai_keywords (real / inherited)
3 q_a_pairnone — arm projects NULL::text[] for ai_keywords; the nearest array is scope_tag, but scope ≠ topical keyword (conflating them is a product call)
4 referencenone — no keyword array on reference_items

Unresolved (all genuinely ambiguous): (a) does the facet match ai_keywords only — making it a document-grain narrow that silently excludes answers/references when active — or a per-grain union (ai_keywords ∪ scope_tag ∪ …)?; (b) single text vs text[] with AND/OR semantics; (c) what “owner_kind-scoped” (the §9 phrasing) means concretely. Baking a guessed shape into a DDL signature is the worst place to guess — a wrong shape forces another DROP/recreate + wrapper + type regen (a full third churn on top of the OBS-3 fast-path and this Task), the exact cost the fold-in is meant to avoid. This also exceeds the S460-ratified OD-1/2/3 scope. So the default 12-arg signature in §2.4 excludes the keyword param.

But the churn argument is real — so OQ-144-2 offers a ratifiable minimal proposal that, if the owner accepts it now, folds in cheaply here and unblocks {135.21} (its task-level dep re-points from the unshipped id-131 param onto this Task; its consumer work stays in id-135). Minimal proposal: add trailing filter_keyword text DEFAULT NULL; NARROW semantics AND (filter_keyword IS NULL OR filter_keyword = ANY(<arm_keyword_source>)) with <arm_keyword_source> = sd.ai_keywords for arms 1/2 and arms 3/4 excluded when the facet is active (consistent with how filter_subtopic excludes the subtopic-less answer grain, §2.4) — i.e. a document-grain keyword narrow, matching the §9 “owner_kind-scoped” hint. If ratified, it slots as the 13th param alongside the other filter_* params and adds one WHERE-guard per document arm; the migration, wrapper regen and type regen are otherwise unchanged (genuinely one extra param, not a second churn).

RESOLVED — this minimal proposal is REJECTED (staff review, specs/id-144-hybrid-search-completion/notes/s460-oq144-2-staff-review.md). A facet-labelled control that silently excludes the primary answer grain is a data-honesty defect, not a facet. Task 144 therefore ships the ratified 24-col/12-arg signature with NO keyword param; the keyword facet becomes its own design slice (decision list in §6 OQ-144-2), and {135.21} re-points its task-level dep onto that slice.

Match-semantics footnote (review §3c) — carry into the future slice, not this Task. The = ANY(sd.ai_keywords) equality above is case-sensitive and is only correct today because keyword writes are normalised at ingest (normaliseTag, lib/ai/classify.ts:1690). Any future free-text keyword-facet input MUST be normalised through the same path before binding, or exact-match silently misses case/spacing variants.

3. MCP surface + bl-26 + id-71 documentation impact map (mandated answers)

Section titled “3. MCP surface + bl-26 + id-71 documentation impact map (mandated answers)”

MCP consumers (question 1). Both MCP call sites (search.ts:139 runItemSearch; search.ts:337 findSimilarItemsImpl, shared by find’s similar_to branch and find_duplicates) call hybrid_search with a named-param object and do not pass any of the 5 new params → unchanged behaviour (trailing DEFAULT NULL). The MCP SearchResult mapping (search.ts:201–212) and SimilarItem mapping (:369–377) pick a fixed field set (id/title/suggested_title/content_type/ primary_domain/primary_subtopic/summary/similarity) and ignore extra columns → the 3 new result columns are inert on the MCP path. No MCP tool contract change lands in this Task. The MCP find tool’s type filter (content_type === 'q_a_pair', :168) and scope filter (primary_domain, :176) keep working precisely because content_type is left intact (§1.4). Optional future follow-up (NOT this Task, → backlog): push find’s client-side type/scope post-filters onto the new server params to fix the same OBS-4-shaped ranking issue on the MCP path (its ×3 over-fetch, :105, currently mitigates it).

bl-26 / outputSchema (question 2). bl-26 = “R-WP22 MCP outputSchema full rollout to remaining tools” (slot DefineToolConfig.outputSchema, shared.ts:138). The find tool cannot declare an outputSchema today — FindResponseSchema is a z.union, and the SDK’s normalizeObjectSchema() returns undefined for unions (crash on undefined._zod), so it is deliberately omitted (search.ts:38–43, 587–589). Independently, the 24-col RPC change surfaces no new field on the MCP SearchResult/SimilarItem envelopes (fixed mappings, above). Therefore nothing lands in this Task for bl-26, and this Task creates no new bl-26 obligation — the result-shape change does not reach the MCP output envelopes. bl-26 remains its own residual (union-SDK-gap-bound for find).

id-71 documentation (question 3). Checked the id-71 spec dir (specs/id-71-ai-tooling/{PRODUCT,RESEARCH,SYNTHESIS,TECH}.md) and the client-archive client-briefs/Knowledge Hub — Claude Integration Guide.md. All id-71 scope_tag references describe the find tool’s scope/type params preserving corpus-level q_a_pairs+scope_tag semantics (PRODUCT:365, RESEARCH:121, SYNTHESIS:109, TECH:376) — none document the hybrid_search return-column set or the /api/search response fields. The Integration Guide’s only search line is an RLS troubleshooting note (:519), not a field contract. Because the MCP find/get tool contract is unchanged by this Task (§3 above), no id-71 documentation needs updating. (The docs that would change if anything did — none here — are owned by the docs-site docubot lane, not this spec.)

bun run test (never bun test); behaviour-first per test-philosophy.md (assert the observable contract, not the SQL text). Unit tests use the shared Supabase mock; the RPC body itself is proven by test:integration (real Supabase) since a mock cannot execute PL/pgSQL.

Consumed invariantVerification
OD-1 scope_tag/source_url projectiontest:integration: seed a published q_a_pair with scope_tag = ARRAY['internal-it'] and a reference_item with a source_url; call hybrid_search on a matching query; assert the answer row’s scope_tag = ['internal-it'] and the reference row’s source_url is the seeded URL. Unit: toCorpusSearchResult maps a RawCorpusSearchRow with populated scope_tag/source_url onto answer.scopeTags/reference.sourceUrl (no longer []/null).
OD-2 / BI-14 owner_kind routingUnit: resolveCorpusKind('source_document')→'document', 'content_chunk'→'document', 'q_a_pair'→'answer', 'reference_item'→'reference'. test:integration: assert arm 1 rows carry owner_kind='source_document' while content_type retains the editorial value (e.g. not 'source_document').
BI-15 filter_kind narrows server-sidetest:integration: a query returning a mix of grains; assert filter_kind='answer' returns only q_a_pair-owner rows AND returns a full limit_count page when > limit_count answers match (the OBS-4 regression guard — previously impossible client-side).
BI-16 domain/subtopic/date filterstest:integration: filter_domain restricts to the arm domain expr; filter_subtopic excludes answers (no subtopic grain); filter_date_from/_to on seeded captured_date/valid_from/published_at bound the result set. Unit: SearchBodySchema now retains kind/domain/subtopic/dateFrom/dateTo (previously stripped) and rejects a bad kind enum / non-ISO date. Route test: route.ts forwards the 5 params to the .rpc mock.
BI-10/BI-20 default + pagination unaffectedtest:integration: all-NULL filters reproduce the pre-change result set and order; the limit-raising load-more returns stable, dupe-free pages (relies on the §5 tie-breaker).
Regression: MCP + scriptsbun run test MCP suite (__tests__/mcp/) + test:mcp-eval unaffected (the find/find_duplicates envelopes are unchanged); scripts/kb-search.ts / eval-search.ts still run (cols additive).
  • OBS-3 tie-breaker clobber (HIGH — sequencing). OBS-3 ships ORDER BY deduped.similarity DESC, deduped.id as fast-path migration 20260710190000 via CREATE OR REPLACE. This Task’s DROP+CREATE recreates the whole function, so if the executor recreates from the 20260702120000 source they will silently revert the tie-breaker and re-introduce the pagination bug. Mitigation: (a) both id144 migrations MUST sort after 20260710190000_bl431_obs3_hybrid_search_tiebreak.sql (explicit ls supabase/migrations | tail -3 filename-sort check in §2.6 step 1 — migration new can auto-stamp earlier the same day; hand-bump if so); (b) the CREATE body MUST include the two-key ORDER BY — called out in §2.6 step 1 and the §4 BI-20 test. The Checker verifies the final ORDER BY has both keys.
  • api-wrapper drift / PGRST202 (MEDIUM). Forgetting migration 2 leaves api.hybrid_search at the old 7-arg/21-col shape → the new params silently ignored or a PGRST202 at runtime, invisible to mocked bun run test (DR-030/DR-032). Mitigation: DR-032 same-batch companion migration + a test:integration assertion that the new params take effect through the API layer.
  • Date-column NULL-handling (LOW — ratified). valid_from/published_at are nullable and the owner-ratified STRICT-EXCLUDE drops NULL-date rows when a bound is set (a dateless answer/reference is excluded from a date-bounded search — the accepted semantics, §2.4). If ever revisited, lenient (col IS NULL OR col >= from) is a one-clause change per arm.
  • Type regen omitted (MEDIUM). Without the --schema public,api regen, route.ts cannot pass the new params type-safely. Mitigation: §2.6 step 3 is a gated step; typecheck fails loudly if skipped.
  • scope_tag GIN vs equality (LOW). This Task only projects scope_tag; it does not filter on it (no && overlap query added). The GIN index (idx_q_a_pairs_scope_tag) is untouched. A future scope-overlap filter param is out of scope (backlog seam noted in 21-scope-tag.md consumers).
  • OQ-144-1 (date-grain semantics, OD-3) — RESOLVED (owner-ratified, post-S460). The per-arm date-column mapping (SD/chunk captured_date, qa valid_from, ref published_at) and STRICT-EXCLUDE NULL-date handling are ratified and now settled in the spec body (§2.4/§2.5). No open question remains.
  • OQ-144-2 (keyword-facet fold-in, {135.21}) — RESOLVED: EXCLUDED (owner-ratified staff-review option (d)). Task 144 ships the ratified 24-col/12-arg signature with NO keyword param. The keyword facet gets its own design slice; {135.21} re-points its task-level dep from the (never-shipped) id-131 param onto that slice. Staff-review decision list, recorded verbatim (specs/id-144-hybrid-search-completion/notes/s460-oq144-2-staff-review.md):
    • Grain coverage — resolve dependent facet (a keyword control visible only under kind=document, narrowing on ai_keywords) vs true cross-grain (which requires pipeline keyword extraction for q_a_pairs/reference_items — they carry no keyword array today). scope_tag must NEVER be a keyword source — it is the safety/scoping vocabulary (Item-3 provenance, §1.2); conflating it with topical keywords is a safety defect.
    • Shapetext[] with OR-within-facet (a result matches if it carries ANY selected keyword).
    • Case rule — normalise free-text input via normaliseTag before binding (see the §2.7 footnote).
    • Facet counts — explicitly DEFERRED (not in the first slice).
    • Zero-churn interim — a suggestion chip that sets ?q=<keyword> rides the existing query_text / ai_keywords ranking legs today, needing no RPC change.

This TECH cites no external-library symbols. Every symbol is internal Canonical (hybrid_search, useCorpusSearch, resolveCorpusKind, toCorpusSearchResult, SearchBodySchema, the /api/search route), Postgres/Supabase RPC surface (.rpc(), api.* wrapper, record_embeddings/ record_lifecycle/q_a_pairs/reference_items columns), or framework built-ins (Zod z.enum/ .datetime(), TanStack useInfiniteQuery) — all outside the import-and-call verification scope (external-library symbols only, per shared-discipline §Empirical verification). No verification block required. Column existence + nullability were confirmed against the migration DDL (canonical for constraints — the generated types are NOT authoritative on nullability here): q_a_pairs.scope_tag, q_a_pairs.valid_from, reference_items.source_url, reference_items.published_at, source_documents.captured_date all present. reference_items.source_url is NOT NULL (20260617130000_squash_baseline.sql:7226; no ALTER … DROP NOT NULL in the 19-migration reference_items sweep) — arm 4 projects ri.source_url directly; the id-135 target reference.sourceUrl: string | null is a safe superset.

8. DR-intents (for the Orchestrator to write on main — not written in-branch)

Section titled “8. DR-intents (for the Orchestrator to write on main — not written in-branch)”
  • DR-intent A (owner_kind is the grain discriminator; content_type stays editorial). hybrid_search (and any future polymorphic search RPC) projects a dedicated owner_kind column as the grain key ∈ record_embeddings.owner_kind; content_type is never overloaded as a grain discriminator. A new grain extends the owner_kind vocabulary (DR-036 lineage), never a content_type literal. Prevents re-litigating the OBS-2 conflation.
  • DR-intent B (per-grain “content date” columns) — now UNCONDITIONAL (OQ-144-1 ratified). Record the canonical per-grain date-anchor mapping (SD/chunk captured_date, qa valid_from, ref published_at) + STRICT-EXCLUDE NULL handling so future date-scoped features on these grains reuse it rather than re-deriving.
  • DR-intent C (positional-param cap — cite as DR-052). hybrid_search’s positional-param set is CAPPED at the Task-144 shape (12 args). The next filter axis triggers a deliberate contract-evolution decision, not another positional param: either a strictly-validated jsonb filters arg (MUST RAISE on an unknown key — silent-ignore reproduces the OBS-4 dead-filter class) or a versioned hybrid_search_v2. The keyword-facet slice (OQ-144-2) is the first work that hits this cap.

End of {144.3} TECH. Completes designed id-135 behaviour: OD-1 projections + OD-2 owner_kind + OD-3 server-side filters, mapped 1:1 to BI-10/13/14/15/16/20 and TECH §5; migration plan (DROP+CREATE 24-col/12-arg, per-arm NULL typing, dedup carry-through, api-wrapper + type regen, DR-032/DR-035 posture); app-side threading + read-boundary rewrite; MCP + bl-26 + id-71 impact answered (all “no change this Task”); the {135.21} keyword-facet param evaluated (§2.7) and, per the ratified staff review, EXCLUDED from Task 144 (its minimal proposal REJECTED as a data-honesty defect — deferred to its own design slice); OBS-3 tie-breaker carry-forward flagged as the top sequencing risk with an explicit filename-sort check; OQ-144-1 RESOLVED (date semantics ratified, settled in §2.4), OQ-144-2 RESOLVED (keyword facet EXCLUDED); three DR-intents returned (B unconditional; C caps the positional-param set at 12 args, DR-052). No ledger writes, no commits.