Skip to content

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 api schema (the relation TS actually queries; public is PostgREST-unexposed) via a new §“The api-schema migration class”; B2 — a pre-M2 debris-wipe makes the schema migration executable at live head (27 entity_mentions rows + 17 entity_relationships rows all dangle off content_items, zero resolve to source_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 over pg_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.


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_OPTIONdb.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.tsDB_OPTION = { db: { schema: 'api' } } cast as {schema:'public'} (the type seam): runtime routes to api, types stay on public base-table shapes. public is UNEXPOSED to PostgREST; a public-only helper is unreachable from the app.
    • scripts/generate-api-views.tsSURFACE_TABLES (61 string literals at head incl. content_items, content_item_workspaces, content_history, read_marks; NOT content_templates; post-ID-131: 61 − 4 + 2 = 59) + SURFACE_RPCS (the .rpc() surface). Emits one whole-surface DROP VIEW IF EXISTS … ; CREATE VIEW … WITH (security_invoker=true) + INVOKER fn-wrapper migration. --check is a CI drift gate.
    • scripts/check-api-view-coverage.tsINTERNAL_ONLY_TABLES (10, incl. content_templates): every public base table must be in SURFACE_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_items view at L221-295; api.filter_by_keywords(keyword_list,match_mode) RETURNS SETOF public.content_items wrapper at L1469-1480 (the no-op stub, BI-28). 12 api views reference content_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_items DDL + 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_by self, 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-NULL verification_history.content_item_id CASCADE 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_id CASCADE, cited_q_a_pair_id CASCADE, citing_form_response_id CASCADE, created_by SET NULL) — correction: the prior draft said 3 FKs. cited_target_kind enum = {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_documents lean (no updated_at, no classification, no governance, no publication_status, no title); q_a_pairs (source_document_id nullable; question_embedding vector(1024); no primary_domain — confirmed absent); content_chunks carries content_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_extractions carries source_content_item_id uuid with 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_embedding TEXT (the EMB-STORE normalisation target).
  • Live row counts (28/06/2026 — the debris-wipe basis, B2): content_items 33, entity_mentions 27 (0 resolve to source_documents), entity_relationships 17, content_history 34; content_chunks/q_a_extractions/citations/classification_disputes/content_item_workspaces/ read_marks/ingestion_quality_log/verification_history = 0; feed_articles 2 (0 with a non-null content_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 (all hnsw (embedding vector_cosine_ops) WITH (m='16', ef_construction='64')). q_a_pairs.question_embedding has 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); seeds sd:{rel_path} (flow.py:2111/2529, URL sd:{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-sites content_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 writes None); extraction stamp content_items_id (extraction.py:261-264); QA sidecar emitCorpusSidecar (promote-corpus.ts:579-648; DB-id read-back :406).
  • gitnexus impact (upstream): _ExtractionStampHIGH risk, 16 direct dependents (flow.py, extraction.py subclasses ClassificationExtractionStamped/QAFormExtractionStamped/ EntityMentionExtractionStamped, pair_resolver.py, parity_driver.py, form_extractors/shared.py,
    • 9 scripts/tests/test_cocoindex_*). The content_items_id re-parent (extraction.py:261-264) must re-run gitnexus impact at Executor time and update all three stamped subclasses + fixtures in lockstep. fetchContentForMatchingLOW, 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).

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)”
  1. feed_articles (decision c) — already re-parented; only a DROP remains. Carries BOTH content_item_id (FK→content_items SET NULL, 0 non-null rows) AND reference_item_id (FK→reference_items SET NULL). No writer sets content_item_id (_backlink_feed_articles flow.py:2699-2721 sets reference_item_id only; TS lib/intelligence/pipeline.ts:321-338/:405-423 set neither). → M6 drops the dead column + its FK + index. No re-parent.
  2. q_a_pairs has no primary_domain (confirmed absent). The win-rate aggregate scope and the facet policy join cannot read a domain off q_a_pairs. → record_lifecycle.domain denormalisation is load-bearing (M1a column; M3-trig sync); resolved in the facet design.
  3. source_documents has no updated_at. recalculate_all_freshness keys evergreen/regulation freshness off updated_at. SD gains updated_at net-new (BI-11, M3), so the facet recalc reads owner.updated_at for both kinds — the column add must precede the recalc rewrite.
  4. PRODUCT↔TECH reconciliations (corrections, surfaced explicitly): (a) BI-10 “content_chunks has no content_item_id today” 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_id has no FK → M2 is rename + ADD FK, not a repoint; (c) citations has 4 enforced FKs, not 3.
  5. Two citation readers to add to the BI-23 consumer group: lib/mcp/formatters/procurements.ts (CitationResult.cited_content_item_id at :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 separate app/api/items/route.ts:368 reads the derived content_items.citation_count column (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):

  1. scripts/generate-api-views.ts — hardcoded SURFACE_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; thin SECURITY INVOKER SQL wrapper per RPC overload). Re-running after a table/column/fn lands re-emits a superset. The OUTPUT_FILE constant is re-timestamped per Task (ID-115 → ID-130 precedent) — ID-131 creates a NEW …_id131_api_views_regen.sql that sorts AFTER all ID-131 DDL.
  2. scripts/generate-api-views.ts --check (CI) — regenerates to a buffer, diffs vs the committed migration; non-zero exit on drift. Also asserts every view security_invoker, no api SECURITY DEFINER, per-fn REVOKE EXECUTE … FROM PUBLIC.
  3. scripts/check-api-view-coverage.ts (CI) — INTERNAL_ONLY_TABLES allow-list; every public base table must be in SURFACE_TABLES OR 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 TABLE errors on the view dependency). e.g. M6’s ALTER citations DROP COLUMN cited_content_item_id is blocked by api.citations until 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_lifecycle is 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: emitView mirrors only the BASE table’s grants into the (fail-closed) api view, so RLS alone is insufficient — an ungranted api view returns nothing to authenticated/service_role.

The concrete ID-131 edits (all in the G-API group):

  • generate-api-views.ts SURFACE_TABLES: += 'record_lifecycle', 'record_embeddings'; −= 'content_items', 'content_item_workspaces', 'content_history', 'read_marks'.
  • generate-api-views.ts SURFACE_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_keywords is REMOVED from SURFACE_RPCS outright ({131.20} §9 §7.5 DROPs the base fn entirely — there is no surviving (search_terms text[]) variant to keep, superseding the earlier “STAYS in SURFACE_RPCS” framing); its wrapper drops with the removed name in the regen, and the dead (keyword_list text[], match_mode text)→SETOF content_items overload’s wrapper is pre-dropped by exact signature in M6.
  • generate-api-views.ts OUTPUT_FILE: re-timestamp the constant to a new …_id131_api_views_regen.sql that sorts AFTER all ID-131 DDL (the ID-115→ID-130 precedent). This is the byte-diff target the --check guard compares against — the 4th concrete edit, alongside SURFACE_TABLES/SURFACE_RPCS/INTERNAL_ONLY_TABLES.
  • check-api-view-coverage.ts INTERNAL_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_history is DROPPED (§FK & trigger disposition), also SURFACE_TABLES −= 'verification_history'
    • pre-drop api.verification_history in M6; same for ingestion_quality_log if the drop-column option is taken (−= 'ingestion_quality_log' + pre-drop). The 4 named SURFACE_TABLES removals above are the UNCONDITIONAL set; these two are gated on the §FK disposition calls.

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:

  1. 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.
  2. One final …_id131_api_views_regen.sql (the LAST migration) re-runs the generator against the post-M6 catalog (with the edited SURFACE_TABLES/SURFACE_RPCS) and recreates the entire surviving surface — new api.record_lifecycle / api.record_embeddings, rebuilt api.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).
  3. Acceptance gate (CI, must be green): bun scripts/generate-api-views.ts --check AND bun scripts/check-api-view-coverage.ts AND supabase gen types regen committed with zero content_items references.

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 reset applies 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)DDLapi consequenceBIDepends on
M0id131_seed_contract_testNo DDL. Vitest freeze test asserting _KH_PIPELINE_DOC_NS + the three citeable seed formats.noneBI-7none (lands first)
M0cid131_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-14none
M1aid131_record_lifecycle_facetCREATE 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-onlyq_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-22source_documents, q_a_pairs exist
M1bid131_record_embeddings_storeCREATE 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-17none (additive)
M2id131_extract_reparentcontent_chunks: rename content_item_idsource_document_id, ADD FK→source_documents (was bare uuid). entity_mentions: rename content_item_idsource_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_idsource_document_id, repoint FK→source_documents. classification_disputes: rename content_item_idsource_document_id, repoint FK. q_a_extractions: rename source_content_item_idsource_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-15M0c, M1a/M1b, source_documents
M3id131_sd_classification_colsADD 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-11source_documents
M3-trigid131_record_lifecycle_domain_syncrecord_lifecycle.domain write-time sync trigger (below) — created here, not M1a, because it reads source_documents.primary_domain (M3).noneBI-21M1a, M3
M4aid131_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-23none
M4bid131_cite_ext_winrate_fixADD 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_searchBI-23, BI-24, BI-25, BI-26M4a, M1a (facet.domain)
M5id131_search_dedup_freshness_rpcRewrite 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-32M2, M3, M3-trig, M1a, M1b, M4b
M6id131_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 regenBI-9, BI-12, BI-13, BI-34every consumer re-pointed
M-APIid131_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 itselfBI-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: arg p_content_item_id uuidp_q_a_pair_id uuid; predicate cited_kind='content_item' AND cited_content_item_id=$1cited_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 the JOIN content_items ci; group scope by record_lifecycle.domain for owner_kind='q_a_pair' (Finding 2 — q_a_pairs has no primary_domain); key on cited_q_a_pair_id; keep the counts_toward_win_rate denominator + the stage='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 off workspaces.domain_metadata->>'outcome' onto form_outcome_types.counts_toward_win_rate via form_responses→form_questions→form_templates→form_outcome_types, LOCK the denominator to counts_toward_win_rate=true, re-anchor cited_kind='content_item''q_a_pair' keyed on cited_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:

Armtitlesuggested_titlecontent_typedomain/subtopicsummary
source_documentsfilenamenew suggested_title (M3)new content_type (M3)new cols (M3)new summary (M3)
content_chunksparent SD filename + heading_text— (NULL)'content_chunk'join parent SDcontent excerpt
q_a_pairsquestion_text— (NULL)'q_a_pair'record_lifecycle.domainanswer_standard excerpt
reference_itemstitle— (NULL)'reference_item'primary_domain/primary_subtopicsummary

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)Dispositionapi
hybrid_search(…)Polymorphic UNION; win_stats re-anchor (M4b+M5), BI-27/28regen
get_content_win_rate(p_content_item_id)p_q_a_pair_id / cited_q_a_pair_id (M4b), BI-26regen (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 uuidDROPPED 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-32regen
find_exact_duplicates(p_content_hash,p_exclude_id)→ q_a_pairs normalised-question hash (M5), BI-32regen
find_similar_content(…) ×2 overloads→ record_embeddings / q_a_pairs (caller content-dedup.ts:108), BI-32regen
resolve_near_dup_confirm_unique(…)dedup resolution → q_a_pairs (M5), BI-32none
recalculate_all_freshness()set-based over facet (M5), BI-22regen
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/31regen
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_idsource_document_id (chunks re-parented M2); vector read cc.embeddingrecord_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_idsource_document_id (entities stay, BI-14)none
get_entity_co_occurrence(…)re-point content_item_idsource_document_idnone
get_entity_summary(…)re-point content_item_idsource_document_idregen
merge_entities(…)re-point content_item_idsource_document_idregen
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 — FLAGregen
get_coverage_matrix(p_layer)count typed records; callers coverage/route.ts:32, coverage/gaps:405 — FLAGregen
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 — FLAGregen
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 rollupnone

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 DROPapi
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_countscontent_items.user_tags dropped (BI-11); manual IMS taggingdrop
Author family (3): get_author_analysis, get_unique_authors, get_top_authorsauthor_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 — FLAGdrop / drop / regen→drop
get_audit_content_items(…), get_domain_subtopic_counts(), get_source_documents()no live TS caller; content_items-shapednone
filter_by_keywords(keyword_list,match_mode)→SETOF content_itemsthe no-op stub (BI-28)drop
search_content(…) ×2 overloadsno live TS caller; superseded by hybrid_search — FLAG (confirm dead)none
get_grouped_activity_feed(…)activity feed over content_item_id; IMS dashboard — FLAGnone
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)

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 the content_items trigger binding dies with the table.
  • coerce_empty_classification_to_null (on content_items)RELOCATE to source_documents (classification cols moved there, M3) — recommended; else DROP.
  • ensure_v1_history_at_commit (on content_items)DROP (maintains content_history, dropped in M6).
  • auto_version_content_history, content_history_auto_version (on content_history)DROP (the content_history table is dropped in M6; label correction — these bind to content_history, not content_items).
  • enforce_archive_state_consistencyDROP (archive state is IMS).
  • validate_layer_key (SECDEF)DROP (content_items.layer dies; layer is NOT re-homed onto source_documents — it is the Guides audience axis (D5), owned by a sibling Guides Task; the 03-layer-vocabulary/layer_vocabulary CV 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 to source_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 → regen api.ingestion_quality_log; drop-the-whole-table → SURFACE_TABLES −= 'ingestion_quality_log' + pre-drop api.ingestion_quality_log in 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 parallel q_a_pair_id if q_a_pairs get verified, OR (recommended, simplest) re-home verification onto the facet (record_lifecycle.verified_at/verified_by, BI-20) and DROP verification_history if 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 → regen api.verification_history; DROP the table → SURFACE_TABLES −= 'verification_history' + pre-drop api.verification_history in 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_disputes re-parented M2, citations extended 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 §Parallelization G-* groups are sized off this UNION.
BIChangeKey sites (file:line)
BI-1, BI-2No 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-6Identity seeds unchanged.flow.py:1640, :2111/:2529/:2940, :2941, :2270/:2568; q_a_pairs.id DEFAULT gen_random_uuid()
BI-7SEED-CONTRACT freeze test (M0).new __tests__/pipeline/seed-contract.test.ts
BI-8Anchors-only — no code change (DB-internal q_a_pair citation stays valid).
BI-9content_items + api.content_items do not exist (M6 + M-API).DROP TABLE + DROP VIEW; 191 .from sites cleared
BI-10FK 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-12Delete 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-13Drop quality_issues_pending view + the quality-flag fn family (M6 / §Function disposition).squash_baseline.sql:8029-8041
BI-14, BI-15Re-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-16Retire __qa__/*.md sidecar; keep q_a_pairs.source_document_id.promote-corpus.ts:579-648 (retire); keep DB-id read-back :406
BI-17record_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-21record_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-22OWN 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-24CITE-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-26Single 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-288-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-31Forms 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-32Dedup 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-33Remove 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-34Drop 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_itemsq_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.

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.

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 NULL AND to_regclass('api.content_items') IS NULL; bun scripts/generate-api-views.ts --check green AND bun scripts/check-api-view-coverage.ts green; bun lint + typecheck pass with zero content_items references; 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 _ExtractionStamp re-parent): a full-replace walk writes content_chunks, entity_mentions, entity_relationships, q_a_extractions with non-null source_document_id and never touches a content_item parent. Update the 4 stale fixtures that assert content_items_id shape (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_embeddings to corpus scale; measure the O1 fuzzy-answer path (hybrid_search q_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_freshness sweeps source_document owners only; a q_a_pair facet 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; a reference_item cannot get a facet row (CHECK).
  • BI-23-26 → CITE-EXT/WINRATE: a q_a_pair citation round-trips writer→reader; the 3 win-rate fns return q_a_pair-anchored aggregates with the counts_toward_win_rate denominator; hybrid_search.win_stats no longer reads workspaces.domain_metadata; M4a precedes M4b (enum-safety).
  • BI-27/28 → search contract: MCP find returns the same 8 fields with documented per-type derivations (NULL suggested_title on non-SD arms surfaced); find_related_items and filter_by_keywords are DROPPED ENTIRELY ({131.20} §9 §7.4/§7.5); the SETOF content_items filter_by_keywords overload is gone with the rest.
  • BI-29-31 → forms matching: fetchContentForMatching/search_for_form_response match q_a_pairs (primary) + reference_items; source_documents is 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_duplicates over q_a_pairs; SD has no content_text_hash.
  • BI-33/34 → generic create path + content_templates + content_item_workspaces gone; scope_tag overlap serves cross-workspace scope.
  1. 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).
  2. 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.
  3. _ExtractionStamp re-parent (HIGH — 16 dependents). Mitigation: re-run gitnexus impact at Executor time; gitnexus rename (not find-and-replace); update 3 stamped subclasses + fixtures in lockstep; serial within scripts/cocoindex_pipeline/.
  4. 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-writes on each moved column to pin every site; largest serial group.
  5. M0c debris-wipe vs M2 ADD-FK (HIGH if skipped). 27 entity_mentions + 17 entity_relationships rows 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.
  6. hybrid_search two-slice sequencing (MEDIUM). win_stats re-anchor (M4b Slice A) precedes the UNION body (M5 Slice B). Mitigation: explicit slice labels.
  7. EMB-STORE recall/latency parity (MEDIUM). Mitigation: partial per-owner_kind indexes + the BI-17 benchmark gate; re-calibrate forms thresholds (BI-31).
  8. verification_history NOT NULL FK (MEDIUM). Mitigation: resolve the NOT NULL (re-parent or drop the table) before dropping the FK — §FK & trigger disposition.
  9. Schema parity prod↔staging. .env.local/prod both point at zjqbrdctesqvouboziae; apply migrations foreground; regen types; watch task-view-vendor-drift / schema-parity side 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, M1b record_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 the ci:/content_items content-branch writes, re-point embedding writes → record_embeddings), extend flow.py/extraction.py to persist classification_confidence/classification_reasoning/secondary_domain/secondary_subtopic onto source_documents (D1/F4 — currently computed-but-discarded), extraction.py:261-264 (_ExtractionStamp, HIGH), promote-corpus.ts (retire sidecar) + the 4 stale Python fixtures. gitnexus impact on _ExtractionStamp first.
  • 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 off content_items.freshness_checked_at → the record_lifecycle facet — 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 + the source_content_ids/matched_content_ids consumers (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), edit check-api-view-coverage.ts (INTERNAL_ONLY_TABLEScontent_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]/* (NB components/settings/governance-section.tsx is KEPT + repointed in G-GOV-FACET, not deleted — E3), the IMS-only app/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-adjacent lib/* (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.tsx is REBOUND to typed q_a_pairs with '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-edit api.* SQL — regenerate via scripts/generate-api-views.ts; public.*() helpers are unreachable from the app (ID-115 PGRST106 boundary) and surface only via the api schema. Tool-discipline: gitnexus impact({direction:'upstream'}) before modifying any symbol (warn HIGH/CRITICAL — _ExtractionStamp is HIGH), gitnexus detect_changes() before committing, gitnexus rename for renames; ast-dataflow column-reads/column-writes to 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.