Context
PR #10253 (resolves #10252) shipped the config-gated aiConfig.mailbox.defaultReplyPolicy: 'open' | 'blocked' selector. In 'open' mode (the library default for homogeneous trusted-frontier swarms), any authenticated agent can DM any other authenticated agent. All PermissionService primitives — grantPermission, revokePermission, CAN_REPLY_TO graph edges — remain live but the enforcement path in MailboxService.addMessage skips them entirely.
During Gemini's cross-family review of #10253, she flagged an architectural gap in her Depth Floor:
"If an operator explicitly uses revokePermission to remove a CAN_REPLY_TO edge in 'open' mode, it has no enforcement effect. We may eventually need a hybrid model or a negative BLOCKED_BY edge if local-swarm agent misbehavior becomes a real issue, but for now, the binary toggle is sufficient."
Tobi's response: "blocking should not be a no-op in open mode. definitely worth a ticket."
This ticket scopes that follow-up. The concern is real: currently the only way to isolate a single noisy or misbehaving agent is to flip the entire swarm to 'blocked' mode, which is disproportionate and breaks the UX for every well-behaved peer. Operators need per-agent enforcement granularity that works within the 'open' default.
The Problem
The existing validScopes in PermissionService.mjs:42 are all positive capability edges:
['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO']
In 'open' reply policy, the enforcement path at MailboxService.mjs:~110 skips the CAN_REPLY_TO grant check entirely. revokePermission successfully removes the edge from the graph, but the subsequent addMessage call still succeeds because the enforcement path is gated by strictReplyPolicy and the removed edge is never consulted.
Concretely, this means:
- Bob cannot mute a noisy Alice without flipping the whole deployment to
'blocked' mode
revokePermission(CAN_REPLY_TO, from: alice, to: bob) is effectively a no-op observable only via listPermissions
- The positive-capability-only scope set is architecturally insufficient for the
'open' mode's need
The Architectural Reality
The primitive needed is a negative-intent edge that operates as an override, not a capability. Unlike CAN_REPLY_TO (which grants reach in a default-deny world), BLOCKED_BY must revoke reach in a default-allow world.
Edge semantics (consistent with existing scope naming convention):
A BLOCKED_BY B — source: A (the blocked sender), target: B (the blocking recipient)
- Created by: recipient calls
grantPermission({to: sender-to-block, scope: 'BLOCKED_BY'}) — the "grant the block" framing parallels the existing capability-edge directionality
- Enforced in:
MailboxService.addMessage write-path, consulted in BOTH modes (see below)
Interaction with defaultReplyPolicy:
| Mode |
Default |
BLOCKED_BY effect |
'open' |
Allow |
Acts as per-agent override: hasPermission(sender, recipient, 'BLOCKED_BY') → reject with Unauthorized |
'blocked' |
Deny unless granted/trust-lifted |
Redundant-but-safe: if CAN_REPLY_TO is absent, block fires anyway; if CAN_REPLY_TO is present, BLOCKED_BY still overrides (block wins) |
The "block wins" semantic in 'blocked' mode matches operator intent — if a recipient has explicitly blocked a sender, re-granting CAN_REPLY_TO later via grantPermission should not silently re-enable reach. Block takes precedence until explicitly revoked.
Architectural relationship to #10251 (closed as superseded):
#10251 proposed BLOCKED_BY as a full substrate flip — replacing CAN_REPLY_TO with a blocklist model. That was rejected because it destroyed the multi-user Memory Core deployment path (per #10146 strict-isolation primitive). This ticket takes the scope name from #10251 but applies it as an ADDITIVE primitive alongside the config-gated default — the architectural shape #10251 should have had in the first place. Credit to Gemini for the original scope-name intuition.
The Fix
Extend validScopes in ai/mcp/server/memory-core/services/PermissionService.mjs:42:
validScopes = ['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO', 'BLOCKED_BY']
Wire enforcement in ai/mcp/server/memory-core/services/MailboxService.mjs:~106-140 (atop the existing config-gated block):
if (!isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) {
if (PermissionService.hasPermission(sentBy, to, 'BLOCKED_BY')) {
throw new Error(`Unauthorized: ${to} has blocked messages from ${sentBy}.`);
}
}
if (strictReplyPolicy && !isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) {
}
Tests (test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs):
- New describe block or additions to both existing
'blocked' + 'open' mode suites
- Scenarios:
'open' mode + Bob blocks Alice → Alice's DM to Bob rejected; Alice's broadcast reaches Bob (broadcasts bypass BLOCKED_BY — recipient-unaware at write time)
'open' mode + Bob blocks Alice → Bob's DM to Alice still succeeds (block is directional — Alice cannot reach Bob, but Bob can still reach Alice)
'blocked' mode + Bob grants CAN_REPLY_TO to Alice + Bob then blocks Alice → block wins, Alice's DM rejected
revokePermission({to: alice, scope: 'BLOCKED_BY'}) removes the block; Alice's DM resumes working (subject to the mode's base enforcement)
listPermissions surfaces BLOCKED_BY edges in the grantedToOthers projection
Documentation (learn/agentos/tooling/MemoryCoreMcpAuth.md):
- Extend §Cross-Tenant Permissions "Valid Scopes" list to include
BLOCKED_BY
- New subsection or extension under §Reply Policy Deployment Modes clarifying the "block wins" precedence and the broadcast bypass
Acceptance Criteria
Out of Scope
- Global allowlist/blocklist that applies to all senders regardless of grant state — defer until empirical demand
- Auto-expiry on BLOCKED_BY edges (e.g., "block expires after 24h") — additional complexity, defer until need
- Notification to blocked sender that their message was rejected — information-disclosure concern (unblocks attacker to probe block state); recipients should not be required to notify blockers by design
- Rate limiting — orthogonal concern mentioned in
MailboxService.mjs:117 but still deferred until the spam surface materializes empirically
- Role or human target blocking — blocks apply only to direct AgentIdentity → AgentIdentity edges; role/human targets remain write-permissive
- UI for block management — MCP tool surface is sufficient for agent-driven administration
Avoided Traps
- "Reuse
revokePermission(CAN_REPLY_TO) as the block primitive": rejected. That was Gemini's Depth Floor flag — in 'open' mode, the enforcement path doesn't consult the scope, so revocation is a no-op. Positive capability revocation is architecturally wrong for negative intent; a dedicated negative-intent edge type is the coherent solution.
- "Flip to blocklist substrate (replace CAN_REPLY_TO with BLOCKED_BY)": rejected per #10251's supersession rationale. BLOCKED_BY is ADDITIVE to CAN_REPLY_TO, not a replacement. Both modes retain their existing semantics; BLOCKED_BY operates as an override layer above them.
- "Make BLOCKED_BY only fire in
'open' mode": rejected. Operators blocking in 'blocked' mode expect the block to persist even if they later re-grant CAN_REPLY_TO (e.g., for a scoped trust window). Block wins across modes is the coherent precedence.
- "Use reachable-counterparty SENT_TO edge deletion as the block mechanism": rejected. SENT_TO edges are write-side history artifacts; mutating them to effect blocks would corrupt the audit trail and break thread reconstruction. Block must be a separate edge type.
Related
- Parent: #10252 (config-gated reply policy — this ticket extends the spectrum)
- #10253 (PR merging #10252) — source of the Depth Floor flag that filed this
- #10251 (CLOSED as superseded — originally proposed
BLOCKED_BY as substrate flip; this ticket is the refined additive-primitive version)
- #10146 (cross-tenant permission edges framework — extends the validScopes set this ticket modifies)
- #10139 (mailbox A2A primitive — transitive parent)
- #10179 (reachable-counterparty trust-lift — block wins over trust-lift in
'blocked' mode, per precedence)
- #9999 (cloud-native multi-tenant epic — multi-user deployments will exercise this primitive as part of noisy-tenant mitigation)
Origin Session ID
Origin Session ID: 7ad90eb3-2218-4200-977b-fbfe546d64a8
Handoff Retrieval Hints
Retrieval Hint: "BLOCKED_BY open mode per-agent mute reply policy override"
Retrieval Hint: "additive negative-intent edge type vs substrate flip"
Retrieval Hint: "block wins precedence CAN_REPLY_TO re-grant"
Cross-reference: PR #10253 comments (Gemini's Depth Floor challenge + tobi's response authorizing this follow-up)
Context
PR #10253 (resolves #10252) shipped the config-gated
aiConfig.mailbox.defaultReplyPolicy: 'open' | 'blocked'selector. In'open'mode (the library default for homogeneous trusted-frontier swarms), any authenticated agent can DM any other authenticated agent. AllPermissionServiceprimitives —grantPermission,revokePermission,CAN_REPLY_TOgraph edges — remain live but the enforcement path inMailboxService.addMessageskips them entirely.During Gemini's cross-family review of #10253, she flagged an architectural gap in her Depth Floor:
Tobi's response: "blocking should not be a no-op in open mode. definitely worth a ticket."
This ticket scopes that follow-up. The concern is real: currently the only way to isolate a single noisy or misbehaving agent is to flip the entire swarm to
'blocked'mode, which is disproportionate and breaks the UX for every well-behaved peer. Operators need per-agent enforcement granularity that works within the'open'default.The Problem
The existing
validScopesinPermissionService.mjs:42are all positive capability edges:['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO']In
'open'reply policy, the enforcement path atMailboxService.mjs:~110skips theCAN_REPLY_TOgrant check entirely.revokePermissionsuccessfully removes the edge from the graph, but the subsequentaddMessagecall still succeeds because the enforcement path is gated bystrictReplyPolicyand the removed edge is never consulted.Concretely, this means:
'blocked'moderevokePermission(CAN_REPLY_TO, from: alice, to: bob)is effectively a no-op observable only vialistPermissions'open'mode's needThe Architectural Reality
The primitive needed is a negative-intent edge that operates as an override, not a capability. Unlike
CAN_REPLY_TO(which grants reach in a default-deny world),BLOCKED_BYmust revoke reach in a default-allow world.Edge semantics (consistent with existing scope naming convention):
A BLOCKED_BY B— source: A (the blocked sender), target: B (the blocking recipient)grantPermission({to: sender-to-block, scope: 'BLOCKED_BY'})— the "grant the block" framing parallels the existing capability-edge directionalityMailboxService.addMessagewrite-path, consulted in BOTH modes (see below)Interaction with
defaultReplyPolicy:'open'hasPermission(sender, recipient, 'BLOCKED_BY')→ reject with Unauthorized'blocked'The "block wins" semantic in
'blocked'mode matches operator intent — if a recipient has explicitly blocked a sender, re-grantingCAN_REPLY_TOlater viagrantPermissionshould not silently re-enable reach. Block takes precedence until explicitly revoked.Architectural relationship to #10251 (closed as superseded):
#10251 proposed
BLOCKED_BYas a full substrate flip — replacingCAN_REPLY_TOwith a blocklist model. That was rejected because it destroyed the multi-user Memory Core deployment path (per #10146 strict-isolation primitive). This ticket takes the scope name from #10251 but applies it as an ADDITIVE primitive alongside the config-gated default — the architectural shape #10251 should have had in the first place. Credit to Gemini for the original scope-name intuition.The Fix
Extend
validScopesinai/mcp/server/memory-core/services/PermissionService.mjs:42:validScopes = ['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO', 'BLOCKED_BY']Wire enforcement in
ai/mcp/server/memory-core/services/MailboxService.mjs:~106-140(atop the existing config-gated block):// BLOCKED_BY check fires in BOTH reply-policy modes — operator explicit blocks // always override both the 'open' default-allow AND the 'blocked'-mode reachable- // counterparty trust-lift. Block wins; re-granting CAN_REPLY_TO does not silently // re-enable reach. To restore reach, the recipient must explicitly revokePermission // the BLOCKED_BY edge. if (!isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) { if (PermissionService.hasPermission(sentBy, to, 'BLOCKED_BY')) { throw new Error(`Unauthorized: ${to} has blocked messages from ${sentBy}.`); } } // existing strict-mode check (unchanged) if (strictReplyPolicy && !isRoleOrHuman && to !== 'AGENT:*' && to !== sentBy) { // ... existing CAN_REPLY_TO grant check + trust-lift ... }Tests (
test/playwright/unit/ai/mcp/server/memory-core/services/MailboxService.spec.mjs):'blocked'+'open'mode suites'open'mode + Bob blocks Alice → Alice's DM to Bob rejected; Alice's broadcast reaches Bob (broadcasts bypass BLOCKED_BY — recipient-unaware at write time)'open'mode + Bob blocks Alice → Bob's DM to Alice still succeeds (block is directional — Alice cannot reach Bob, but Bob can still reach Alice)'blocked'mode + Bob grants CAN_REPLY_TO to Alice + Bob then blocks Alice → block wins, Alice's DM rejectedrevokePermission({to: alice, scope: 'BLOCKED_BY'})removes the block; Alice's DM resumes working (subject to the mode's base enforcement)listPermissionssurfaces BLOCKED_BY edges in thegrantedToOthersprojectionDocumentation (
learn/agentos/tooling/MemoryCoreMcpAuth.md):BLOCKED_BYAcceptance Criteria
BLOCKED_BYadded tovalidScopesarray inPermissionService.mjsMailboxService.addMessageabove the existing config-gated check; fires in BOTH'open'and'blocked'modes'blocked'mode — re-granting does not silently restore reachto: 'AGENT:*') bypass BLOCKED_BY — recipients filter at read time, not at write timegrantPermission({to, scope: 'BLOCKED_BY'})/revokePermission({to, scope: 'BLOCKED_BY'})/listPermissionsall work with the new scope without additional code changes (validScopes extension suffices)MemoryCoreMcpAuth.mdupdated with the new scope in the Valid Scopes table and a block-precedence note in §Reply Policy Deployment Modesai/mcp/client/mcp-cli.mjs: manually block a peer, verify DM fails; revoke block, verify DM resumesOut of Scope
MailboxService.mjs:117but still deferred until the spam surface materializes empiricallyAvoided Traps
revokePermission(CAN_REPLY_TO)as the block primitive": rejected. That was Gemini's Depth Floor flag — in'open'mode, the enforcement path doesn't consult the scope, so revocation is a no-op. Positive capability revocation is architecturally wrong for negative intent; a dedicated negative-intent edge type is the coherent solution.'open'mode": rejected. Operators blocking in'blocked'mode expect the block to persist even if they later re-grantCAN_REPLY_TO(e.g., for a scoped trust window). Block wins across modes is the coherent precedence.Related
BLOCKED_BYas substrate flip; this ticket is the refined additive-primitive version)'blocked'mode, per precedence)Origin Session ID
Origin Session ID:
7ad90eb3-2218-4200-977b-fbfe546d64a8Handoff Retrieval Hints
Retrieval Hint:
"BLOCKED_BY open mode per-agent mute reply policy override"Retrieval Hint:"additive negative-intent edge type vs substrate flip"Retrieval Hint:"block wins precedence CAN_REPLY_TO re-grant"Cross-reference: PR #10253 comments (Gemini's Depth Floor challenge + tobi's response authorizing this follow-up)