---
id: 11632
title: 'Phase 0/1D — KB Tenant Isolation Read-Side: QueryService/SearchService where-Filter + Fail-Closed Test Suite'
state: CLOSED
labels:
  - enhancement
  - ai
  - testing
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-05-19T11:54:40Z'
updatedAt: '2026-06-07T17:13:32Z'
githubUrl: 'https://github.com/neomjs/neo/issues/11632'
author: neo-opus-ada
commentsCount: 0
parentIssue: 11625
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy:
  - '[x] 11631 Phase 0/1C — KB Tenant Isolation Write-Side: VectorService Server-Stamping + Tenant-Aware chunkId + Spoof-Rejection'
blocking:
  - '[x] 11645 Phase 5B — KB Tenant Isolation Integration Tests (post-Phase-0/1D)'
closedAt: '2026-05-20T09:03:30Z'
---
# Phase 0/1D — KB Tenant Isolation Read-Side: QueryService/SearchService where-Filter + Fail-Closed Test Suite

## Context

Sub of Phase 0/1 Epic [#11625](https://github.com/neomjs/neo/issues/11625) (meta-Epic [#11624](https://github.com/neomjs/neo/issues/11624)). Graduated from [Discussion #11623](https://github.com/neomjs/neo/discussions/11623) Cycle 2.5/2.6.

**Closes the Phase 0/1 security boundary** — read-side tenant filter + end-to-end fail-closed test suite validates write-side stamping ([#11631](https://github.com/neomjs/neo/issues/11631) Phase 0/1C) actually works.

**Topology anchor:** Per [ADR 0003 — Chroma Topology Unified Only](../tree/dev/learn/agentos/decisions/0003-chroma-topology-unified-only.md), this work injects tenant filters into `collection.query` calls against the `knowledge-base` collection in the unified Chroma daemon. Memory Core's `MemoryService.queryMemories` + `SummaryService.querySummaries` filter `neo-agent-memory` / `neo-agent-sessions` separately — same pattern, separate collections.

## The Problem

V-B-A confirmed (Cycle 2 GPT): [`QueryService.mjs:116-128`](../tree/dev/ai/services/knowledge-base/QueryService.mjs#L116-L128) builds Chroma `where` clause ONLY from `type` predicate; no tenant/visibility filter. Without read-side enforcement, even with write-side server-stamping (Phase 0/1C), every tenant retrieves every other tenant's content.

## The Fix

### Read-side filter

In [`ai/services/knowledge-base/QueryService.mjs`](../tree/dev/ai/services/knowledge-base/QueryService.mjs) and [`SearchService.mjs`](../tree/dev/ai/services/knowledge-base/SearchService.mjs):

1. Every `collection.query()` call injects `where: {tenantId: {$in: [<requester>, '<team-namespace>']}}` derived from authenticated AgentIdentity context
2. Filter context server-side, NOT client payload
3. Team-namespace constant (e.g., `'neo-shared'`) hardcoded for Neo curated content visibility across tenants

Mirrors `MemoryService.mjs:391-410` query-time policy filter pattern.

### Q13b enforcement-layer V1 lean

Per Discussion #11623 §4 Q13b — V1 implements **application-layer filter** (Option A); V2 reconsiders Chroma-layer hardening if cross-tenant leak class manifests in production. Phase 0/1D ships Option A.

### Fail-Closed Test Suite

The load-bearing security test substrate (8 cases per Discussion §8 +7 absorption):

1. **Tenant isolation**: tenant A cannot retrieve tenant B `private` chunks via `query_documents` / `ask_knowledge_base` / any public KB facade
2. **Team visibility**: Neo `'neo-shared'` chunks visible across tenants (regression-test for `team`-equivalence)
3. **Cross-tenant chunk shadow rejection**: same `sourcePath` under two tenants yields distinct chunk ids; A's content cannot suppress B's via id collision
4. **Spoof-rejection — write-side**: forged client `tenantId` in `parsed-chunk-v1` payload → server-overwritten OR rejected per `spoofRejectionMode`; warning log emitted
5. **Spoof-rejection — read-side**: forged client `tenantId` in query parameter → ignored (filter derived from server-side authenticated AgentIdentity only)
6. **Backup-record path isolation**: `manageDatabaseBackup({action: 'import'})` restore path bypasses ingestion validation by-design (preserves embeddings), but tenant boundary still respected via existing record metadata
7. **Search hydration tenant-aware**: search results cite `chunk.metadata.source` (now tuple), don't claim file existence under server `neoRootDir` for non-local-tenant content (placeholder behavior pending Phase 2 Q12 hydration resolution; this AC asserts current SearchService respects tenant-tuple semantics)
8. **End-to-end via every public facade**: `ask_knowledge_base`, `query_documents`, `get_class_hierarchy`, `get_document_by_id`, `list_documents` ALL respect tenant filter; no facade can bypass

## Acceptance Criteria

- [ ] `QueryService.queryDocuments` injects `where: {tenantId: {...}}` from authenticated AgentIdentity
- [ ] `SearchService` query path inherits filter (since it consumes `QueryService` output)
- [ ] Team-namespace constant (`'neo-shared'`) documented in `aiConfig` + JSDoc
- [ ] Application-layer filter Option A implemented; Chroma-layer hardening deferred to Phase 2-or-later per Q13b lean
- [ ] All 8 fail-closed test cases pass
- [ ] Tests parameterize over every public KB MCP facade (`ask_knowledge_base`, `query_documents`, `get_class_hierarchy`, `get_document_by_id`, `list_documents`)
- [ ] Spec coverage report demonstrates no facade bypass

## Out of Scope

- Chroma-layer enforcement (Q13b Option B) → future Discussion if Option A leak class manifests
- Hydration mode choice (Q12) → Phase 2D (this sub asserts current SearchService respects tenant-tuple; doesn't refactor hydration)
- AgentIdentity context propagation through service stack → Phase 2 (this sub uses mocked AgentIdentity in tests)

## Related

- **Parent:** [#11625](https://github.com/neomjs/neo/issues/11625)
- **Blocked-by:** Phase 0/1A (schemas), Phase 0/1B (registry), Phase 0/1C (write-side stamping)
- **Blocks:** Phase 0/1 Epic closeout
- **Discussion source:** [#11623](https://github.com/neomjs/neo/discussions/11623) §4 Q13b + §8 fail-closed test cases + §10 Graduation Criteria #13

## Origin Session ID

`7360e917-1733-4cdd-a6f3-5ac51c34b838`

## Handoff Retrieval Hints

- `QueryService.mjs:116-128` is the read-side surface
- `SearchService.mjs:95-100` is the search-result-consumer surface
- `MemoryService.mjs:391-410` is the Memory Core read-side filter pattern to mirror
- Fail-closed test suite is load-bearing — author tests FIRST to define security boundary, then implement filter against them

## Timeline

- 2026-05-19T11:54:42Z @neo-opus-ada added the `enhancement` label
- 2026-05-19T11:54:42Z @neo-opus-ada added the `ai` label
- 2026-05-19T11:54:42Z @neo-opus-ada added the `testing` label
- 2026-05-19T11:54:42Z @neo-opus-ada added the `architecture` label
- 2026-05-19T11:58:45Z @neo-opus-ada added parent issue #11625
- 2026-05-19T11:59:34Z @neo-opus-ada marked this issue as being blocked by #11631
- 2026-05-19T12:01:25Z @neo-opus-ada cross-referenced by #11624
- 2026-05-19T12:07:57Z @neo-opus-ada cross-referenced by #11643
- 2026-05-19T12:09:00Z @neo-opus-ada cross-referenced by #11645
- 2026-05-19T12:09:51Z @neo-opus-ada marked this issue as blocking #11645
- 2026-05-19T12:45:47Z @neo-opus-ada changed title from **Phase 0/1D — memorySharing KB Port Read-Side: QueryService/SearchService where-Filter + Fail-Closed Test Suite** to **Phase 0/1D — KB Tenant Isolation Read-Side: QueryService/SearchService where-Filter + Fail-Closed Test Suite**
- 2026-05-19T12:53:21Z @neo-opus-ada cross-referenced by #11625
- 2026-05-19T13:32:15Z @neo-opus-ada cross-referenced by PR #11647
- 2026-05-19T13:52:55Z @neo-opus-ada cross-referenced by #11629
- 2026-05-20T01:11:47Z @neo-gpt cross-referenced by PR #11662
- 2026-05-20T01:33:13Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-20T01:34:55Z @neo-opus-ada unassigned from @neo-opus-ada
- 2026-05-20T01:43:50Z @neo-gpt cross-referenced by PR #11666
- 2026-05-20T02:20:33Z @neo-opus-ada cross-referenced by #11626
- 2026-05-20T02:25:00Z @neo-gpt cross-referenced by #11628
- 2026-05-20T03:06:53Z @neo-opus-ada cross-referenced by PR #11668
- 2026-05-20T03:31:30Z @neo-opus-ada cross-referenced by #11637
- 2026-05-20T05:59:03Z @tobiu referenced in commit `68eb22e` - "docs(agentos): Phase 3A cloud-deployment guide scaffold (#11627) (#11668)

* docs(agentos): Phase 3A cloud-deployment invariant guide scaffold (#11627)

Phase 3A of Epic #11624 (Cloud-Native KB Ingestion) — the invariant-oriented
guide scaffold. Drafts the 3 pre-draftable guide documents @neo-gpt's #11627
readiness audit identified as authorable now (conceptual / invariant, not
endpoint-exact), plus learn/tree.json integration for KB discoverability.

New guide tree `learn/agentos/cloud-deployment/`:

- Overview.md — what cloud-native KB ingestion is; the Phase 0/1 contract
  split (parsed-chunk-v1, path-identity tuple, deletion-signaling, SourceRegistry,
  sourcePaths, write-side stamping); unified-Chroma topology anchor; default-
  source inheritance; Source-vs-Parser registry split; relationship to Neo's
  curated content.
- Security.md — the 4-invariant threat model; write-side tenant stamping +
  spoof-rejection policy (#11662); tenant-aware chunk IDs; read-side filter
  status (#11632 pending); parser-execution boundary policy; KB-as-cache vs
  MC-as-store recovery model; auth-flow invariant.
- MigrationPath.md — zero-config upgrade for existing single-repo deployments;
  what stays the same vs what an operator opts into; config-template clone-sync;
  no-breaking-changes inventory; deprecation timeline.

Discipline — strictly invariant-focused per the #11627 audit constraint: every
section describes the SHIPPED substrate (#11647/#11659/#11661/#11662 contracts);
Phase-2-pending surfaces (ingestSourceFiles endpoint, tenant-config persistence
#11637, parser-execution runtime, auth handshake) are marked explicit
`[Phase 2/3 — pending]` placeholders — no endpoint-name invention.

Phase 3B (contract-exact Configuration/CustomSources/CustomParsers docs) +
Phase 3C (runnable examples/hooks) stay blocked by #11637 + Phase 2 #11626 —
PR uses `Related: #11627`, ticket stays open for those slices.

Substrate-path note: `learn/agentos/**` guide docs are conditionally-loaded
World-Atlas reference content (KB-sync-ingested on demand), NOT always-loaded
turn substrate — zero always-loaded-byte accretion.

(#11627)

* docs(agentos): correct merge-status framing in Phase 3A guides (#11627)

@neo-gpt's peer-role review boundary on the #11627 Phase 3A claim caught a
merge-status overclaim before the docs harden. The Phase 3A guide scaffold
(1a3043a12) described PRs #11661 (per-source path externalization) and #11662
(write-side tenant stamping) as shipped/live substrate — but both are still
OPEN: approved and green at the operator merge gate, not merged.

Verified against live PR state: #11647 + #11659 MERGED; #11661 + #11662 OPEN.

Reworded across all three guides so merge-gated contracts read as "approved,
pending the operator merge gate" rather than "shipped":

- Status banners: "shipped, invariant" -> "invariant".
- Overview.md — contract-split intro states per-PR merge status explicitly;
  Phase 0/1B description uses scope language, not merge-status language.
- Security.md — write-side-stamping + tenant-aware-chunk-ID sections
  retitled "PR #11662 — approved, pending merge".
- MigrationPath.md — config-key + byte-equivalence references annotated
  with merge-gate status.

Invariant content unchanged — only merge-state framing corrected. A
friction-to-gold catch from cross-family peer review.

(#11627)

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-20T06:56:47Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-05-20T07:44:01Z @neo-opus-ada cross-referenced by PR #11674
- 2026-05-20T08:00:14Z @neo-opus-ada referenced in commit `fd0690d` - "feat(kb): read-side tenant isolation filter + fail-closed suite (#11632)

QueryService.queryDocuments and DocumentService (listDocuments,
getDocumentById) inject a server-derived `where: {tenantId: {$in:
[<requester>, neo-shared]}}` filter so a tenant retrieves only its own
chunks plus Neo's curated corpus. Closes the read-side gap that left
write-side stamping (#11631) unenforced. No request context -> no filter
(single-tenant fallthrough, byte-equivalent with pre-#11632 behavior)."
- 2026-05-20T09:03:30Z @tobiu referenced in commit `93276d7` - "feat(kb): read-side tenant isolation filter + fail-closed suite (#11632) (#11674)

QueryService.queryDocuments and DocumentService (listDocuments,
getDocumentById) inject a server-derived `where: {tenantId: {$in:
[<requester>, neo-shared]}}` filter so a tenant retrieves only its own
chunks plus Neo's curated corpus. Closes the read-side gap that left
write-side stamping (#11631) unenforced. No request context -> no filter
(single-tenant fallthrough, byte-equivalent with pre-#11632 behavior).

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-20T09:03:30Z @tobiu closed this issue
- 2026-05-20T12:01:09Z @neo-opus-ada cross-referenced by PR #11678
- 2026-05-20T20:26:19Z @neo-opus-ada cross-referenced by PR #11694
- 2026-05-20T23:19:18Z @neo-gpt cross-referenced by PR #11703
- 2026-05-21T01:18:54Z @neo-opus-ada cross-referenced by PR #11707
- 2026-05-21T17:32:55Z @neo-gpt cross-referenced by #11720
- 2026-05-21T22:29:15Z @neo-gpt cross-referenced by PR #11742
- 2026-05-22T02:19:05Z @neo-gpt cross-referenced by #11743
- 2026-05-25T00:52:38Z @neo-opus-ada cross-referenced by PR #11943
- 2026-05-29T02:22:25Z @neo-opus-ada cross-referenced by #12163
- 2026-06-01T05:30:49Z @neo-opus-ada cross-referenced by PR #12290
- 2026-06-01T08:36:52Z @neo-opus-ada cross-referenced by #12296

