ID-111 {111.3} — Reference-item read/browse/detail UI: TECH
ID-111 — Reference-item read/browse/detail UI (TECH)
Section titled “ID-111 — Reference-item read/browse/detail UI (TECH)”Spec tier: PRODUCT + TECH + PLAN. This TECH slice was added after ratification of two
decisions that diverge from PRODUCT.md’s recommended defaults: B-30 = a NEW reference_list
RPC (not the direct reference_items SELECT the spec recommended) and B-28 = richer
source-document provenance (load the source_documents row, not ingestion_source alone).
Both raise the technical surface above PRODUCT.md’s “low-risk read UI” framing — B-30 introduces
an authored-not-applied migration and a new shared read seam; B-28 introduces a second read
(an FK join) on the detail page. This TECH covers those two seams and the call-path changes the
browse page, search endpoint, and detail page need to consume them. Everything else in PRODUCT.md
(the pages, the success-card wiring, the filter UX, the empty/loading/error states) is unchanged
and is implemented from PRODUCT.md’s invariants directly.
Predecessors (READ IN FULL):
PRODUCT.md{111.2} (numbered invariants B-1..B-31) andRESEARCH-seed-s355.md{111.1} (S355 inventory) in this directory. This TECH does not restate their behaviour; it maps the divergent decisions onto concrete code/SQL changes.
Context
Section titled “Context”The reference read substrate (reference_items table + reference_search + reference_get_verbatim
RPCs) is shipped and has zero TypeScript callers today. PRODUCT.md §Problem documents the RPC
signatures verbatim; this section adds only the facts the two new seams depend on, with line
references into the squash baseline migration.
Code-intelligence orientation (verbatim, not paraphrased):
gitnexus_query({query: 'reference item search verbatim retrieval read path', repo: 'canonical'})returned no execution flow over the reference RPCs. Top processes wereproc_85_post(POST → IsOk,lib/edit-intent/write-back.ts) andproc_86_post(POST → ResolveAbsolutePath) — both unrelated to references.definitionslistedregisterContentTools(lib/mcp/tools/content.ts),runItemSearch/runChunkSearch(lib/mcp/tools/search.ts) — the content_items MCP search path, not references — plus thequery-keys.tsbrowse/search/librarykey factories. Noreference_*symbol appeared. This confirms PRODUCT.md’s orientation: the reference read RPCs participate in no indexed flow.gitnexus_context({name: 'tryQuery', repo: 'canonical'})→Function:lib/supabase/safe.ts:tryQuery(lines 159–180), called by ~30 routes/pages incl.app/item/[id]/page.tsxand the cron/governance read paths; outgoing call toSupabaseError(lib/supabase/safe.ts). This is the safe-read wrapper the detail page and thesource_documentsjoin will use.gitnexus_context({name: 'generateEmbedding', repo: 'canonical'})→ ambiguous, 3 candidates; the feature-relevant one isFunction:lib/ai/embed.ts:generateEmbedding(line 96 — the singleton helper/api/searchuses). The other two are script-local (scripts/eval-search.ts:130,scripts/catalogue-standard-sq.ts:2066) and out of scope.- A
grepsweep forreference_search|reference_get_verbatim|reference_listacrossapp/ lib/ components/ hooks/ types/returns the single comment atapp/api/ingest/url/route.ts:142and nothing else —reference_listdoes not exist anywhere (TS or SQL), confirming it is net-new.gitnexus orientation: no existing symbols match the reference read path — greenfield read surface over existing RPCs; the new reference_list RPC is additive.
Schema-routing fact (load-bearing for the migration — ID-115).
lib/supabase/schema.ts documents that after the PostgREST schema-isolation cutover, public is
UNEXPOSED and the dedicated api schema is the only Data API surface. Every supabase-js client
threads DB_OPTION so .from('x') resolves to api.x and .rpc('y') resolves to api.y at
runtime. The existing reference RPCs are therefore defined twice in the squash baseline — a
public.reference_search(...) body (lines 4626–4684) and a thin api.reference_search(...) INVOKER
wrapper (lines 1059–1067) that does SELECT * FROM public.reference_search(...); likewise for
reference_get_verbatim (public 4519–4545, api 1037–1045). Consequence: the new
reference_list RPC MUST be created in BOTH schemas — a public.reference_list(...) body plus an
api.reference_list(...) wrapper — or the supabase-js .rpc('reference_list') call resolves to a
non-existent api.reference_list and 404s at runtime.
Relevant code + SQL (with line references):
supabase/migrations/20260617130000_squash_baseline.sql— the squashed baseline (individual ID-75/ID-110 migration files no longer exist on disk; this is the only migration). Reference seams:public.reference_searchbody — lines 4626–4684 (theWHERE ri.embedding IS NOT NULL,LEFT(...,200)previews, internalembedding*0.6 + fulltext*0.4blend). MUST NOT be altered (B-25).public.reference_get_verbatimbody — lines 4519–4545. MUST NOT be altered (B-25).api.*wrappers —reference_search1059–1067,reference_get_verbatim1037–1045.public.reference_itemstable — lines 7219–7236 (cols:id, title, body, summary, source_url, published_at, primary_domain, primary_subtopic, layer, embedding vector(1024), source_document_id NOT NULL, ingestion_source CHECK IN ('rss_feed','url_import'), op_id, created_at, updated_at).api.reference_itemsview (security_invoker) 7246–7262.public.source_documentstable — lines 7460–7485. B-28-relevant columns:original_filename,filename,mime_type,file_size,extraction_method(the producer, e.g.pullmd_*/docling),source_url,created_at(the landed-at timestamp — there is no separatefetched_atcolumn;created_atis the closest provenance signal).api.source_documentsview 7507–7530.- FK:
reference_items.source_document_id → source_documents.id ON DELETE RESTRICT— lines 9918–9919 (one source_documents row per reference, NOT NULL, guaranteed present). - Index
idx_reference_items_published_at (published_at DESC)— line 9212. The newreference_listdefault ordering mirrors this index. - RLS
reference_items_select FOR SELECT TO authenticated USING (true)— lines 10958–10961. - Grant pattern to mirror for the new RPC:
public.reference_get_verbatimREVOKE-from-PUBLIC + GRANT authenticated/service_role at lines 12834–12836;api.reference_get_verbatimat 11753–11755.
lib/supabase/schema.ts—DB_OPTIONruntime-schema seam (see above).app/api/search/route.ts(1–120) — the content_items search route:withRequestContextwrapper,getAuthenticatedClient()+if (!auth.success) return authFailureResponse(auth),generateEmbedding(query.trim())(lib/ai/embed.ts:96),supabase.rpc('hybrid_search', { query_embedding: JSON.stringify(embedding), … }),export const maxDuration = 60. The {111.8} reference search endpoint mirrors this exactly, swapping the RPC name + params.app/item/[id]/page.tsx(1–90) — the server-component detail pattern:await createClient()(@/lib/supabase/server),.from(...).select(...).single()with a PGRST116 read-after-write retry loop,notFound()on miss, and a parallelPromise.allof secondary reads each wrapped intryQuery(...)(@/lib/supabase/safe). The reference detail page reuses this shape: primary read viareference_get_verbatim, secondarysource_documentsread viatryQuery(B-28).lib/supabase/safe.ts:159tryQuery<T>— the safe-read wrapper for the B-28 secondary read.lib/ai/embed.ts:96generateEmbedding— the embedding helper for {111.8}.
Proposed changes
Section titled “Proposed changes”Two seams change relative to PRODUCT.md’s recommended defaults; the rest of the feature is unchanged.
Seam 1 — NEW reference_list RPC (B-30) + its migration (authored, NOT applied)
Section titled “Seam 1 — NEW reference_list RPC (B-30) + its migration (authored, NOT applied)”Decision (B-31, picked explicitly): SERVER-SIDE filter pushdown. Because B-30 now mandates an
RPC for the default list anyway, the four reference filters (primary_domain, primary_subtopic,
ingestion_source, published_at date range — PRODUCT.md B-16) are pushed into reference_list as
optional parameters rather than applied client-side over a fetched page. Rationale: a single
round-trip, the filter predicates ride the same published_at DESC index scan, and pagination
(p_limit/p_offset) stays correct under filtering (client-side filtering of a bounded page would
make the “load more” count wrong — a B-19 hazard). The Checker accepts either approach per B-31;
this spec commits to server-side and the browse page (111.9) is written to that contract.
New function signature (created in BOTH schemas per the ID-115 routing fact):
public.reference_list( p_limit integer DEFAULT 48, p_offset integer DEFAULT 0, p_primary_domain text DEFAULT NULL, p_primary_subtopic text DEFAULT NULL, p_ingestion_source text DEFAULT NULL, -- 'rss_feed' | 'url_import' | NULL (no filter) p_published_from timestamptz DEFAULT NULL, p_published_to timestamptz DEFAULT NULL) RETURNS TABLE( reference_id uuid, title text, summary_preview text, -- LEFT(COALESCE(summary,''),200) — mirrors reference_search preview shape body_preview text, -- LEFT(body,200) source_url text, published_at timestamptz, primary_domain text, primary_subtopic text, layer text, ingestion_source text, source_document_id uuid)The return shape is a strict subset of reference_search’s columns (it omits embedding_score /
fulltext_score, which are search-only ranking artefacts) so the browse page can render the default
list and search results through the same ReferenceListItem card — the list mode simply has no
score fields. This keeps {111.9}‘s card single-shape.
Body (key clauses):
LANGUAGE plpgsql STABLE SECURITY DEFINER+SET search_path = public, extensions(CLAUDE.md function-search_path rule; mirrors the existing reference RPCs).- Selects from
public.reference_items riwith the previews computed asreference_searchdoes (LEFT(COALESCE(ri.summary,''),200),LEFT(ri.body,200)) for preview parity. - No
WHERE embedding IS NOT NULL— the default list is a plain corpus listing and MUST include embedding-null rows (PRODUCT.md B-12; the B-30 caveat that such rows are listable-but-unsearchable is preserved because search still goes throughreference_search). - Filter predicates applied as
(p_x IS NULL OR ri.col = p_x)for the three equality filters and(p_published_from IS NULL OR ri.published_at >= p_published_from)/(p_published_to IS NULL OR ri.published_at <= p_published_to)for the range — each filter inert when its param is NULL, composing with AND (PRODUCT.md B-16 compose-AND). - Ordering:
ORDER BY ri.published_at DESC NULLS LAST, ri.idthenLIMIT p_limit OFFSET p_offset.NULLS LASTrealises PRODUCT.md B-12 (nullpublished_atsorts after valued rows, not dropped); the, ri.idtiebreak makes pagination deterministic across pages (rows with equal/NULLpublished_atkeep a stable order soOFFSETpaging never skips or repeats a row). Mirrorsidx_reference_items_published_at(squash baseline line 9212) for the leading key. api.reference_list(...)wrapper:SELECT * FROM public.reference_list(p_limit => p_limit, …),LANGUAGE sql(or plpgsql) INVOKER — byte-for-byte the pattern ofapi.reference_search(squash baseline 1059–1067).
Grants (mirror the existing reference RPCs exactly):
REVOKE ALL ON FUNCTION public.reference_list(integer,integer,text,text,text,timestamptz,timestamptz) FROM PUBLIC;GRANT ALL ON FUNCTION public.reference_list(...) TO authenticated;GRANT ALL ON FUNCTION public.reference_list(...) TO service_role;REVOKE ALL ON FUNCTION api.reference_list(...) FROM PUBLIC;GRANT ALL ON FUNCTION api.reference_list(...) TO authenticated;GRANT ALL ON FUNCTION api.reference_list(...) TO service_role;(REVOKE … FROM PUBLIC covers the anon-EXECUTE concern in the write-tech-spec checklist;
reference_list is never granted to anon.)
Migration file (AUTHORED ONLY — NOT applied; see Risks). Create via
supabase migration new id111_reference_list_rpc so it lands at
supabase/migrations/<new-timestamp>_id111_reference_list_rpc.sql (the squash baseline timestamp is
20260617130000; the new file sorts after it). The migration contains only the two CREATE OR REPLACE FUNCTION statements + the six grant statements above — purely additive; it touches no
existing object and therefore cannot alter reference_ingest / reference_search (B-25 satisfied
structurally — the migration is CREATE OR REPLACE of a new name only). Filename guard: no
client/counterparty name (the id111_… slug is clean).
B-25 confirmation: the migration issues no DROP/ALTER/CREATE OR REPLACE against
reference_ingest, reference_search, or reference_get_verbatim — their signatures and bodies are
untouched. reference_list is purely additive.
Seam 2 — Detail-page source_documents join for richer provenance (B-28)
Section titled “Seam 2 — Detail-page source_documents join for richer provenance (B-28)”The detail page (111.6) gains a second read after the primary reference_get_verbatim succeeds.
reference_get_verbatim returns source_document_id (NOT NULL, FK ON DELETE RESTRICT — the
source_documents row is guaranteed to exist), so the page does:
const sd = await tryQuery( supabase.from('source_documents') .select('original_filename, filename, mime_type, file_size, extraction_method, source_url, created_at') .eq('id', reference.source_document_id) .maybeSingle(), 'reference.detail.source_document',);- Read path:
tryQueryfrom@/lib/supabase/safe(the same wrapperapp/item/[id]/page.tsxuses for its secondary reads). Runs in parallel with nothing else here (the primary read must resolve first to knowsource_document_id), so it is a sequential second await — acceptable, both are single indexed lookups. - Surfaced metadata (B-28): original filename (prefer
original_filename, fall back tofilename), theextraction_methodrendered in plain language (e.g. “Extracted via pullmd” / “Extracted via Docling” — never the raw enum), and the document’screated_atas the “fetched” / landed timestamp formatted DD/MM/YYYY (PRODUCT.md B-27 date rule).source_urlis already on the reference row; do not duplicate it. - Degradation (silent-failure discipline): if the
source_documentsread fails (transport/RPC error), the page logs viatryQuery’slogBestEffortWarnpath and renders the detail page WITHOUT the enriched source-doc block, falling back to theingestion_sourceplain-language line (PRODUCT.md B-2 — “Imported from URL” / “From an RSS feed”). The page does NOT 404 and does NOT blank: a failed provenance enrichment must not take down a readable reference (PRODUCT.md B-7 spirit — never silently render empty). The primaryreference_get_verbatimfailure paths (not-found →notFound(), transport error → error+retry) are unchanged from PRODUCT.md B-5/B-7.
Downstream call-path changes (consume the new seam)
Section titled “Downstream call-path changes (consume the new seam)”- Browse list path (111.9): the default-list hook calls
supabase.rpc('reference_list', { p_limit, p_offset, p_primary_domain, p_primary_subtopic, p_ingestion_source, p_published_from, p_published_to })via TanStackuseInfiniteQuery(cursor = offset), replacing PRODUCT.md B-30 option (a)‘s directfrom('reference_items').select(...). Filters are passed as RPC params (server-side pushdown, B-31), not applied client-side. The search path still POSTs the {111.8} endpoint. - Search endpoint (111.8): unchanged in contract — still embeds + calls
reference_search. It does NOT callreference_list(search needs the embedding + score columns). The browse page picks list-vs-search mode and calls the corresponding path. - Types (111.5):
ReferenceListItemis now the 11-fieldreference_listreturn shape PLUS the two optional score fieldsembedding_score/fulltext_score(present only on search results). Model the scores as optional (embedding_score?: number) so one type serves both the list RPC and the search RPC.ReferenceDetailis unchanged (14-fieldreference_get_verbatimshape). A separate smallReferenceSourceDocumenttype (7 fields) models the B-28 join result; derive field types fromTables<'source_documents'>per CLAUDE.md conventions.
Testing and validation
Section titled “Testing and validation”Behaviour-first (reference/test-philosophy.md); shared Supabase mock for unit tests; bun run test
(never bun test). Each row maps a PRODUCT.md invariant to a concrete check.
| PRODUCT.md invariant | Verification |
|---|---|
B-12 (default list published_at DESC, NULLs last, none dropped) | reference_list RPC test: rows returned in published_at DESC order with NULL-published rows last and present, not filtered out. Browse-page unit: default mode renders that order. |
| B-16 / B-17 (filters compose AND, server-pushdown) | reference_list returns only rows matching each non-NULL filter param; combining domain + source + date range ANDs them; NULL params are inert. Browse-page: changing a filter re-queries the RPC with the param set and reflects it in the URL. |
| B-19 (pagination reaches all, count never lies) | reference_list with p_limit/p_offset paginates deterministically (the published_at DESC NULLS LAST, id tiebreak): no row skipped/repeated across pages under equal/NULL published_at. |
| B-25 (shared RPC seam untouched) | Migration review: the file issues NO DROP/ALTER/CREATE OR REPLACE against reference_ingest/reference_search/reference_get_verbatim; reference_list is a new name. Grant block mirrors the existing reference RPCs (REVOKE PUBLIC + authenticated/service_role, no anon). |
| B-28 (richer source-doc provenance) | Detail-page test: with a resolvable source_document_id, the page surfaces original filename + plain-language extraction method + landed date (DD/MM/YYYY); with a failing source_documents read, it degrades to the ingestion_source line and still renders the reference (no 404/blank). |
| B-2, B-5, B-7 (provenance line, 404, error+retry) | Detail-page tests unchanged from PRODUCT.md: unknown/invalid id → notFound(); primary RPC transport error → error+retry; provenance line present in all success cases. |
| B-13, B-14, B-23 (search endpoint) | {111.8} endpoint tests unchanged: embeds + calls reference_search, references-only, returns raw score columns. The new reference_list path is NOT exercised by the search endpoint. |
| RPC routing (ID-115) | Manual / integration check that supabase.rpc('reference_list', …) resolves at runtime (i.e. api.reference_list exists) — the dual-schema creation is what makes the .rpc() call non-404. Type-regen (--schema public,api) after apply surfaces reference_list in database.types.ts. |
Empirical-verification scope (OQ-3 / Q-EX2). This spec cites NO external-library symbols. Every
symbol referenced is internal KH (tryQuery, generateEmbedding, DB_OPTION, the reference RPCs),
Postgres built-ins (LEFT, COALESCE, ts_rank, ORDER BY … NULLS LAST), or
Next.js/supabase-js framework surface (notFound, .rpc(), .from().select()) — all out of the
import-and-call verification scope (external-library symbols only, per shared-discipline
§Empirical verification). No verification block is required. The one runtime contract that COULD
drift is the ID-115 .rpc()→api routing — covered by the “RPC routing” validation row above
(the dual-schema creation is the mitigation, not an unverified assumption).
Risks and mitigations
Section titled “Risks and mitigations”- Migration authored-not-applied (CLAUDE.md / dispatch brief). The Platform DB
zjqbrdctesqvouboziaeis prod+staging; paralleldb pushfrom worktrees collide and an unverified push lands on prod silently (supabase/CLAUDE.md). Mitigation: the executor AUTHORS theid111_reference_list_rpcmigration file only. Migration-apply + type-regen are Orchestrator-on-MAIN post-merge intents: (1)cat supabase/.temp/project-refthen relink to the staging ref from.env.localif drifted; (2)supabase db pushforeground (it prompts — never background, it hangs); (3) regen typessupabase gen types typescript --project-id <ref> --schema public,api > supabase/types/database.types.ts(ID-115 dual-schema, deterministic order). Until applied,supabase.rpc('reference_list')404s — so the browse-page (111.9) executor’s local unit tests MUST mock the RPC (they do not need the live function), and the E2E (111.10) gate runs only after the Orchestrator applies the migration. - Single-schema-creation footgun (ID-115). If the executor creates only
public.reference_listand forgets theapi.reference_listwrapper, every.rpc('reference_list')call 404s at runtime while local SQL tests againstpublicpass — a silent prod break. Mitigation: the {N.x} RPC subtaskdetailsmandates BOTH functions in one migration file and cites thereference_searchdual-definition (squash baseline 1059 + 4626) as the template; the “RPC routing” validation row gates it. reference_listpreview/order drift fromreference_search. If the previews or the column set diverge, the browse card can’t render list + search rows uniformly. Mitigation: the RPC reusesreference_search’s exactLEFT(...,200)preview expressions and returns a strict subset of its columns;ReferenceListItemcarries the scores as optional.- B-28 join failure cascading to a dead detail page. A
source_documentsread error must not blank the reference. Mitigation:tryQuery+ graceful degradation to theingestion_sourceline (Seam 2); the primary read owns the 404/error paths, the enrichment read is best-effort.
Follow-ups
Section titled “Follow-ups”- None required for this slice. (If a future need arises to paginate filtered SEARCH results the same
way as the list,
reference_searchwould need an offset param — explicitly out of scope here and a B-25-bounded change for ID-71 to coordinate, not this Task.)