LearnNewsExamplesServices
Frontmatter
id10252
titleMailbox reply policy: config-gated default for deployment-tier selection
stateClosed
labels
enhancementaiarchitecturecore
assigneesneo-opus-ada
createdAtApr 23, 2026, 7:29 PM
updatedAtJun 7, 2026, 7:20 PM
githubUrlhttps://github.com/neomjs/neo/issues/10252
authorneo-opus-ada
commentsCount0
parentIssue10139
subIssues
10255 Per-agent blocking in open reply policy must override default-open (#10253 follow-up)
subIssuesCompleted0
subIssuesTotal1
blockedBy[]
blocking[]
closedAtApr 23, 2026, 8:05 PM

Mailbox reply policy: config-gated default for deployment-tier selection

Closed v13.0.0/archive-v13-0-0-chunk-5 enhancementaiarchitecturecore
neo-opus-ada
neo-opus-ada commented on Apr 23, 2026, 7:29 PM

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:

  1. Explicit CAN_REPLY_TO graph edge from sentBy to to (via PermissionService.hasPermission), OR
  2. 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.mjsgrantPermission / 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: {
    /**
     * 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 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):

// 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:

  • 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

  • ai/mcp/server/memory-core/config.mjs gains mailbox.defaultReplyPolicy with 'open' default and full JSDoc
  • Server.mjs:106 permission gate wrapped in aiConfig.mailbox.defaultReplyPolicy === 'blocked' conditional; all other existing logic preserved
  • Existing permission tests pass with explicit beforeEach/afterEach policy setup (symmetric reset)
  • New 'open' policy mode describe block exercises first-message bootstrap success, grant-still-callable, read-path-unchanged scenarios
  • Empirical validation: npm 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)
  • Empirical validation of the flip: temporarily set defaultReplyPolicy: 'blocked' → same send fails with Unauthorized → set back to 'open' → send succeeds
  • learn/agentos/tooling/MemoryCoreMcpAuth.md §Mailbox Reply Policy documents the spectrum, including multi-user / multi-tenant deployment configuration guidance
  • PermissionService unit tests unchanged (primitives intact)

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
tobiu referenced in commit a0685e6 - "feat(memory-core): config-gated mailbox default reply policy (#10252) (#10253) on Apr 23, 2026, 8:05 PM
tobiu closed this issue on Apr 23, 2026, 8:05 PM