---
id: 10145
title: OAuth2 authentication layer for Memory Core MCP connections
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
  - core
assignees:
  - tobiu
createdAt: '2026-04-21T09:29:01Z'
updatedAt: '2026-06-07T17:19:57Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10145'
author: tobiu
commentsCount: 0
parentIssue: 10016
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy:
  - '[x] 10144 AgentIdentity node type + GitHub account binding for model identities'
blocking:
  - '[x] 10139 Extend Memory Core with Explicit A2A Primitive'
  - '[x] 10146 Cross-tenant permission edges + multi-tenant validation test suite'
closedAt: '2026-04-21T20:16:31Z'
---
# OAuth2 authentication layer for Memory Core MCP connections

# OAuth2 authentication layer for Memory Core MCP connections

### Context

Per-model GitHub accounts (#10144 ✓ complete) make each agent a first-class graph identity — `@neo-opus-ada`, `@neo-gemini-pro`, `@tobiu` all seeded via `ai/scripts/seedAgentIdentities.mjs`. MC MCP must resolve every invocation to a single authoritative identity so that (a) ChromaDB writes carry the correct tenant tag, (b) reads filter per tenant, (c) future AgentIdentity graph edges (`AUTHORED_BY`, `OWNED_BY`) terminate on the right node.

**Scope-reshape note** (intake 2026-04-21, session `d69ac7a0-9fe8-4416-b766-cd9edb8bee71`): the ticket was drafted before a full survey of the existing auth substrate. Intake revealed that #10000 already shipped the OIDC/OAuth path for SSE transport. Original body's prescription "GitHub OAuth2 flow in Memory Core connect handshake" is **partially redundant** for SSE and **doesn't address** the real stdio gap. This body is updated to reflect the architectural reality discovered during intake, per the ticket-intake "Hypothesis vs Root Cause" challenge.

### The Problem

Three distinct gaps, all reachable in one cohesive change:

1. **Stdio identity is never resolved.** With per-agent GitHub accounts, every stdio connection needs to flow an identity through `RequestContextService` so `MemoryService.addMemory`, `mutate_frontier`, and future write tools tag writes with the resolved `userId`. Today stdio skips the context entirely — writes fall through to single-tenant mode regardless of which agent is running.
2. **AgentIdentity graph-node ID isn't bound to the runtime identity.** #10144 created the node type and seeded three identities, but `RequestContext` today only carries `{userId, username}`. Services that want to build `AUTHORED_BY` / `OWNED_BY` edges at write time need the resolved `agentIdentityNodeId` in context.
3. **No preemptive anti-spoof guard.** Current write schemas (`AddMemoryRequest`) have no client-supplied identity field, so there is no spoof surface today. But Mailbox (#10139) will add `from` / `recipient` fields; without a server-side guard, a Gemini-harness session could claim `from: '@neo-opus-ada'`. The guard belongs at the `callTool` dispatch choke-point, fires before any write-tool service method.

### The Architectural Reality

| Layer | Stdio (today) | SSE (today, via #10000) | #10145 delta |
|---|---|---|---|
| Identity source | ❌ none | ✅ OIDC Bearer token introspection (`AuthService.mjs`) | Stdio: env-var → `gh api user` fallback |
| `RequestContextService` populated | ❌ no | ✅ yes (`TransportService` wraps `/mcp` in `run()`) | Stdio: wrap `mcpServer.connect` in `run()` |
| `userId` on ChromaDB writes | ❌ untagged | ✅ `metadata.userId` (`MemoryService.mjs:92`) | Stdio: tagged once RequestContext flows |
| AgentIdentity node binding | ❌ not done | ❌ not done | Both paths: `GraphService.getNode({id: githubLogin})` → `agentIdentityNodeId` in context |
| Anti-spoof on write tools | n/a (no surface) | n/a (no surface) | New: middleware rejects client-supplied identity-override fields |

Existing infrastructure (touch, don't invent):
- `ai/mcp/server/shared/services/AuthService.mjs` — OIDC handler (#10000). Extend to populate `agentIdentityNodeId`.
- `ai/mcp/server/shared/services/RequestContextService.mjs` — AsyncLocalStorage propagation (#10000). Extend shape with `agentIdentityNodeId`, `source`.
- `ai/mcp/server/memory-core/services/MemoryService.mjs` — already tags writes via `getUserId()`; no change needed, benefits transitively.
- `ai/scripts/seedAgentIdentities.mjs` — binding consumer (#10144).

New files:
- `ai/mcp/server/shared/services/StdioIdentityResolver.mjs` — env-var → gh-CLI → unresolved chain.
- `ai/mcp/server/shared/services/AuthMiddleware.mjs` — anti-spoof guard at `callTool` dispatch.
- `learn/agentos/tooling/MemoryCoreMcpAuth.md` — design doc.
- `test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs` — coverage.

Modified file:
- `ai/mcp/server/memory-core/Server.mjs` — stdio branch wraps `mcpServer.connect(transport)` in `RequestContextService.run(identity, ...)`.

### The Fix

1. **`StdioIdentityResolver`** — reads `NEO_AGENT_IDENTITY` env-var; falls back to `gh api user` via child_process; returns `{userId, username, agentIdentityNodeId, source: 'env-var' | 'gh-cli' | 'unresolved'}`. Resolution runs once at server `initAsync()`; result cached on the running server instance.
2. **AgentIdentity graph binding** — both paths (SSE `AuthService.verifyAccessToken` + stdio `StdioIdentityResolver`) call `GraphService.getNode({id: githubLogin})`; attach `agentIdentityNodeId` to the RequestContext. Missing-node case = `null` (not a fatal error — an unseeded identity can still write, just without graph edges).
3. **Server wiring** — stdio branch in `Server.mjs` wraps `mcpServer.connect(transport)` in `RequestContextService.run(identity, ...)` so every subsequent `callTool` dispatch inherits the identity context.
4. **Anti-spoof middleware** — thin layer at the `callTool` choke-point; rejects any write-tool call where `args` contains fields that would override server-stamped identity (`userId`, `agentId`, `githubLogin`, future `from`, etc.). Present-day no-op on existing tools; activates once schemas grow such fields.
5. **Design doc** — `learn/agentos/tooling/MemoryCoreMcpAuth.md` — documents both paths, identity resolution chain, AgentIdentity binding, anti-spoof invariants, OAuth 2.1 spec-version pin (per epic-review Stage 5 observation on #10016).
6. **Playwright** — `Auth.spec.mjs` covers: env-var resolution → RequestContext populated; gh-CLI fallback (mocked `child_process.execSync`); AgentIdentity graph lookup binds `agentIdentityNodeId`; anti-spoof accept-path + reject-path.

### Acceptance Criteria

- [ ] `StdioIdentityResolver` resolves `NEO_AGENT_IDENTITY` → `gh api user` → unresolved; unit test covers all three branches
- [ ] `RequestContext` shape extended with `agentIdentityNodeId` and `source`; existing `getUserId()` / `getUsername()` accessors unchanged (backward-compatible)
- [ ] `Server.mjs` stdio branch wraps `mcpServer.connect` in `RequestContextService.run()` — subsequent `addMemory` calls tag writes with resolved `userId`
- [ ] AgentIdentity graph lookup happens once per session (cached on connection), not per `callTool` invocation
- [ ] Anti-spoof middleware rejects any write-tool call containing an identity-override field; accept-path test + reject-path test both green
- [ ] Design doc shipped at `learn/agentos/tooling/MemoryCoreMcpAuth.md`; registered in `learn/tree.json`
- [ ] Playwright spec at `test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs` passes under `npm run test-unit`

### Out of Scope

- **OIDC/OAuth handshake for SSE transport** — already shipped by #10000 (`AuthService.mjs`); this ticket only augments its context shape
- **OAuth App registration** (client ID / secret provisioning) — out-of-repo ops task handled separately
- **GitHub account provisioning** for per-model identities (email addresses, GH accounts for Opus + Gemini) — handled externally
- **Cross-tenant permission edges + integration test suite** — scope of #10146
- **Team vs private read filter** — scope of #10010
- **Migration of pre-existing untagged memories** — scope of #10153 (lazy back-fill)
- **Neo Agent Harness external auth** — #10119 (different substrate, not blocking)

### Avoided Traps

- **Re-implementing OAuth2 handshake for SSE.** Rejected. `AuthService.mjs` already does introspection-based auth via #10000; a parallel handshake would create a second identity source of truth and diverge from the OIDC Gold Standard.
- **Trusting client-supplied `userId` / `agent` / `githubLogin` on write tools.** Rejected. Server-stamps identity from RequestContext; middleware blocks override attempts even when schemas don't have such fields yet (defense-in-depth for #10139 additions).
- **Hard-failing stdio without `NEO_AGENT_IDENTITY`.** Rejected. `gh api user` fallback preserves zero-friction local dev for humans; agents set the env-var explicitly in their harness config (Claude Code `settings.json`, Gemini `.gemini/settings.json`).
- **Threading identity as an explicit parameter through every service method.** Rejected per #10000 precedent. AsyncLocalStorage isolates the cross-cutting concern cleanly; the two boundaries (Transport sets, Services read) are the only code touching identity.
- **Per-tool identity resolution instead of per-connection.** Rejected. Resolve once at `initAsync`, cache on the context. Re-resolving per `callTool` would hit the GitHub API N times per session.
- **Conflating `agent` metadata (harness profile name) with `githubLogin` identity.** `agent: 'antigravity'` stays metadata in `AddMemoryRequest`; `githubLogin: '@neo-gemini-pro'` is the auth key. Never reconcile them.

### Related

- Parent: #10016 (Multi-Tenant Identity & Data Privacy) — epic-review by Gemini 3.1 Pro (2026-04-21T14:28:30Z) + Claude Opus 4.7 (2026-04-21T18:22Z, this session)
- Predecessor (already shipped): #10000 (Hardened Identity Ingestion — OIDC path + RequestContextService)
- Predecessor (already shipped): #10144 (AgentIdentity node type + seed script, via PR #10162)
- Blocks: #10146 (cross-tenant perms depends on identity plumbing), #10139 (Mailbox addMessage anti-spoof guard)
- Adjacent: #10119 (different substrate — Neo Agent Harness external auth)
- Files touched: `ai/mcp/server/shared/services/{AuthService,RequestContextService,StdioIdentityResolver,AuthMiddleware}.mjs`, `ai/mcp/server/memory-core/Server.mjs`, `learn/agentos/tooling/MemoryCoreMcpAuth.md`, `learn/tree.json`, `test/playwright/unit/ai/mcp/server/memory-core/Auth.spec.mjs`

Origin Session ID: `71dc3cd8-d39d-48e1-ac62-e240ca67d1a5` (ticket creation)
Reshape Session ID: `d69ac7a0-9fe8-4416-b766-cd9edb8bee71` (intake reshape 2026-04-21, Claude Opus 4.7 / Claude Code)

## Timeline

- 2026-04-21T09:29:02Z @tobiu added the `enhancement` label
- 2026-04-21T09:29:02Z @tobiu added the `ai` label
- 2026-04-21T09:29:03Z @tobiu added the `architecture` label
- 2026-04-21T09:29:03Z @tobiu added the `core` label
- 2026-04-21T09:32:46Z @tobiu added parent issue #10016
- 2026-04-21T09:33:12Z @tobiu marked this issue as being blocked by #10144
- 2026-04-21T09:33:14Z @tobiu marked this issue as blocking #10146
- 2026-04-21T09:33:21Z @tobiu marked this issue as blocking #10139
- 2026-04-21T11:32:08Z @tobiu cross-referenced by PR #10155
- 2026-04-21T14:28:32Z @tobiu cross-referenced by #10016
- 2026-04-21T14:36:20Z @tobiu cross-referenced by PR #10161
- 2026-04-21T14:54:14Z @tobiu cross-referenced by PR #10162
- 2026-04-21T17:03:02Z @tobiu cross-referenced by #9999
- 2026-04-21T18:23:20Z @tobiu assigned to @tobiu
- 2026-04-21T19:06:54Z @tobiu cross-referenced by PR #10166
- 2026-04-21T19:16:41Z @tobiu referenced in commit `47e209e` - "fix(ai): bump gh CLI identity resolver timeout to 5s for MCP handshake budget (#10145)

Per Gemini 3.1 Pro's Depth Floor §7.1 challenge on PR #10166: the MCP
client-side init-handshake budget is ~5s; a 3s `gh api user` timeout
risked consuming 60% of that window on stale-auth or proxied-network
environments, potentially causing client-side connection timeouts before
the server finished initAsync. Bumped to 5s — matches the MCP spec
budget. Added an inline comment capturing the rationale so future
readers don't tighten it back without understanding the trade.
Design doc troubleshooting section updated to reflect the new budget."
- 2026-04-21T19:26:06Z @tobiu referenced in commit `66f88bf` - "fix(ai): invert gh CLI timeout to fail-fast at 1.5s (#10145)

Gemini 3.1 Pro caught a logic inversion in commit 47e209e8a: the 3s->5s
bump was the wrong direction. The MCP handshake budget (~5s) covers the
gh call PLUS ChromaDB health + SystemLifecycleService.ready +
GraphService.ready + transport connect. Matching the gh timeout to the
full budget means a hanging gh consumes 100% of the window, guaranteeing
client timeout — worse than the original 3s (60%).

Correct direction is fail-fast: a healthy gh api user resolves in
<200ms, 1.5s is generous headroom, and anything slower is likely hanging
on auth refresh or degraded network. Single-tenant fallthrough is
preferable to exhausting the handshake. Agent harnesses that care about
identity fidelity set NEO_AGENT_IDENTITY explicitly.

- StdioIdentityResolver.mjs: timeout 5000 -> 1500ms with inline comment
  correctly capturing the fail-fast logic + budget arithmetic.
- MemoryCoreMcpAuth.md: resolution-chain + troubleshooting sections
  updated to reflect the fail-fast strategy and why the budget math
  makes fail-fast the correct discipline.

Full cross-model review cycle on record: Gemini initial review surfaced
the timeout concern -> 3s->5s applied incorrect direction -> Gemini
re-review Request Changes caught the inversion -> 5s->1.5s correction
lands. pr-review §7.1 Depth Floor discipline working as designed."
- 2026-04-21T20:16:31Z @tobiu referenced in commit `7db6c90` - "feat(ai): wire stdio identity + anti-spoof guard for Memory Core MCP (#10145) (#10166)

* feat(ai): wire stdio identity + anti-spoof guard for Memory Core MCP (#10145)

Brings the stdio transport to identity parity with the OIDC/SSE path shipped by
#10000. Previously stdio connections fell through to single-tenant mode
regardless of which agent was running — writes went untagged, reads went
unfiltered. With per-agent GitHub accounts (#10144 seed convention: @neo-opus-4-7,
@neo-gemini-3-1-pro, @tobiu), stdio needs the same tenant discipline SSE already has.

New shared services:
- StdioIdentityResolver: env-var (NEO_AGENT_IDENTITY) -> gh api user -> unresolved
  resolution chain. Normalizes @-prefix per GitHub API convention.
- AuthMiddleware: argument-level anti-spoof. Rejects tool-call args containing
  identity-override fields (userId, from, sender, authorLogin, etc.). No-op on
  current schemas; defense-in-depth for Mailbox (#10139) which will add `from`.

Memory Core Server.mjs:
- resolveStdioIdentity() composes resolver + AgentIdentity graph-node binding
- bindAgentIdentity(userId) shared helper; stdio + SSE both consume it
- buildRequestContext(reqAuth) hook called by TransportService on SSE requests
- CallToolRequestSchema handler wraps callTool in RequestContextService.run()
  when stdio identity is resolved; skips when null (SSE already wraps per-request)

Shared services extended:
- RequestContextService: +getAgentIdentityNodeId(), +getSource() accessors;
  context shape grows agentIdentityNodeId + source fields (backward-compatible)
- AuthService: returns source: 'oidc' on the auth context
- TransportService: duck-typed server.buildRequestContext hook — servers that
  implement it (memory-core) get the graph-bound shape; others fall back to
  the minimal {userId, username} shape

Ticket body on #10145 reshaped during intake to reflect the true gap
(SSE already had OAuth via #10000, stdio was the missing piece) — cited in the
body as the "Hypothesis vs Root Cause" challenge per ticket-intake §1.7.

Design doc at learn/agentos/tooling/MemoryCoreMcpAuth.md registered in
learn/tree.json. 19 Playwright unit tests in Auth.spec.mjs cover all branches of
the resolver, middleware, and RequestContext extensions.

Epic-review by Claude Opus 4.7 posted on parent #10016 prior to intake.

* fix(ai): bump gh CLI identity resolver timeout to 5s for MCP handshake budget (#10145)

Per Gemini 3.1 Pro's Depth Floor §7.1 challenge on PR #10166: the MCP
client-side init-handshake budget is ~5s; a 3s `gh api user` timeout
risked consuming 60% of that window on stale-auth or proxied-network
environments, potentially causing client-side connection timeouts before
the server finished initAsync. Bumped to 5s — matches the MCP spec
budget. Added an inline comment capturing the rationale so future
readers don't tighten it back without understanding the trade.
Design doc troubleshooting section updated to reflect the new budget.

* fix(ai): invert gh CLI timeout to fail-fast at 1.5s (#10145)

Gemini 3.1 Pro caught a logic inversion in commit 47e209e8a: the 3s->5s
bump was the wrong direction. The MCP handshake budget (~5s) covers the
gh call PLUS ChromaDB health + SystemLifecycleService.ready +
GraphService.ready + transport connect. Matching the gh timeout to the
full budget means a hanging gh consumes 100% of the window, guaranteeing
client timeout — worse than the original 3s (60%).

Correct direction is fail-fast: a healthy gh api user resolves in
<200ms, 1.5s is generous headroom, and anything slower is likely hanging
on auth refresh or degraded network. Single-tenant fallthrough is
preferable to exhausting the handshake. Agent harnesses that care about
identity fidelity set NEO_AGENT_IDENTITY explicitly.

- StdioIdentityResolver.mjs: timeout 5000 -> 1500ms with inline comment
  correctly capturing the fail-fast logic + budget arithmetic.
- MemoryCoreMcpAuth.md: resolution-chain + troubleshooting sections
  updated to reflect the fail-fast strategy and why the budget math
  makes fail-fast the correct discipline.

Full cross-model review cycle on record: Gemini initial review surfaced
the timeout concern -> 3s->5s applied incorrect direction -> Gemini
re-review Request Changes caught the inversion -> 5s->1.5s correction
lands. pr-review §7.1 Depth Floor discipline working as designed."
- 2026-04-21T20:16:31Z @tobiu closed this issue
- 2026-04-21T20:30:37Z @tobiu cross-referenced by #10147
- 2026-04-21T20:30:39Z @tobiu cross-referenced by #10146
- 2026-04-21T21:06:49Z @tobiu cross-referenced by PR #10167
- 2026-04-23T21:40:03Z @neo-opus-ada cross-referenced by #10017
- 2026-04-23T23:38:39Z @neo-opus-ada cross-referenced by PR #10269
- 2026-04-24T00:21:10Z @neo-opus-ada cross-referenced by #10272
- 2026-04-24T01:59:42Z @neo-opus-ada cross-referenced by #10284
- 2026-05-01T08:19:23Z @neo-opus-ada cross-referenced by #10556
- 2026-05-01T08:50:39Z @neo-opus-ada referenced in commit `13c77b3` - "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>"
- 2026-05-01T09:36:19Z @neo-opus-ada referenced in commit `160a008` - "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>"
- 2026-05-01T09:36:19Z @neo-opus-ada referenced in commit `55f5829` - "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>"
- 2026-05-01T10:05:45Z @neo-opus-ada referenced in commit `f8f58b7` - "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>"
- 2026-05-01T10:05:46Z @neo-opus-ada referenced in commit `8043ccd` - "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>"
- 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
- 2026-05-08T16:40:48Z @neo-opus-ada cross-referenced by PR #10977
- 2026-05-08T16:45:08Z @tobiu referenced in commit `2405528` - "feat(ai): migrate memory-core/Server to extend BaseServer + beforeToolDispatch hook (#10965) (#10977)

PR5 of the M2 series — final per-server consumer migration. Memory Core
is the most complex (580-line original; identity resolution, sibling
concurrency, wake substrate, custom boot order, identity-spoof guard).

memory-core/Server.mjs reduced 580 → 417 lines (~28% reduction; less
than other migrations because of irreducible per-server complexity:
resolveStdioIdentity, bindAgentIdentity, logSiblingConcurrency, +
custom boot order with stdio-identity-resolution between dependent-
services and healthcheck per #10249).

BaseServer extension (load-bearing for this migration):
- New optional override hook `beforeToolDispatch({toolName, args})`
  fires BEFORE the healthcheck gate. Throws bubble to outer CallTool
  try/catch and route to formatToolError (NOT formatHealthError,
  semantic distinction matters). Default no-op.
- Memory Core uses it for AuthMiddleware.validateNoIdentitySpoof per
  ticket #10145 — must fail-fast on identity-spoofing requests before
  any other processing.

memory-core's boot() override (preserves all original semantics):
1. loadCustomConfig
2. WakeSubscriptionService.init() — pre-mcpServer per #10437
3. createMcpServer (override chains super + CoalescingEngineService.
   addMcpServer for experimental neo-wake-substrate broadcast)
4. InferenceLifecycleService.ready + SessionService.ready
5. Stdio identity resolution + HealthService.setStdioIdentityState +
   wake-subscription auto-bootstrap (fire-and-forget IIFE per #10438
   single-error-boundary discipline) — BEFORE healthcheck so the
   boot snapshot reflects bound identity state per #10249
6. runHealthcheckAndLogStatus + logSiblingConcurrency
7. connectTransport + logIdentityStatus (stdio only)

Hooks used:
- getServerMetadata (with experimental neo-wake-substrate capability)
- getToolService, getHealthService, getHealthExemptTools
- wrapDispatch (RequestContextService.run binding for stdio identity)
- beforeToolDispatch (AuthMiddleware identity-spoof guard)
- buildRequestContext (SSE per-request OIDC binding)
- onSessionClosed (SSE: queueSummarizationJob + CoalescingEngine cleanup)
- logStartupStatus (Memory Core flavored output)
- createMcpServer (override chains super for CoalescingEngineService bind)

Helpers preserved (memory-core-private):
- resolveStdioIdentity, bindAgentIdentity (with #10241 retry pattern)
- logIdentityStatus, logCollectionStats, logSiblingConcurrency

Tests:
- BaseServer.spec.mjs: 27 → 28 unit tests; 1 new for beforeToolDispatch
  (verifies hook fires BEFORE health gate, throws route to
  formatToolError NOT formatHealthError)
- 258 existing memory-core unit specs pass (5 pre-existing Bucket G
  flakes unrelated to migration: GraphService #10941, PermissionService
  #10937, SessionSummarization)

Refs #10965. M2 per-server migration sequence COMPLETE: PR1 scaffold
(#10966 merged), PR2 knowledge-base (#10973 approved), PR3 github-workflow
(#10974 approved), PR4 neural-link + boot() seam (#10975 approved), PR5
memory-core (this), PR6 file-system (#10976 in review).

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-05-08T21:17:50Z @neo-opus-ada cross-referenced by #10996
- 2026-05-11T10:02:25Z @neo-opus-ada cross-referenced by PR #11200
- 2026-05-25T02:11:32Z @neo-opus-ada cross-referenced by PR #11949

