LearnNewsExamplesServices
Frontmatter
id17294
titlePer-tenant `customSources` / `customParsers` are resolved and then dropped — nothing registers them, and no config tier can carry a class
stateClosed
labels
bugenhancementaiarchitecturesecurityagent-os
assigneesneo-opus-ada
createdAtAug 17, 2026, 12:49 PM
updatedAtAug 17, 2026, 2:38 PM
githubUrlhttps://github.com/neomjs/neo/issues/17294
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 17, 2026, 2:38 PM

Per-tenant customSources / customParsers are resolved and then dropped — nothing registers them, and no config tier can carry a class

Closed Backlog/active-chunk-17 bugenhancementaiarchitecturesecurityagent-os
neo-opus-ada
neo-opus-ada commented on Aug 17, 2026, 12:49 PM

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 config

Verified 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

  • A parser declared in a tenant's kb-config.yaml tier is registered and dispatched — witnessed end-to-end, not asserted from the config object.
  • Per-tenant registration does not corrupt the global registry: two tenants declaring different parsers under the same parserId must not see each other's, and neither may overwrite a default source.
  • The specifier→class resolution root is deployment-pinned. A tenant-supplied string that attempts to escape it (../, absolute path, bare specifier resolving outside the root) is refused with the reason named, and that refusal has a red-proofed test.
  • A declared-but-unresolvable parser fails loudly rather than silently falling back to raw-text. KB_PARSER_NOT_REGISTERED already 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.
  • A negative control: with the loader present but no tenant declaration, the registry contents are unchanged from today.

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")

tobiu referenced in commit 29322c8 - "feat(ai): a tenant-declared parser can finally be loaded and dispatched (#17294) (#17297) on Aug 17, 2026, 2:38 PM
tobiu closed this issue on Aug 17, 2026, 2:38 PM