ID-131 {131.3} TECH — OKF L-records refactor (content_items elimination)
TECH — OKF L-records refactor (content_items elimination)
Section titled “TECH — OKF L-records refactor (content_items elimination)”Status:
REVISED — 28/06/2026.The migration + consumer-rewrite plan for the FOUNDATION Task of the OKF re-architecture. Authored by a FRESH Planner (Q-PLANNER-2 — a different instance from the {131.1}/{131.2} authors), with the ratified {131.2} PRODUCT.md read in full as the contract. Every TECH change maps 1:1 to a Behaviour invariant (BI-N).Revision (FIX MODE): a 3-lens adversarial verification returned NEEDS_REVISION. This pass clears three blockers and folds the corrections, preserving the sound M0→M6 skeleton: B1 — the whole plan is re-cast through the ID-115
apischema (the relation TS actually queries;publicis PostgREST-unexposed) via a new §“The api-schema migration class”; B2 — a pre-M2 debris-wipe makes the schema migration executable at live head (27entity_mentionsrows + 17entity_relationshipsrows all dangle offcontent_items, zero resolve tosource_documents, so M2’s ADD-FK hard-fails without the wipe); B3 — the function disposition is expanded from ~11 to the complete ~64-function inventory (authoritative pull overpg_proc, 28/06/2026). M4 is split (enum-add cannot share a txn with the CHECK rewrite). All numbers re-pulled from the live DB (zjqbrdctesqvouboziae) + the TS corpus at head.Spec chain: {131.1} RESEARCH → {131.2} PRODUCT → {131.3} TECH (this doc) → {131.4} PLAN.
How to read this: the Migration set is the ordered DDL plan (now with an explicit per-migration api consequence column); The api-schema migration class explains the
api-view/wrapper discipline threaded through every migration; Function disposition is the complete DROP/REWRITE/RELOCATE grid; Proposed changes per invariant is the BI→change map (the Checker’s per-invariant compliance grid). Behaviour lives in PRODUCT.md — this doc is HOW.De-identification: the real first ingest corpus is “the first client”; the name is omitted.
Context
Section titled “Context”content_items is a 67-column polymorphic god-table (verified at head). ID-131 eliminates it with
zero data migration of production knowledge (BI-1, BI-2) and re-homes its roles onto typed records,
two net-new polymorphic tables (record_lifecycle facet, record_embeddings store), and an extended
citations contract. This section grounds the plan in current head; PRODUCT.md owns the behaviour.
The single most important structural fact (B1): TS never queries public.content_items. After the
ID-115 PostgREST schema-isolation cutover, public is unexposed and every supabase-js client is
routed to the dedicated api schema at runtime via lib/supabase/schema.ts (DB_OPTION →
db.schema='api'): .from('content_items') resolves to api.content_items, .rpc('hybrid_search')
to api.hybrid_search. The api surface is ~61 security_invoker views + the INVOKER RPC wrappers,
all column-explicit (no SELECT *) and generated by scripts/generate-api-views.ts from
hardcoded SURFACE_TABLES / SURFACE_RPCS lists, guarded in CI by generate-api-views.ts --check
(migration byte-matches the catalog) and scripts/check-api-view-coverage.ts (every public base table
is surface-or-internal-only). The original TECH was written against public; every table/column/fn
change below must also propagate to its api view/wrapper or the app silently breaks (PGRST106 / missing
relation) AND CI fails. This is fully threaded in §“The api-schema migration class”.
Code-intelligence orientation (verbatim — Checker: confirm this ran)
Section titled “Code-intelligence orientation (verbatim — Checker: confirm this ran)”Tools (28/06/2026): supabase execute_sql (project zjqbrdctesqvouboziae — authoritative function
inventory over pg_proc/pg_get_functiondef, FK/CHECK/trigger lists, api-view-dependency list, live
row counts), gitnexus query/context + ast-dataflow (TS symbol blast radius), ripgrep over the TS
corpus + the Python pipeline + supabase/migrations/*.sql. Tool catalogue: .gitnexus/CLAUDE.md,
.ast-dataflow/CLAUDE.md (cited, not reproduced).
- api-layer grounding (verbatim outputs):
lib/supabase/schema.ts—DB_OPTION = { db: { schema: 'api' } }cast as{schema:'public'}(the type seam): runtime routes toapi, types stay onpublicbase-table shapes.publicis UNEXPOSED to PostgREST; apublic-only helper is unreachable from the app.scripts/generate-api-views.ts—SURFACE_TABLES(61 string literals at head incl.content_items,content_item_workspaces,content_history,read_marks; NOTcontent_templates; post-ID-131: 61 − 4 + 2 = 59) +SURFACE_RPCS(the.rpc()surface). Emits one whole-surfaceDROP VIEW IF EXISTS … ; CREATE VIEW … WITH (security_invoker=true)+ INVOKER fn-wrapper migration.--checkis a CI drift gate.scripts/check-api-view-coverage.ts—INTERNAL_ONLY_TABLES(10, incl.content_templates): every public base table must be inSURFACE_TABLES(→ api view) OR this allow-list, else CI fails.- ID-130 precedent migration
supabase/migrations/20260625160000_id130_api_views_regen.sql(2286 lines):api.content_itemsview at L221-295;api.filter_by_keywords(keyword_list,match_mode) RETURNS SETOF public.content_itemswrapper at L1469-1480 (the no-op stub, BI-28). 12 api views referencecontent_item(live pull):citations, classification_disputes, content_chunks, content_history, content_item_workspaces, content_items, entity_mentions, feed_articles, ingestion_quality_log, q_a_extractions, read_marks, verification_history.
content_itemsDDL + inbound graph (supabase, 28/06/2026): 67 cols. Inbound FKs = exactly 10 enforced — CASCADE×6 (citations,classification_disputes,content_item_workspaces,ingestion_quality_log,read_marks,verification_history); SET NULL×4 (content_history,content_items.superseded_byself,entity_relationships.source_item_id,feed_articles) — plus 3 bare-uuid (no FK):content_chunks.content_item_id(uuid NOT NULL),entity_mentions.content_item_id(uuid NOT NULL),q_a_extractions.source_content_item_id(uuid nullable). Confirms BI-10’s “10 + 2 + 1”. NB the two NOT-NULL bare columns and the NOT-NULLverification_history.content_item_idCASCADE are landmines for a naive FK-only drop (see §FK & trigger disposition).citations(supabase): 15 cols + 4 CHECKs (citation_type,cited_location_kind,cited_one_of,citing_one_of) + 4 FKs (cited_content_item_idCASCADE,cited_q_a_pair_idCASCADE,citing_form_response_idCASCADE,created_bySET NULL) — correction: the prior draft said 3 FKs.cited_target_kindenum ={content_item, q_a_pair}only.citations_cited_one_of_chk= exactly-one across the two current kinds (the verified idiom M4 extends). 0 rows.- Typed-record shapes (supabase):
source_documentslean (noupdated_at, no classification, no governance, nopublication_status, no title);q_a_pairs(source_document_idnullable;question_embedding vector(1024); noprimary_domain— confirmed absent);content_chunkscarriescontent_item_id uuid NOT NULL+embedding vector(1024)(correction: PRODUCT BI-10 says “CC has none today” — FALSE; the column exists, so M2 is a RENAME, not an ADD);q_a_extractionscarriessource_content_item_id uuidwith no FK (correction: this is an ADD-FK after rename, not a repoint);reference_items.embedding vector(1024);form_template_requirements.requirement_embedding vector(1024);company_profiles.company_embeddingTEXT (the EMB-STORE normalisation target). - Live row counts (28/06/2026 — the debris-wipe basis, B2):
content_items33,entity_mentions27 (0 resolve tosource_documents),entity_relationships17,content_history34;content_chunks/q_a_extractions/citations/classification_disputes/content_item_workspaces/read_marks/ingestion_quality_log/verification_history= 0;feed_articles2 (0 with a non-nullcontent_item_id— confirms decision (c) is data-safe). - Vector index inventory (head): 3 HNSW indexes —
idx_content_items_embedding,idx_content_chunks_embedding,idx_reference_items_embedding(allhnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64')).q_a_pairs.question_embeddinghas no HNSW index today. This is the EMB-STORE parity baseline (BI-17 benchmark). - Pipeline write-sites (ripgrep + Read, head):
_KH_PIPELINE_DOC_NS = uuid.UUID("fbfaf1ff-1ee4-583c-9757-1674465b2ec1")(flow.py:1640); seedssd:{rel_path}(flow.py:2111/2529, URLsd:{item.url}:2940),ri:{item.url}(:2941),qa:{rel_path}:{idx}(:2270/:2568),ci:{rel_path}(:1943/:2112/:3085 — dies),chunk:{rel_path}:{position}(:2242-2243); parent write-sitescontent_chunks.content_item_id(flow.py:2245),entity_mentions.content_item_id(:2381),entity_relationships.source_item_id(:2456),q_a_extractions.source_content_item_id(:2271; schema col flow.py:1215; sidecar :2571 writesNone); extraction stampcontent_items_id(extraction.py:261-264); QA sidecaremitCorpusSidecar(promote-corpus.ts:579-648; DB-id read-back :406). gitnexus impact(upstream):_ExtractionStamp→ HIGH risk, 16 direct dependents (flow.py,extraction.pysubclassesClassificationExtractionStamped/QAFormExtractionStamped/EntityMentionExtractionStamped,pair_resolver.py,parity_driver.py,form_extractors/shared.py,- 9
scripts/tests/test_cocoindex_*). Thecontent_items_idre-parent (extraction.py:261-264) must re-rungitnexus impactat Executor time and update all three stamped subclasses + fixtures in lockstep.fetchContentForMatching→ LOW, 1 caller (template-coverage.ts:519). RPCs (hybrid_search,get_content_win_rate, …) are not code-graph nodes (reached via.rpc('<name>')string literals; blast radius = the TS caller set + the SQL bodies pulled verbatim).
- 9
Current-head findings that refine PRODUCT (FLAGGED — none contradicts a ratified decision)
Section titled “Current-head findings that refine PRODUCT (FLAGGED — none contradicts a ratified decision)”feed_articles(decision c) — already re-parented; only a DROP remains. Carries BOTHcontent_item_id(FK→content_items SET NULL, 0 non-null rows) ANDreference_item_id(FK→reference_items SET NULL). No writer setscontent_item_id(_backlink_feed_articlesflow.py:2699-2721 setsreference_item_idonly; TSlib/intelligence/pipeline.ts:321-338/:405-423set neither). → M6 drops the dead column + its FK + index. No re-parent.q_a_pairshas noprimary_domain(confirmed absent). The win-rate aggregatescopeand the facet policy join cannot read a domain offq_a_pairs. →record_lifecycle.domaindenormalisation is load-bearing (M1a column; M3-trig sync); resolved in the facet design.source_documentshas noupdated_at.recalculate_all_freshnesskeys evergreen/regulation freshness offupdated_at. SD gainsupdated_atnet-new (BI-11, M3), so the facet recalc readsowner.updated_atfor both kinds — the column add must precede the recalc rewrite.- PRODUCT↔TECH reconciliations (corrections, surfaced explicitly): (a) BI-10 “content_chunks has no
content_item_idtoday” is FALSE — it exists (uuid NOT NULL), so M2 is a RENAME (correct) but the prose is wrong; (b)q_a_extractions.source_content_item_idhas no FK → M2 is rename + ADD FK, not a repoint; (c)citationshas 4 enforced FKs, not 3. - Two citation readers to add to the BI-23 consumer group:
lib/mcp/formatters/procurements.ts(CitationResult.cited_content_item_idat :173, rendered at :185) +app/api/items/[id]/route.ts(.eq('cited_content_item_id', id)at :668, in the UC4 re-anchor block :653-670). NB this is the[id]route (a real.from('citations')reader); the separateapp/api/items/route.ts:368reads the derivedcontent_items.citation_countcolumn (dies with the table) — two different files, both handled.
The api-schema migration class (B1 — threaded through M0→M6)
Section titled “The api-schema migration class (B1 — threaded through M0→M6)”Every schema change in ID-131 has an api consequence. The app reads api.*, not public.*; CI
fails on api drift. This section is the discipline; the Migration-set table carries a per-row “api
consequence” column, and §Parallelization adds a dedicated G-API group.
The mechanism (3 moving parts):
scripts/generate-api-views.ts— hardcodedSURFACE_TABLES+SURFACE_RPCS; introspects the LOCAL catalog and emits ONE idempotent whole-surface migration (DROP VIEW IF EXISTS api.<t>; CREATE VIEW api.<t> WITH (security_invoker=true) AS SELECT <explicit cols> FROM public.<t>;per table; thinSECURITY INVOKERSQL wrapper per RPC overload). Re-running after a table/column/fn lands re-emits a superset. TheOUTPUT_FILEconstant is re-timestamped per Task (ID-115 → ID-130 precedent) — ID-131 creates a NEW…_id131_api_views_regen.sqlthat sorts AFTER all ID-131 DDL.scripts/generate-api-views.ts --check(CI) — regenerates to a buffer, diffs vs the committed migration; non-zero exit on drift. Also asserts every viewsecurity_invoker, no apiSECURITY DEFINER, per-fnREVOKE EXECUTE … FROM PUBLIC.scripts/check-api-view-coverage.ts(CI) —INTERNAL_ONLY_TABLESallow-list; every public base table must be inSURFACE_TABLESOR the allow-list, else fail. Catalog-level least-privilege checks.
The dependency rule (the load-bearing gotcha): a security_invoker view with an explicit column
list hard-depends on every projected column; an INVOKER wrapper hard-depends on its public.<fn>.
Therefore:
- Column/table DROP → the dependent api view must be dropped before the DDL (else
DROP COLUMN/DROP TABLEerrors on the view dependency). e.g. M6’sALTER citations DROP COLUMN cited_content_item_idis blocked byapi.citationsuntil that view is dropped/recreated. - Column ADD → free; the view simply omits the new column until the regen recreates it.
- Fn DROP or signature/return change → the api wrapper must be dropped before the public DROP /
arg-renaming
CREATE OR REPLACE(the wrapper depends on the exact signature). - New table → add to
SURFACE_TABLES(so the regen emits its view) — else.from('record_lifecycle')→api.record_lifecycleis unreachable AND the coverage guard fails on an un-mirrored base table. Also GRANT the base table the same {SELECT,INSERT,UPDATE,DELETE} roles as the typed-record tables in M1a/M1b:emitViewmirrors only the BASE table’s grants into the (fail-closed) api view, so RLS alone is insufficient — an ungranted api view returns nothing toauthenticated/service_role.
The concrete ID-131 edits (all in the G-API group):
generate-api-views.tsSURFACE_TABLES:+= 'record_lifecycle', 'record_embeddings';−= 'content_items', 'content_item_workspaces', 'content_history', 'read_marks'.generate-api-views.tsSURFACE_RPCS: remove every DROPPED fn (tag family, author family,toggle_star,get_reading_patterns,get_filter_counts,get_item_workspaces,get_topic_*,get_content_gaps, … per §Function disposition); keep every REWRITTEN fn (its wrapper regenerates, but a signature change — e.g.get_content_win_rate p_content_item_id → p_q_a_pair_id— needs the old wrapper explicitly dropped first).filter_by_keywordsis REMOVED fromSURFACE_RPCSoutright ({131.20} §9 §7.5 DROPs the base fn entirely — there is no surviving(search_terms text[])variant to keep, superseding the earlier “STAYS inSURFACE_RPCS” framing); its wrapper drops with the removed name in the regen, and the dead(keyword_list text[], match_mode text)→SETOF content_itemsoverload’s wrapper is pre-dropped by exact signature in M6.generate-api-views.tsOUTPUT_FILE: re-timestamp the constant to a new…_id131_api_views_regen.sqlthat sorts AFTER all ID-131 DDL (the ID-115→ID-130 precedent). This is the byte-diff target the--checkguard compares against — the 4th concrete edit, alongsideSURFACE_TABLES/SURFACE_RPCS/INTERNAL_ONLY_TABLES.check-api-view-coverage.tsINTERNAL_ONLY_TABLES:−= 'content_templates'(the table is dropped in M6; leaving a dropped table in the allow-list is stale, not fatal, but remove it for hygiene).- Conditional (deferred-decision) table removals — thread through both the list AND M6: if
verification_historyis DROPPED (§FK & trigger disposition), alsoSURFACE_TABLES −= 'verification_history'- pre-drop
api.verification_historyin M6; same foringestion_quality_logif the drop-column option is taken (−= 'ingestion_quality_log'+ pre-drop). The 4 namedSURFACE_TABLESremovals above are the UNCONDITIONAL set; these two are gated on the §FK disposition calls.
- pre-drop
The execution shape (recommended — teardown-light + one final regen): the api regen is whole-surface, so the cleanest correct sequence within the single ID-131 deploy is:
- In each column-DROP / table-DROP / fn-DROP / fn-sig-change migration (M4b citations cols are ADDs →
free; M5 fn rewrites with sig changes; M6 drops), the migration first issues the targeted
DROP VIEW IF EXISTS api.<affected>/DROP FUNCTION IF EXISTS api.<affected>(<old sig>)for exactly the objects whose base it is about to change, then does the base DDL. The 12 content_item-referencing api views + the dropped-fn wrappers are the affected set. - One final
…_id131_api_views_regen.sql(the LAST migration) re-runs the generator against the post-M6 catalog (with the editedSURFACE_TABLES/SURFACE_RPCS) and recreates the entire surviving surface — newapi.record_lifecycle/api.record_embeddings, rebuiltapi.source_documents(+classification cols),api.citations(+cited_*cols, −cited_content_item_id),api.content_chunks/api.entity_mentions(renamed col), and every rewritten RPC wrapper. Removed tables/fns are simply absent (no orphan, because step 1 dropped their objects). - Acceptance gate (CI, must be green):
bun scripts/generate-api-views.ts --checkANDbun scripts/check-api-view-coverage.tsANDsupabase gen typesregen committed with zerocontent_itemsreferences.
Why not regen-per-migration? The generator is whole-surface and writes one file; the CI guards check the FINAL branch state (a fresh
db resetapplies all migrations, then--check). Intermediate api validity between migrations within one PR is not load-bearing (the app is offline during migrate; it only ever sees the merged final state). One final regen + targeted pre-drops is correct, minimal, and matches the ID-115/130 idempotent-regen precedent. The per-migration “api consequence” column below is the map the Executor uses to know which views/wrappers each migration’s final regen must rebuild.
Deferred decisions resolved (PRODUCT §“Deferred to {131.3}”)
Section titled “Deferred decisions resolved (PRODUCT §“Deferred to {131.3}”)”(b) Facet FK idiom → per-kind nullable FKs + an exactly-one CHECK (mirror citations_cited_one_of_chk)
Section titled “(b) Facet FK idiom → per-kind nullable FKs + an exactly-one CHECK (mirror citations_cited_one_of_chk)”Decision: record_lifecycle uses per-kind nullable FKs (source_document_id, q_a_pair_id),
each REFERENCES … ON DELETE CASCADE, plus an exactly-one-of CHECK tying owner_kind to the matching
non-null FK — the verified idiom already live on citations (citations_cited_one_of_chk). A STORED
generated owner_id uuid GENERATED ALWAYS AS (COALESCE(source_document_id, q_a_pair_id)) STORED plus a
UNIQUE (owner_kind, owner_id) give the (owner_kind, owner_id) query ergonomics BI-18 describes.
VERIFIED valid Postgres (created + rolled back live: per-kind nullable FK + exactly-one CHECK +
STORED generated COALESCE + composite UNIQUE all accepted).
Rationale (referential-integrity over polymorphism ergonomics): the owner set is small, closed, and
fully DB-backed ({source_document, q_a_pair} — BI-22 OWN-clock, one facet). Real FKs give CASCADE
cleanup on owner delete (an orphaned governance row is a governance bug a trigger would only catch
late), and the codebase already has the verified exactly-one-of pattern, so the Checker can compare
against a known-good constraint. The (owner_kind, owner_id) + validating-trigger alternative is
rejected because it adds no integrity the FK gives for free and needs a bespoke trigger + an
orphan-sweep cron.
Contrast — record_embeddings deliberately uses the OTHER idiom ((owner_kind, owner_id) + a
owner_kind CHECK, no FKs) because its kind set includes concept, which has no DB row (its
identity is a bundle path); a per-kind FK is structurally impossible for concept. Two net-new tables,
two idioms, each justified by its kind set. (BI-19 reference_item-exclusion is a record_lifecycle
CHECK; record_embeddings includes reference_item + content_chunk as embedding owners — different
axis, no conflict.)
(c) feed_articles re-parent target → reference_item, already wired; ID-131 only DROPs the dead column
Section titled “(c) feed_articles re-parent target → reference_item, already wired; ID-131 only DROPs the dead column”_backlink_feed_articles (flow.py:2699-2721, called :3008) already populates feed_articles.reference_item_id
(the typed home); feed_articles.content_item_id has a live FK but no writer and 0 non-null rows. →
M6 drops feed_articles.content_item_id + its FK + index (data-safe). No re-parent, no writer change.
(d) Supersession stays INLINE (self-FK on the typed records, not the facet); RI lifecycle facet DEFERRED (D7)
Section titled “(d) Supersession stays INLINE (self-FK on the typed records, not the facet); RI lifecycle facet DEFERRED (D7)”Supersession is an inline self-FK on each typed record, NOT a record_lifecycle axis (the facet
carries no supersession column): q_a_pairs.superseded_by already exists (kept); source_documents
uses its existing parent_id version chain; and ID-131 ADDs a net-new reference_items.superseded_by
self-FK (REFERENCES reference_items(id) ON DELETE SET NULL, M3) so reference evidence can be superseded
inline. This keeps supersession on the read-path (hot) where valid_from/valid_to already live (BI-20).
DEFERRED to the Intelligence-domain track (follows id-130 — D7; NOT built in ID-131): the
reference_item Freshness/Validity facet (RI freshness/validity/expiry — the reason reference_item
is excluded from record_lifecycle, BI-19) and the Contradiction edge table (cross-record
contradiction detection). These are a follow-on lifecycle workstream; ID-131 ships neither migration.
Tracking: bl-90 + docs/research/human-validation-auto-metadata-research.md.
Migration set (every DDL change, in BI-24 / §Sequencing-safe order)
Section titled “Migration set (every DDL change, in BI-24 / §Sequencing-safe order)”All net-new functions: SET search_path = public, extensions + REVOKE EXECUTE … FROM anon;. All new
tables: RLS enabled, service_invoker policy mirroring the typed-record tables. DDL via supabase migration new + supabase db push (foreground — interactive CLI hangs background shells); never MCP
execute_sql for DDL. Type regen (supabase/types/database.types.ts) after each schema-touching
migration; never hand-edit. Safe-today basis: every drop/re-parent below is over empty or disposable
rows once the pre-M2 debris-wipe (M0c) runs; full-replace re-ingest rebuilds L-records on a new preview
branch (BI-2). The api consequence column names the views/wrappers the final id131_api_views_regen
(G-API) must rebuild for that migration’s base change (per §The api-schema migration class).
| # | Migration (file slug) | DDL | api consequence | BI | Depends on |
|---|---|---|---|---|---|
| M0 | id131_seed_contract_test | No DDL. Vitest freeze test asserting _KH_PIPELINE_DOC_NS + the three citeable seed formats. | none | BI-7 | none (lands first) |
| M0c | id131_predebris_wipe (B2 — before M2) | No schema change — DELETE of disposable E2E debris on live head so M2’s ADD-FK is satisfiable: DELETE FROM entity_mentions (27), DELETE FROM entity_relationships (17), DELETE FROM content_history (34), DELETE FROM content_items (33; CASCADE children all 0 rows). NOT a data migration (nothing copied — BI-1 holds). Distinct from BI-2’s full-replace re-ingest: this clears the schema-migration path on the live DB head. | none (no relation shape change) | BI-1, BI-14 | none |
| M1a | id131_record_lifecycle_facet | CREATE TABLE record_lifecycle (per-kind FK idiom, PER-AXIS owner_kind — D7): owner_kind text CHECK (owner_kind IN ('source_document','q_a_pair')), source_document_id uuid REFERENCES source_documents(id) ON DELETE CASCADE, q_a_pair_id uuid REFERENCES q_a_pairs(id) ON DELETE CASCADE, exactly-one CHECK, owner_id uuid GENERATED ALWAYS AS (COALESCE(source_document_id,q_a_pair_id)) STORED, domain text. Two column axes with different owner sets (D7): the Review/Governance axis (governance_review_status, governance_review_due, governance_reviewer_id, verified_at, verified_by, content_owner_id) spans {source_document, q_a_pair}; the Freshness/expiry/review-cadence axis (freshness default 'fresh', freshness_checked_at, previous_freshness, lifecycle_type default 'evergreen', expiry_date, next_review_date, review_cadence_days) is source_document-only — q_a_pairs get NO freshness/expiry/review-cadence columns (D7), enforced by a per-axis CHECK CHECK (owner_kind = 'source_document' OR (freshness IS NULL AND freshness_checked_at IS NULL AND previous_freshness IS NULL AND lifecycle_type IS NULL AND expiry_date IS NULL AND next_review_date IS NULL AND review_cadence_days IS NULL)). Plus created_at, updated_at, UNIQUE (owner_kind, owner_id). The domain write-time sync trigger is NOT here — it reads source_documents.primary_domain (added in M3), so it lands in M3-trig (correction). | + api.record_lifecycle (add to SURFACE_TABLES) | BI-18, BI-19, BI-20, BI-21, BI-22 | source_documents, q_a_pairs exist |
| M1b | id131_record_embeddings_store | CREATE TABLE record_embeddings: id, owner_kind text CHECK (owner_kind IN ('source_document','content_chunk','q_a_pair','reference_item','concept')), owner_id uuid, model text, embedding vector(1024), created_at, updated_at, UNIQUE (owner_kind, owner_id, model); per-owner_kind partial HNSW indexes … USING hnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64') WHERE owner_kind='<kind>'. | + api.record_embeddings (add to SURFACE_TABLES) | BI-17 | none (additive) |
| M2 | id131_extract_reparent | content_chunks: rename content_item_id→source_document_id, ADD FK→source_documents (was bare uuid). entity_mentions: rename content_item_id→source_document_id, ADD FK; rename UNIQUE entity_mentions_canonical_name_entity_type_content_item_id_key → …source_document_id_key. entity_relationships: rename source_item_id→source_document_id, repoint FK→source_documents. classification_disputes: rename content_item_id→source_document_id, repoint FK. q_a_extractions: rename source_content_item_id→source_document_id, ADD FK (was bare uuid — correction). Lockstep with G-PIPELINE (flow.py:2245/2381/2456/2271, extraction.py:261-264). | regen api.content_chunks, api.entity_mentions, api.entity_relationships, api.classification_disputes, api.q_a_extractions (renamed projected col) | BI-10, BI-14, BI-15 | M0c, M1a/M1b, source_documents |
| M3 | id131_sd_classification_cols | ADD to source_documents ~25-30 net-new classification + inline-hot cols: primary_domain, primary_subtopic, secondary_domain, secondary_subtopic, ai_keywords text[], summary, suggested_title, classified_at, classification_confidence, classification_reasoning, content_type, captured_date, summary_data jsonb, updated_by, updated_at (Finding 3), publication_status text NOT NULL DEFAULT 'published' (inline hot — BI-20). classification_model is DROPPED, not ported (dead — 0 consumers as a stored value; D1/E2). created_by maps to existing uploaded_by. Producer split (D1/F4): content_type/primary_domain/primary_subtopic/suggested_title are currently written to content_items via ci_target.declare_row (flow.py:2193-2215) — NOT to source_documents (SOURCE_DOCUMENTS_SCHEMA (flow.py:1240-1268) declares none of them); relocating the producer onto source_documents is {131.22} G-PRODUCER-CLASS ({131.25} reconcile vs flow.py HEAD e5c9b203); classification_confidence/classification_reasoning/secondary_domain/secondary_subtopic are computed-but-currently-discarded → extend flow.py/extraction.py to persist them (they ARE consumed by review-queue/quality/forms/provenance — F4, so this is a real pipeline-write add, not a free column ADD, now the app-side classify.ts is retired). Also ADD to reference_items: thumbnail_url text (net-new nullable, ship empty — D4 backfill-later) + superseded_by uuid REFERENCES reference_items(id) ON DELETE SET NULL (net-new inline supersession — D7). Also ADD 'manually_authored' to q_a_pairs_origin_kind_check (D3 — manual-authoring origin). | regen api.source_documents (new cols, free ADD) + api.reference_items (thumbnail_url, superseded_by) | BI-11 | source_documents |
| M3-trig | id131_record_lifecycle_domain_sync | record_lifecycle.domain write-time sync trigger (below) — created here, not M1a, because it reads source_documents.primary_domain (M3). | none | BI-21 | M1a, M3 |
| M4a | id131_cite_enum_values (must precede M4b — separate txn) | ALTER TYPE cited_target_kind ADD VALUE 'reference_item'; … ADD VALUE 'source_document'; … ADD VALUE 'concept'. PG15: a newly added enum value cannot be used in the same transaction it is added (“unsafe use of new value of enum type”), so these are committed in their own migration before any DDL/DML that references the new labels. | none (enum) | BI-23 | none |
| M4b | id131_cite_ext_winrate_fix | ADD citations.cited_reference_item_id uuid REFERENCES reference_items(id) ON DELETE CASCADE, cited_source_document_id uuid REFERENCES source_documents(id) ON DELETE CASCADE, cited_concept_path text. Rewrite citations_cited_one_of_chk exactly-one across all 5 kinds. Rewrite the 3 win-rate fns (below). Atomic at PR/deploy granularity (data-safe: citations=0) — correction: BI-24’s “one atomic migration” is reworded to atomic-at-deploy because M4a’s enum-add cannot share M4b’s txn. | regen api.citations (new cols), api.get_content_win_rate (sig change → drop old wrapper first), api.get_aggregate_win_rate_stats, api.hybrid_search | BI-23, BI-24, BI-25, BI-26 | M4a, M1a (facet.domain) |
| M5 | id131_search_dedup_freshness_rpc | Rewrite the full REWRITE set (see §Function disposition): hybrid_search → polymorphic UNION (Slice B); find_related_items + filter_by_keywords DROPPED ENTIRELY ({131.20} §9 §7.4/§7.5 — supersedes the earlier drop-6-IMS-cols / re-point framing); find_duplicate_pairs/find_exact_duplicates → q_a_pairs; recalculate_all_freshness/get_freshness_breakdown/get_review_breakdown_stats over the facet; check_content_exists, search_for_form_response, search_content_chunks, find_similar_content, merge_item_metadata, the entity-family fns; DROP the no-op filter_by_keywords(text[],text) SETOF content_items stub. EMB-STORE completeness: DROP the 5 inline vector cols (content_chunks.embedding, q_a_pairs.question_embedding, reference_items.embedding, form_template_requirements.requirement_embedding, company_profiles.company_embedding) after reads move to record_embeddings. | regen every rewritten RPC wrapper (sig/return changes → drop old wrappers first); regen api.content_chunks/api.q_a_pairs/api.reference_items/api.form_template_requirements/api.company_profiles (vector col dropped) | BI-17, BI-22, BI-27, BI-28, BI-32 | M2, M3, M3-trig, M1a, M1b, M4b |
| M6 | id131_drops (LAST DDL before the regen) | Pre-drop the affected api objects (per §api-schema class): DROP VIEW IF EXISTS api.content_items, api.content_item_workspaces, api.content_history, api.read_marks, api.citations, api.feed_articles (the last two because M6 ALTERs their columns — explicit-col security_invoker views hard-depend); DROP FUNCTION IF EXISTS api.filter_by_keywords(keyword_list text[], match_mode text) (the dead overload, by exact signature — the name stays in SURFACE_RPCS for the surviving (search_terms text[]) variant, so the regen won’t drop this overload) + DROP FUNCTION IF EXISTS api.<each other dropped fn>(<sig>). Then: DROP VIEW quality_issues_pending; DROP TABLE content_item_workspaces, content_templates, read_marks, content_history; ALTER feed_articles DROP COLUMN content_item_id (+FK+index); DROP citations.cited_content_item_id (+FK) and drop content_item from the exactly-one CHECK (the dead enum label stays — PG can’t cheaply drop enum values; note it); DROP the 5 dead trigger functions (§FK & trigger disposition); DROP TABLE content_items. (If §FK disposition drops verification_history/ingestion_quality_log, pre-drop their api views here too.) | api.content_items + 3 removed-table views + api.citations/api.feed_articles dropped here; the dead filter_by_keywords overload + removed-fn wrappers dropped here; api.feed_articles/api.citations rebuilt by the final regen | BI-9, BI-12, BI-13, BI-34 | every consumer re-pointed |
| M-API | id131_api_views_regen (LAST — generated) | bun scripts/generate-api-views.ts (with edited SURFACE_TABLES/SURFACE_RPCS) → fresh whole-surface DROP/CREATE over the post-M6 catalog. Rebuilds the entire surviving api surface. | the regen itself | BI-9 (api parity) | M6 + all generator-list edits |
The 3 win-rate function rewrites (M4b — land together, BI-24/25/26)
Section titled “The 3 win-rate function rewrites (M4b — land together, BI-24/25/26)”Verified head: get_content_win_rate(p_content_item_id uuid) keys on cited_content_item_id and
get_aggregate_win_rate_stats() JOINs content_items for ci.primary_domain; both already gate the
denominator on form_outcome_types.counts_toward_win_rate=true (the id-130 final-award-only work).
hybrid_search.win_stats is the laggard (off workspaces.domain_metadata).
get_content_win_rate: argp_content_item_id uuid→p_q_a_pair_id uuid; predicatecited_kind='content_item' AND cited_content_item_id=$1→cited_kind='q_a_pair' AND cited_q_a_pair_id=$1. Return shape preserved (total_citations, winning_citations, losing_citations, pending_citations, win_rate). Callers:effectiveness/route.ts:81,procurement.ts:544. api wrapper: drop+recreate (signature change).get_aggregate_win_rate_stats: drop theJOIN content_items ci; groupscopebyrecord_lifecycle.domainforowner_kind='q_a_pair'(Finding 2 — q_a_pairs has noprimary_domain); key oncited_q_a_pair_id; keep thecounts_toward_win_ratedenominator + thestage='shortlist'pass-rate block. Caller:analytics/win-rate/route.ts:75.hybrid_search.win_stats(Slice A — in M4b, body still scans content_items elsewhere): re-point offworkspaces.domain_metadata->>'outcome'ontoform_outcome_types.counts_toward_win_rateviaform_responses→form_questions→form_templates→form_outcome_types, LOCK the denominator tocounts_toward_win_rate=true, re-anchorcited_kind='content_item'→'q_a_pair'keyed oncited_q_a_pair_id. Non-q_a_pair UNION arms (SD/CC/RI) get no win boost (q_a_pair-only signal — document it). The two hybrid_search edits are non-adjacent (Slice A M4b, Slice B M5) — sequence explicitly.
The hybrid_search polymorphic UNION (M5 — Slice B, BI-27/28)
Section titled “The hybrid_search polymorphic UNION (M5 — Slice B, BI-27/28)”Body becomes UNION ALL across four arms, each deriving the preserved 8-field value-path contract
(id, title, suggested_title, content_type, primary_domain, primary_subtopic, summary, similarity —
search.ts:191-201) plus the wider 21-col return shape; embedding reads from record_embeddings
(owner_kind, owner_id) not an inline column:
| Arm | title | suggested_title | content_type | domain/subtopic | summary |
|---|---|---|---|---|---|
source_documents | filename | new suggested_title (M3) | new content_type (M3) | new cols (M3) | new summary (M3) |
content_chunks | parent SD filename + heading_text | — (NULL) | 'content_chunk' | join parent SD | content excerpt |
q_a_pairs | question_text | — (NULL) | 'q_a_pair' | record_lifecycle.domain | answer_standard excerpt |
reference_items | title | — (NULL) | 'reference_item' | primary_domain/primary_subtopic | summary |
BI-27 explicit drift surfaced to the owner: suggested_title is a content_items-only AI field with no
typed home on SD-children — NULL for content_chunk/q_a_pair/reference_item arms, populated only
for source_document. title is a per-type derivation (filename / question / RI title), not a literal
column. Both made explicit per BI-27 (no silent drift). find_related_items return loses all six
IMS-vestige cols (platform, author_name, source_domain, thumbnail_url, priority, user_tags), not just
user_tags (BI-28).
recalculate_all_freshness rewrite (M5, BI-22 — PER-AXIS, D7)
Section titled “recalculate_all_freshness rewrite (M5, BI-22 — PER-AXIS, D7)”Head body runs four per-lifecycle_type UPDATEs over content_items. Rewrite as set-based UPDATEs over
record_lifecycle for owner_kind='source_document' only, joined to the owner for updated_at
(Finding 3 — SD gains it in M3). Per-axis (D7): q_a_pairs are NOT freshness-swept — they carry no
freshness/expiry/review-cadence columns, so the freshness UPDATE set excludes q_a_pair owners
entirely (it does NOT add them as a lifecycle_type='evergreen' set). The q_a_pair read-path boundary
stays fully inline: q_a_pairs.valid_to is the hard hot boundary (BI-20, read-path, stays inline),
with no soft cold gradient (the facet expiry_date soft gradient is a source_document-only column).
The cron’s hard-coded 'Content item' copy in TransitionItem (freshness-transitions) generalises to
the source_document owner. entity_mentions stays on its metadata->>'expiry_date' JSON path and
does not join the facet (BI-22 sub-clause); the now-dead entity→content_items reverse-bridge
(entities/[canonical_name]/metadata/route.ts:87-141, UPDATE content_items SET expiry_date,lifecycle_type … WHERE id = ANY(contentIds) at L127) re-points onto the typed record / facet without moving entities
into the facet.
record_lifecycle.domain write-time sync (M3-trig)
Section titled “record_lifecycle.domain write-time sync (M3-trig)”Trigger keeps domain = the owner’s domain: source_document owner → source_documents.primary_domain
(M3); q_a_pair owner → its source_document_id’s primary_domain when present, else 'unclassified'
(Finding 2 — q_a_pairs carries no domain, 2 of 4 origin_kinds are sourceless). Preserves the one-hop
policy join into governance_config (UNIQUE(domain), unchanged — BI-21) for the quality-score /
freshness / review crons, and supplies the win-rate aggregate scope (M4b). Created in M3-trig (not
M1a) because it dereferences source_documents.primary_domain, which does not exist until M3.
Function disposition (B3 — complete inventory, authoritative pull 28/06/2026)
Section titled “Function disposition (B3 — complete inventory, authoritative pull 28/06/2026)”pg_get_functiondef ILIKE '%content_item%' (table OR *content_item_id columns) over public returns
68 rows / 65 distinct functions (3 overload-doubles: filter_by_keywords, find_similar_content,
search_content) — the prior draft dispositioned ~11. Every one gets an explicit
disposition. api = the wrapper action in the G-API regen (regen = recreate; drop = remove from
SURFACE_RPCS + explicit DROP FUNCTION api.<fn> in M6; none = no api wrapper today). FLAGs need a
caller-survival confirm at {131.4} decomposition (the Executor runs gitnexus impact + caller grep).
REWRITE — live value-paths re-homed onto typed records / facet / record_embeddings
Section titled “REWRITE — live value-paths re-homed onto typed records / facet / record_embeddings”| Function (args) | Disposition | api |
|---|---|---|
hybrid_search(…) | Polymorphic UNION; win_stats re-anchor (M4b+M5), BI-27/28 | regen |
get_content_win_rate(p_content_item_id) | → p_q_a_pair_id / cited_q_a_pair_id (M4b), BI-26 | regen (sig) |
get_aggregate_win_rate_stats() | drop content_items JOIN; group by facet.domain; cited_q_a_pair_id (M4b) | regen |
find_related_items(p_item_id,…) | DROPPED ENTIRELY ({131.20} §9 §7.4 → ontology-grounded backlog) — supersedes the earlier re-anchor / drop-6-IMS-cols framing (M6) | drop |
filter_by_keywords(search_terms text[])→SETOF uuid | DROPPED ENTIRELY ({131.20} §9 §7.5 → future hybrid_search facet param) — supersedes the earlier re-point framing; removed from SURFACE_RPCS (M6) | drop |
find_duplicate_pairs(…) | → q_a_pairs (M5), BI-32 | regen |
find_exact_duplicates(p_content_hash,p_exclude_id) | → q_a_pairs normalised-question hash (M5), BI-32 | regen |
find_similar_content(…) ×2 overloads | → record_embeddings / q_a_pairs (caller content-dedup.ts:108), BI-32 | regen |
resolve_near_dup_confirm_unique(…) | dedup resolution → q_a_pairs (M5), BI-32 | none |
recalculate_all_freshness() | set-based over facet (M5), BI-22 | regen |
get_freshness_breakdown() | over facet (M5) | regen |
get_review_breakdown_stats() | over facet (M5) | regen |
check_content_exists(ids) | exist-check across typed records (caller lib/citations.ts:146) | regen |
search_for_form_response(…) | forms matching → q_a_pairs (primary) + reference_items (optional); source_documents = provenance-only, NOT a match source (D2/E5 — SD has no embedding/answer-grain) (caller match/route.ts:131), BI-29/30/31 | regen |
search_content_chunks(…,filter_content_item_id,…) | broader than a column rename: re-point the JOIN content_items ci (reads title/suggested_title/content_type/primary_domain/primary_subtopic/publication_status) onto source_documents (cols land M3) AND re-home the governance filters (governance_review_status, next_review_date) onto record_lifecycle (facet, M1a); filter_*/return content_item_id→source_document_id (chunks re-parented M2); vector read cc.embedding→record_embeddings (M5 drops the inline col) | regen |
merge_item_metadata(p_item_id,p_new_data) | → SD metadata (surviving callers upload/route.ts:850, vision.ts:190, extract-content.ts:143; IMS-route callers die with G-IMS-DELETE) | regen |
delete_duplicate_entity_mentions(p_canonical_name) | re-point content_item_id→source_document_id (entities stay, BI-14) | none |
get_entity_co_occurrence(…) | re-point content_item_id→source_document_id | none |
get_entity_summary(…) | re-point content_item_id→source_document_id | regen |
merge_entities(…) | re-point content_item_id→source_document_id | regen |
coerce_empty_classification_to_null() (trigger) | RELOCATE trigger to source_documents (classification cols moved M3) | none |
get_coverage_summary() | count typed records (SD+q_a_pairs); callers coverage/route.ts:33, cron/coverage-alerts:85 — FLAG | regen |
get_coverage_matrix(p_layer) | count typed records; callers coverage/route.ts:32, coverage/gaps:405 — FLAG | regen |
get_guide_content(p_guide_slug) | re-point guide content binding to typed records — FLAG (guides survive?) | none |
get_guide_coverage() | re-point guide binding; callers guides/route.ts:127, coverage/guides:195 — FLAG | regen |
get_popular_keywords(p_limit) | over SD ai_keywords (caller search/suggestions:19) | regen |
get_dashboard_attention_counts(p_user_id,p_role) | governance counts over facet (caller lib/dashboard.ts:316) — FLAG (dashboard splits) | regen |
get_document_version_chain(p_document_id) | was wrongly DROP — 2 live callers on KEPT surfaces (source-documents/[id]/versions/route.ts:34 + lib/mcp/tools/content.ts:1852) AND its body queries content_items (breaks at DROP TABLE regardless): re-anchor the version/parent_id chain onto source_documents (the chain lives there); re-home/drop the content_item_count rollup | none |
DROP — IMS-vestige / dies with table, dropped column, or deleted UI
Section titled “DROP — IMS-vestige / dies with table, dropped column, or deleted UI”| Function(s) | Why DROP | api |
|---|---|---|
Tag family (11): bulk_delete_tags, bulk_merge_tags, delete_tag, merge_tags, rename_tag, suggest_tags, find_duplicate_tags, get_all_tag_counts, get_tag_counts_filtered, get_tags_by_domain, get_user_tag_counts | content_items.user_tags dropped (BI-11); manual IMS tagging | drop |
Author family (3): get_author_analysis, get_unique_authors, get_top_authors | author_name dropped (BI-11) | drop (get_top_authors none) |
toggle_star(p_item_id,p_starred) | starred dropped (BI-11) | drop |
get_reading_patterns(p_days) | read_marks dropped (BI-10) | drop |
get_filter_counts() | browse filter panel deleted (BI-12); callers hooks/browse/* | drop |
get_item_workspaces(p_item_id) | content_item_workspaces dropped (BI-34) | drop |
get_topic_layers(p_topic_id) | caller items/[id]/layers/route.ts (IMS item route, G-IMS-DELETE) | drop |
get_topic_deep_dive(p_keyword), get_trend_analysis(…), get_content_gaps() | IMS insights analytics (caller app/api/insights/route.ts); distinct from the surviving coverage-gaps surface — FLAG | drop / drop / regen→drop |
get_audit_content_items(…), get_domain_subtopic_counts(), get_source_documents() | no live TS caller; content_items-shaped | none |
filter_by_keywords(keyword_list,match_mode)→SETOF content_items | the no-op stub (BI-28) | drop |
search_content(…) ×2 overloads | no live TS caller; superseded by hybrid_search — FLAG (confirm dead) | none |
get_grouped_activity_feed(…) | activity feed over content_item_id; IMS dashboard — FLAG | none |
Quality-flag family: get_items_with_quality_flags(), get_quality_issue_counts(), run_quality_scan(p_batch_name) | quality_issues_pending view dropped (BI-13); IMS quality surface — FLAG (confirm no facet-quality survives) | drop / drop / none |
get_content_owner_stats(), get_verification_stats(), bulk_assign_content_owner(…) | no live TS caller; governance-owner/verification analytics — FLAG (REWRITE onto facet if an owner/verification dashboard survives) | drop (get_content_owner_stats) / none / none |
update_citation_count() (SECDEF trigger) | citation_count is derived not stored (BI-20) — FLAG (REWRITE to maintain a q_a_pairs count only if a stored count is kept) | n/a (trigger) |
Trigger functions that die/relocate in M6
Section titled “Trigger functions that die/relocate in M6”5 triggers BIND to content_items (pulled via pg_trigger — their bodies don’t all contain the
literal, so the function-inventory query above misses pure-NEW/OLD triggers). A further 2
history-versioning fns BIND to content_history (not content_items — they surfaced in the inventory
only because they reference the content_item_id column); they die when M6 drops content_history:
update_updated_at_column(on content_items) — SHARED generic fn; KEEP the function, only thecontent_itemstrigger binding dies with the table.coerce_empty_classification_to_null(on content_items) — RELOCATE tosource_documents(classification cols moved there, M3) — recommended; else DROP.ensure_v1_history_at_commit(on content_items) — DROP (maintainscontent_history, dropped in M6).auto_version_content_history,content_history_auto_version(on content_history) — DROP (thecontent_historytable is dropped in M6; label correction — these bind to content_history, not content_items).enforce_archive_state_consistency— DROP (archive state is IMS).validate_layer_key(SECDEF) — DROP (content_items.layerdies;layeris NOT re-homed ontosource_documents— it is the Guides audience axis (D5), owned by a sibling Guides Task; the03-layer-vocabulary/layer_vocabularyCV is retained for Guides).
FK & trigger disposition (corrections — explicit per-column calls)
Section titled “FK & trigger disposition (corrections — explicit per-column calls)”ingestion_quality_log.content_item_id(uuid nullable, FK CASCADE, 0 rows) — re-parent tosource_document_id(rename + repoint FK→source_documents) OR drop the column if ingestion-quality logging is retired with the IMS surface. Recommended: re-parent (ingestion QA is a live pipeline value-path). Nullable + 0 rows → data-safe either way. api consequence: re-parent → regenapi.ingestion_quality_log; drop-the-whole-table →SURFACE_TABLES −= 'ingestion_quality_log'+ pre-dropapi.ingestion_quality_login M6 (§api-schema class).verification_history.content_item_id(uuid NOT NULL, FK CASCADE, 0 rows) — landmine: a bare FK-drop leaves a NOT-NULL orphan column. Verification moves with governance (BI-10) → either rename + repoint FK→source_documents (keep NOT NULL — safe at 0 rows) and add a parallelq_a_pair_idif q_a_pairs get verified, OR (recommended, simplest) re-home verification onto the facet (record_lifecycle.verified_at/verified_by, BI-20) and DROPverification_historyif the per-event audit table is not load-bearing. Decide at decomposition; must not drop the FK without resolving the NOT NULL. api consequence: re-parent → regenapi.verification_history; DROP the table →SURFACE_TABLES −= 'verification_history'+ pre-dropapi.verification_historyin M6 (§api-schema class).- The 6 CASCADE FK children that are dropped wholesale (
content_item_workspaces,read_marks,content_history) or already 0-row (classification_disputesre-parented M2,citationsextended M4b) need no separate data step — M0c + M6 cover them.
Proposed changes per invariant (BI → change map + consumer-rewrite inventory)
Section titled “Proposed changes per invariant (BI → change map + consumer-rewrite inventory)”The Checker uses this as the per-invariant compliance grid. Footprint (the UNION, not the .from
subset): the rewrite touches ~137 TS files carrying a broad content_items string (92 files / 191
.from('content_items') sites + RPC-only callers + 58 files reading content_item_id-style columns +
lib/query/fetchers.ts/query-keys.ts + lib/dedup/* + lib/diff/adapters/content-item-revision.ts +
lib/intelligence/pipeline.ts + lib/governance/publication-transitions.ts + lib/validation/schemas.ts
lib/mcp/formatters/*) + 65 public functions + their api wrappers + 61 api views (→ 59 post-edit) + the 2 generator/guard files (generate-api-views.ts[3 edits: SURFACE_TABLES/SURFACE_RPCS/OUTPUT_FILE] +check-api-view-coverage.ts[INTERNAL_ONLY_TABLES]). The§ParallelizationG-* groups are sized off this UNION.
| BI | Change | Key sites (file:line) |
|---|---|---|
| BI-1, BI-2 | No knowledge-data migration; full-replace rebuild. Migration set is schema + writer + debris-DELETE only — no INSERT … SELECT FROM content_items. M0c DELETEs disposable debris (not a copy). | (assertion verified per-migration in review) |
| BI-3, BI-4, BI-5, BI-6 | Identity seeds unchanged. | flow.py:1640, :2111/:2529/:2940, :2941, :2270/:2568; q_a_pairs.id DEFAULT gen_random_uuid() |
| BI-7 | SEED-CONTRACT freeze test (M0). | new __tests__/pipeline/seed-contract.test.ts |
| BI-8 | Anchors-only — no code change (DB-internal q_a_pair citation stays valid). | — |
| BI-9 | content_items + api.content_items do not exist (M6 + M-API). | DROP TABLE + DROP VIEW; 191 .from sites cleared |
| BI-10 | FK re-home per inventory (M2 re-parents; M6 drops). feed_articles → column DROP (c). | M2, M6; §FK & trigger disposition |
| BI-11 | ~25-30 net-new SD cols (M3, classification_model DROPPED as dead — D1); IMS cols dropped with table; answer_standard/answer_advanced dropped; thumbnail_url re-homed net-new onto reference_items (D4), not dropped. | M3; drops: platform, author_name, source_domain, starred, user_tags, priority, brief, detail, reference, file_path, source_file |
| BI-12 | Delete IMS browse/item UI (not rewrite). | components/content/*, components/item-detail/metadata-sidebar.tsx+editor-view.tsx, components/browse/filter-panel.tsx, app/item/[id]/* |
| BI-13 | Drop quality_issues_pending view + the quality-flag fn family (M6 / §Function disposition). | squash_baseline.sql:8029-8041 |
| BI-14, BI-15 | Re-parent chunks/mentions/relationships + q_a_extractions to source_document_id; pipeline writes parent day-one. | M2 + flow.py:2245/2381/2456/2271 (+:1215), extraction.py:261-264 (_ExtractionStamp, HIGH) |
| BI-16 | Retire __qa__/*.md sidecar; keep q_a_pairs.source_document_id. | promote-corpus.ts:579-648 (retire); keep DB-id read-back :406 |
| BI-17 | record_embeddings store (M1b); absorb 6 scattered vector cols (5 vector + company_profiles.company_embedding TEXT→normalise); DROP all 5 inline vector cols in M5; pgvector via migration. | M1b, M5; flow.py embedding writes → store inserts |
| BI-18-21 | record_lifecycle facet (M1a); hot/cold split; governance_config unchanged; denormalised domain sync (M3-trig). | M1a, M3-trig; G-GOV-FACET (incl. components/settings/governance-section.tsx fetchLastFreshnessCheck :102-118 → facet, E3) |
| BI-22 | OWN clock; per-axis owner sets (D7) — freshness/expiry sweep source_document-only, q_a_pairs carry no freshness cols (NOT swept); review/governance axis spans both; entity_mentions stays on metadata path; reverse-bridge re-pointed. | M1a, M5; entities/[canonical_name]/metadata/route.ts:127 |
| BI-23, BI-24 | CITE-EXT + WINRATE-FIX (M4a enum, then M4b atomic): enum + cols + CHECK + writers + readers + all 3 win-rate fns. | M4a, M4b; procurement.ts:463/:472/:544, draft-stream/route.ts:330, effectiveness/route.ts:60/:81/:103/:154, items/route.ts:368 (citation_count col), app/api/items/[id]/route.ts:668 (.eq citations reader), lib/mcp/formatters/procurements.ts:173/185, analytics/win-rate/route.ts:75 |
| BI-25, BI-26 | Single canonical outcome = form_outcome_types.counts_toward_win_rate; re-point hybrid_search.win_stats off workspaces.domain_metadata; re-anchor all 3 fns content_item→q_a_pair. | M4b (3 fn rewrites above) |
| BI-27, BI-28 | 8-field contract preserved with explicit per-type derivations; 2 search RPCs rewritten (hybrid_search, search_content_chunks), 2 DROPPED (find_related_items, filter_by_keywords) per {131.20} §9; drop the no-op filter_by_keywords stub; dedup RPCs → q_a_pairs. | M5; search.ts:116/:191-201/:280/:321, search/route.ts, search/preview/route.ts:76, use-browse-data.ts:183, app/item/[id]/page.tsx:58 |
| BI-29, BI-30, BI-31 | Forms matching off content_items → q_a_pairs (primary) + reference_items (optional); source_documents = provenance-only, NOT a match source (D2); arrays re-pointed; blank/answered fork preserved; thresholds re-calibrated vs EMB-STORE. | template-coverage.ts:519, :40/:49 thresholds, :162/:270 cosineSimilarity; source_content_ids/matched_content_ids consumers (TS alias n) |
| BI-32 | Dedup home = q_a_pairs (normalised-question hash; content_text_hash GENERATED col dies with content_items); SD gets no text hash. | M5 (find_duplicate_pairs/find_exact_duplicates/find_similar_content/resolve_near_dup_confirm_unique); admin/content-dedup/*, content.ts, supersession.ts, dedup/review-actions.ts |
| BI-33 | Remove generic create path + content_templates; Q&A-create mode REBOUND to typed q_a_pairs with 'manually_authored' origin_kind (D3/F2 — in-scope, reuse-not-rebuild); per-type greenfield authoring scaffold still deferred. | app/item/new/* (create-content-client Q&A mode REBOUND; new-item-tabs, batch/* removed), app/api/items POST (route.ts:249/:261), hooks/use-content-templates.ts, lib/content/content-templates.ts |
| BI-34 | Drop content_item_workspaces + content_templates; cross-workspace scope via scope_tag overlap. | M6; use-browse-data.ts:199 |
BI-33 deferral target (PRODUCT instruction): the per-type minimal greenfield q_a_pair authoring scaffold is OUT of ID-131 — a later AI-tooling / onboarding Task (id-71 {71.16} / ID-59 / ID-69). Named in Non-goals.
Manual q_a_pair authoring — IN-SCOPE ID-131 sub-task (D3/F2, reuse-and-repoint, NOT greenfield):
distinct from the deferred greenfield scaffold above, the manual-authoring path is delivered by
reusing the existing shipped Q&A editor (live + mature since S198 — app/item/new/create-content-client.tsx
Q&A-create mode, components/qa/qa-answer-display.tsx Tiptap inline edit, app/library/library-content.tsx
viewer): rebind its data layer from content_items → typed q_a_pairs, and add the manual create
path under a net-new 'manually_authored' origin_kind (one-line CHECK add on
q_a_pairs_origin_kind_check, M3). Provenance: source_document_id stays NULLABLE (set only if the
author cites an ingested doc; otherwise origin_kind carries the origin). Gated by the
content_items→q_a_pairs cutover. This refines G-MANUAL-REMOVE / BI-33: the Q&A-specific editor
surface is KEPT-and-rebound, only the generic content create path + content_templates are removed.
Non-goals (ID-131)
Section titled “Non-goals (ID-131)”The L-concepts producer + OKF bundle + resource: scheme + git knowledge-sync; the ontology pass + the
allowed_types/allowed_relations register; the {127.4} promotion-confidence E2E corpus; per-type
greenfield manual authoring scaffolds (the manual q_a_pair path via Q&A-editor reuse IS in-scope —
D3/F2); the reference_item Freshness/Validity facet + Contradiction edge table (Intelligence-domain
track, follows id-130 — D7); source_documents version-threading (version/parent_id stay unwritten —
same-path re-ingest overwrites in place); the layer/layer_vocabulary Guides audience-axis system
(sibling Guides Task — layer is NOT re-homed onto source_documents; D5 — ID-131 only drops
content_items.layer + validate_layer_key). ID-131 re-parents the 3 extraction
write-sites but does not touch the producer, the ontology register, or the synthetic corpus.
Full-replace / zero-data-migration mechanism
Section titled “Full-replace / zero-data-migration mechanism”L-records is rebuilt by full-replace re-ingest on a new, empty preview branch (BI-2). Deterministic
uuid5 seeds (unchanged — BI-3/4/6) reproduce the same SD/RI/extraction ids every rebuild; q_a_pairs
re-mints its opaque gen_random_uuid() master PK and the many-raw→one-master merge re-establishes by
re-pointing promoted_to_pair_id (BI-5). Two distinct operations, both consistent with BI-1: (1) the
M0c debris-wipe DELETEs disposable E2E debris on the live DB head so the schema migration (M2’s
ADD-FK) is satisfiable — nothing is copied; (2) the full-replace re-ingest rebuilds knowledge on a
fresh preview branch. The pipeline writes the new parents (source_document_id) and stores
(record_embeddings, record_lifecycle) from day one because content_items will not exist (BI-14). The
33 debris content_items rows are discarded, not migrated.
Reversibility covers client-prod, not just Platform (D11/F1). The disposability premise is not
Platform-only. Client-prod (rovrymhhffssilaftdwd) is not live / has no real users; id-45 runs a
full-replace re-ingest (cocoindex full walk) that WIPES & rebuilds it from the gated corpus, so its
current content — incl. the 396 content_type='q_a_pair' god-table rows and the 55 legacy og:image
thumbnail_urls — is pre-OKF debris, not data to migrate; it is discarded by the wipe exactly as the
33 Platform rows are. Consequently any release/E2E or promotion gate that asserts ingestion success must
filter pipeline_name='kh_canonical_pipeline' AND op_id IS NOT NULL (the only real-ingestion rows),
not bare status='completed' (dominated by cron/MCP heartbeat rows — E1).
SEED-CONTRACT freeze test (BI-7 — M0, no schema dependency, lands first)
Section titled “SEED-CONTRACT freeze test (BI-7 — M0, no schema dependency, lands first)”A Vitest test (bun run test) asserting, verbatim against flow.py: _KH_PIPELINE_DOC_NS == fbfaf1ff-1ee4-583c-9757-1674465b2ec1 and the three citeable seed-string formats sd:{rel_path},
ri:{source_url}, qa:{rel_path}:{idx}. Any change fails CI. The two internal seeds ci:{rel_path}
(dies with content_items) and chunk:{rel_path}:{position} (retrieval grain) are explicitly OUT of the
contract and never bundle-cited.
Testing and validation
Section titled “Testing and validation”bun run test (Vitest; never bun test), python3 -m pytest scripts/tests/ (pipeline), behaviour-first
per test-philosophy.md. Map to BIs:
- BI-7 → the M0 freeze test.
- BI-9 + api parity → post-M6/M-API assertions:
to_regclass('public.content_items') IS NULLANDto_regclass('api.content_items') IS NULL;bun scripts/generate-api-views.ts --checkgreen ANDbun scripts/check-api-view-coverage.tsgreen;bun lint+ typecheck pass with zerocontent_itemsreferences;rg "from\('content_items'\)" app lib components hooks= 0. - B2 debris-wipe → a migration-replay test on a fresh stack (
supabase db reset) applies M0c→M-API without FK-violation errors (the ADD-FK in M2 succeeds because the wipe ran). - BI-10/14/15 → pipeline integration (
scripts/tests/test_cocoindex_flow_write_path.py+ siblings, updated for the_ExtractionStampre-parent): a full-replace walk writescontent_chunks,entity_mentions,entity_relationships,q_a_extractionswith non-nullsource_document_idand never touches a content_item parent. Update the 4 stale fixtures that assertcontent_items_idshape (test_cocoindex_flow_context.py,test_cocoindex_flow_stage_counts.py,test_cocoindex_flow_failure_mode.py:2147-2151). - BI-17 → EMB-STORE retrieval-latency benchmark: seed
record_embeddingsto corpus scale; measure the O1 fuzzy-answer path (hybrid_searchq_a_pair arm) end-to-end < 30s vs the 3 per-table HNSW baseline; decide partial-per-owner_kind index vs single-index iterative-scan on the numbers. - BI-18-22 → facet integration:
recalculate_all_freshnesssweepssource_documentowners only; aq_a_pairfacet row carries no freshness/expiry/review-cadence columns (per-axis CHECK rejects them — D7) and is never freshness-swept; a CASCADE delete of an owner removes its facet row; areference_itemcannot get a facet row (CHECK). - BI-23-26 → CITE-EXT/WINRATE: a
q_a_paircitation round-trips writer→reader; the 3 win-rate fns return q_a_pair-anchored aggregates with thecounts_toward_win_ratedenominator;hybrid_search.win_statsno longer readsworkspaces.domain_metadata; M4a precedes M4b (enum-safety). - BI-27/28 → search contract: MCP
findreturns the same 8 fields with documented per-type derivations (NULLsuggested_titleon non-SD arms surfaced);find_related_itemsandfilter_by_keywordsare DROPPED ENTIRELY ({131.20} §9 §7.4/§7.5); theSETOF content_itemsfilter_by_keywordsoverload is gone with the rest. - BI-29-31 → forms matching:
fetchContentForMatching/search_for_form_responsematch q_a_pairs (primary) + reference_items;source_documentsis provenance-only (not a match source — D2); a blank form yields zero content rows (id-80 fork preserved); re-calibrated 0.55/0.35 thresholds hold. - BI-32 → dedup:
find_duplicate_pairs/find_exact_duplicatesover q_a_pairs; SD has nocontent_text_hash. - BI-33/34 → generic create path +
content_templates+content_item_workspacesgone;scope_tagoverlap serves cross-workspace scope.
Risks and mitigations
Section titled “Risks and mitigations”- api-layer drift (HIGH, B1 — DECISIVE). Any missed view/wrapper = silent app break (PGRST106 / missing relation) + red CI. Mitigation: the §api-schema migration class + the per-migration api-consequence column + the G-API group; the two CI guards are the acceptance gate; regen via the generator (never hand-edit api SQL).
- CITE-EXT/WINRATE split landing (HIGH, BI-24). Mitigation: M4a (enum) then M4b (atomic cols+CHECK+
writers+readers+3 fns);
citations=0 so no live citation can orphan; reviewer asserts no intermediate ships. _ExtractionStampre-parent (HIGH — 16 dependents). Mitigation: re-rungitnexus impactat Executor time;gitnexus rename(not find-and-replace); update 3 stamped subclasses + fixtures in lockstep; serial withinscripts/cocoindex_pipeline/.- Governance facet under-scope (HIGH — ~66 TS files + crons + the facet fn rewrites). Mitigation:
convert
recalculate_all_freshness+ review queue first;ast-dataflow column-reads/column-writeson each moved column to pin every site; largest serial group. - M0c debris-wipe vs M2 ADD-FK (HIGH if skipped). 27
entity_mentions+ 17entity_relationshipsrows all dangle off content_items (0 resolve to SD) → M2 hard-fails without M0c. Mitigation: M0c ordered before M2; replay test on a fresh stack. hybrid_searchtwo-slice sequencing (MEDIUM). win_stats re-anchor (M4b Slice A) precedes the UNION body (M5 Slice B). Mitigation: explicit slice labels.- EMB-STORE recall/latency parity (MEDIUM). Mitigation: partial per-owner_kind indexes + the BI-17 benchmark gate; re-calibrate forms thresholds (BI-31).
verification_historyNOT NULL FK (MEDIUM). Mitigation: resolve the NOT NULL (re-parent or drop the table) before dropping the FK — §FK & trigger disposition.- Schema parity prod↔staging.
.env.local/prod both point atzjqbrdctesqvouboziae; apply migrations foreground; regen types; watchtask-view-vendor-drift/schema-parityside workflows.
Parallelization — file-ownership groupings for {131.4} PLAN (sized off the UNION footprint)
Section titled “Parallelization — file-ownership groupings for {131.4} PLAN (sized off the UNION footprint)”Worktree isolation per group; cherry-pick (not merge) parallel branches; agents git fetch origin {branch} && git reset --hard origin/{branch} first. Sequencing: M0/G-SEED + G-SCHEMA first (parallel) → M0c
G-DEBRIS → G-PIPELINE + G-SD-COLS → G-CITE-WINRATE (M4a→M4b) → G-SEARCH + G-GOV-FACET → G-DEDUP + G-FORMS →
G-API regen → G-IMS-DELETE + G-MANUAL-REMOVE last.
- G-SEED (independent, early): the BI-7 freeze test. No schema dep.
- G-SCHEMA (parallel, distinct migration files): M1a
record_lifecycle, M1brecord_embeddings. - G-DEBRIS (M0c, before M2): the debris-wipe migration.
- G-PIPELINE (serial —
scripts/cocoindex_pipeline/+lib/q-a-pairs/): M2 re-parents in lockstep with flow.py (:2245/:2381/:2456/:2271/:1215, remove theci:/content_items content-branch writes, re-point embedding writes →record_embeddings), extend flow.py/extraction.py to persistclassification_confidence/classification_reasoning/secondary_domain/secondary_subtopicontosource_documents(D1/F4 — currently computed-but-discarded), extraction.py:261-264 (_ExtractionStamp, HIGH), promote-corpus.ts (retire sidecar) + the 4 stale Python fixtures.gitnexus impacton_ExtractionStampfirst. - G-SD-COLS: M3 (SD classification +
updated_at+publication_status) + M3-trig (domain sync). - G-CITE-WINRATE (M4a enum → M4b atomic): procurement.ts, draft-stream/route.ts, effectiveness/route.ts, items/route.ts, app/api/items/[id]/route.ts:668, lib/mcp/formatters/procurements.ts:173/185, analytics/win-rate/route.ts.
- G-SEARCH (after typed cols): M5 search RPCs + search.ts, search/route.ts, search/preview/route.ts, use-browse-data.ts, app/item/[id]/page.tsx + the EMB-STORE inline-vector-col drops.
- G-GOV-FACET (largest serial): crons (quality-score, freshness-transitions, review-cadence,
classification-quality), governance/review, review/{queue,cadence,stats,action,assignments,
publication-bulk-action}, source-documents/[id]/send-to-review, freshness/{calculate,recalculate-all},
governance.ts, review.ts, dashboard.ts (split: governance counts rewritten, IMS parts deleted), reorient.ts,
change-reports.ts, entities/[canonical_name]/metadata/route.ts, components/settings/governance-section.tsx
(repoint
fetchLastFreshnessCheck:102-118 offcontent_items.freshness_checked_at→ therecord_lifecyclefacet — E3; this UI consumer was previously omitted) + the facet fn rewrites (recalc/breakdown). - G-DEDUP: admin/content-dedup/* + content.ts/supersession.ts/dedup/review-actions.ts + the dedup/
similarity RPCs → q_a_pairs (
find_duplicate_pairs,find_exact_duplicates,find_similar_content,resolve_near_dup_confirm_unique). - G-FORMS: template-coverage.ts +
search_for_form_response+ thesource_content_ids/matched_content_idsconsumers (folds into the CITE consumer rewrite; re-calibrate thresholds). - G-API (after all base DDL/fn rewrites): edit
generate-api-views.ts(SURFACE_TABLES± /SURFACE_RPCS), editcheck-api-view-coverage.ts(INTERNAL_ONLY_TABLES−content_templates), add the targeted pre-drops to M6, re-run the generator →id131_api_views_regen.sql; the two CI guards must go green. - G-IMS-DELETE (independent, last):
components/content/*,components/item-detail/*,components/browse/filter-panel.tsx,components/dashboard/owned-content-health.tsx,app/item/[id]/*(NBcomponents/settings/governance-section.tsxis KEPT + repointed in G-GOV-FACET, not deleted — E3), the IMS-onlyapp/api/items/[id]/*routes (archive, classify, files, images, layers, metadata, owner, priority, rollback, batch*), the IMS insights/quality/tag/author API routes (app/api/insights, quality, tags, search/suggestions browse parts), and the IMS-adjacentlib/*(ai/{classify,extract-content,summarise,vision} — keep the SD-metadata writers, content/content-suggestions, dashboard, edit-intent/, entities/entity-metadata-bridge, provenance/ item-provenance, queue/handlers/batch-reclassify, topic-inference, source-documents/) — delete vs re-point decided per-file at decomposition. - G-MANUAL-REMOVE (independent, last): app/item/new/* (the Q&A-create mode of
create-content-client.tsxis REBOUND to typedq_a_pairswith'manually_authored'origin_kind — D3/F2, NOT removed; only the generic content create path goes), app/api/items POST, hooks/use-content-templates.ts, lib/content/content-templates.ts.
Sibling-only Subtask deps hold within each group; the cross-group ordering above is the Task-level sequence {131.4} encodes as sibling deps inside ID-131 (no cross-Task Subtask dep arises).
Empirical-verification note (OQ-3 — Q-EX2)
Section titled “Empirical-verification note (OQ-3 — Q-EX2)”ID-131 introduces no net-new external-library API call (RESEARCH §4; re-confirmed). The only external
surface is pgvector: the vector type + hnsw (… vector_cosine_ops) are live (3 HNSW indexes verified
28/06/2026, m=16 ef_construction=64), declared via Supabase migration — ID-131 deliberately avoids
cocoindex declare_vector_index (flow.py:3668-3673). pgvector vector / hnsw (… vector_cosine_ops):
PRESENT (verified against the live DB, project zjqbrdctesqvouboziae, 28/06/2026). The cocoindex
producer APIs are a sibling Task’s surface. No ABSENT/SIGNATURE_DRIFT/BEHAVIOUR_DRIFT found.
Quality bars (inherited by every Executor)
Section titled “Quality bars (inherited by every Executor)”Semantic design tokens only; UK English (DD/MM/YYYY, “colour”, “organisation”); auth.success +
authFailureResponse(auth); getAuthorisedClient() / sb() / tryQuery() Supabase safety; no barrel
re-exports (direct file imports); TanStack Query only (keys in lib/query/); bun run test (never bun test); behaviour-first tests (test-philosophy.md). New PL/pgSQL: SET search_path = public, extensions
REVOKE EXECUTE … FROM anon;. api discipline: never hand-editapi.*SQL — regenerate viascripts/generate-api-views.ts;public.*()helpers are unreachable from the app (ID-115 PGRST106 boundary) and surface only via theapischema. Tool-discipline:gitnexus impact({direction:'upstream'})before modifying any symbol (warn HIGH/CRITICAL —_ExtractionStampis HIGH),gitnexus detect_changes()before committing,gitnexus renamefor renames; ast-dataflowcolumn-reads/column-writesto pin every TS consumer site, grep for the Python pipeline + SQL migrations.
Amendment (S443, 2026-07-04) — M3 producer-split note superseded by the {131.22} landing
Section titled “Amendment (S443, 2026-07-04) — M3 producer-split note superseded by the {131.22} landing”The §M3 row’s producer-split description (“currently written to content_items via
ci_target.declare_row… relocating the producer onto source_documents is {131.22}”) is
now HISTORICAL: {131.22} G-PRODUCER-CLASS landed (main 2db0c46e, S443) — the pipeline
classification producer writes source_documents. A deliberate content_type DUAL-WRITE
to content_items remains (its content_type is NOT NULL with no DEFAULT) and is removed
at M6/{131.19}. Any reading of M3 asserting classification was already pipeline-written to
source_documents BEFORE {131.22} is wrong — ci_target was the write target until then.