Skip to content

Phase 0.2.9 — FK coverage audit (createdby / updatedby / id columns)

Phase 0.2.9 — FK coverage audit (created_by / updated_by / *_id columns)

Section titled “Phase 0.2.9 — FK coverage audit (created_by / updated_by / *_id columns)”

Audit date: 2026-05-06 Branch: content-items-investigation Schema source: supabase/types/database.types.ts (3973 lines, 46 tables, 1 view) + supabase/migrations/ (72 files; FK constraint inventory primarily in 20260416102457_pre_squash_reconciliation.sql and 20260503225703_migrate_auth_user_fks_to_user_profiles.sql)

  1. Enumerated every *_by and non-PK *_id column across all 46 public.* tables and 1 view by reading the Row blocks of supabase/types/database.types.ts.
  2. For each column, cross-referenced the table’s Relationships array (the auto-generated Database types only enumerate columns with declared FK constraints) and confirmed against the canonical ADD CONSTRAINT ... FOREIGN KEY ... REFERENCES lines in the pre-squash baseline migration. There are 82 declared FK constraints across the public schema; the S27 retarget migration (20260503225703_migrate_auth_user_fks_to_user_profiles.sql) flipped 35 of them from auth.users(id) to public.user_profiles(id).
  3. For columns lacking a declared FK, classified by examining (a) column type (uuid vs text/varchar/uuid[]), (b) write-site behaviour in lib/, app/, scripts/, (c) intent comments in pre-squash DDL (e.g. COMMENT ON COLUMN).
  4. Severity-tagged P0/P1/P2 against missing FKs based on whether values are user UUIDs that should JOIN to user_profiles (P0/P1), structural lineage IDs (P0), array/polymorphic shapes that cannot have a single FK (N/A), or label strings (N/A).

Total enumerated: 35 user-attribution *_by columns + 13 non-PK *_id columns + 4 array/uuid-collection columns = 52 candidate columns, of which 39 are wired correctly, 9 are missing FKs, and 4 are N/A — intentional (polymorphic, label string, or array).

The 35 user-attribution FKs retargeted by S27 (20260503225703) are all wired correctly post-retarget. The 9 missing FKs are clustered in two groups: 6 missed content_items columns (archived_by, verified_by, governance_reviewer_id, content_owner_id, parent_id, source_document_id) and 3 missed elsewhere (workspaces.created_by, workspaces.updated_by, processing_queue.created_by, digests.created_by).

Table.columnTypeFK target (declared)VerdictSeverityNotes
bid_questions.assigned_touuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_questions.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_questions.project_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
bid_questions.template_requirement_iduuidtemplate_requirements(id)WIRED-CORRECTLY
bid_questions.matched_content_idsuuid[]N/A — INTENTIONALPostgres cannot FK array elements; integrity at app layer.
bid_response_history.edited_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_response_history.response_iduuidbid_responses(id) ON DELETE CASCADEWIRED-CORRECTLY
bid_response_history.source_content_idsuuid[]N/A — INTENTIONALArray column.
bid_responses.approved_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_responses.drafted_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_responses.last_edited_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
bid_responses.question_iduuidbid_questions(id) ON DELETE CASCADEWIRED-CORRECTLY
bid_responses.source_content_idsuuid[]N/A — INTENTIONALArray column.
classification_disputes.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
classification_disputes.disputed_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
classification_disputes.resolved_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
company_profiles.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
content_chunks.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
content_chunks.parent_chunk_iduuidcontent_chunks(id) ON DELETE CASCADEWIRED-CORRECTLYSelf-FK (heading hierarchy).
content_citations.bid_response_iduuidbid_responses(id) ON DELETE CASCADEWIRED-CORRECTLY
content_citations.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
content_citations.created_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
content_history.content_item_iduuid (nullable)content_items(id) ON DELETE SET NULLWIRED-CORRECTLY
content_history.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
content_item_workspaces.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
content_item_workspaces.workspace_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
content_items.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
content_items.updated_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
content_items.source_biduuidworkspaces(id) ON DELETE SET NULLWIRED-CORRECTLYScoped to workspace.type = ‘bid’ (semantic, not enforced).
content_items.superseded_byuuidcontent_items(id) ON DELETE SET NULLWIRED-CORRECTLYSelf-FK; CHECK constraint prevents self-reference.
content_items.archived_byuuid— (idx only)MISSING-FKP0Index idx_content_items_archived_by; written as user.id from session; NOT in S27 retarget list. Audit trail orphan-prone.
content_items.verified_byuuid— (idx only)MISSING-FKP0Index idx_content_items_verified_by; written as user.id in app/api/review/action/route.ts; NOT in S27 retarget list.
content_items.governance_reviewer_iduuid— (idx only)MISSING-FKP0Index idx_content_items_governance_reviewer_id; written as user.id in lib/mcp/tools/governance.ts; NOT in S27 retarget list.
content_items.content_owner_iduuid— (idx only, partial)MISSING-FKP0Index idx_content_items_content_owner_id + composite freshness index; written via lib/auth/owner-default.ts resolver as a UUID; NOT in S27 retarget list. Backfill migration exists (20260428145733).
content_items.parent_iduuid— (no idx, no FK)MISSING-FK or DEAD-COLUMNP1Declared in DDL but not written anywhere in TS/Python codebase. Possibly legacy/unused. Open question — confirm before adding FK or marking for drop.
content_items.source_document_iduuid— (idx only)MISSING-FKP0Pre-squash COMMENT ON COLUMN reads “FK to the source_documents row…” but the constraint was never created. Index idx_content_items_source_document_id exists. Lineage tracking compromised.
content_templates.created_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
coverage_targets.created_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
coverage_targets.domain_iduuidtaxonomy_domains(id) ON DELETE CASCADEWIRED-CORRECTLY
coverage_targets.updated_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
digests.created_byuuid— (idx only)MISSING-FKP1Index idx_digests_created_by; written as user UUID; NOT in S27 retarget list. Lower severity because change reports are derivative artefacts.
digests.generated_bycharacter varying NOT NULL DEFAULT ‘claude-sonnet’N/A — INTENTIONALModel identifier label (e.g. claude-sonnet), not a user FK.
digests.item_idsuuid[]N/A — INTENTIONALArray column.
entity_mentions.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
entity_relationships.source_item_iduuidcontent_items(id) ON DELETE SET NULLWIRED-CORRECTLY
feed_articles.content_item_iduuidcontent_items(id) ON DELETE SET NULLWIRED-CORRECTLY
feed_articles.external_idtextN/A — INTENTIONALExternal RSS GUID, not a public table id.
feed_articles.feed_source_iduuidfeed_sources(id) ON DELETE CASCADEWIRED-CORRECTLY
feed_articles.prompt_version_iduuidfeed_prompts(id)WIRED-CORRECTLY
feed_articles.workspace_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
feed_flags.feed_article_iduuidfeed_articles(id) ON DELETE CASCADEWIRED-CORRECTLY
feed_flags.flagged_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
feed_flags.prompt_version_iduuidfeed_prompts(id)WIRED-CORRECTLY
feed_flags.resolved_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
feed_prompts.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
feed_prompts.workspace_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
feed_sources.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
feed_sources.workspace_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
governance_config.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
governance_config.reviewer_iduuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
governance_config.updated_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
guide_sections.guide_iduuidguides(id) ON DELETE CASCADEWIRED-CORRECTLY
guide_sections.parent_section_iduuidguide_sections(id) ON DELETE CASCADEWIRED-CORRECTLYSelf-FK.
guides.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
ingestion_quality_log.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
ingestion_quality_log.ingestion_batchtextN/A — INTENTIONALLiam’s specific suspect. Populated by run_quality_scan(p_batch_name) with a free-form batch label string. No target table exists or is intended. The column propagates through the quality_issues_pending view.
ingestion_quality_log.created_byuuid (nullable)— (no idx)MISSING-FKP2Column exists in database.types.ts but no migration declares it; need ground-truth check. Open question — was column added post-squash without an FK, or is it a stub?
ingestion_quality_log.resolved_byuuid (nullable)— (no idx)MISSING-FKP2Same caveat as created_by — appears in types but not in any migration’s REFERENCES clause. Open question.
notifications.entity_iduuid NOT NULLN/A — INTENTIONALPolymorphic; CHECK enumerates 6 entity_types pointing at different tables (content_item, digest, template_requirement, domain, source_document, entity_mention). Single FK impossible.
notifications.user_iduuid NOT NULLauth.users(id)WIRED-CORRECTLYIntentionally retained on auth.users per S27 narrative (per-user identity link).
pipeline_runs.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
pipeline_runs.workspace_iduuid (nullable)workspaces(id) ON DELETE SET NULLWIRED-CORRECTLY
pipeline_runs.items_createduuid[]N/A — INTENTIONALArray column.
processing_queue.created_byuuid— (idx only)MISSING-FKP1Index idx_processing_queue_created_by; written as user UUID for content-creation jobs. NOT in S27 retarget list.
read_marks.content_item_iduuidcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
read_marks.user_iduuidauth.users(id)WIRED-CORRECTLYIntentionally retained on auth.users per S27.
review_assignments.assigned_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
review_assignments.reviewer_iduuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
si_processing_queue.feed_source_iduuidfeed_sources(id) ON DELETE CASCADEWIRED-CORRECTLY
si_processing_queue.workspace_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
source_document_diffs.affected_content_item_iduuidcontent_items(id) ON DELETE SET NULLWIRED-CORRECTLY
source_document_diffs.created_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
source_document_diffs.new_document_iduuidsource_documents(id) ON DELETE CASCADEWIRED-CORRECTLY
source_document_diffs.old_document_iduuidsource_documents(id) ON DELETE CASCADEWIRED-CORRECTLY
source_document_diffs.reviewed_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
source_documents.archived_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
source_documents.parent_iduuidsource_documents(id) ON DELETE SET NULLWIRED-CORRECTLYSelf-FK (version chain).
source_documents.pipeline_run_iduuidpipeline_runs(id) ON DELETE SET NULLWIRED-CORRECTLY
source_documents.uploaded_byuuiduser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
source_documents.workspace_iduuidworkspaces(id) ON DELETE SET NULLWIRED-CORRECTLY
tag_morphology_drift_flags.decided_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
tag_morphology_drift_flags.affected_content_idsuuid[]N/A — INTENTIONALArray column.
taxonomy_domains.recommended_bytextN/A — INTENTIONALSource/model label (e.g. 'pipeline', 'manual'), not a user FK.
taxonomy_subtopics.domain_iduuidtaxonomy_domains(id) ON DELETE CASCADEWIRED-CORRECTLY
taxonomy_subtopics.recommended_bytextN/A — INTENTIONALSource/model label.
taxonomy_sync_state.synced_bytext NOT NULL DEFAULT ‘manual’N/A — INTENTIONALSync source label.
template_completions.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
template_completions.job_iduuid (nullable)processing_queue(id)WIRED-CORRECTLY
template_completions.template_iduuidtemplates(id) ON DELETE CASCADEWIRED-CORRECTLY
template_fields.question_iduuid (nullable)bid_questions(id) ON DELETE SET NULLWIRED-CORRECTLY
template_fields.template_iduuidtemplates(id) ON DELETE CASCADEWIRED-CORRECTLY
templates.created_byuuiduser_profiles(id) (post-S27)WIRED-CORRECTLY
templates.project_iduuidworkspaces(id) ON DELETE CASCADEWIRED-CORRECTLY
user_notification_prefs.user_iduuid PRIMARY KEYauth.users(id) ON DELETE CASCADEWIRED-CORRECTLYIntentionally retained on auth.users per S27.
user_profiles.iduuid PRIMARY KEYauth.users(id) ON DELETE CASCADEWIRED-CORRECTLYIntentionally retained on auth.users per S27 (the mirror table itself).
user_roles.granted_byuuid (nullable)user_profiles(id) (post-S27)WIRED-CORRECTLY
user_roles.user_iduuid NOT NULLauth.users(id) ON DELETE CASCADEWIRED-CORRECTLYIntentionally retained on auth.users per S27.
verification_history.content_item_iduuid NOT NULLcontent_items(id) ON DELETE CASCADEWIRED-CORRECTLY
verification_history.performed_byuuid NOT NULLuser_profiles(id) ON DELETE SET NULL (post-S27)WIRED-CORRECTLY
workspaces.created_byuuid— (idx only)MISSING-FKP1Index idx_workspaces_created_by; written as user UUID; NOT in S27 retarget list.
workspaces.updated_byuuid— (idx only)MISSING-FKP1Index idx_workspaces_updated_by; written as user UUID; NOT in S27 retarget list.

Five high-impact content_items columns plus one structural lineage column. All are written as UUIDs and intended to JOIN; orphans accumulate silently today.

  1. content_items.archived_by — should reference public.user_profiles(id) (ON DELETE SET NULL). Audit trail for archived content is not enforced — deleting a user_profiles row leaves dangling references.
  2. content_items.verified_by — should reference public.user_profiles(id) (ON DELETE SET NULL). Verification is a content-quality signal; orphans pollute “Verified by N users” UI.
  3. content_items.governance_reviewer_id — should reference public.user_profiles(id) (ON DELETE SET NULL). Governance review queues JOIN on this; broken refs silently drop items from reviewer dashboards.
  4. content_items.content_owner_id — should reference public.user_profiles(id) (ON DELETE SET NULL). The owner-notification pipeline (lib/source-documents/source-document-notifications.ts) blasts emails based on this column; orphans silently mute alerts to a content owner who no longer exists, which is closer to “wrong” than “no-op”.
  5. content_items.source_document_id — should reference public.source_documents(id) (ON DELETE SET NULL). The pre-squash COMMENT ON COLUMN explicitly says it is a FK, but the constraint was never created. Lineage tracking and re-ingestion diffing assume integrity.

Proposed fix migration name (do not implement now): 20260507XXXXXX_add_missing_content_items_fks.sql. Mirror the S27 idempotent precondition-repair pattern — null out orphan UUIDs that don’t exist in the target table before adding the constraint, otherwise the ALTER will fail on production data.

Lower severity because either the table is operational rather than user-facing, or the writer pattern means orphans are extremely unlikely.

  1. workspaces.created_by — should reference public.user_profiles(id).
  2. workspaces.updated_by — should reference public.user_profiles(id).
  3. processing_queue.created_by — should reference public.user_profiles(id). Processing queue rows are short-lived; orphans expire within retention.
  4. digests.created_by — should reference public.user_profiles(id). Change-report artefacts are derivative; a broken FK on this side affects “who triggered this digest” attribution only.
  5. content_items.parent_id — direction unclear: column declared in pre-squash DDL but not actively written or read anywhere in the TS/Python codebase. Either add an FK to content_items(id) (self-FK) or drop the column. Tracked as P1 pending the open-question resolution below.
  1. ingestion_quality_log.created_by + ingestion_quality_log.resolved_by — appear in database.types.ts but no migration declares them. Either typegen drift or an undocumented manual schema patch. Resolve provenance before adding FKs (see open questions).

auth.users vs user_profiles re-targeting status

Section titled “auth.users vs user_profiles re-targeting status”

The S27 migration (20260503225703_migrate_auth_user_fks_to_user_profiles.sql) retargets 35 application-level FKs from auth.users(id) to public.user_profiles(id). Per its own narrative, 5 user-attribution columns are intentionally retained on auth.users:

  • user_profiles.id (the mirror PK)
  • user_roles.user_id
  • user_notification_prefs.user_id
  • notifications.user_id
  • read_marks.user_id

These five are wired correctly by design.

No *_by columns currently present in the schema still point at auth.users(id) when they should point at user_profiles(id). The S27 migration was complete for the columns that had FKs to retarget. The 9 P0/P1 missing FKs identified above were never wired in the first place — they are the columns that S27 couldn’t retarget because no FK existed.

If re-ingest writes new historical user-attribution rows, the P0 fixes should land first. Specifically:

  • All 5 P0 content_items FKs must land before re-ingest. The pipeline writes content_owner_id, created_by, updated_by, and (post-classification) governance_reviewer_id — adding these constraints first means any orphan UUID written by a buggy or stale pipeline is rejected immediately, not silently accepted as an orphan that we discover months later.
  • The two ingestion_quality_log P2 columns should be ground-truthed before re-ingest because the pipeline writes to that table.

P1 (workspaces, processing_queue, digests) can land after re-ingest — these aren’t on the ingest write path, and they don’t accumulate row-level drift from a single ingest pass.

  1. content_items.parent_id — keep, drop, or wire? The column is declared in the pre-squash baseline but is not written or read in any TS or Python code. There is no idx_content_items_parent_id index. It may be vestigial (carried over from an early dual-purpose content_items/source_documents design before they split), or it may be reserved for a future hierarchical content feature. Confidence ≈ 60% it’s vestigial. Recommend a one-line SELECT count(*) FROM content_items WHERE parent_id IS NOT NULL; before deciding.

  2. ingestion_quality_log.created_by and resolved_by provenance. These columns appear in database.types.ts (regenerated from the live DB) but no migration in supabase/migrations/ declares them. The pre-squash baseline DDL (20260416102457_pre_squash_reconciliation.sql:3823-3831) shows the table as 8 columns: id, content_item_id, flag_type, details, resolved, created_at, severity, ingestion_batch. The current types file shows 12 columns including created_by, resolution_notes, resolved_at, resolved_by. Confidence < 80% on origin — likely an undocumented manual ALTER TABLE or a missing migration. Need to either find the origin or treat the production schema as canonical and write a reconciling migration.

  3. source_document_id comment-vs-implementation drift detection. The pre-squash COMMENT ON COLUMN public.content_items.source_document_id IS 'FK to the source_documents row…'; is the only place the FK is documented. Are there other columns where comments describe FKs that were never created? Recommend a sweep: pg_attribute.attname || ' ' || obj_description joined against pg_constraint to surface comment-vs-FK gaps. Out of scope for 0.2.9 itself but a good follow-up audit.

  4. S27 narrative says “30 user-referencing columns across 24 tables retargeted from auth.users(id) to user_profiles(id)” — but the migration body retargets 35 constraints across 24 tables. The brief said 30; the brief was wrong. Confirmed by counting the ALTER TABLE ... ADD CONSTRAINT blocks in the S27 migration. Note for any future docs that quote the count.