Context
#10146 (closed, shipped via PR #10167) codified the strict-isolation + opt-in visibility default policy for the mailbox substrate. That default is architecturally correct for multi-user / multi-tenant deployments of Memory Core — cross-tenant boundaries enforced via CAN_REPLY_TO grants plus the #10179 reachable-counterparty trust-lift.
However, the same default creates empirical bootstrap friction for homogeneous trusted-frontier swarms (the current local development scenario: Claude Opus + Gemini 3.1 Pro + human operator, all owned by a single operator, all mutually trusted). The friction surfaced this session: after #10250 landed the runtime bind fix and @neo-opus-ada ↔ @neo-gemini-pro could finally send authenticated messages, Claude's first direct DM to Gemini was rejected with "Unauthorized: Cannot send to @neo-gemini-pro. Requires CAN_REPLY_TO permission or prior message history." — classic first-message bootstrap. We worked around it via broadcast-to-AGENT:* (the same pattern Gemini's 14:57Z broadcast used), but in a swarm that scales to N homogeneous peers, O(N²) manual grant discipline is a real UX tax.
A rushed response is to flip the default globally — rip out the permission gate, delete enforcement code, default-open like Slack. This session empirically investigated that path (session 57b8fba5): it trades a single-tenant UX win for re-introduction debt against the roadmap's multi-user Memory Core deployment and any managed / external-organization installations. #10146's Avoided Traps section explicitly rejected implicit-trust reintroduction for the same reason.
Option C (this ticket) is the scope-correct alternative: a deployment-time config knob that selects the default policy ('blocked' | 'open'), with all existing primitives (grantPermission, CAN_REPLY_TO edges, reachable-counterparty trust-lift) preserved intact. Different deployments pick different defaults; the substrate supports both without conditional code paths beyond a single guard.
The Problem
Current MailboxService.addMessage at Server.mjs:106-139 gates non-broadcast DMs behind:
- Explicit
CAN_REPLY_TO graph edge from sentBy to to (via PermissionService.hasPermission), OR
- Reachable-counterparty trust-lift: iterate
SENT_TO edges where target ∈ {sentBy, AGENT:*}, find matching SENT_BY edge whose sender is to (#10179)
Absent either, the send is rejected Unauthorized.
For homogeneous single-tenant swarms (current local dev):
- Cost: bootstrap friction on first contact between any two agents that haven't broadcast or exchanged prior messages
- Value: near-zero — all identities belong to the same operator and are mutually trusted by construction
- Scaling profile: O(N²) grant edges or N broadcast rounds before full-mesh DM capability
For multi-user / multi-tenant deployments (roadmap per #9999):
- Cost: negligible — grants are explicit at tenant-onboarding time
- Value: load-bearing — enforces the tenant boundary; prevents cross-customer information leakage
- Scaling profile: bounded by tenant cardinality, not agent cardinality
One default cannot optimize both. The substrate needs a deployment-selected knob.
The Architectural Reality
Single enforcement surface that changes:
ai/mcp/server/memory-core/Server.mjs:106-139 — the only code path in the mailbox that consults CAN_REPLY_TO. (Note: listMessages / getMessage use CAN_READ_INBOX_OF, a different scope — this ticket does NOT touch those read paths. Read-side cross-tenant isolation stays strict per #10146 regardless of reply policy, because reading someone else's inbox is categorically different from sending them a message.)
Primitives that stay intact:
ai/mcp/server/memory-core/services/PermissionService.mjs — grantPermission / revokePermission / listPermissions / hasPermission all live, all callable; just unused in the default path when policy is 'open'
validScopes at PermissionService.mjs:42 — ['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO'] unchanged
- Reachable-counterparty trust-lift iteration — unchanged, still the bootstrap fallback for
'blocked' mode
CAN_REPLY_TO graph edges — still created by grantPermission, still queryable by listPermissions, still part of the Native Edge Graph consent topology (just with lower enforcement-path weight in 'open' deployments)
- Existing
MailboxService.spec.mjs permission-enforcement tests — stay as 'blocked'-mode regression guard with explicit setup
Config surface addition:
ai/mcp/server/memory-core/config.mjs — new top-level mailbox block carrying defaultReplyPolicy with full JSDoc covering the policy spectrum and deployment-tier reasoning
The Fix
1. Config surface
ai/mcp/server/memory-core/config.mjs:
mailbox: {
defaultReplyPolicy: 'open'
}Default choice rationale: 'open' for the shipped default because (a) matches the current local-dev reality, (b) multi-tenant deployments configure their own config.mjs as part of installation anyway, so the deployment default is a per-deployment choice not a library default. If operators disagree, the knob is one line.
2. Enforcement surface
ai/mcp/server/memory-core/Server.mjs (modify the existing block at :106-139, NO deletions):
if (!isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy && aiConfig.mailbox.defaultReplyPolicy === 'blocked') {
let canReply = PermissionService.hasPermission(sentBy, to, 'CAN_REPLY_TO');
}One conditional wrapping the existing gate. Zero deletions.
3. Test surface
test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs:
- Existing permission-enforcement tests: add
beforeEach that sets aiConfig.mailbox.defaultReplyPolicy = 'blocked' explicitly, afterEach that restores the original value. Symmetric reset per feedback_symmetric_spec_cleanup memory — Playwright fullyParallel interleaves specs within a worker, cross-singleton config mutations need both-ends guards.
- New
describe('open policy mode', ...) block — exercises:
- First-message bootstrap between two identities with no prior message history AND no
CAN_REPLY_TO grant → succeeds
grantPermission still callable; CAN_REPLY_TO edge still created; listPermissions returns it
- Revoking a grant in
'open' mode does not restore bootstrap-block (per spec — policy is global, not per-edge)
- Read-path (
listMessages / getMessage) still strict when not own inbox — CAN_READ_INBOX_OF isolation unchanged by reply policy
4. Documentation surface
learn/agentos/tooling/MemoryCoreMcpAuth.md — extend with a new section §Mailbox Reply Policy covering:
- The two policy modes + their deployment-tier correspondence
- How operators configure the knob per deployment
- The trust-lift's role when
'blocked' is selected
- Relationship to
CAN_READ_* scopes (independent)
Acceptance Criteria
Out of Scope
- Changing
CAN_READ_INBOX_OF / CAN_READ_MEMORIES_OF / CAN_READ_SESSIONS_OF read-scope enforcement — stays strict regardless of reply policy. Reading someone's inbox is categorically different from sending them a message; asymmetric treatment is intentional.
- Removing or deprecating
CAN_REPLY_TO scope — primitive stays, just with contextual enforcement.
- Per-identity or per-tenant policy granularity (e.g., "
'open' for these identities, 'blocked' for those"). Would require AgentIdentity-tier metadata; explicit non-goal for v1. Future ticket if empirical demand emerges from mixed-trust-tier deployments (e.g., a reduced-capability model like gemma4-31b joining a frontier swarm — may want 'blocked' default for lightweight agents only).
- Rate limiting. Orthogonal — rate-limit mitigation is mentioned in
MailboxService.mjs:117 as deferred until spam surface materializes empirically; this ticket does not couple policy selection to rate limiting.
- Any changes to
PermissionService.grantPermission target-existence guard (#10231 already landed, intact).
Avoided Traps
- "Flip the default globally and delete enforcement code" — rejected. This session empirically investigated the deletion path (session
57b8fba5): removes #10146's shipped primitive, creates v12.2 multi-user re-introduction debt, breaks the semantic coherence with the other three validScopes. Config-gated default preserves the primitive for deployments that need it.
- "Block-list inversion (default-open + explicit
CAN_NOT_RECEIVE_FROM edges)" — rejected. Would require inverting the edge semantics across the substrate; symmetric in graph shape but asymmetric in migration cost (existing CAN_REPLY_TO edges become dead).
- "Tenant-scoped default via
AgentIdentity.properties.tenant" — considered, deferred. Would require tenant metadata on existing seeds (@neo-opus-ada, @neo-gemini-pro, @tobiu) and introduces a primitive that isn't needed until multi-user Memory Core actually ships. Revisit when #10016 lands production-shape workloads.
- "Default
'blocked' to preserve secure-by-default" — considered, rejected for library default. Multi-user / multi-tenant deployments ship their own config.mjs as part of installation, so the deployment default is always explicit at that layer. Library default of 'open' matches the first-contact shape of local-dev consumers; operators running production installations configure both the OAuth2 layer and the reply policy as a deliberate deployment step. Knob is one line either direction.
Related
- Parent: #10139 (mailbox primitives parent; this is substrate-behavior config)
- #10146 (CLOSED, shipped via #10167) — codified the current strict-isolation default; this ticket does not revert it, it adds a deployment-time selector on top
- #10016 (multi-user / multi-tenant identity sub-epic under #9999) — primary consumer of the
'blocked' mode preservation
- #9999 (cloud-native / multi-tenant Memory Core epic) — transitive parent via #10139 / #10016
- #10179 (reachable-counterparty trust-lift, shipped) — complementary relaxation for
'blocked' mode bootstraps
- #10249 / PR #10250 — unblocked A2A runtime, enabling this ticket's empirical validation paths via
ai/mcp/client/mcp-cli.mjs fresh-spawn testing
Origin Session ID
Origin Session ID: 57b8fba5-2bd4-4446-ab46-6d1c9086a19d
Handoff Retrieval Hints
Retrieval Hint: "mailbox defaultReplyPolicy open blocked config gate"
Retrieval Hint: "Option C mailbox permission spectrum deployment tier"
Retrieval Hint: "single-tenant dev swarm vs multi-user deployment CAN_REPLY_TO"
Commit-range anchor: dev HEAD at ticket filing captures the post-#10250 state — bindAgentIdentity bound cleanly via await; #10179 trust-lift live.
Related memory context (query via query_raw_memories):
- Session
57b8fba5-2bd4-4446-ab46-6d1c9086a19d — Option A/B/C/D exploration + #10250 empirical validation + this ticket origin
- Session
5f746864-85c4-4b2c-bd79-64c76a8a2be3 — #10249/#10250 await-fix cycle (substrate behavior knowledge)
- Earlier sessions from #10146 codification chain referenced in #10146's body
Context
#10146 (closed, shipped via PR #10167) codified the strict-isolation + opt-in visibility default policy for the mailbox substrate. That default is architecturally correct for multi-user / multi-tenant deployments of Memory Core — cross-tenant boundaries enforced via
CAN_REPLY_TOgrants plus the#10179reachable-counterparty trust-lift.However, the same default creates empirical bootstrap friction for homogeneous trusted-frontier swarms (the current local development scenario: Claude Opus + Gemini 3.1 Pro + human operator, all owned by a single operator, all mutually trusted). The friction surfaced this session: after #10250 landed the runtime bind fix and
@neo-opus-ada ↔ @neo-gemini-procould finally send authenticated messages, Claude's first direct DM to Gemini was rejected with "Unauthorized: Cannot send to @neo-gemini-pro. Requires CAN_REPLY_TO permission or prior message history." — classic first-message bootstrap. We worked around it via broadcast-to-AGENT:*(the same pattern Gemini's 14:57Z broadcast used), but in a swarm that scales to N homogeneous peers, O(N²) manual grant discipline is a real UX tax.A rushed response is to flip the default globally — rip out the permission gate, delete enforcement code, default-open like Slack. This session empirically investigated that path (session
57b8fba5): it trades a single-tenant UX win for re-introduction debt against the roadmap's multi-user Memory Core deployment and any managed / external-organization installations. #10146's Avoided Traps section explicitly rejected implicit-trust reintroduction for the same reason.Option C (this ticket) is the scope-correct alternative: a deployment-time config knob that selects the default policy (
'blocked'|'open'), with all existing primitives (grantPermission,CAN_REPLY_TOedges, reachable-counterparty trust-lift) preserved intact. Different deployments pick different defaults; the substrate supports both without conditional code paths beyond a single guard.The Problem
Current
MailboxService.addMessageatServer.mjs:106-139gates non-broadcast DMs behind:CAN_REPLY_TOgraph edge fromsentBytoto(viaPermissionService.hasPermission), ORSENT_TOedges where target ∈ {sentBy,AGENT:*}, find matchingSENT_BYedge whose sender isto(#10179)Absent either, the send is rejected
Unauthorized.For homogeneous single-tenant swarms (current local dev):
For multi-user / multi-tenant deployments (roadmap per #9999):
One default cannot optimize both. The substrate needs a deployment-selected knob.
The Architectural Reality
Single enforcement surface that changes:
ai/mcp/server/memory-core/Server.mjs:106-139— the only code path in the mailbox that consultsCAN_REPLY_TO. (Note:listMessages/getMessageuseCAN_READ_INBOX_OF, a different scope — this ticket does NOT touch those read paths. Read-side cross-tenant isolation stays strict per #10146 regardless of reply policy, because reading someone else's inbox is categorically different from sending them a message.)Primitives that stay intact:
ai/mcp/server/memory-core/services/PermissionService.mjs—grantPermission/revokePermission/listPermissions/hasPermissionall live, all callable; just unused in the default path when policy is'open'validScopesatPermissionService.mjs:42—['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO']unchanged'blocked'modeCAN_REPLY_TOgraph edges — still created bygrantPermission, still queryable bylistPermissions, still part of the Native Edge Graph consent topology (just with lower enforcement-path weight in'open'deployments)MailboxService.spec.mjspermission-enforcement tests — stay as'blocked'-mode regression guard with explicit setupConfig surface addition:
ai/mcp/server/memory-core/config.mjs— new top-levelmailboxblock carryingdefaultReplyPolicywith full JSDoc covering the policy spectrum and deployment-tier reasoningThe Fix
1. Config surface
ai/mcp/server/memory-core/config.mjs:mailbox: { /** * Default reply policy for non-broadcast DMs. Controls whether `addMessage` to a * specific AgentIdentity target requires a prior CAN_REPLY_TO grant or reachable- * counterparty trust-lift (#10146 strict-isolation default), or accepts any * authenticated sender as a peer. * * - 'blocked' — strict-isolation default policy from #10146. Suited for multi-user / * multi-tenant Memory Core deployments and mixed-trust-tier installations where * cross-tenant boundaries must be enforced at the substrate. Cross-tenant reach * requires explicit `CAN_REPLY_TO` grants via `grantPermission`. Reachable- * counterparty trust-lift (#10179) relaxes the bootstrap once any broadcast or * direct message has flowed between the pair. * * - 'open' — peer-trust mode. Suited for homogeneous trusted-frontier swarms where * every authenticated identity is a peer owned by the same operator (e.g., local * development with Claude + Gemini + future frontier models). Eliminates the * first-message bootstrap tax. `CAN_REPLY_TO` edges still queryable via * `grant_permission` / `list_permissions`; enforcement simply skips the check. * Does NOT weaken read-path scoping (`CAN_READ_INBOX_OF` etc. remain strict). * * Runtime-live: operators changing this at runtime see subsequent `addMessage` * calls honor the new value; in-flight requests complete under the policy in * effect at entry. * * @member {'blocked'|'open'} defaultReplyPolicy='open' */ defaultReplyPolicy: 'open' }Default choice rationale:
'open'for the shipped default because (a) matches the current local-dev reality, (b) multi-tenant deployments configure their ownconfig.mjsas part of installation anyway, so the deployment default is a per-deployment choice not a library default. If operators disagree, the knob is one line.2. Enforcement surface
ai/mcp/server/memory-core/Server.mjs(modify the existing block at :106-139, NO deletions):// Check reply permission (strict-isolation mode only per aiConfig.mailbox.defaultReplyPolicy #<this-ticket>). // In 'open' mode (homogeneous trusted-frontier swarm), skip the gate entirely; all authenticated // peers can DM. PermissionService.grantPermission / revokePermission / CAN_REPLY_TO edges stay // fully live for operators who opt into strict mode or grant explicit permissions anyway. if (!isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy && aiConfig.mailbox.defaultReplyPolicy === 'blocked') { let canReply = PermissionService.hasPermission(sentBy, to, 'CAN_REPLY_TO'); // ... existing trust-lift iteration + throw — unchanged }One conditional wrapping the existing gate. Zero deletions.
3. Test surface
test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs:beforeEachthat setsaiConfig.mailbox.defaultReplyPolicy = 'blocked'explicitly,afterEachthat restores the original value. Symmetric reset perfeedback_symmetric_spec_cleanupmemory — PlaywrightfullyParallelinterleaves specs within a worker, cross-singleton config mutations need both-ends guards.describe('open policy mode', ...)block — exercises:CAN_REPLY_TOgrant → succeedsgrantPermissionstill callable;CAN_REPLY_TOedge still created;listPermissionsreturns it'open'mode does not restore bootstrap-block (per spec — policy is global, not per-edge)listMessages/getMessage) still strict when not own inbox —CAN_READ_INBOX_OFisolation unchanged by reply policy4. Documentation surface
learn/agentos/tooling/MemoryCoreMcpAuth.md— extend with a new section§Mailbox Reply Policycovering:'blocked'is selectedCAN_READ_*scopes (independent)Acceptance Criteria
ai/mcp/server/memory-core/config.mjsgainsmailbox.defaultReplyPolicywith'open'default and full JSDocServer.mjs:106permission gate wrapped inaiConfig.mailbox.defaultReplyPolicy === 'blocked'conditional; all other existing logic preservedbeforeEach/afterEachpolicy setup (symmetric reset)'open' policy modedescribe block exercises first-message bootstrap success, grant-still-callable, read-path-unchanged scenariosnpm run ai:mcp-client -- --server memory-core --call-tool add_message --args '{"to":"@neo-gemini-pro","subject":"Option C empirical","body":"..."}'succeeds on a first-contact send without prior history (currently fails with Unauthorized)defaultReplyPolicy: 'blocked'→ same send fails with Unauthorized → set back to'open'→ send succeedslearn/agentos/tooling/MemoryCoreMcpAuth.md§Mailbox Reply Policy documents the spectrum, including multi-user / multi-tenant deployment configuration guidancePermissionServiceunit tests unchanged (primitives intact)Out of Scope
CAN_READ_INBOX_OF/CAN_READ_MEMORIES_OF/CAN_READ_SESSIONS_OFread-scope enforcement — stays strict regardless of reply policy. Reading someone's inbox is categorically different from sending them a message; asymmetric treatment is intentional.CAN_REPLY_TOscope — primitive stays, just with contextual enforcement.'open'for these identities,'blocked'for those"). Would require AgentIdentity-tier metadata; explicit non-goal for v1. Future ticket if empirical demand emerges from mixed-trust-tier deployments (e.g., a reduced-capability model like gemma4-31b joining a frontier swarm — may want'blocked'default for lightweight agents only).MailboxService.mjs:117as deferred until spam surface materializes empirically; this ticket does not couple policy selection to rate limiting.PermissionService.grantPermissiontarget-existence guard (#10231 already landed, intact).Avoided Traps
57b8fba5): removes #10146's shipped primitive, creates v12.2 multi-user re-introduction debt, breaks the semantic coherence with the other threevalidScopes. Config-gated default preserves the primitive for deployments that need it.CAN_NOT_RECEIVE_FROMedges)" — rejected. Would require inverting the edge semantics across the substrate; symmetric in graph shape but asymmetric in migration cost (existingCAN_REPLY_TOedges become dead).AgentIdentity.properties.tenant" — considered, deferred. Would require tenant metadata on existing seeds (@neo-opus-ada,@neo-gemini-pro,@tobiu) and introduces a primitive that isn't needed until multi-user Memory Core actually ships. Revisit when #10016 lands production-shape workloads.'blocked'to preserve secure-by-default" — considered, rejected for library default. Multi-user / multi-tenant deployments ship their ownconfig.mjsas part of installation, so the deployment default is always explicit at that layer. Library default of'open'matches the first-contact shape of local-dev consumers; operators running production installations configure both the OAuth2 layer and the reply policy as a deliberate deployment step. Knob is one line either direction.Related
'blocked'mode preservation'blocked'mode bootstrapsai/mcp/client/mcp-cli.mjsfresh-spawn testingOrigin Session ID
Origin Session ID:
57b8fba5-2bd4-4446-ab46-6d1c9086a19dHandoff Retrieval Hints
Retrieval Hint:
"mailbox defaultReplyPolicy open blocked config gate"Retrieval Hint:"Option C mailbox permission spectrum deployment tier"Retrieval Hint:"single-tenant dev swarm vs multi-user deployment CAN_REPLY_TO"Commit-range anchor: dev HEAD at ticket filing captures the post-#10250 state —bindAgentIdentitybound cleanly via await; #10179 trust-lift live.Related memory context (query via
query_raw_memories):57b8fba5-2bd4-4446-ab46-6d1c9086a19d— Option A/B/C/D exploration + #10250 empirical validation + this ticket origin5f746864-85c4-4b2c-bd79-64c76a8a2be3— #10249/#10250 await-fix cycle (substrate behavior knowledge)