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
CorpusSearchResultcontract); this Task completes designed behaviour on thehybrid_searchRPC, 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-armcontent_type/owner_kind conflation NOW (not deferred) — resolution in §1.4. OD-3 server-side filter wiring approved: extendSearchBodySchema(kind/domain/subtopic/dateFrom/dateTo), add matching RPC params, thread throughroute.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 migration20260710190000; this migration builds ON TOP of it (see the §5 sequencing hazard).
1. Context
Section titled “1. Context”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 processproc_132_corpussearchcontent(CorpusSearchContent → CorpusSearchBox → useDebouncedPreview).definitionsname the RPC consumers directly:Function:lib/mcp/tools/search.ts:runItemSearch(86–243) andregisterSearchTools(73–737);Function:hooks/corpus-search/use-corpus-search.ts:resolveCorpusKind(87–94) andqueryFn(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.parseconsumer edge; the consumer isapp/api/search/route.ts:42viaparseBody, 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 site | Role | Change in this Task? |
|---|---|---|---|
| 1 | app/api/search/route.ts:91 | Human /api/search (Surface-A) | YES — threads the 5 new filter params (§2.5). |
| 2 | lib/mcp/tools/search.ts:139 | MCP find (item granularity, runItemSearch) | No — see §3 MCP impact map. |
| 3 | lib/mcp/tools/search.ts:337 | MCP find/find_duplicates (findSimilarItemsImpl) | No — see §3. |
| 4 | scripts/kb-search.ts:258 | CLI search tool | No (unchanged; benefits from cols passively). |
| 5 | scripts/eval-search.ts:150 | Eval harness | No (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_tag—text[] NOT NULL DEFAULT ARRAY[]::text[], a NATIVE T6 WP1 baseline column (ontology CV21-scope-tag.md; GIN indexidx_q_a_pairs_scope_tag). The q_a_pair arm (:231–263) never projects it → the hook hard-codesscopeTags: [](use-corpus-search.ts:112).reference_items.source_url—text NOT NULL(supabase/migrations/20260617130000_squash_baseline.sql:7226; the 19-migration reference_items sweep shows noALTER … DROP NOT NULLever targeting it — the investigation had it right). The reference_item arm (:291–319) never projects it → the hook hard-codessourceUrl: null(:123). This is the more serious gap:sourceUrlis 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 projectsri.source_urldirectly, and the id-135 targetreference.sourceUrl: string | nullstays 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.ts — CorpusSearchResult 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_typeis a closed 7-value editorial-shape enum (article/blog/pdf/note/research/other/document) that lives only onsource_documents. “Neitherq_a_pairsnorfeed_articlescarries acontent_typecolumn.” Socontent_typeis 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_kindis 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_kindtoday; it gainsowner_kind = 'source_document'while keepingcontent_type = sd.content_type(‘guidance’ etc.). The conflation is closed because the honest signal now exists in its own column. kindderivation re-points to readowner_kind(unambiguous) instead ofcontent_type(overloaded) — see §2.2. A future 4th grain extends the owner_kind vocabulary (precedent: therecord_embeddings.owner_kindCHECK extension, DR-036) and gets an explicit, non-colliding literal instead of silently collapsing todocument.- 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 MCPfindtool’stypefilter keys offcontent_type === 'q_a_pair'(search.ts:168–172); repurposing it would break that filter. Keepingcontent_typeintact and addingowner_kindis 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 invariant | Proposed 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):
| Arm | scope_tag | source_url | owner_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_tag | NULL::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) withscope_tag: string[] | null,source_url: string | null,owner_kind: string. - Re-point
resolveCorpusKind(:88–94) to switch onowner_kind:'q_a_pair'→answer,'reference_item'→reference,'source_document'|'content_chunk'→document. Keep an explicit default→documentbut this is now the honest-signal path (a 4th owner_kind would fall through todocument— acceptable untilCorpusKinditself gains a member, an id-135-contract change out of scope here). Update the:67–76gap-note comment (the “content_type carries owner_kind” drift is resolved). - In
toCorpusSearchResult(:104–125): replacescopeTags: []withrow.scope_tag ?? []; replacesourceUrl: nullwithrow.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 setnarrowed = mapped(thereachedEnd = mapped.length < pageParamheuristic 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 NULLfilter_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:
| Arm | domain expr | subtopic expr |
|---|---|---|
| 1 SD | sd.primary_domain::text | sd.primary_subtopic::text |
| 2 chunk | sd.primary_domain::text | sd.primary_subtopic::text |
| 3 qa | COALESCE(rl.domain, 'unclassified')::text | (none — arm emits NULL::text) |
| 4 ref | ri.primary_domain::text | ri.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):
| Arm | date column | rationale |
|---|---|---|
| 1 SD | sd.captured_date | already the projected date — “when the content was captured/authored”. |
| 2 chunk | sd.captured_date | inherits the parent SD (chunk carries only created_at/updated_at). |
| 3 qa | qa.valid_from | temporal-validity start — the “as-of” date; created_at is an ingestion timestamp. |
| 4 ref | ri.published_at | the 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— extendSearchBodySchemawith:kind: z.enum(['answer','document','reference']).optional(),domain: z.string().optional(),subtopic: z.string().optional(), anddateFrom/dateToaccepting either a bare dateYYYY-MM-DDOR aZ-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 consumercomponents/corpus-search/corpus-search-controls.tsx<input type="date">(forwarded byuse-corpus-search.ts:244–245) emits a bareYYYY-MM-DD, whichz.string().datetime()rejects — as it also rejects offsetted datetimes; onlyZ-suffixed pass. Validate a UNION, e.g.z.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.string().datetime()])(bare date ORZ-suffixed ISO). Offsetted datetimes remain rejected (documented — a caller needing one normalises toZfirst).app/api/search/route.ts:44— destructure the 5 new fields. The ROUTE normalises a baredateFromto the UTC start-of-day…T00:00:00.000Zand a baredateToto the UTC end-of-day…T23:59:59.999Z(inclusive) before binding; aZ-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 asfilter_kind,filter_domain,filter_subtopic,filter_date_from,filter_date_towith the?? undefinedidiom already used forapplication_type(keeps the JSON-RPC payload free ofnull). No change toSearchResponseSchema(results: z.array(z.unknown())stays opaque — the new columns flow through untyped, consumed by the hook’sRawCorpusSearchRow, per the {131.19} typed-envelope deferral).
2.6 Migration plan / DDL
Section titled “2.6 Migration plan / DDL”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):
<ts>_id144_hybrid_search_projection_filters.sql—DROP FUNCTION public.hybrid_search(<the exact 7-arg signature at :63>)thenCREATE FUNCTIONthe 24-col / 12-arg version. Recreate the entire existing body verbatim (win_statsCTE, 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-breakerORDER BY deduped.similarity DESC, deduped.id(§5). Executor filename-sort check (load-bearing):supabase migration newauto-stamps by wall-clock, and the OBS-3 fast-path20260710190000_bl431_obs3_hybrid_search_tiebreak.sqlis stamped TODAY 19:00 — amigration newrun earlier the same day sorts BEFORE it and silently reverts the tie-breaker. Before push, runls supabase/migrations | tail -3and confirm BOTH id144 migrations sort AFTER that filename; hand-bump the timestamp prefix if not. KeepLANGUAGE 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, sodr035_born_locked_functionsfires on the freshCREATEand 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 postgresand refresh theCOMMENT.<ts+1>_id144_hybrid_search_api_wrapper.sql— regenerate theapi.hybrid_searchwrapper (hybrid_searchis inSURFACE_RPCS,scripts/generate-api-views.ts:215). Runbun scripts/generate-api-views.tsagainst the DB after migration 1 is applied so it introspects the new 24-col / 12-arg signature and emits theLANGUAGE sql SECURITY INVOKERpassthrough (precedent20260703210000_id138_api_rpc_wrappers.sql;emitFunction()filtersanonout of the mirrored grants, DR-035). Supabase-js.rpc('hybrid_search', …)resolves toapi.hybrid_searchat runtime (config.toml schemas=["api"], DR-030) — without this the new params/cols are unreachable (PGRST202).- Type regen (after BOTH migrations,
supabase/CLAUDE.mdrecipe, both schemas, deterministic order):supabase gen types typescript --project-id <platform-ref> --schema public,api > supabase/types/database.types.ts. This lands the newArgs/Returnsonpublic.hybrid_searchsoroute.tstype-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.tsx →
useCorpusSearch 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:
| Arm | keyword-like array available |
|---|---|
| 1 SD / 2 chunk | sd.ai_keywords (real / inherited) |
| 3 q_a_pair | none — arm projects NULL::text[] for ai_keywords; the nearest array is scope_tag, but scope ≠ topical keyword (conflating them is a product call) |
| 4 reference | none — 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.)
4. Testing and validation
Section titled “4. Testing and validation”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 invariant | Verification |
|---|---|
| OD-1 scope_tag/source_url projection | test: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 routing | Unit: 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-side | test: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 filters | test: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 unaffected | test: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 + scripts | bun 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). |
5. Risks and mitigations
Section titled “5. Risks and mitigations”- OBS-3 tie-breaker clobber (HIGH — sequencing). OBS-3 ships
ORDER BY deduped.similarity DESC, deduped.idas fast-path migration20260710190000viaCREATE OR REPLACE. This Task’s DROP+CREATE recreates the whole function, so if the executor recreates from the20260702120000source they will silently revert the tie-breaker and re-introduce the pagination bug. Mitigation: (a) both id144 migrations MUST sort after20260710190000_bl431_obs3_hybrid_search_tiebreak.sql(explicitls supabase/migrations | tail -3filename-sort check in §2.6 step 1 —migration newcan auto-stamp earlier the same day; hand-bump if so); (b) theCREATEbody MUST include the two-keyORDER BY— called out in §2.6 step 1 and the §4 BI-20 test. The Checker verifies the finalORDER BYhas both keys. - api-wrapper drift /
PGRST202(MEDIUM). Forgetting migration 2 leavesapi.hybrid_searchat the old 7-arg/21-col shape → the new params silently ignored or aPGRST202at runtime, invisible to mockedbun run test(DR-030/DR-032). Mitigation: DR-032 same-batch companion migration + atest:integrationassertion that the new params take effect through the API layer. - Date-column NULL-handling (LOW — ratified).
valid_from/published_atare 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,apiregen,route.tscannot 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 in21-scope-tag.mdconsumers).
6. Open Questions (owner ratification)
Section titled “6. Open Questions (owner ratification)”- OQ-144-1 (date-grain semantics, OD-3) — RESOLVED (owner-ratified, post-S460). The per-arm
date-column mapping (SD/chunk
captured_date, qavalid_from, refpublished_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 onai_keywords) vs true cross-grain (which requires pipeline keyword extraction forq_a_pairs/reference_items— they carry no keyword array today).scope_tagmust 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. - Shape —
text[]with OR-within-facet (a result matches if it carries ANY selected keyword). - Case rule — normalise free-text input via
normaliseTagbefore 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 existingquery_text/ai_keywordsranking legs today, needing no RPC change.
- Grain coverage — resolve dependent facet (a keyword control visible only under
7. Empirical verification
Section titled “7. Empirical verification”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 dedicatedowner_kindcolumn as the grain key ∈record_embeddings.owner_kind;content_typeis never overloaded as a grain discriminator. A new grain extends the owner_kind vocabulary (DR-036 lineage), never acontent_typeliteral. 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, qavalid_from, refpublished_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-validatedjsonb filtersarg (MUST RAISE on an unknown key — silent-ignore reproduces the OBS-4 dead-filter class) or a versionedhybrid_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.