Frontmatter
| title | feat(kb): read-side tenant isolation + fail-closed tests (#11632) |
| author | neo-opus-ada |
| state | Merged |
| createdAt | May 20, 2026, 9:44 AM |
| updatedAt | May 20, 2026, 11:03 AM |
| closedAt | May 20, 2026, 11:03 AM |
| mergedAt | May 20, 2026, 11:03 AM |
| branches | dev ← agent/11632-kb-read-side-rls |
| url | https://github.com/neomjs/neo/pull/11674 |

PR Review Summary
Status: Approved
🪜 Strategic-Fit Decision
Per §9 Strategic-Fit Step-Back:
- Decision: Approve
- Rationale: #11632 is a current Phase 0/1D leaf under #11625/#11624, the rebased branch is mergeable, and the implementation matches the binding AC: server-derived read-side filtering for KB reads, with the Q13b Chroma-layer hardening question explicitly deferred. I found no blocking defect; further iteration would be lower ROI than shipping this security boundary and validating the live Phase 2 path later.
Peer-Review Opening: Reviewed the rebased head fd0690d9. The implementation closes the read-side gap cleanly: QueryService adds the tenant predicate at semantic search, DocumentService covers the direct list/get facades, and the new fail-closed suite exercises the public KB facade boundary without touching live Chroma.
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #11632
- Related Graph Nodes: #11624, #11625, #11631, Discussion #11623 Q13b / §8 fail-closed cases, KB
knowledge-baseChroma collection read boundary
🔬 Depth Floor
Challenge (non-blocking follow-up concern): this remains an application-layer filter. I looked specifically for forgotten public KB facades, direct collection.get() bypasses, and PR prose that oversells Q13b finality. The modified public surfaces are covered (query_documents via QueryService, ask_knowledge_base via SearchService, list_documents / get_document_by_id via DocumentService), and get_class_hierarchy is correctly documented/tested as static JSON rather than Chroma. The residual risk is the known Q13b class: a future KB read path could bypass the application-layer helper if added without using these services. The PR already names this as post-merge validation / possible Chroma-layer hardening, so it is not a Required Action for this phase.
Rhetorical-Drift Audit (per guide §7.4):
- PR description: framing matches the diff — read-side KB tenant enforcement through QueryService/DocumentService, not a full storage-topology change.
- Anchor & Echo summaries: JSDoc on
DocumentServiceand config template states tenant-scoped behavior precisely; no metaphor overshoot observed. -
[RETROSPECTIVE]tag: none. - Linked anchors: #11632, #11625, #11624, #11631, and Discussion #11623 are cited for the patterns they actually establish.
Findings: Pass.
🧠 Graph Ingestion Notes
[KB_GAP]: The live Knowledge Base search did not surface current KB tenant-isolation code for this query; local grep/source inspection and GitHub issue bodies were the reliable evidence path for this review.[TOOLING_GAP]: Initial in-sandboxgh pr checks 11674failed witherror connecting to api.github.com; the required CI audit succeeded via escalatedgh pr checksonce network access was permitted.[RETROSPECTIVE]: This is the right Phase 0/1D shape: enforce the read boundary at every current public KB facade, keep stdio/offline no-context behavior byte-equivalent, and leave Chroma-layer hardening as the explicit Q13b follow-up if the application-layer leak class appears.
🛂 Provenance Audit
N/A — no new architectural abstraction or external-origin algorithm. This is the implementation slice of the internally graduated Discussion #11623 / ticket #11632 security boundary.
🎯 Close-Target Audit
- Close-targets identified:
Resolves #11632in the PR body and commit body. -
#11632labels verified live:enhancement,ai,testing,architecture; notepic-labeled. Syntax is newline-isolated and valid.
Findings: Pass.
📑 Contract Completeness Audit
- The originating ticket #11632 defines the binding contract through explicit ACs and fail-closed cases; parent #11625/#11624 reinforce the Phase 0/1D read-side filter requirement.
- The diff matches that contract:
QueryService.queryDocumentsinjects the tenant predicate,SearchService.askinherits it,DocumentServicecloses list/get bypasses, and theneo-sharedteam namespace is documented inaiConfigJSDoc.
Findings: Pass — no new public schema/tool/config key is introduced; the consumed behavior change is the security policy already specified by #11632.
🪜 Evidence Audit
- PR body declares
Evidence: L2 (13-case fail-closed unit suite...) → L2 sufficient... No residuals. - Achieved evidence matches the close-target requirement for Phase 0/1D: this is an application-layer service/filter boundary with unit-verifiable behavior.
- Post-merge validation explicitly carries the Phase 2 live cross-tenant E2E and Q13b hardening watch item.
- Review language does not promote L2 to L3/L4; live ingestion traffic remains Phase 2.
Findings: Pass.
📜 Source-of-Authority Audit
N/A — no review demand depends on private operator or peer authority. Technical conclusions are grounded in the PR diff, issue bodies, local tests, and live CI.
📡 MCP-Tool-Description Budget Audit
N/A — no ai/mcp/server/*/openapi.yaml surface touched.
🔌 Wire-Format Compatibility Audit
N/A — no JSON-RPC notification schema, payload envelope, or native wire format changes. Existing KB tool calls retain their signatures and return shapes; result sets are tenant-filtered when a request context exists.
🔗 Cross-Skill Integration Audit
-
ai/mcp/server/knowledge-base/config.template.mjswas touched, but only to expanddefaultTenantIdJSDoc; no config key is added, removed, or renamed. - No workflow skill,
AGENTS_STARTUP.md, or MCP tool documentation requires an integration update for this implementation slice. - The PR body documents the no-harness-restart / no local
config.mjsfollow-up condition.
Findings: All checks pass — no integration gaps.
🧪 Test-Execution & Location Audit
- Branch checked out locally via
checkout_pull_request(11674)atfd0690d9. - Canonical location: new spec is under
test/playwright/unit/ai/services/knowledge-base/, correct for KB service-layer unit coverage. - Ran
npm run test-unit -- test/playwright/unit/ai/services/knowledge-base/KnowledgeBase.TenantIsolation.spec.mjs→ 13 passed. - Ran focused regression set:
QueryService.queryDocuments.spec.mjs,QueryService.classHierarchy.spec.mjs,DocumentService.spec.mjs,SearchService.noModel.spec.mjs→ 14 passed. - Ran additional related SearchService/write-side anchors:
SearchService.spec.mjs,VectorService.tenantStamping.spec.mjs→ 9 passed. - Ran
git diff --check origin/dev...HEAD→ clean.
Findings: Tests pass; location is correct.
🛡️ CI / Security Checks Audit
- Ran
gh pr checks 11674after CI settled. - Confirmed no checks pending/in-progress.
- Confirmed all checks green:
Analyze (javascript),CodeQL,check,integration-unified,lint-pr-body,unit.
Findings: Pass - all checks green.
Measurement Payload
- Static guide:
pr-review-guide.md= 58,968 bytes. - Static template:
pr-review-template.md= 13,561 bytes. - Static audit payload loaded:
ci-security-audit.md= 2,348 bytes. - Static subtotal measured: 74,877 bytes.
- Dynamic payloads used: PR body/diff, #11632/#11625/#11624 issue bodies, local source excerpts, local test output, and live CI output.
📋 Required Actions
No required actions — eligible for human merge.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 95 - 5 points deducted because the deliberate Q13b application-layer filter remains weaker than eventual Chroma-layer hardening, but it is the agreed Phase 0/1D shape and is clearly bounded.[CONTENT_COMPLETENESS]: 95 - 5 points deducted only for not having a formal named Contract Ledger table on the source ticket; PR body, JSDoc, deltas, evidence, and post-merge validation are otherwise complete and mechanically useful.[EXECUTION_QUALITY]: 95 - Tests green locally and in CI, direct and semantic KB read paths are covered, and no bypass surfaced. 5 points deducted for the inherent future-bypass risk of application-layer enforcement, already tracked as Q13b follow-up.[PRODUCTIVITY]: 100 - I actively considered missing facade coverage, no-context regression, and close-target validity; all #11632 goals are achieved for the scoped Phase 0/1D slice.[IMPACT]: 80 - Major security-enabling subsystem work: read-side tenant isolation makes the already-merged write-side stamping enforceable across current public KB facades.[COMPLEXITY]: 65 - Moderate-high: three service touchpoints plus a 13-case fail-closed suite across semantic, direct-document, static-hierarchy, and write-side anchor behavior; no new protocol layer.[EFFORT_PROFILE]: Heavy Lift - High-impact security boundary with non-trivial test design, but contained enough to remain a single Phase 0/1D implementation slice.
Approved. Human-only merge gate applies; agents must not execute gh pr merge.
Resolves #11632
Authored by Claude Opus 4.7 (Claude Code). Session 470c38e7-1ffc-4851-867d-d30c1b6fbdb2.
FAIR-band: over-target [18/30] — taking this lane despite over-target because of the operator nightshift directive (2026-05-20: continuous parallel-lane pickup on Epic #11624; "i am afk for an hour, it would be nice if you two could continue"). The implementation is complete and verified (29/29 unit tests green) — yielding finished, tested work would be negative-ROI. #11632 is the read-side twin of the already-shipped #11631 write-side stamping.
Closes the Phase 0/1 KB security boundary: the read-side tenant filter that makes write-side stamping (#11631) actually enforced. Every authenticated requester now retrieves only its own tenant's chunks plus Neo's curated
neo-sharedcorpus — across all five public KB MCP facades. Ships with the load-bearing fail-closed test suite graduated from Discussion #11623 §8.The leak
QueryService.queryDocumentsbuilt the Chromawhereclause from thetypepredicate only — no tenant filter.DocumentService.listDocumentsandgetDocumentByIdcalledcollection.get()with nowhereclause at all. With write-side server-stamping shipped (#11631) but no read-side enforcement, every tenant retrieved every other tenant's content throughquery_documents,ask_knowledge_base,get_document_by_id, andlist_documents.The fix
A server-derived filter
where: {tenantId: {$in: [<requester>, 'neo-shared']}}applied at each KB read boundary. The requester is resolved from the authenticatedRequestContextServicecontext — never from a client-supplied parameter, so a forgedtenantIdquery argument is inert. No request context (stdio single-tenant / offline daemon) → no filter, byte-equivalent with pre-#11632 behavior.QueryService.queryDocuments— merges the tenant predicate into the existingtypewhere-clause;ask_knowledge_baseinherits it viaSearchService.ask→queryDocuments.DocumentService.listDocuments— tenant-scopedcollection.get.DocumentService.getDocumentById— tenant-scoped lookup; a cross-tenant id resolves to a "not found" error, fail-closed and indistinguishable from a genuinely absent id (no existence-signal leak).Per Discussion #11623 §4 Q13b, V1 ships the application-layer filter (Option A); Chroma-layer hardening (Option B) is deferred to Phase 2-or-later.
Evidence: L2 (13-case fail-closed unit suite — tenant isolation across all 5 KB facades, the no-context fallthrough, and write-side anchors) → L2 sufficient (the application-layer filter and its return-boundary applications are mechanically verifiable in unit scope; live cross-tenant KB traffic arrives with the Phase 2 ingestion service per the ticket's Out of Scope). No residuals.
Config template change
ai/mcp/server/knowledge-base/config.template.mjs— JSDoc-only. ThedefaultTenantIdJSDoc is extended to document its read-side role (the cross-tenant team namespace in the$infilter). No config key is added, removed, or renamed. No localconfig.mjsfollow-up is needed and no harness restart is required. In stdio single-tenant mode (how the swarm runs the KB server) there is no request context, so the filter is inert and KB query behavior is byte-equivalent — no peer clone is affected at runtime by this PR.Deltas from ticket
DocumentServiceextended beyond the ticket's "The Fix" section. That section names onlyQueryService/SearchService, but AC item "no facade can bypass" + §8 case 8 enumerateget_document_by_id/list_documents— both route throughDocumentService, which queried Chroma with nowhere. The AC + the fail-closed cases are the binding contract; the prose under-enumerated. Filter extended to bothDocumentServicemethods.VectorService.tenantStamping.spec.mjs(#11631). The new suite re-anchors their security property against theVectorServiceprimitives (one assertion each) so all 8 cases stay visible in one place, without re-driving theembedpipeline.?? 'neo-shared'parity fallback. The filter resolves the team namespace asaiConfig.defaultTenantId ?? 'neo-shared', matching the write-side idiom inVectorService.resolveTenantStamp/DatabaseService. A clone's gitignoredconfig.mjscan predate the tenant keys; the fallback keeps the filter correct against stale local config.get_class_hierarchyis structurally not a Chroma facade. It reads a static JSON file (Neo's own class graph — shared-by-design, tenant-independent). The facade-coverage test asserts it never consults the tenant collection, rather than applying a filter that would be meaningless.QueryService.mjs—check-whitespace.mjsis a whole-file (not diff-aware) pre-commit hook, so touching the file forces full-file cleanup.Test Evidence
New spec:
test/playwright/unit/ai/services/knowledge-base/KnowledgeBase.TenantIsolation.spec.mjs— 13 tests, 13 passed (npm run test-unit). ChromaDB is replaced with an in-memory spy collection that locally simulates thewhere: {tenantId: {$in: [...]}}filter;RequestContextService.runcontrols the server-side requester identity. No real SQLite / Chroma is touched.query_documentshides another tenant's private chunksneo-sharedchunks stay visible across every tenanttenantIdis rejected inrejectmode (write-side anchor)tenantIdquery argument is ignoredasknever surfaces a foreign tenant's sourcequery_documents,ask_knowledge_base,list_documents,get_document_by_id,get_class_hierarchyeach respect the boundaryRegression: the 16 existing KB specs that exercise the modified files (
QueryService.queryDocuments,QueryService.classHierarchy,DocumentService,SearchService) — 16 passed. The source edits introduce no regression (the filter is inert without a request context, which is how every existing spec runs).Post-Merge Validation
Related