Skip to content

S529 A1 — The ingest model, and what document classification is FOR under it

S529 A1 — The ingest model, and what classification is FOR under it

Section titled “S529 A1 — The ingest model, and what classification is FOR under it”

Lane A1, task id-417 D1. Read-only on code. HEAD 385bcdf1c, Platform staging rbwqewalexrzgxtvcqrh.

The question, verbatim: Under the connector-first ingest model that the platform now has or intends, what requirement does document classification serve — and is that requirement still live?

This lane does not return a taxonomy verdict. It returns the ingest model and what that model implies, so a verdict becomes derivable.

Headline. The connector-first model does not expand classification’s remit — it fences it. Two of the four ratified retention classes are zero-byte and never produce classified content, so “more inlets” does not mean “more classification”. And the one requirement for primary_domain/primary_subtopic that traces to a ratified source is not retrieval filtering (S528 lane 1’s territory) and not guides (lane 1’s surviving anchor) — it is OKF topic-concept identity minting, a consumer lane 1 never opened. That requirement is live, but it is a fallback grain behind scope_tag, which changes what follows from it.


Authority: initiatives/core-product/knowledge-base-foundations/okf-platform/corpus-reframe-review.html (R1–R8, all eight read in full), reference/platform-direction.md, DR-025, DR-124.

The reframe’s core move is that connectors are not a fork:

“Under the reframe it isn’t a fork at all; it’s the same sieve with more inlets. What changes now is cheap and structural: the source-binding register gets designed connector-first (every binding records its origin type — upload, watched folder, Drive/SharePoint, transcript feed, URL — plus retention class and cadence), so each cocoindex native connector later slots into an existing row shape instead of forcing a redesign.”

R8 states the same as a ruling: “The source-binding register is designed connector-first in ID-138 (origin type, locator, retention class, cadence, auth as first-class columns) so Drive/SharePoint/transcripts/discovery-agent inlets slot in without redesign.”

The §2 flow diagram names the evidence-streams box: “uploads · watched folders · SharePoint / Drive · transcripts · websites · HRIS — each binding: keep / ingest-once / live”.

1.2 R2 — the two gates, and which one classification sits behind

Section titled “1.2 R2 — the two gates, and which one classification sits behind”

“The front door becomes light: connecting/admitting a source is an admin action that assigns its retention class (DR-020 restated as this binding gate). The authoritative gate is knowledge admission: promotion + dedup review + confidence gate for records; ontology linter + publish gate for concepts. Authority is earned at sign-off, not inherited from a folder.

This matters for §3: classification is produced by the sieve, between the two gates. It is neither a binding-gate input nor a signed-off artefact. Nothing in R1–R8 gives a classifier output any authority.

1.3 The inlet classes and the retention/cadence shape each carries

Section titled “1.3 The inlet classes and the retention/cadence shape each carries”

reference/platform-direction.md:42 names the four classes; the CHECK constraint is the in-DB restatement (§2.1). The reframe adds the fourth explicitly:

“there’s a fourth pattern beyond the three retention classes: external-referenced — knowledge the platform points at but never ingests. The binding register should be able to record those too.”

Inlet class (ratified)Retention classBytes ingested?CadenceClassified?
Gated uploadkeep_and_watch or ingest_onceyesre-walk / noneyes
Watched folder (localfs)keep_and_watchyesre-walkyes
Drive / SharePoint / HRIS connectorlive_connectedcache-onlyrefresh cadencecache-grain only
Transcript feed, completed forms, id-45 onboardingingest_onceonce, then archivednone — never re-walkedyes, once
URL / website(see DR-124)reference itemnoneno register row
Connected CRM (e.g. HubSpot)external_referencednevern/anever

R4 pins the first concrete assignment: the first-client curated Q&A documents are ingest_once“Extract → dedupe → cited dataset → client sign-off → canonical rows. The moment a pair is edited in the platform, the document is historical; we never watch it for changes.”

DR-124 (2026-08-03, accepted) supplies the discriminator that makes the table coherent — and it is a lifecycle test, not an identity test:

Source documents are walked; reference items are not. This is visible in the schema itself: the keep_and_watch retention class means re-walked, and that clock is a source_documents concern. A reference item has no walk cadence to carry.”

1.4 The consequence the reframe does not spell out, but its own classes force

Section titled “1.4 The consequence the reframe does not spell out, but its own classes force”

Two of the four retention classes are zero-byte. live_connected and external_referenced bindings store “only locator + auth” (migration 20260703160050_id138_sd_source_binding_cols.sql, auth column COMMENT). The app states it at the route boundary — app/api/ingest/folder-drop/route.ts:56-62:

live_connected / external_referenced are zero-byte connector bindings (driven by source_documents.locator/auth/cadence, DR-025) with no meaningful ‘uploaded object’ and are never offered here.”

So the growth story in R8 — more inlets — is not a growth story for document classification. As connectors land, the fraction of bindings that are consumed-in-place rises. Classification applies only to the walked/ingested classes. Challenging the framing in my own brief: “connector-first” reads like an expansion; measured against the retention classes it is a partition, and classification lives on one side of it.


2.1 The register’s columns exist; its writers do not

Section titled “2.1 The register’s columns exist; its writers do not”

source_documents carries the full connector-first column set. Measured (information_schema.columns, staging):

ColumnTypeNullableDefault
origin_typetextYES
locatortextYES
retention_classtextYES
cadencetextYES
authjsonbYES
admission_statustextNO'admitted'
primary_domainvarcharNO'unclassified'
primary_subtopicvarcharNO'unclassified'

Constraint probe (pg_constraint on source_documents, filtered to domain/subtopic/content_type/retention/origin) returned exactly one row:

source_documents_retention_class_check
CHECK (retention_class = ANY (ARRAY['keep_and_watch','ingest_once','live_connected','external_referenced']))

primary_domain and primary_subtopic have no CHECK and no FK. The retention vocabulary is enforced in the database; the subject vocabulary is not.

2.2 Who writes the binding columns — the measured answer

Section titled “2.2 Who writes the binding columns — the measured answer”

The admission writer is the SECURITY DEFINER RPC resolve_or_mint_source_identity (body retrieved via pg_get_functiondef). Its INSERT column list is:

(id, filename, original_filename, mime_type, file_size, content_hash,
storage_path, logical_path, origin_type, retention_class, op_id)

locator, cadence and auth are absent from the admission writer entirely. A repo-wide search for any writer of those three (p_cadence|p_locator|p_auth|cadence =|set … cadence, excluding review_cadence_days, which is the unrelated freshness clock on record_lifecycle) returned zero write sites — only the four unrelated hits in cadence-renewal.test.ts and lib/queue/cooperative-cancel.ts.

Corroborating projection (staging, 85 source_documents rows): locator_set = 0, cadence_set = 0, auth_set = 0. Stated as corroboration of the writer reading, not as liveness evidence — per the S528 owner ruling, population proves nothing in either direction. The writer absence is the argument; the counts merely agree with it.

AMENDED S529-A1 follow-up. Two writers were added to the tables below after §7 closed the app-side-classifier gap. The binding-column conclusions are unchanged — neither new writer touches origin_type, retention_class, cadence, locator or auth. What changed is the primary_domain/primary_subtopic writer set, now tabled separately.

Binding columns — unchanged by §7:

Writerorigin_typeretention_classAnchor
TS upload leghardcoded 'upload'caller’s choice, default keep_and_watchlib/upload/folder-drop.ts:410-411
Pipeline ingest_once one-shot (P4)threaded paramhardcoded "ingest_once"scripts/cocoindex_pipeline/flow.py:3504-3512
Pipeline main walk (ingest_file content branch)not passed → NULLnot passed → NULLscripts/cocoindex_pipeline/flow.py:2090-2097
reference_ingest RPC (URL inlet)not in INSERT → NULLnot in INSERT → NULLpg_get_functiondef, §7.4

source_documents.primary_domain / primary_subtopic writers — the set §6 flagged as possibly incomplete. It was:

WriterPostureAnchor
Python _upsert_source_documentwrites classifier value, floors nullish → 'unclassified'flow.py:3150-3151
TS classifyContent (added §7)writes coerced value, floors nullish → 'unclassified'lib/ai/classify.ts:1717-1731, :1803-1805
reference_ingest RPC (added §7)omits both columns → DB DEFAULT 'unclassified'§7.4

The third row is the finding. The primary walk — the watched-folder inlet, which is v1’s stated primary binding (platform-direction.md:42) — calls _resolve_source_identity with content_hash, rel_path, filename, mime_type, file_size, op_id and nothing else; both binding params default to None (flow.py:2888-2890). The row it mints carries no origin type and no retention class.

Lifecycle columns are deliberately excluded from the re-walk UPDATE set (flow.py:3136-3139: “admission_status / retention_class / origin_type are DELIBERATELY absent from the update set — written on admission, PRESERVED on a re-walk conflict”), so a NULL minted by the walk is never repaired by a later walk. It is NULL for the row’s life unless something else writes it. Nothing else does.

The source-binding register records origin type and retention class only for the two inlets that were hand-wired to pass them; it records cadence, locator and auth for nothing. R8’s “first-class columns” exist as schema. As behaviour, the register today is:

  • origin type — one live value ('upload'), one live writer.
  • retention class — two live values, DB-enforced, two live writers; the primary watched-folder walk supplies neither.
  • cadence / locator / auth — design-only. Zero writers. The live_connected and external_referenced classes are representable and not reachable: they pass the CHECK but no code path assigns them, and the columns that would drive them are unwritten.

So the platform intends the connector-first model (ratified R8, columns shipped, CHECK shipped) and has roughly the upload half of it. The connector half is a typed placeholder. That is the honest state, and it is exactly what R8 promised — a row shape connectors slot into later — so this is scope not yet built, not drift. I flag it as a gap in capability, not a defect in execution.


3. What classification is FOR under that model — the core answer

Section titled “3. What classification is FOR under that model — the core answer”

I looked for a requirement whose source is ratified and current, not a consumer count.

  • Not a binding-gate input. The binding gate assigns retention class (R2); origin_type drives that default (migration column COMMENT). Classification is produced downstream by the sieve and feeds nothing at the gate.
  • Not knowledge-admission authority. R2 puts authority at promotion/sign-off and, for concepts, at the ontology linter + publish gate. The classifier’s own posture agrees: in scripts/cocoindex_pipeline/extraction.py, content_type hard-rejects out-of-taxonomy values (field_validator raises, :523-534) while primary_domain/primary_subtopic are soft-warn only (model_validator _surface_out_of_taxonomy_classification, :536-576) — the row is written regardless. Combined with §2.1’s absent CHECK/FK, the subject axis is unvalidated free text at every boundary it crosses.
  • Not retrieval filtering, on current evidence. S528 lane 1 §1.2–§1.4 measured the retrieval facets structurally over-excluding. I did not re-derive that and do not rely on it.

3.2 What it IS for — the one requirement with a ratified source

Section titled “3.2 What it IS for — the one requirement with a ratified source”

primary_domain + primary_subtopic mint the identity of topic concepts in the OKF bundle.

scripts/cocoindex_pipeline/sources/l_records.py:926:

rel_path=f"topics/{_slugify(domain)}--{_slugify(subtopic)}.md",

rel_path is not a label. l_records.py:191-194: “Concept identity — the bundle rel_path (BI-2) — the cocoindex memo key. A concept has no DB row and no uuid of its own; renaming this path changes the concept’s identity.”

The enumeration (_SQL_TOPIC_DOMAIN_SUBTOPICS, l_records.py:380-388) selects DISTINCT sd.primary_domain, sd.primary_subtopic over published q_a_pairs joined to their parent source_documents; the companion _SQL_TOPIC_DOMAIN_SUBTOPIC_VERSION (l_records.py:622-643) computes the memo delta-fingerprint on the same grain. The ConceptKey is built at l_records.py:922-934.

The requirement’s source is ratified, and it is the concept bundle, not the app:

  • R6“Base system CVs ship with the product; client-configured CV overlays live in the client’s bundle repo, versioned and linted alongside the concepts they govern.” The bundle needs a governed subject vocabulary.
  • The “Guides are concepts” passage“Guides are concepts. Product/service guides are curated concept-clusters, and the existing ‘coverage’ idea … is the enrichment/discovery loop wearing product clothes.”
  • id-132 defines the topic concept type; l_records.py:42 documents the grain: “distinct q_a_pairs.scope_tag values, PLUS distinct (primary_domain, primary_subtopic) pairs for scope-tag-less pairs”.

This requirement is live. It sits on the ratified path from R6 → id-132 → first bundle publication, which the reframe’s §7 step 3 names as the next thing to land.

3.3 The qualification that changes everything downstream

Section titled “3.3 The qualification that changes everything downstream”

Classification is the fallback grain, not the primary one. l_records.py:212-218, verbatim:

domain — “topic locator (fallback grouping): the parent source_document’s primary_domain, paired with subtopic, for scope-tag-less q_a_pairs clusters.

The primary grain is scope_tag (l_records.py:904: rel_path=f"topics/{_slugify(tag)}.md"), and scope_tag is what the ratified glossary names as the q_a_pair relevance mechanism — reference/entity-glossary.md:17: “Relevance is computed at query time via scope_tag overlap, not a scoping FK.”

So the requirement classification actually serves is narrower than “the bundle needs topic concepts”. It is: “cluster the published q_a_pairs that carry no scope_tag.” That is a degradation path for records missing their primary grouping key — real, live, and load- bearing for bundle identity, but conditional on scope-tag-less pairs existing at all.

3.4 A structural defect on that path, measured

Section titled “3.4 A structural defect on that path, measured”

The enumeration guards with AND sd.primary_domain IS NOT NULL AND sd.primary_subtopic IS NOT NULL (l_records.py:386, and again at :640). Both columns are NOT NULL DEFAULT ‘unclassified’ (§2.1), and the writer floors them: flow.py:3150-3151 passes primary_domain or "unclassified". The guard is inert — it can never exclude a row.

I replayed the exact enumeration SQL against staging. It returns 4 topic-concept identities, one of which is:

domain = "compliance", subtopic = "unclassified" → topics/compliance--unclassified.md

(the other three are BI16id144-… test fixtures). A sentinel value mints a real, permanent concept identity in the bundle. This is structural, not population-dependent: the guard cannot fire at any corpus size, and BI-2 makes the minted path the concept’s identity — so it cannot later be renamed without minting a different concept.

Under R2 this is the concern: unvalidated sieve output crossing into the concept layer without passing the knowledge-admission gate. Under R6 the ontology linter is the mechanism that should be catching it. I am not ruling on the fix — that is id-133/id-132 territory — but this is a finding that belongs to whoever owns first bundle publication, and it was not in S528’s census.


4. What this implies for primary_domain / primary_subtopic

Section titled “4. What this implies for primary_domain / primary_subtopic”

Implications only. This lane returns no retire/keep verdict.

  1. The connector-first model does not create new demand for classification. Its growth direction (§1.4) is toward zero-byte, consumed-in-place bindings that are never classified. Any argument of the shape “connectors are coming, so we’ll need the taxonomy more” is unsupported by the ratified model — the classes point the other way. This is the clearest thing my lane can hand the verdict.

  2. There is a live requirement anchor S528 lane 1 did not find. I checked: lane 1 contains no reference to l_records, ConceptKey, the concept producer, or topic concepts (grep over all 506 lines for l_records|topic concept|ConceptKey|topics/|concept returns only two incidental prose hits, :64 and :379). Its surviving anchor is guides. Any verdict resting on lane 1’s coverage as complete would be resting on an incomplete consumer map. I flag this as a coverage correction, not as consumer-counting: one anchor with a ratified source outweighs fifteen without, and the point of naming it is that its requirement is ratified, not that it adds to a tally.

  3. The anchor is weaker than it first looks, in a specific way. It is a fallback for scope-tag-less pairs (§3.3). Its strength is therefore a function of whether the ratified model intends every promoted q_a_pair to carry a scope_tag. If yes, the fallback is transitional. If no, it is permanent. I could not settle this — see §5.

  4. Whatever survives is needed as a vocabulary, and separately as a binding. The bundle path needs a governed subject vocabulary co-located with the bundle (R6). It does not, on anything I found, need a per-source_documents free-text column with no CHECK, no FK, and a sentinel default that mints concept identities. Those are two separable questions and the evidence lands differently on each.

  5. This turns partly on lane A2’s guides question — named dependency. Lane 1’s surviving requirement anchor is guides (guides.domain_filter, guide_sections.subtopic_filter, expected_layer). The reframe says “Guides are concepts” — which, if taken at face value, means guides and my topic-concept anchor are the same requirement arriving twice, once as the current app feature and once as its ratified target form. If A2 concludes that, then the vocabulary is required and the open question is only which surface owns it. If A2 concludes guides remain a distinct app-side feature, there are two independent requirements and the vocabulary is required twice over. Either way the vocabulary survives; what differs is the binding. I cannot resolve which without A2’s finding, and I decline to guess.


Each carries its question verbatim.

U1 — “Does the ratified model intend every promoted q_a_pair to carry a scope_tag?” This is the hinge under §3.3. If every promoted pair carries one, the domain/subtopic fallback is a transitional path and its requirement expires at first bundle publication. If not, it is permanent. entity-glossary.md:17 establishes scope_tag as the relevance mechanism but is silent on whether it is mandatory at promotion; I found no ratified statement either way, and q_a_pairs.scope_tag is nullable in the schema (the enumeration’s own scope_tag IS NULL OR array_length(...) IS NULL branch exists precisely because it can be absent). Verdict: UNDECIDABLE. Owner ruling or a promotion-path spec is needed.

U2 — “Is origin_type on the main walk NULL by decision or by omission?” §2.3 measures the main walk passing neither binding param while the ingest_once path and the TS upload leg both pass them. I found no comment, DR, or spec clause stating the walk should leave them NULL, and the “preserved on re-walk” comment (flow.py:3136-3139) reads as designed for the case where they were set at admission. It looks like omission, but “looks like” is not evidence and I will not record it as a defect on that basis. Verdict: UNDECIDABLE.

U3 — “Under R6, does the ontology linter govern primary_domain/primary_subtopic, or only concept frontmatter?” R6 says CVs govern “the concepts they govern”. The linter as built (producer/validator.py:check_concept, per the id-133 mechanic doc) gates concept writes. Whether a source_documents classification value is in scope for that gate — which would close §3.4 — is not stated in R6 and I did not find it ruled anywhere. Verdict: UNDECIDABLE, and it is a prerequisite for anyone acting on §3.4.


  • The taxonomy_domains / taxonomy_subtopics tables themselves. I measured the columns on source_documents and their constraints; I did not audit the CV tables, their provenance enum, or sync:taxonomy. Lane 1 §6 covers them.
  • Guides. Deliberately out of scope — lane A2 owns it. My §4.5 dependency is stated, not investigated; I did not open guides.domain_filter, guide_sections, or the resolver.
  • The retrieval path. I did not re-derive or verify lane 1’s hybrid_search / filter_domain / filter_subtopic measurements. §3.1 cites them as lane 1’s finding and rests nothing on them.
  • secondary_domain / secondary_subtopic / ai_keywords / summary_data. Present on source_documents and left at NULL by the Path-A writer (flow.py:2176-2182 comment); I did not trace whether anything reads them.
  • The app-side (TypeScript) classifierCLOSED in §7. It does write source_documents.primary_domain/primary_subtopic; §2.3 is amended. Residual sub-gaps from that investigation are listed at §7.7 rather than here.
  • Non-source_documents homes for the classification family. form_templates, reference_items and record_lifecycle all surfaced in greps with adjacent columns; I scoped strictly to the register.
  • The live_connected / external_referenced read paths. I established nothing writes them. I did not check whether anything is built to read them (the server.py:853 pull-sync query filters retention_class = $1, which I noted but did not trace).
  • GitNexus did not contribute. bunx gitnexus impact primary_subtopic --direction upstream returned impactedCount: 0 against two extraction.py candidates — the column’s real consumers reach it through SQL string literals (l_records.py:380-388), which are not graph edges. Recorded so the null result is not mistaken for absence of consumers; it is a tool coverage limit. The consumer map in §3.2 came from direct reads, not from the graph.
  • Historic stores. I ran four mempalace_search passes (connector inlets, classification taxonomy, retention classes, the two gates). I did not consult the archive palace or the cold transcript backup.

7. The app-side classifier — closing §6’s largest gap

Section titled “7. The app-side classifier — closing §6’s largest gap”

Follow-up pass, same lane, READ-ONLY. lib/ai/skills/classification.md is under board ruling A5 — HELD; I read it and changed nothing. No file in canonical/ was modified.

SectionChange
§2.3Split into two tables. Binding-column table unchanged in substance, plus a fourth row for reference_ingest (also NULL). A new primary_domain/primary_subtopic writer table added — the set grew from 1 to 3.
§6The app-side-classifier bullet is struck and redirected to §7.7.
§4.1Unchanged. See §7.6 — nothing found here bears on the retention-class argument.

Answer to the lead’s implied risk: the gap did not invalidate §2.3’s binding-column findings, because the TS classifier writes the subject axis only. It never touches origin_type, retention_class, cadence, locator or auth. §4’s implications rest on the binding columns and stand. What the gap did hide is a second writer of the exact column pair §3.2 identified as concept-identity-minting — which makes §3.4 worse, not better.

7.1 Does it write primary_domain/primary_subtopic? Yes.

Section titled “7.1 Does it write primary_domain/primary_subtopic? Yes.”

lib/ai/classify.ts:1803-1805:

const { error: updateError } = await supabase
.from('source_documents')
.update(updateData)
.eq('id', itemId);

updateData (:1717-1731) sets primary_domain, primary_subtopic, secondary_domain, secondary_subtopic, ai_keywords, summary, suggested_title, classification_confidence, classification_reasoning, classified_at, updated_by — with the same sentinel floor the Python path uses (result.primary_domain ?? 'unclassified').

A stale comment I had to test rather than trust. app/api/cron/classification-quality/route.ts:87-99 declares a “KNOWN INTERIM GAP … classifyContent() … still reads/writes content_items internally and expects a content_items.id … every candidate will surface as action: 'error'.” That comment is stale. A grep of lib/ai/classify.ts for content_items returns 8 hits, all of them prose in comments describing the migration (:1384-1387 “re-pointed off content_items onto source_documents (M3 …)”, :1713 “G-IMS-DELETE KEEP-list: re-pointed off content_items onto …”). Every actual table reference in the file is source_documents / taxonomy_* / record_embeddings / entity_* (grep of .from('…') — 12 hits, no content_items). {131.22} landed; the route comment was never updated. Also independently settled: the content_items table was dropped (DR-034 / {127.25}), so the comment describes I/O against a table that no longer exists.

Consequence for §2.3: classifyContent is a live, functioning writer of the classification family on source_documents, not a broken one. Anyone reading that route comment would conclude the opposite.

7.2 What triggers it — and the requirement question

Section titled “7.2 What triggers it — and the requirement question”

The only non-test caller of classifyContent is app/api/cron/classification-quality/route.ts:18. (The 14 other @/lib/ai/classify importers are all __tests__/.)

That cron is not scheduled. Executed:

python3 -c "import json; d=json.load(open('vercel.json')); ..."
→ TOTAL CRONS: 8
freshness-transitions, review-cadence, content-gaps, quality-score,
intelligence-cleanup, process-queue, datapath-watch, attachment-orphan-sweep
→ classification-quality scheduled? False

The route’s own docstring says “Runs weekly (Sundays 04:00 UTC)” and app/api/cron/quality-score/route.ts:4 says “Runs weekly (Sundays 05:00 UTC — after classification-quality at 04:00)”. Both describe a schedule that is not in vercel.json. There is a maxDuration function-config entry (vercel.json:50) but no crons entry. It is reachable only by a manually-issued authenticated GET (verifyCronAuth).

Requirement-first, as directed. The requirement this surface serves, per its docstring: “Reclassifies items with low confidence, outdated classification, or never classified. Auto-updates if same domain+subtopic with improved confidence; flags for human review if domain/subtopic changed.” That is re-classification maintenance — a requirement that is strictly derivative of classification being wanted at all. It cannot be its own justification: if §3’s answer is that the subject axis serves only the topic-concept fallback, then a weekly re-classifier serves that fallback and nothing else.

I am not calling this dead. Absence from vercel.json is a measured fact about scheduling, not a ruling about intent, and the two docstrings show it was intended to run. See U4.

7.3 Does it agree with the Python classifier? No — and this is the finding.

Section titled “7.3 Does it agree with the Python classifier? No — and this is the finding.”

Three postures on the same two columns:

Pathcontent_typeprimary_domainprimary_subtopic
Python extraction.pyHARD-reject (field_validator raises, :523-534)SOFT-WARN, writes the true value (:536-576)SOFT-WARN, writes the true value
TS classify.tsnot written by this pathSILENT COERCE (validateDomain, :653-662)NO taxonomy validation at all (coerceSubtopic, :743-749)

validateDomain (lib/ai/classify.ts:653-662) never rejects and never warns:

export function validateDomain(domain: string, validDomains: string[]): string {
const slug = slugifyDomain(domain);
const exact = validDomains.find((d) => d === slug);
if (exact) return exact;
const closest = validDomains.find((d) => d.includes(slug) || slug.includes(d));
return closest ?? validDomains[0]; // Fallback to first domain
}

An out-of-taxonomy domain is silently rewritten to validDomains[0]. The list is built at :1433-1437 with .eq('is_active', true).order('display_order'). Measured against staging:

select name, display_order from taxonomy_domains order by display_order asc nulls last limit 5;
security(1), compliance(2), implementation(3), support(4), corporate(5)

So an unrecognised domain silently becomes security — the highest-stakes domain in a bid taxonomy — carrying full classification_confidence from the LLM and no warning anywhere.

The two postures are not just different, they are opposed in kind. Python’s soft-warn writes the honest out-of-taxonomy value and increments a taxonomy_miss counter, so the miss is visible and correctable. The TS path writes a fabricated in-taxonomy value that is indistinguishable from a correct classification downstream. A silent coercion is worse than a soft warning, because it destroys the evidence that anything went wrong.

And primary_subtopic gets no vocabulary check on the TS path at allcoerceSubtopic only maps empty/whitespace → null'unclassified' at the write. There is no TS analogue of Python’s _VALID_SUBTOPICS.

Why this compounds §3.4. §3.2 established that (primary_domain, primary_subtopic) mints OKF topic-concept identity (topics/{domain}--{subtopic}.md, BI-2 = identity). §3.4 showed a sentinel minting topics/compliance--unclassified.md. §7.3 adds that a coerced domain can mint topics/security--… for a document that was never about security. Both defects feed the same identity-minting path, from two different writers, with three different validation postures between them.

7.4 A fourth source_documents minter, found en route

Section titled “7.4 A fourth source_documents minter, found en route”

reference_ingest (pg_get_functiondef, staging) still mints a source_documents row (v_sd_id = uuid5(ns, 'sd:'||p_source_url)) ahead of the reference_items row, for the NOT NULL … ON DELETE RESTRICT FK. Its INSERT column list is:

(id, filename, original_filename, mime_type, file_size, content_hash,
storage_path, source_url, status, extraction_method, extraction_metadata, op_id)

Neither classification column nor any binding column is in it. Those rows take 'unclassified' / 'unclassified' by DEFAULT and NULL binding columns.

classifyText (lib/ai/classify.ts:1280) is explicitly “Pure, DB-write-free” (:1260-1279) — the URL route (app/api/ingest/url/route.ts:243, :284-285) routes its output to reference_items.primary_domain/primary_subtopic via the RPC, a different table. So the URL inlet classifies the reference item, and leaves its synthetic source_documents row at the sentinel.

This is DR-124’s synthetic row, still being minted. DR-124 was accepted 2026-08-03 (this session’s date) and rules the row should not exist. I record the RPC as unchanged as of HEAD 385bcdf1c — that is a statement about implementation lag against a same-day decision, not a claim that anything is wrong. Retiring it is id-364/DR-124 work, not mine.

Not used for this pass, and I want the reason on the record rather than a null result. The TS reads/writes here go through Supabase client builders (.from('source_documents').update(...)) and the concept-side reads go through SQL string literals — neither is a call-graph edge, which is the same limitation §6 recorded for impact primary_subtopic. Everything in §7 came from direct reads, two pg_get_functiondef calls, one taxonomy_domains query and a vercel.json parse. No absence in §7 is inferred from a tool returning nothing.

  • §4.1 (connectors don’t expand classification’s remit) — unchanged. Nothing in §7 touches the retention classes. If anything it strengthens §4.1: the URL inlet, the one non-upload inlet with a live classifier, writes its classification to reference_items, and DR-124 has ruled reference items out of the walked-source register entirely.
  • §4.4 (vocabulary vs binding are separable) — strengthened, materially. §7.3 is direct evidence that the binding is the defective half: one column pair, three writers, three incompatible validation postures, no DB constraint (§2.1) adjudicating between them. The vocabulary (taxonomy_domains, ordered, is_active-flagged) is doing its job in both paths; it is the write-side contract that does not exist.
  • §4.2 (lane 1’s consumer map is incomplete) — reinforced, with the same caveat. Lane 1 did not open classify.ts’s source_documents writer either. I repeat that I am not consumer-counting: the point is that a verdict resting on lane 1’s coverage as complete would miss both a ratified-source consumer (§3.2) and two writers (§7.1, §7.4).

U4 — “Is classification-quality’s absence from vercel.json a retirement, a regression, or a deliberate hold?” Measured: the route exists, is auth-gated, functions correctly against source_documents (§7.1), and two docstrings assert a Sunday-04:00 schedule that is not configured. Three readings fit the same evidence — it was descheduled deliberately (and the docstrings went stale, exactly as :87-99 did); it was never scheduled and the docstrings are aspirational; or it was dropped in a vercel.json edit unnoticed. I found nothing that distinguishes them — no DR, no task file I am entitled to cite as correctness evidence (DR-123), no migration. Git archaeology on vercel.json could narrow it and I did not run it — it was outside the read-only scope I was given and would not be dispositive anyway. Verdict: UNDECIDABLE.

  • canonical-taxonomy.generated.md and classification.md — I read classification.md (A5-HELD, unmodified) and confirmed the generated taxonomy file exists (5,729 bytes, 7 Jul). I did not diff either against taxonomy_domains/taxonomy_subtopics to check for prompt/DB drift. If the prompt lists domains the DB no longer has, §7.3’s coercion fires more often — I have not measured that, and it is the natural next probe.
  • buildClassificationPrompt / buildTaxonomyString — read only far enough to establish both classifier paths share them (:1450-1452 says the prompt is “byte-identical” across classifyContent and classifyText). Not verified independently.
  • The entity/relationship half of classify.ts (entity_mentions / entity_relationships upserts at :2101, :2141). Out of scope — my lane is the classification family.
  • reference_items.primary_domain consumers. I established the URL inlet writes them; I did not trace who reads them, nor whether the reference grain feeds any concept path.
  • secondary_domain / secondary_subtopic. Now known to be written by the TS path (:1723-1724) where the Python path leaves them NULL — a fourth asymmetry between the two classifiers. I did not pursue it.
  • I did not re-run the §2 or §3 measurements. §7 is additive; those stand as executed in the first pass against the same HEAD.