Context
A deployment that ingests many tenant repositories cannot host a parser per repo — the per-repo overhead does not scale past a handful. The shape that does scale is parsers hosted in one separately-ingested repository, declared per tenant by the deployment author. That requires two things the tree does not have, both measured below.
Split with @neo-opus-vega so the halves run in parallel: this ticket is the neo-side loader; the corpus-specific Source/Parser implementations and their hosting recipe are Vega's.
The Problem
1. Per-tenant registration never happens
IngestionService.getTenantConfig({tenantId}) resolves a full per-tenant profile through a three-tier chain — KnowledgeBaseTenantConfig graph node (kb-config:<tenantId>) → kb-config.yaml bootstrap → aiConfig defaults — returning useDefaultSources, rawRepoSource, useDefaultParsers, customSources, customParsers, sourcePaths.
Nothing consumes the custom halves. applyConfigToRegistry — the only function that writes into SourceRegistry — is called exactly once:
ai/services/knowledge-base/source/_export.mjs:102 export function applyConfigToRegistry(registry, config, {defaults} = {})
ai/services/knowledge-base/source/_export.mjs:151 applyConfigToRegistry(SourceRegistry, aiConfig); // import time, GLOBAL configVerified shape-aware (applyConfigToRegistry(, ?.(, ['…']): that is the only call site in the tree. So the registry is populated once at module load from the global config, and a customParsers entry declared in a tenant's tier is resolved into an object that is returned or stored and never registered.
Dispatch is NOT the gap. IngestionService.resolveParser (:1610) reads getParserIds?.() / getParsers?.() and dispatches at :1577-1596 on file.parserId || 'raw-text', throwing KB_PARSER_NOT_REGISTERED for a declared-but-absent parser. That chain works. The gap is that per-tenant declarations never reach the registry it reads.
2. No config tier can carry a class
Registry entries are live references — {SourceClass, sourceName?} and {ParserClass, parserId?}. But two of the three resolution tiers are data tiers:
| tier |
can hold |
can hold a class? |
KnowledgeBaseTenantConfig graph node |
JSON node properties |
no |
kb-config.yaml bootstrap |
YAML scalars |
no |
aiConfig defaults |
a JS module |
yes |
And nothing resolves a specifier → class. Searched the whole KB surface (ai/services/knowledge-base/, ai/mcp/server/knowledge-base/) for await import / import( / pathToFileURL / createRequire: the only dynamic imports are better-sqlite3 (KBRecorderService:91) and TextEmbeddingService (HealthService:66). No loader exists.
So a deployment author can declare a parser in the data tiers today and it is inert by construction — not misconfigured, unreachable.
The Fix
A per-tenant registration path plus a specifier→class resolver, so a data tier can name a parser module and have it actually register.
The design question this ticket owns, and it is a containment question rather than a plumbing one: letting a data tier name a module to import() means tenant-supplied configuration can select code to execute. The resolution root must therefore be deployment-authored, not tenant-authored. Candidate shapes to weigh — this ticket picks one with rationale rather than presuming:
- resolve against an allowlisted module root the deployment pins (env/leaf), tenant strings confined below it
- resolve against a mounted path supplied by the deployment
- resolve repo-relative inside the already-ingested hosting repo's mirror
Whichever wins, a tenant string must not be able to escape the pinned root, and a failure to resolve must be loud — the raw-text fallback currently makes a missing parser look like a coverage gap rather than a broken declaration.
Acceptance Criteria
Out of Scope
- The Source/Parser implementations themselves and the hosting-repo recipe — @neo-opus-vega's half of the split.
- Chunk sizing. Parsers cut on semantic boundaries; reasonable size is the consequence. Anything still oversize is hard-cut by the existing
filterEmbeddingInputBudget / splitOversizedEmbeddingChunk. Logic built around what parsers are for is a strict decline (operator ruling; #17260 closed not planned on exactly that).
- Parser dispatch wiring — already live at
IngestionService:1577-1596. An earlier sweep reported the registry write-only; that was a false negative (optional chaining escaped the pattern).
- Source-family enumeration and coverage verification — #11735.
Avoided Traps
- Calling
applyConfigToRegistry per tenant against the shared singleton. The registry is a singleton keyed by name with idempotent overwrite semantics — a naive per-tenant call lets the last tenant win and silently reshape another tenant's ingestion.
- Treating the loader as ordinary config plumbing. A data tier naming an importable module is an execution-selection surface. ADR-0019 governs how leaves resolve; it does not make a tenant-supplied specifier safe.
- Letting an unresolvable declaration degrade to
raw-text. That is the failure mode which makes a broken deployment look like an inventory gap — the same shape as a census reporting zero instead of unknown.
Related
#11735 (inventory + coverage verification — enumerates, does not build) · ai/services/knowledge-base/source/_export.mjs:102,151 · ai/services/knowledge-base/IngestionService.mjs getTenantConfig / resolveParser · ai/services/knowledge-base/source/SourceRegistry.mjs · ADR 0019 (config-leaf resolution) · #17260 (closed not planned)
Live latest-open sweep: open queue checked 2026-08-17T10:4xZ. Nothing covers per-tenant registration or specifier resolution; #11735 is enumeration and was checked line by line.
Origin Session ID: 80b326bf-b37a-4efd-8313-1a9eae09e9c4
Retrieval Hint: query_raw_memories("customParsers resolved never registered applyConfigToRegistry specifier to class loader per tenant")
Context
A deployment that ingests many tenant repositories cannot host a parser per repo — the per-repo overhead does not scale past a handful. The shape that does scale is parsers hosted in one separately-ingested repository, declared per tenant by the deployment author. That requires two things the tree does not have, both measured below.
Split with @neo-opus-vega so the halves run in parallel: this ticket is the neo-side loader; the corpus-specific Source/Parser implementations and their hosting recipe are Vega's.
The Problem
1. Per-tenant registration never happens
IngestionService.getTenantConfig({tenantId})resolves a full per-tenant profile through a three-tier chain —KnowledgeBaseTenantConfiggraph node (kb-config:<tenantId>) →kb-config.yamlbootstrap →aiConfigdefaults — returninguseDefaultSources,rawRepoSource,useDefaultParsers,customSources,customParsers,sourcePaths.Nothing consumes the custom halves.
applyConfigToRegistry— the only function that writes intoSourceRegistry— is called exactly once:ai/services/knowledge-base/source/_export.mjs:102 export function applyConfigToRegistry(registry, config, {defaults} = {}) ai/services/knowledge-base/source/_export.mjs:151 applyConfigToRegistry(SourceRegistry, aiConfig); // import time, GLOBAL configVerified shape-aware (
applyConfigToRegistry(,?.(,['…']): that is the only call site in the tree. So the registry is populated once at module load from the global config, and acustomParsersentry declared in a tenant's tier is resolved into an object that is returned or stored and never registered.Dispatch is NOT the gap.
IngestionService.resolveParser(:1610) readsgetParserIds?.()/getParsers?.()and dispatches at:1577-1596onfile.parserId || 'raw-text', throwingKB_PARSER_NOT_REGISTEREDfor a declared-but-absent parser. That chain works. The gap is that per-tenant declarations never reach the registry it reads.2. No config tier can carry a class
Registry entries are live references —
{SourceClass, sourceName?}and{ParserClass, parserId?}. But two of the three resolution tiers are data tiers:KnowledgeBaseTenantConfiggraph nodekb-config.yamlbootstrapaiConfigdefaultsAnd nothing resolves a specifier → class. Searched the whole KB surface (
ai/services/knowledge-base/,ai/mcp/server/knowledge-base/) forawait import/import(/pathToFileURL/createRequire: the only dynamic imports arebetter-sqlite3(KBRecorderService:91) andTextEmbeddingService(HealthService:66). No loader exists.So a deployment author can declare a parser in the data tiers today and it is inert by construction — not misconfigured, unreachable.
The Fix
A per-tenant registration path plus a specifier→class resolver, so a data tier can name a parser module and have it actually register.
The design question this ticket owns, and it is a containment question rather than a plumbing one: letting a data tier name a module to
import()means tenant-supplied configuration can select code to execute. The resolution root must therefore be deployment-authored, not tenant-authored. Candidate shapes to weigh — this ticket picks one with rationale rather than presuming:Whichever wins, a tenant string must not be able to escape the pinned root, and a failure to resolve must be loud — the
raw-textfallback currently makes a missing parser look like a coverage gap rather than a broken declaration.Acceptance Criteria
kb-config.yamltier is registered and dispatched — witnessed end-to-end, not asserted from the config object.parserIdmust not see each other's, and neither may overwrite a default source.../, absolute path, bare specifier resolving outside the root) is refused with the reason named, and that refusal has a red-proofed test.raw-text.KB_PARSER_NOT_REGISTEREDalready exists for the registered-but-absent case; the unresolvable-specifier case needs its own named code.applyConfigToRegistry's import-time global call keeps working byte-identically for a zero-config deployment — no behaviour change where no tenant declares anything.Out of Scope
filterEmbeddingInputBudget/splitOversizedEmbeddingChunk. Logic built around what parsers are for is a strict decline (operator ruling; #17260 closednot plannedon exactly that).IngestionService:1577-1596. An earlier sweep reported the registry write-only; that was a false negative (optional chaining escaped the pattern).Avoided Traps
applyConfigToRegistryper tenant against the shared singleton. The registry is a singleton keyed by name with idempotent overwrite semantics — a naive per-tenant call lets the last tenant win and silently reshape another tenant's ingestion.raw-text. That is the failure mode which makes a broken deployment look like an inventory gap — the same shape as a census reporting zero instead of unknown.Related
#11735 (inventory + coverage verification — enumerates, does not build) ·
ai/services/knowledge-base/source/_export.mjs:102,151·ai/services/knowledge-base/IngestionService.mjsgetTenantConfig/resolveParser·ai/services/knowledge-base/source/SourceRegistry.mjs· ADR 0019 (config-leaf resolution) · #17260 (closednot planned)Live latest-open sweep: open queue checked 2026-08-17T10:4xZ. Nothing covers per-tenant registration or specifier resolution; #11735 is enumeration and was checked line by line.
Origin Session ID: 80b326bf-b37a-4efd-8313-1a9eae09e9c4
Retrieval Hint:
query_raw_memories("customParsers resolved never registered applyConfigToRegistry specifier to class loader per tenant")