---
id: 10255
title: Per-agent blocking in open reply policy must override default-open (#10253 follow-up)
state: CLOSED
labels:
  - enhancement
  - ai
  - architecture
  - core
assignees:
  - neo-opus-ada
createdAt: '2026-04-23T18:12:07Z'
updatedAt: '2026-04-23T22:13:43Z'
githubUrl: 'https://github.com/neomjs/neo/issues/10255'
author: neo-opus-ada
commentsCount: 0
parentIssue: 10252
subIssues: []
subIssuesCompleted: 0
subIssuesTotal: 0
blockedBy: []
blocking: []
closedAt: '2026-04-23T22:13:43Z'
---
# Per-agent blocking in open reply policy must override default-open (#10253 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. 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:

```js
['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

1. **Extend `validScopes` in `ai/mcp/server/memory-core/services/PermissionService.mjs:42`**:
   ```js
   validScopes = ['CAN_READ_INBOX_OF', 'CAN_READ_MEMORIES_OF', 'CAN_READ_SESSIONS_OF', 'CAN_REPLY_TO', 'BLOCKED_BY']
   ```

2. **Wire enforcement in `ai/mcp/server/memory-core/services/MailboxService.mjs:~106-140`** (atop the existing config-gated block):
   ```js
   // 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 ...
   }
   ```

3. **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

4. **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

- [ ] `BLOCKED_BY` added to `validScopes` array in `PermissionService.mjs`
- [ ] Enforcement wired in `MailboxService.addMessage` above the existing config-gated check; fires in BOTH `'open'` and `'blocked'` modes
- [ ] Block semantic is directional — A blocking B rejects B's DMs to A, does not affect A's DMs to B
- [ ] Block wins over CAN_REPLY_TO grants in `'blocked'` mode — re-granting does not silently restore reach
- [ ] Broadcasts (`to: 'AGENT:*'`) bypass BLOCKED_BY — recipients filter at read time, not at write time
- [ ] `grantPermission({to, scope: 'BLOCKED_BY'})` / `revokePermission({to, scope: 'BLOCKED_BY'})` / `listPermissions` all work with the new scope without additional code changes (validScopes extension suffices)
- [ ] 4+ regression tests covering the scenarios above
- [ ] `MemoryCoreMcpAuth.md` updated with the new scope in the Valid Scopes table and a block-precedence note in §Reply Policy Deployment Modes
- [ ] Empirical validation via `ai/mcp/client/mcp-cli.mjs`: manually block a peer, verify DM fails; revoke block, verify DM resumes

# 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)

## Timeline

- 2026-04-23T18:12:09Z @neo-opus-ada assigned to @neo-opus-ada
- 2026-04-23T18:12:09Z @neo-opus-ada added the `enhancement` label
- 2026-04-23T18:12:09Z @neo-opus-ada added the `ai` label
- 2026-04-23T18:12:09Z @neo-opus-ada added the `architecture` label
- 2026-04-23T18:12:10Z @neo-opus-ada added the `core` label
- 2026-04-23T18:13:20Z @neo-opus-ada added parent issue #10252
- 2026-04-23T21:50:19Z @neo-gemini-pro cross-referenced by PR #10266
- 2026-04-23T22:13:43Z @tobiu referenced in commit `770cc6b` - "feat(ai): per-agent blocking in open reply policy (#10255) (#10266)

* chore: ticket sync [skip ci]

* feat(ai): per-agent blocking in open reply policy (#10255)

---------

Co-authored-by: tobiu <tobiasuhlig78@gmail.com>"
- 2026-04-23T22:13:43Z @tobiu closed this issue
- 2026-04-23T22:17:40Z @neo-opus-ada cross-referenced by PR #10263
- 2026-04-23T22:22:29Z @neo-opus-ada cross-referenced by #9999
- 2026-04-24T00:21:10Z @neo-opus-ada cross-referenced by #10272

