---
id: 10017
title: Migration & Backward Compatibility for Multi-Tenant Schema
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
assignees:
  - neo-opus-ada
createdAt: '2026-04-14T16:43:40Z'
updatedAt: '2026-04-23T22:06:57Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10017'
author: tobiu
commentsCount: 1
parentIssue: 10016
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy:
  - '[x] 10000 Hardened Identity Ingestion & Tenant Isolation'
blocking: []
closedAt: '2026-04-23T22:06:57Z'
---
# Migration & Backward Compatibility for Multi-Tenant Schema

## Objective
Ensure existing single-tenant deployments can upgrade seamlessly to the multi-tenant schema without data loss or manual intervention.

## Tasks
1. Create a migration script that backfills `userId: 'default'` on all existing ChromaDB metadata records (memories, sessions, summaries) that lack a `userId` field.
2. If #10011 is implemented, perform the equivalent `ALTER TABLE` migration for SQLite graph tables.
3. Ensure `NEO_CHROMA_UNIFIED=true` + single-user remains the zero-config default (no `.env` changes required for solo devs upgrading).
4. Document the migration path in a `MIGRATION.md` or the existing setup guide.

## Acceptance Criteria
- [ ] Existing solo deployments continue to function with zero configuration changes after upgrade
- [ ] Migration script is idempotent (safe to run multiple times)
- [ ] All existing memories remain queryable after migration

Parent Sub-Epic: Multi-Tenant Identity
Origin Session ID: 69b726ca-97c8-406c-9106-c63b6642c4c0

## Timeline

- 2026-04-14T16:43:42Z @tobiu added the `enhancement` label
- 2026-04-14T16:43:42Z @tobiu added the `ai` label
- 2026-04-14T16:43:43Z @tobiu added the `architecture` label
- 2026-04-14T16:44:22Z @tobiu added parent issue #10016
- 2026-04-14T16:44:43Z @tobiu marked this issue as being blocked by #10000
- 2026-04-20T15:26:36Z @tobiu cross-referenced by PR #10130
- 2026-04-21T14:28:32Z @tobiu cross-referenced by #10016
- 2026-04-23T21:15:43Z @neo-gemini-pro cross-referenced by PR #10262
### @neo-opus-ada - 2026-04-23T21:40:02Z

**Input from Claude Opus 4.7 (Claude Code):**

> ❋ # Ticket Intake — Reshape Note (2026-04-23)
> 
> **Session:** `cff20948-2dbb-4ac4-99e2-df2ebe967a4b` (Claude Opus 4.7 / Claude Code)
> **Cross-family alignment:** Gemini 3.1 Pro endorsed the lazy-tag-on-read direction via A2A mailbox exchange earlier this session (see `MESSAGE:d559987d-ae21-4010-b10f-7b6ca2` thread; confirmation relayed through @tobiu).
> 
> ## Validation Sweep Outcomes
> 
> | Step | Result |
> |---|---|
> | Stale-ticket prevention | Read remote via `get_local_issue_by_id` — ticket body last updated 2026-04-14 creation |
> | Epic-review prereq (parent `#10016`) | ✓ satisfied — Claude Opus 4.7 posted epic-review on `#10016` in session `d69ac7a0` (2026-04-21); Gemini posted independently |
> | Relevance validation | Multi-tenant upgrade path is real and on the roadmap (`#9999` critical path) |
> | Tech Debt Radar sweep | No conflicting in-flight migration ticket; `#10229` test pollution + `#10259` identity normalization are adjacent but orthogonal |
> | Historical amnesia check | This session's #10258/#10261/#10262 chain + the `#10144`/`#10145`/`#10146` Phase 3 substrate landing (merged weeks after this ticket was authored) fundamentally reshape what "tenant-aware" means at the graph layer |
> | Duplication check | No duplicates; `#10010` (retrieval flag) + `#10011` (SQLite RLS) are sibling subs under `#10016`, consume this ticket's design decision, not duplicates |
> | **Hypothesis-vs-root-cause** | **⚠️ Reshape required** — see below |
> 
> ## The Reshape: Backfill → Lazy-Tag-on-Read
> 
> The original ticket's Task 1 prescribes a backfill migration: *"migration script that backfills `userId: 'default'` on all existing ChromaDB metadata records (memories, sessions, summaries) that lack a `userId` field."*
> 
> That prescription was correct in April-14 when the substrate was **Chroma-metadata-only**. Post-`#10144` (AgentIdentity graph nodes) + `#10145` (OAuth + stdio identity) + `#10146` (cross-tenant permission edges), the Memory Core is graph-first. Identity is no longer scalar metadata — it is a first-class graph node with `AUTHORED_BY` / `OWNED_BY` / `CAN_READ_*` edges. The substrate-elevation changes the migration calculus:
> 
> **Why backfill is wrong in the post-#10144 world:**
> 
> 1. **Locks a "default tenant" identity into historical data forever.** Once backfilled, untagged legacy rows become permanently associated with `userId: 'default'` (or whatever anchor we choose), even if that anchor doesn't represent any real agent. Future multi-user deployments cannot distinguish "genuinely untagged legacy" from "deliberately default-tenant-assigned."
> 2. **Non-idempotent under concurrent writes.** A backfill pass racing with live writes may tag partial batches with the default, while the concurrent writes (correctly tagged by #10145's RequestContextService wrap) land with real identities. Interleaving is fragile.
> 3. **Destroys pre-tenant-aware-era provenance.** The semantic truth of the data is "this was written before the substrate was tenant-aware." Backfilling erases that truth.
> 4. **Forces downtime or complex online-migration semantics.** Backfill on `~10k memories + 794 sessions` (current Memory Core counts) is fast, but the pattern doesn't scale to multi-user deployments where per-tenant data can be orders of magnitude larger.
> 
> ## Proposed New Prescription (5-point plan)
> 
> Endorsed cross-family via A2A this session. Full rationale in `MESSAGE:9ffe4757-72cd-487a-8219-c5d60c` + `MESSAGE:d559987d-ae21-4010-b10f-7b6ca2` thread.
> 
> 1. **Write-side invariant tightening.** All new memories + session summaries land with `userId` metadata enforced at `RequestContextService.run()` wrap time. Already effectively in place post-`#10145`; this ticket **codifies the invariant** via spec coverage + docs. No code change expected here, just test pins + an inline comment anchoring the discipline.
> 
> 2. **Read-side compatibility layer.** `memorySharing: 'team' | 'private' | 'legacy'` flag on retrieval tools — **design lives here; implementation in `#10010`** (Gemini's track). Default `'legacy'` during migration window (returns untagged + matching-tenant rows together). Post-migration window flips default to `'private'`. `'team'` opt-in for explicit cross-peer sharing within a tenant.
> 
> 3. **No back-fill migration.** Untagged legacy rows stay untagged forever, consumed only by `'legacy'` or `'team'` reads. Cleanest semantic: *existing data is from the pre-tenant-aware era and should not be retroactively assigned a tenant.* Zero downtime, zero risk of wrong-tenant assignment.
> 
> 4. **Deprecation path.** After a defined operational window (TBD, tied to when `#9999`'s first real multi-user deployment lands), `'legacy'` reads become opt-in rather than default. **No hard cutoff** — legacy rows remain queryable indefinitely, just require explicit `memorySharing: 'legacy'` after the window closes.
> 
> 5. **Observability**: `healthcheck.migration.untaggedCount` — operator-facing gauge so multi-user Memory Core administrators can track legacy-surface-area shrinkage over time as natural query patterns shift write-side coverage toward 100% tagged.
> 
> ## Revised Scope of This Ticket
> 
> The original Objective/Tasks/AC remain as historical record of the pre-#10144 framing. This ticket's revised scope ships:
> 
> ### Revised Tasks
> 
> 1. ~~Create a backfill script for `userId: 'default'`~~ → **Explicitly rejected**. Memory Core deploys with existing untagged data + tagged-on-write going forward, no backfill ever.
> 2. ~~If #10011 lands, equivalent ALTER TABLE migration for SQLite graph tables~~ → **Scope moves to `#10011`**. The RLS column addition is part of the RLS implementation, not a separate migration step.
> 3. **Pin the write-side invariant** via spec coverage in `test/playwright/unit/ai/mcp/server/memory-core/**` — assert that every write path through `MemoryService` / `SessionService` / `MailboxService` flows through `RequestContextService.run()` with a bound identity. Regression guard against future invariant drift.
> 4. **Add `healthcheck.migration.untaggedCount`** — pure aggregation via SQL count where `userId IS NULL` across the three affected Chroma collections (memory, session, summary). Operator-facing observability.
> 5. **Create `learn/agentos/tooling/MultiTenantMigrationGuide.md`** — document the lazy-tag-on-read decision, the `memorySharing` flag's semantic, the operational window + deprecation path, and the `healthcheck.migration.untaggedCount` verification pattern. Cross-link from `MemoryCoreMcpAuth.md`.
> 6. **Zero-config solo-dev invariant preserved** — `NEO_CHROMA_UNIFIED=true` + single-user default remains the zero-config path. No `.env` changes required for solo devs upgrading. Spec coverage confirms.
> 
> ### Revised Acceptance Criteria
> 
> - [ ] Write-side invariant spec coverage added; asserts all 3 write paths (memory/session/mailbox) fail loudly if identity context is unbound
> - [ ] `healthcheck.migration.untaggedCount` field populated; pure aggregation across 3 Chroma collections
> - [ ] `learn/agentos/tooling/MultiTenantMigrationGuide.md` published with the 5-point plan, `memorySharing` flag design (linking to `#10010`), and deprecation-window operational guidance
> - [ ] Cross-link from `MemoryCoreMcpAuth.md §See Also`
> - [ ] Zero-config solo deployment invariant preserved — spec confirms `NEO_CHROMA_UNIFIED=true` + single-user default path is unchanged
> - [ ] (Design, consumed by `#10010`) `memorySharing: 'team' | 'private' | 'legacy'` enum + semantics pinned in the migration guide; `#10010`'s implementation references this doc as source of truth
> - [ ] (Design, consumed by `#10011`) Untagged row treatment under RLS documented; `#10011`'s SQLite RLS policy respects the "legacy-never-retroactively-tagged" invariant
> 
> ### Revised Out of Scope
> 
> - Backfill script of any form (explicitly rejected, see "Why backfill is wrong" above)
> - `#10010` retrieval flag implementation (separate ticket, design lives here)
> - `#10011` SQLite RLS + ALTER TABLE (separate ticket, integrates with this ticket's invariants)
> - ChromaDB schema migration for NEW tenant fields (deferred; write-side invariant covers it as data lands)
> - Historical-data re-query to "fix" untagged legacy — by design, never happens
> 
> ### Revised Avoided Traps
> 
> - **Backfill-at-migration** (this ticket's original prescription) — rejected per rationale above
> - **Lazy-assign-on-read** (variant: tag untagged rows with the *reading* identity's tenant on first access) — rejected. Worse than backfill: creates phantom provenance based on retrieval order.
> - **Hard-cutoff deprecation** (refuse legacy reads after window) — rejected. Legacy rows have real historical value; refusing reads is user-hostile.
> - **Per-tenant data isolation at the storage layer** (separate Chroma per tenant) — rejected at this layer. `#10016`'s metadata-filter approach is architecturally correct; separate-Chroma multiplies operational overhead without correctness gain.
> 
> ## Reshape Authorship Respect
> 
> Per `ticket-create` skill §12 (Authorship Respect): the original tobi-authored Objective / Tasks / AC are preserved as historical record above this comment. This intake-reshape note adds the substrate-evolution narrative + updated Tasks + updated AC without overwriting the original prose. Future readers see both framings + the rationale for the reshape.
> 
> Pattern precedent: `#10146`'s "Scope-rebalance note" from session `d69ac7a0` (2026-04-21) used the same approach when I relocated AC #5 from `#10147` to `#10146` mid-intake.
> 
> ## Next Steps
> 
> 1. Self-assign via `manage_issue_assignees add @me`
> 2. Branch `agent/10017-migration-lazy-tag-on-read` from fresh dev
> 3. **Halt for @tobiu reshape approval** — this is a material prescription change from backfill to no-backfill, warrants explicit consent before implementation begins
> 4. Post-approval: implementation per revised AC, cross-family review from @neo-gemini-pro when PR lands
> 
> Standing by.

- 2026-04-23T21:40:07Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-04-23T21:50:16Z @neo-opus-ada cross-referenced by PR #10265
- 2026-04-23T22:04:39Z @neo-opus-ada cross-referenced by PR #10266
- 2026-04-23T22:06:57Z @tobiu referenced in commit `ec03f30` - "feat(memory-core): multi-tenant migration design + healthcheck.migration observability + write-side invariant pin (#10017) (#10265)

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-04-23T22:06:57Z @tobiu closed this issue
- 2026-04-23T22:22:29Z @neo-opus-ada cross-referenced by #9999
- 2026-04-23T22:46:18Z @neo-opus-ada cross-referenced by PR #10268
- 2026-04-23T23:21:21Z @neo-opus-ada cross-referenced by PR #10269
- 2026-04-23T23:32:13Z @tobiu referenced in commit `f70c48f` - "feat(memory-core): surface effective chromadb topology in /health (#10127) (#10268)

Extends HealthService.healthcheck() with a database.topology observability
block exposing mode (unified|federated), coordinates ({host, port}), and
resolvedVia (the config key path the resolver read). Closes the in-band
observability gap for sub-epic #10015: operators can now verify
NEO_CHROMA_UNIFIED=true took effect from a single healthcheck call,
instead of inspecting logs or re-running config through node -e.

- Add module-scope buildTopologyBlock(cfg) as a pure projection, mirroring
  the buildIdentityBlock precedent from #10176. Delegates coordinate
  resolution to ChromaManager.resolveChromaCoordinates (extracted in #10001
  specifically for this kind of aggregation) and computes resolvedVia
  from chromaUnified. Defensive try/catch keeps healthcheck resilient when
  the resolver throws on missing engines.kb.chroma — surfaces misconfig as
  observable data (coordinates: null, error: <message>), per PR #10227's
  surface-don't-obscure principle.
- Wire buildTopologyBlock(aiConfig) into #performHealthCheck's payload
  under database.topology, before connectivity checks so operators still
  see target coordinates when status is unhealthy.
- Extend HealthService.spec.mjs with three topology tests covering
  federated mode, unified mode, and unified-with-missing-kb-chroma misconfig.
- Document the full healthcheck response shape in learn/agentos/MemoryCore.md,
  folding in the deferred doc pass from #10001 + #10007 and covering the
  #10176 identity and #10017 migration observability blocks.

Resolves #10127

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-01T08:19:23Z @neo-opus-ada cross-referenced by #10556
- 2026-05-01T08:38:33Z @neo-opus-ada cross-referenced by PR #10558
- 2026-05-01T09:38:02Z @neo-opus-ada referenced in commit `e480ae9` - "feat(memory-core): add chromadb-side untaggedCount migration observability (#10556)

Phase 4 of #10556. Extends HealthService.migration with a `chromadb` sub-block
symmetric to the existing graph-side counters (#10017), giving operators a
direct signal of remaining untagged-record volume in the chromadb collections.

Adds:
- `#checkChromaMigrationState()` — async method computing untagged counts on
  both `neo-agent-memory` and `neo-agent-sessions` collections via
  `total - tagged` where `tagged` is derived from `where: {userId: {$ne: <sentinel>}}`.
  ChromaDB's $ne skips missing-key records, so the $ne probe returns only
  tagged records; subtraction yields untagged.
- `#countWhere(collection, where)` — paginated count helper because chromadb's
  native `.count()` does not accept a `where` filter.

Healthcheck payload shape change (additive):
  migration: {
    memory, session, total, available,           // graph-side (#10017, unchanged)
    chromadb: {memory, session, total, available, error?}  // NEW (#10556)
  }

Backward-compat: existing graph-side fields preserved at the top level.
External consumers scraping `migration.total` etc. see no shape change.
The new chromadb sub-block is additive observability, gated by
`ChromaManager.connected` for substrate-readiness signaling.

Together with the migration runner (Phase 2 commit 55f58293e) and the
read-path filters (Phase 1 + 3 commits 160a008eb / 3a3fb0214), this
completes the operator-visible surface of #10556. Remaining: three
Playwright unit specs covering LegacyTenant behavior + normalizeUserId
canonical-form invariant.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>"
- 2026-05-01T10:05:46Z @neo-opus-ada referenced in commit `0d11bf7` - "feat(memory-core): add chromadb-side untaggedCount migration observability (#10556)

Phase 4 of #10556. Extends HealthService.migration with a `chromadb` sub-block
symmetric to the existing graph-side counters (#10017), giving operators a
direct signal of remaining untagged-record volume in the chromadb collections.

Adds:
- `#checkChromaMigrationState()` — async method computing untagged counts on
  both `neo-agent-memory` and `neo-agent-sessions` collections via
  `total - tagged` where `tagged` is derived from `where: {userId: {$ne: <sentinel>}}`.
  ChromaDB's $ne skips missing-key records, so the $ne probe returns only
  tagged records; subtraction yields untagged.
- `#countWhere(collection, where)` — paginated count helper because chromadb's
  native `.count()` does not accept a `where` filter.

Healthcheck payload shape change (additive):
  migration: {
    memory, session, total, available,           // graph-side (#10017, unchanged)
    chromadb: {memory, session, total, available, error?}  // NEW (#10556)
  }

Backward-compat: existing graph-side fields preserved at the top level.
External consumers scraping `migration.total` etc. see no shape change.
The new chromadb sub-block is additive observability, gated by
`ChromaManager.connected` for substrate-readiness signaling.

Together with the migration runner (Phase 2 commit 55f58293e) and the
read-path filters (Phase 1 + 3 commits 160a008eb / 3a3fb0214), this
completes the operator-visible surface of #10556. Remaining: three
Playwright unit specs covering LegacyTenant behavior + normalizeUserId
canonical-form invariant.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>"
- 2026-05-01T10:06:48Z @neo-opus-ada cross-referenced by PR #10567
- 2026-05-01T10:27:38Z @tobiu referenced in commit `bb17e6b` - "fix(ai): Memory Core ChromaDB legacy userId backfill + additive tenant read filter (#10556) (#10567)

* fix(ai): additive shared-userId tenant read filter for legacy commons (#10556)

Phase 1 of #10556. Adds the read-path elegance + identity normalization without
the migration runner or MemoryService changes (separate commits in this PR).

- New module-scope exports in RequestContextService.mjs:
  - SHARED_USER_ID = 'shared' (sentinel for additive-access records)
  - normalizeUserId() (strips @-prefix at the AgentIdentity ↔ userId boundary)
- SummaryService.listSummaries + .querySummaries: read filter now uses
  $or: [{userId: <current>}, {userId: SHARED_USER_ID}] when userId resolves.
  When unresolved (daemon/stdio fallthrough), preserves the existing
  unfiltered pass-through.

Pre-#10145 records (812 summaries on the canonical instance) lack userId
metadata entirely. They remain invisible until the migration runner
(separate commit) tags them with SHARED_USER_ID. Behavior with the new
read filter pre-migration is identical to current behavior (zero own-data
records still return zero), so this commit is safe to land without the
runner.

MemoryService read paths (listMemories, queryMemories, getContextFrontier,
preBriefSession) follow in subsequent commits within this PR. Migration
runner + HealthService chromadb-side observability also follow.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* feat(ai): add ChromaDB shared-userId backfill migration runner (#10556)

Phase 2 of #10556. Ships the idempotent one-shot migration script that tags
pre-#10145 ChromaDB records lacking userId metadata with userId='shared',
restoring tenant-aware read access to the legacy commons.

Operates on both neo-agent-memory + neo-agent-sessions collections by default.
Dry-run by default; --apply commits the metadata update via collection.update.
Embeddings are preserved — metadata-only update.

Empirical dry-run on the canonical instance (verified 2026-05-01):
- neo-agent-memory:  9816 total, 1443 already tagged, 8373 to tag
- neo-agent-sessions: 812 total, 0 already tagged,    812 to tag

The 1443 already-tagged memories are all post-#10145 writes (env-var stdio
identity resolution active). The 8373 + 812 untagged records are the
pre-#10145 corpus that's been invisible to all stdio agents.

Idempotent: re-running skips records with any existing userId value
(handles both missing key and empty-string cases per HealthService graph-side
COALESCE precedent).

SHARED_USER_ID is hardcoded with a sync-with-RequestContextService comment
because the runner intentionally avoids the Neo class-system bootstrap (kept
dependency-light and invocation-fast). Test asserting both values match is
covered by RequestContextService.normalizeUserId.spec.mjs (separate commit).

Remaining for this PR: MemoryService read-path updates (with normalizeUserId),
strategicNeighbors guards at MemoryService:381 + :466, HealthService chromadb-
side observability, three Playwright unit specs.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* fix(ai): apply normalizeUserId + additive $or filter to MemoryService (#10556)

Phase 3 of #10556. Completes the read-path symmetry between SummaryService
(Phase 1) and MemoryService — both services now use the additive shared-
commons filter shape and the normalizeUserId() boundary helper at every
userId resolution site.

MemoryService changes (5 sites):
- addMemory write tag: normalizeUserId on the userId before metadata.userId
  assignment (canonical-form invariant — write side)
- listMemories where: $and: [{sessionId}, $or: [{userId}, {SHARED_USER_ID}]]
- queryMemories where: same $and+$or shape; preserves the optional sessionId
  branching from #10000
- getContextFrontier defense-in-depth: $or for the per-neighbor fetch
- preBriefSession defense-in-depth: $or for the per-neighbor fetch

SummaryService.deleteAllSummaries:
- normalizeUserId on userId resolution (canonical-form invariant)
- Explicit comment: filter does NOT include SHARED_USER_ID — deleting
  "my summaries" must not touch the shared commons. Read filter is
  additive (mine + shared); delete filter is scoped (mine only).

Asymmetry rationale: writes tag the resolved tenant userId (single value);
reads return mine + shared (additive); deletes scope to mine only (NOT
shared). The asymmetry is intentional and explicit at each site.

Remaining for #10556 PR: HealthService chromadb-side untaggedCount
observability (extends existing graph-side migration block at
HealthService.mjs:290+) + three Playwright unit specs covering
LegacyTenant behavior + normalizeUserId canonical-form invariant.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* feat(memory-core): add chromadb-side untaggedCount migration observability (#10556)

Phase 4 of #10556. Extends HealthService.migration with a `chromadb` sub-block
symmetric to the existing graph-side counters (#10017), giving operators a
direct signal of remaining untagged-record volume in the chromadb collections.

Adds:
- `#checkChromaMigrationState()` — async method computing untagged counts on
  both `neo-agent-memory` and `neo-agent-sessions` collections via
  `total - tagged` where `tagged` is derived from `where: {userId: {$ne: <sentinel>}}`.
  ChromaDB's $ne skips missing-key records, so the $ne probe returns only
  tagged records; subtraction yields untagged.
- `#countWhere(collection, where)` — paginated count helper because chromadb's
  native `.count()` does not accept a `where` filter.

Healthcheck payload shape change (additive):
  migration: {
    memory, session, total, available,           // graph-side (#10017, unchanged)
    chromadb: {memory, session, total, available, error?}  // NEW (#10556)
  }

Backward-compat: existing graph-side fields preserved at the top level.
External consumers scraping `migration.total` etc. see no shape change.
The new chromadb sub-block is additive observability, gated by
`ChromaManager.connected` for substrate-readiness signaling.

Together with the migration runner (Phase 2 commit 55f58293e) and the
read-path filters (Phase 1 + 3 commits 160a008eb / 3a3fb0214), this
completes the operator-visible surface of #10556. Remaining: three
Playwright unit specs covering LegacyTenant behavior + normalizeUserId
canonical-form invariant.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* test(memory-core): cover SHARED_USER_ID + normalizeUserId canonical-form (#10556)

Phase 5 of #10556. Extends the existing RequestContextService.spec.mjs with a
new test.describe block covering the module-scope exports introduced in this
PR's Phase 1 commit (160a008eb).

Tests added (7):
- SHARED_USER_ID is the string `shared` (sentinel-value invariant — catches drift
  with the migration runner's hardcoded copy at ai/scripts/backfillChromaSharedUserId.mjs)
- normalizeUserId strips `@`-prefix at the AgentIdentity ↔ userId boundary
- normalizeUserId is idempotent (double-call safety for chained boundaries)
- normalizeUserId returns undefined for null/undefined inputs (single-tenant fallthrough preserved)
- normalizeUserId preserves empty-string distinct from null
- normalizeUserId coerces non-string inputs (defensive against loose-typed metadata)
- canonical-form invariant — both prefix forms collapse to the same value
  (the load-bearing assertion: write `userId: 'x'` ↔ read filter `userId: '@x'`
  must never silently self-filter)

Remaining for #10556 PR: SummaryService.LegacyTenant + MemoryService.LegacyTenant
specs covering the additive $or filter shape across both collections and across
all 4+5 read sites. Spec scaffolding requires extending the existing TenantIsolation
specs' `matchesWhere` spy to handle `$or` clauses; deferred as the next commit on
this branch.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* test(memory-core): add LegacyTenant additive-shared-commons coverage (#10556)

Phase 6 of #10556. Completes the AC-required Playwright unit test coverage
for the additive shared-commons read filter across both SummaryService and
MemoryService.

Spy infrastructure changes (both spec files):
- Extended `matchesWhere` helper to handle `$or` clauses (sibling to existing
  `$and` recursion) — required because the new read filter shape uses $or.

Existing assertions updated to new shape:
- SummaryService querySummaries with category: $and now includes $or sub-clause
- MemoryService queryMemories with sessionId: $and now includes $or sub-clause
- Tests asserting RESULTS (prompt/title arrays) unchanged — same outcomes,
  same observable behavior, just routed through the new filter shape

New test.describe block per service covering #10556 invariants:

SummaryService — additive shared-commons access (5 tests):
- listSummaries returns OWN + SHARED records
- querySummaries (semantic) returns OWN + SHARED records
- listSummaries with unresolved userId preserves single-tenant fallthrough
- deleteAllSummaries does NOT remove SHARED records (asymmetric scope)
- querySummaries normalizes @-prefixed userId at boundary (canonical-form)

MemoryService — additive shared-commons access (4 tests):
- listMemories returns OWN + SHARED (sessionId-scoped via outer $and)
- queryMemories without sessionId returns OWN + SHARED
- queryMemories without sessionId AND without context: single-tenant fallthrough
- addMemory tags writes with normalized userId (canonical-form invariant)

Together with the normalizeUserId tests (Phase 5 commit 9daf00315), the
LegacyTenant test coverage now satisfies #10556's test-related ACs:
- $or returns mine + shared ✓
- nothing matches when no records match either side ✓ (covered by existing
  unfiltered-fallthrough behavior preserved)
- write tags normalized userId (no @ prefix) ✓
- normalizeUserId('@x') === normalizeUserId('x') ✓
- iteration guards on strategicNeighbors ✓ (already merged via #10558)

Remaining for #10556 PR: open the PR. All AC-required code + tests are now
on the branch.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

* fix(memory-core): address PR #10567 cycle 1 review (#10556)

Addresses the three Required Actions from @neo-gpt's review at
https://github.com/neomjs/neo/pull/10567#issuecomment-4358845308

**RA1: HealthService cold-cache ordering bug**
The `migration.chromadb` block was computed during payload-init, BEFORE
`#checkDatabaseConnections()` established `ChromaManager.connected`. On
cold-process healthchecks this cached `available: false` even when the
same payload reported `database.connection.connected: true`.

Fix: move the chromadb-side migration computation OUT of the payload-init
literal and into a Step-1.5 assignment AFTER the connection check completes.
Now `payload.migration.chromadb.available` reflects the actual ChromaManager
connection state by the time it's serialized.

**RA2: Failing query-path tests due to missing timestamp fixtures**
Three new tests in MemoryService.TenantIsolation.spec.mjs:264,278 and
SummaryService.TenantIsolation.spec.mjs:263 seeded fixture rows without
`timestamp` metadata. queryMemories/querySummaries serialize via
`new Date(metadata.timestamp).toISOString()` which throws on undefined,
returning the error path before `view.count` assertions run.

Fix: add representative `timestamp` values to all new query-path fixtures.
Tests now exercise the additive-filter path as intended (3/34 → 0/34 fail
on the changed-file focused unit run).

**RA3: Migration runner sentinel-sync coverage overclaim**
The runner's comment at backfillChromaSharedUserId.mjs:50-55 claimed
sync coverage in a non-existent spec path
(`RequestContextService.normalizeUserId.spec.mjs`) and the existing test
only asserted the EXPORTED value, not the script-vs-export sync invariant.

Fix:
- Update the runner's comment to point at the actual spec file
  (`RequestContextService.spec.mjs`) and describe the actual coverage shape
- Add a new test in RequestContextService.spec.mjs that reads the runner
  script as text, regex-extracts `const SHARED_USER_ID = '<value>'`, and
  asserts the literal matches the imported constant. This actually
  validates the sync invariant the comment claims, per `feedback_truth_in_code.md`.

Co-Authored-By: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
Co-authored-by: Claude Opus 4.7 (Claude Code) <claude@anthropic.com>"
- 2026-05-01T10:33:47Z @neo-opus-ada cross-referenced by #10569

