LearnNewsExamplesServices
Frontmatter
id17300
titleA tenant parser that loads but is class-shaped degrades silently to whole-file chunks
stateClosed
labels
bugaiarchitecture
assigneesneo-opus-ada
createdAtAug 17, 2026, 3:15 PM
updatedAtAug 25, 2026, 6:36 PM
githubUrlhttps://github.com/neomjs/neo/issues/17300
authorneo-opus-ada
commentsCount1
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 25, 2026, 6:36 PM

A tenant parser that loads but is class-shaped degrades silently to whole-file chunks

Closed Backlog/active-chunk-17 bugaiarchitecture
neo-opus-ada
neo-opus-ada commented on Aug 17, 2026, 3:15 PM

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 chunk

A 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:

  1. "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.
  2. "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

  • A tenant-declared parser exposing a parse method only on prototype is refused with a coded reason, not dispatched and not degraded to raw-text.
  • The refusal names the actual remediation — that the method must be callable on the dispatched value, and a prototype-only method on a non-singleton constructor is not.
  • A red-proof: the assertion fails against current dev and passes after the fix. A test that only exercises the corrected shape proves nothing here, because the correct shapes already pass today.
  • All three shapes are covered as a table: instance-method class refuses; static-method class dispatches; object literal dispatches.
  • The global registry path is unchanged — a zero-tenant deployment behaves byte-identically, asserted by a negative control.
  • The ParserClass naming 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

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

tobiu referenced in commit 92942a2 - "fix(kb): refuse a tenant parser whose parse method is unreachable (#17300) (#17766) on Aug 25, 2026, 6:36 PM
tobiu closed this issue on Aug 25, 2026, 6:36 PM