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.
1. The ingest model as ratified
Section titled “1. The ingest model as ratified”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.
1.1 The frame — one sieve, many inlets
Section titled “1.1 The frame — one sieve, many inlets”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 class | Bytes ingested? | Cadence | Classified? |
|---|---|---|---|---|
| Gated upload | keep_and_watch or ingest_once | yes | re-walk / none | yes |
| Watched folder (localfs) | keep_and_watch | yes | re-walk | yes |
| Drive / SharePoint / HRIS connector | live_connected | cache-only | refresh cadence | cache-grain only |
| Transcript feed, completed forms, id-45 onboarding | ingest_once | once, then archived | none — never re-walked | yes, once |
| URL / website | (see DR-124) | reference item | none | no register row |
| Connected CRM (e.g. HubSpot) | external_referenced | never | n/a | never |
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_watchretention class means re-walked, and that clock is asource_documentsconcern. 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_referencedare zero-byte connector bindings (driven bysource_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. The ingest model as built — measured
Section titled “2. The ingest model as built — measured”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):
| Column | Type | Nullable | Default |
|---|---|---|---|
origin_type | text | YES | — |
locator | text | YES | — |
retention_class | text | YES | — |
cadence | text | YES | — |
auth | jsonb | YES | — |
admission_status | text | NO | 'admitted' |
primary_domain | varchar | NO | 'unclassified' |
primary_subtopic | varchar | NO | '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.
2.3 Which inlets stamp a binding class
Section titled “2.3 Which inlets stamp a binding class”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,locatororauth. What changed is theprimary_domain/primary_subtopicwriter set, now tabled separately.
Binding columns — unchanged by §7:
| Writer | origin_type | retention_class | Anchor |
|---|---|---|---|
| TS upload leg | hardcoded 'upload' | caller’s choice, default keep_and_watch | lib/upload/folder-drop.ts:410-411 |
Pipeline ingest_once one-shot (P4) | threaded param | hardcoded "ingest_once" | scripts/cocoindex_pipeline/flow.py:3504-3512 |
Pipeline main walk (ingest_file content branch) | not passed → NULL | not passed → NULL | scripts/cocoindex_pipeline/flow.py:2090-2097 |
reference_ingest RPC (URL inlet) | not in INSERT → NULL | not in INSERT → NULL | pg_get_functiondef, §7.4 |
source_documents.primary_domain / primary_subtopic writers — the set §6 flagged as
possibly incomplete. It was:
| Writer | Posture | Anchor |
|---|---|---|
Python _upsert_source_document | writes 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.
2.4 The gap, named
Section titled “2.4 The gap, named”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_connectedandexternal_referencedclasses 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.
3.1 What it is not for
Section titled “3.1 What it is not for”- Not a binding-gate input. The binding gate assigns retention class (R2);
origin_typedrives 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_typehard-rejects out-of-taxonomy values (field_validatorraises,:523-534) whileprimary_domain/primary_subtopicare 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
topicconcept type;l_records.py:42documents the grain: “distinctq_a_pairs.scope_tagvalues, 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— “topiclocator (fallback grouping): the parent source_document’sprimary_domain, paired withsubtopic, 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.
-
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.
-
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 forl_records|topic concept|ConceptKey|topics/|conceptreturns only two incidental prose hits,:64and: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. -
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_pairto carry ascope_tag. If yes, the fallback is transitional. If no, it is permanent. I could not settle this — see §5. -
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_documentsfree-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. -
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.
5. UNDECIDABLE items
Section titled “5. UNDECIDABLE items”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.
6. Coverage gaps — what I did NOT check
Section titled “6. Coverage gaps — what I did NOT check”- The
taxonomy_domains/taxonomy_subtopicstables themselves. I measured the columns onsource_documentsand their constraints; I did not audit the CV tables, their provenance enum, orsync: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_subtopicmeasurements. §3.1 cites them as lane 1’s finding and rests nothing on them. secondary_domain/secondary_subtopic/ai_keywords/summary_data. Present onsource_documentsand left atNULLby the Path-A writer (flow.py:2176-2182comment); I did not trace whether anything reads them.The app-side (TypeScript) classifier— CLOSED in §7. It does writesource_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_documentshomes for the classification family.form_templates,reference_itemsandrecord_lifecycleall surfaced in greps with adjacent columns; I scoped strictly to the register. - The
live_connected/external_referencedread paths. I established nothing writes them. I did not check whether anything is built to read them (theserver.py:853pull-sync query filtersretention_class = $1, which I noted but did not trace). - GitNexus did not contribute.
bunx gitnexus impact primary_subtopic --direction upstreamreturnedimpactedCount: 0against twoextraction.pycandidates — 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_searchpasses (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.
7.0 What moved — amendments flagged
Section titled “7.0 What moved — amendments flagged”| Section | Change |
|---|---|
| §2.3 | Split 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. |
| §6 | The app-side-classifier bullet is struck and redirected to §7.7. |
| §4.1 | Unchanged. 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? FalseThe 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:
| Path | content_type | primary_domain | primary_subtopic |
|---|---|---|---|
Python extraction.py | HARD-reject (field_validator raises, :523-534) | SOFT-WARN, writes the true value (:536-576) | SOFT-WARN, writes the true value |
TS classify.ts | not written by this path | SILENT 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 all — coerceSubtopic
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.
7.5 GitNexus, again
Section titled “7.5 GitNexus, again”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.
7.6 What this does to §4
Section titled “7.6 What this does to §4”- §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’ssource_documentswriter 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).
7.7 UNDECIDABLE — one new item
Section titled “7.7 UNDECIDABLE — one new item”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.
7.8 Coverage gaps for this pass
Section titled “7.8 Coverage gaps for this pass”canonical-taxonomy.generated.mdandclassification.md— I readclassification.md(A5-HELD, unmodified) and confirmed the generated taxonomy file exists (5,729 bytes, 7 Jul). I did not diff either againsttaxonomy_domains/taxonomy_subtopicsto 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-1452says the prompt is “byte-identical” acrossclassifyContentandclassifyText). Not verified independently.- The entity/relationship half of
classify.ts(entity_mentions/entity_relationshipsupserts at:2101,:2141). Out of scope — my lane is the classification family. reference_items.primary_domainconsumers. 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.