Context
Found while running the loader from #17297 (merged 29322c8267) against its first real consumer. Not a defect in the containment boundary — that held against every escape constructed against it. It is the one silent path the loader's fail-loud contract does not reach, and the contract's own naming invites it.
Provenance corrected 2026-08-25. An earlier revision of this line attributed the finding to @neo-opus-vega. I could not substantiate that — PR #17297 carries no comments, no review body on it contains the finding, and I authored both that PR and this ticket. The named attribution is removed rather than re-cited; the full correction is in the comment thread.
Filed as a follow-up rather than raced against the merge, per the standing rule that a reviewer challenge landing post-merge is routine.
The Problem
resolveFileChunks probes for a parse method on the returned value:
ai/services/knowledge-base/IngestionService.mjs:1601
const parser = await this.resolveTenantParser({parserId, tenantContext}) ?? this.resolveParser(parserId);
:1603 if (file.parserId && !parser) { throw KB_PARSER_NOT_REGISTERED } // parser is TRUTHY -> skipped
:1609 if (parser?.parseIngestionFile) { ... } // undefined on an instance-method class -> skipped
:1613 if (parser?.parse) { ... } // undefined -> skipped
:1624 return [this.rawFileToParsedRecord(...)] // silent whole-file chunkA class carrying its method on prototype is truthy while both probes read undefined, so the !parser throw is skipped and the file falls through to raw-text. Measured:
| declared shape |
truthy |
typeof p.parseIngestionFile |
silent raw-text |
class F { parseIngestionFile() {} } |
true |
undefined |
yes |
class F { static parseIngestionFile() {} } |
true |
function |
no |
{ parseIngestionFile() {} } |
true |
function |
no |
The method is present the whole time — typeof F.prototype.parseIngestionFile === 'function'. Only the lookup surface differs.
The Architectural Reality
This is the exact failure class the error-code taxonomy in ai/services/knowledge-base/source/tenantParserLoader.mjs was written against, one step short of closing. Its JSDoc states the principle correctly — a declared-but-unloadable parser throws its coded reason rather than returning null, because null falls through to raw-text, which INGESTS SUCCESSFULLY. The unloadable case is closed. The loadable-but-wrong-shape case lands in the same place with no error, no missing output, and a plausible chunk count.
The contract actively points at the broken shape. The config key is named ParserClass; SourceRegistry.registerParser(ParserClass, {parserId}) stores classes; getParsers() documents "Registered Parser classes"; and resolveTenantParser's own JSDoc returns "the tenant's parser class". What actually has to hold is that the method is callable on the registered/resolved value — which a static-method constructor, an object literal, and a Neo.setupClass singleton (whose export IS an instance) all satisfy. Only a plain, non-singleton constructor with prototype-only methods fails. Nothing stated that, so a tenant reading the contract and writing an ordinary class gets a green load, a green sweep, and a quietly worse corpus.
Blast radius is bounded to tenant-declared parsers. The global registry path is populated once at import time by applyConfigToRegistry from static declarations that are already exercised, so this ticket deliberately does not touch it.
The Fix
After resolving a tenant-declared parser, refuse a value exposing neither parseIngestionFile nor parse with its own coded reason, rather than returning it into a dispatch chain that will silently skip it. TENANT_PARSER_ERROR_CODES gains one entry; the check belongs next to the existing noExport refusal in loadTenantParser, which already validates that something was exported and stops short of validating that the something is dispatchable.
The refusal message must name the reachability distinction, since that is the entire defect and it is invisible from the symptom.
Corrected 2026-08-25 (PR #17766 review, @neo-gpt). Two prescriptions above were wrong:
- "The check belongs in
loadTenantParser" is incomplete. A tenant declares a parser two ways — a live entry.ParserClass, or a parserModule the loader imports — and both converge on resolveFileChunks. A guard under only the loader leaves the live-class tier degrading unchanged. The predicate must be applied at the union of both entry paths.
- "Dispatch is static" is wrong, and wrong in the direction that breaks working code.
Neo.setupClass with singleton: true exports an instance, which is the idiom every Source in ai/services/knowledge-base/source/ uses; its prototype methods are reachable on that value and dispatch correctly. Requiring static would tell an author to rewrite a working parser.
Contract Ledger Matrix
Backfilled 2026-08-25 at @neo-gpt's request (PR #17766 RA-4). The ticket shipped without one, which is how the consumed surface came to be described by only one of its two entry paths.
| Target Surface |
Source of Authority |
Proposed Behavior |
Fallback |
Docs |
Evidence |
loadTenantParser (parserModule entry) |
this ticket |
refuses a module export that is not dispatchable, with a coded reason |
none — a declared parser that cannot run is a configuration defect |
module JSDoc |
tenantParserLoader.spec refusal arm |
resolveTenantParser (live ParserClass entry) |
this ticket |
same predicate, same code, before the value reaches dispatch |
none |
method JSDoc |
live-class refusal arm asserting no chunks and the coded reason |
| the shared predicate |
this ticket |
one assertDispatchableParser, so the two entry forms cannot drift |
— |
its own JSDoc |
both arms above exercise it |
| valid export shapes |
Neo.setupClass + the shipped Sources |
static-method constructor, object literal, and singleton instance all dispatch |
— |
CustomParsers.md |
three-shape table + a singleton-instance positive control |
| refusal fallback |
#17428's taxonomy rationale |
KB_TENANT_PARSER_NOT_DISPATCHABLE — never a silent raw-text degradation |
— |
error-code block |
arms assert chunks is undefined |
| global registry |
out of scope, and must stay so |
unchanged; not subject to the tenant guard |
— |
— |
a parser registered globally and dispatched through, not merely an unchanged id list |
| deployment-author docs |
AC-6 |
states what dispatch requires and which shape fails |
— |
learn/agentos/cloud-deployment/CustomParsers.md |
the section is the artifact |
Acceptance Criteria
Out of Scope
- Module content identity in the cache key / materialization digest. A separate concern with its own lane; it composes with this one (a wrong-shape corpus persists across sweeps because the digest cannot see it) but the two fixes are independent.
- Validating what the parser RETURNS. Chunk-shape validation is
parsedChunkValidator's existing job; this ticket is only about whether the parser is reachable at all.
- Any change to
SourceRegistry's storage contract.
Avoided Traps
- Probing
prototype and calling the instance method instead. That would silently accept two dispatch conventions and make the corpus depend on which one a tenant guessed. One convention, refused loudly when unmet.
- Defaulting to
raw-text with a warning. A warning in a sweep of 1,086 files is not observable; the whole point of the taxonomy is that a configuration defect must not be reportable as ingestion success.
- Widening the check to the global registry to look thorough. Its entries are static and already exercised; adding a runtime probe there buys nothing and risks the zero-config path this ticket must leave alone.
Related
#17294 (closed — the loader this completes) · PR #17297 · ai/services/knowledge-base/source/tenantParserLoader.mjs · ai/services/knowledge-base/IngestionService.mjs:1601-1624 · ai/services/knowledge-base/source/SourceRegistry.mjs:85,122
Live latest-open sweep: latest 20 open issues checked 2026-08-17T13:13:44Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages: no claim on this scope.
Origin Session ID: 80b326bf-b37a-4efd-8313-1a9eae09e9c4
Retrieval Hint: query_raw_memories("class-shaped tenant parser silent raw-text fallthrough prototype static dispatch")
Context
Found while running the loader from #17297 (merged
29322c8267) against its first real consumer. Not a defect in the containment boundary — that held against every escape constructed against it. It is the one silent path the loader's fail-loud contract does not reach, and the contract's own naming invites it.Filed as a follow-up rather than raced against the merge, per the standing rule that a reviewer challenge landing post-merge is routine.
The Problem
resolveFileChunksprobes for a parse method on the returned value:ai/services/knowledge-base/IngestionService.mjs:1601 const parser = await this.resolveTenantParser({parserId, tenantContext}) ?? this.resolveParser(parserId); :1603 if (file.parserId && !parser) { throw KB_PARSER_NOT_REGISTERED } // parser is TRUTHY -> skipped :1609 if (parser?.parseIngestionFile) { ... } // undefined on an instance-method class -> skipped :1613 if (parser?.parse) { ... } // undefined -> skipped :1624 return [this.rawFileToParsedRecord(...)] // silent whole-file chunkA class carrying its method on
prototypeis truthy while both probes readundefined, so the!parserthrow is skipped and the file falls through toraw-text. Measured:typeof p.parseIngestionFileclass F { parseIngestionFile() {} }undefinedclass F { static parseIngestionFile() {} }function{ parseIngestionFile() {} }functionThe method is present the whole time —
typeof F.prototype.parseIngestionFile === 'function'. Only the lookup surface differs.The Architectural Reality
This is the exact failure class the error-code taxonomy in
ai/services/knowledge-base/source/tenantParserLoader.mjswas written against, one step short of closing. Its JSDoc states the principle correctly — a declared-but-unloadable parser throws its coded reason rather than returning null, because null falls through toraw-text, which INGESTS SUCCESSFULLY. The unloadable case is closed. The loadable-but-wrong-shape case lands in the same place with no error, no missing output, and a plausible chunk count.The contract actively points at the broken shape. The config key is named
ParserClass;SourceRegistry.registerParser(ParserClass, {parserId})stores classes;getParsers()documents "Registered Parser classes"; andresolveTenantParser's own JSDoc returns "the tenant's parser class". What actually has to hold is that the method is callable on the registered/resolved value — which a static-method constructor, an object literal, and aNeo.setupClasssingleton (whose export IS an instance) all satisfy. Only a plain, non-singleton constructor with prototype-only methods fails. Nothing stated that, so a tenant reading the contract and writing an ordinary class gets a green load, a green sweep, and a quietly worse corpus.Blast radius is bounded to tenant-declared parsers. The global registry path is populated once at import time by
applyConfigToRegistryfrom static declarations that are already exercised, so this ticket deliberately does not touch it.The Fix
After resolving a tenant-declared parser, refuse a value exposing neither
parseIngestionFilenorparsewith its own coded reason, rather than returning it into a dispatch chain that will silently skip it.TENANT_PARSER_ERROR_CODESgains one entry; the check belongs next to the existingnoExportrefusal inloadTenantParser, which already validates that something was exported and stops short of validating that the something is dispatchable.The refusal message must name the reachability distinction, since that is the entire defect and it is invisible from the symptom.
Corrected 2026-08-25 (PR #17766 review, @neo-gpt). Two prescriptions above were wrong:
loadTenantParser" is incomplete. A tenant declares a parser two ways — a liveentry.ParserClass, or aparserModulethe loader imports — and both converge onresolveFileChunks. A guard under only the loader leaves the live-class tier degrading unchanged. The predicate must be applied at the union of both entry paths.Neo.setupClasswithsingleton: trueexports an instance, which is the idiom every Source inai/services/knowledge-base/source/uses; its prototype methods are reachable on that value and dispatch correctly. Requiringstaticwould tell an author to rewrite a working parser.Contract Ledger Matrix
Backfilled 2026-08-25 at @neo-gpt's request (PR #17766 RA-4). The ticket shipped without one, which is how the consumed surface came to be described by only one of its two entry paths.
loadTenantParser(parserModuleentry)tenantParserLoader.specrefusal armresolveTenantParser(liveParserClassentry)assertDispatchableParser, so the two entry forms cannot driftNeo.setupClass+ the shipped SourcesCustomParsers.mdKB_TENANT_PARSER_NOT_DISPATCHABLE— never a silentraw-textdegradationchunksisundefinedlearn/agentos/cloud-deployment/CustomParsers.mdAcceptance Criteria
prototypeis refused with a coded reason, not dispatched and not degraded toraw-text.devand passes after the fix. A test that only exercises the corrected shape proves nothing here, because the correct shapes already pass today.ParserClassnaming is reconciled with what dispatch actually requires wherever a deployment author reads it, so the contract stops pointing at the shape that breaks.Out of Scope
parsedChunkValidator's existing job; this ticket is only about whether the parser is reachable at all.SourceRegistry's storage contract.Avoided Traps
prototypeand calling the instance method instead. That would silently accept two dispatch conventions and make the corpus depend on which one a tenant guessed. One convention, refused loudly when unmet.raw-textwith a warning. A warning in a sweep of 1,086 files is not observable; the whole point of the taxonomy is that a configuration defect must not be reportable as ingestion success.Related
#17294 (closed — the loader this completes) · PR #17297 ·
ai/services/knowledge-base/source/tenantParserLoader.mjs·ai/services/knowledge-base/IngestionService.mjs:1601-1624·ai/services/knowledge-base/source/SourceRegistry.mjs:85,122Live latest-open sweep: latest 20 open issues checked 2026-08-17T13:13:44Z; no equivalent found. A2A in-flight claim sweep over the last 30 messages: no claim on this scope.
Origin Session ID: 80b326bf-b37a-4efd-8313-1a9eae09e9c4
Retrieval Hint:
query_raw_memories("class-shaped tenant parser silent raw-text fallthrough prototype static dispatch")