From 9a20b118b5946b49af5c043eec2bab82d8332e4d Mon Sep 17 00:00:00 2001 From: Grace Date: Mon, 24 Aug 2026 00:41:23 +0200 Subject: [PATCH 1/3] fix(memory-core): every AGENT:* broadcast is quiet by default (#17646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The acceptance layer suppressed broadcast wakes only when a subject or taggedConcept matched one of four collision tags. The routine broadcast vocabulary is far wider — pr-merged, merge-readiness, PR-opened, handoff, defect-note, runtime-maintenance — so every class outside those four woke every active seat unless its author remembered the flag, and authors remembered unevenly. The predecessor (#15987) fixed a matcher that was too literal. The same failure recurred one level up as a vocabulary that was too small, so this keys on addressing instead: a broadcast cannot be action-required for everyone, or it would be addressed to someone. `to === 'AGENT:*'` needs no vocabulary to stay current. Direct messages are untouched and still wake — the noise was never in 1:1 traffic. Waking the fleet becomes a sender election via explicit `wakeSuppressed: false`. Because that is now the only way a broadcast reaches anyone as an interrupt, `priority: 'high'` on a broadcast without the election is rejected rather than silently accepted: it would read as urgent in every listing while reaching no one in time. Two production senders needed the election, both stall-alarm watchdogs whose broadcasts are wrapped in a catch that only logs — omitting it would have left them mute rather than merely quiet, so they now declare the fleet-wide wake they always intended. `a2aCollisionTags` keeps its one remaining consumer (fleet activity reads it to identify lane claims — a question about what a message IS, not how loudly it arrives). Two existing arms moved with the property rather than being deleted: asserting suppression at the mailbox would now stay green with the classifier removed entirely. Skill-loaded substrate nets -101 bytes. Co-Authored-By: Grace --- .../peer-role/references/peer-role-mode.md | 2 +- ai/daemons/orchestrator/Orchestrator.mjs | 12 +- ai/mcp/server/memory-core/openapi.yaml | 4 +- ai/services/memory-core/MailboxService.mjs | 38 +++- ai/services/shared/a2aCollisionTags.mjs | 12 +- learn/agentos/A2A.md | 28 ++- .../memory-core/MailboxService.spec.mjs | 166 +++++++++++++++++- 7 files changed, 234 insertions(+), 28 deletions(-) diff --git a/.agents/skills/peer-role/references/peer-role-mode.md b/.agents/skills/peer-role/references/peer-role-mode.md index 72e79e70b9..67e05b9858 100644 --- a/.agents/skills/peer-role/references/peer-role-mode.md +++ b/.agents/skills/peer-role/references/peer-role-mode.md @@ -119,7 +119,7 @@ add_message({ }); ``` -**Wake-control (the `add_message` `wakeSuppressed` param; #12635 · #14576 · #15987):** classify by **recipient actionability, not primitive name**. **Wake** (omit `wakeSuppressed`): direct review / re-review, `REQUEST_CHANGES`, `[lane-override]`, or coordination overlapping the recipient's active or owned surface. **Claim-class broadcasts are status, not interrupts:** `[lane-claim]` / `[review-claim]` / `[claim-corrected]` / `[drive-claimed]` on `AGENT:*` default quiet (collision safety lives at the claim surfaces — assignee gate + intake re-check); a contested-lane resolution sets `wakeSuppressed: false` explicitly. Use `priority: 'high'` only for act-now wakes: direct review / re-review, lane-unblock, operator relay, contested-lane resolution, `REQUEST_CHANGES`, or owned-surface overlap; other unsuppressed traffic stays `normal`. **May suppress** (`wakeSuppressed: true`): ordinary non-overlapping awareness — observer notes, lane-progress pings, acks; the recipient reads them at their next `list_messages`. Suppressed actionable *direct* lifecycle subjects are still rejected mechanically. (Additive to the session-sunset self-DM suppression, which stays valid.) +**Wake-control (the `add_message` `wakeSuppressed` param; #12635 · #14576 · #15987 · #17646):** **the address decides, not the subject.** Every `AGENT:*` broadcast is quiet by default — no tag vocabulary to remember, and collision safety lives at the claim surfaces (assignee gate + intake re-check). Waking the fleet is an explicit election, `wakeSuppressed: false`, reserved for what every seat must act on now (contested-lane resolution, stalled-pipeline alarm); `priority: 'high'` on a broadcast is **rejected** without it. **Direct** messages still wake by default — that is where actionability lives: review / re-review, `REQUEST_CHANGES`, `[lane-override]`, lane-unblock, operator relay, owned-surface overlap. Suppress a direct message (`wakeSuppressed: true`) only for non-overlapping awareness — observer notes, lane-progress, acks; suppressed actionable direct lifecycle subjects are rejected mechanically. Keep `priority: 'high'` for act-now direct traffic; everything else stays `normal`. (Additive to the session-sunset self-DM suppression, which stays valid.) ### 6.5.1 Lane-Override Protocol (`[lane-override]`) diff --git a/ai/daemons/orchestrator/Orchestrator.mjs b/ai/daemons/orchestrator/Orchestrator.mjs index 6fb51552db..df09057f28 100644 --- a/ai/daemons/orchestrator/Orchestrator.mjs +++ b/ai/daemons/orchestrator/Orchestrator.mjs @@ -343,7 +343,13 @@ export class Orchestrator extends Base { try { await RequestContextService.run({agentIdentityNodeId: sender}, async () => { - await MailboxService.addMessage({to: 'AGENT:*', subject, body, priority: 'high'}); + // `wakeSuppressed: false` is REQUIRED here, not decorative: `AGENT:*` fan-out is + // quiet by default, so a stall alarm that omitted it would persist silently and + // reach seats only at their next natural intake — and the `catch` below would turn + // the coherence rejection into one log line, leaving the watchdog mute. A stalled + // embed pipeline degrades semantic recall for every seat at once, which is the + // narrow class the fleet-wide election exists for. + await MailboxService.addMessage({to: 'AGENT:*', subject, body, priority: 'high', wakeSuppressed: false}); }); } catch (e) { this.writeLog('ERROR', `[Orchestrator] embed-drain stall-alarm A2A broadcast failed: ${e.message}`); @@ -404,7 +410,9 @@ export class Orchestrator extends Base { try { await RequestContextService.run({agentIdentityNodeId: sender}, async () => { - await MailboxService.addMessage({to: 'AGENT:*', subject, body, priority: 'high'}); + // Explicit wake election — see the embed-drain alarm above for why omission would + // make this watchdog mute rather than merely quiet. + await MailboxService.addMessage({to: 'AGENT:*', subject, body, priority: 'high', wakeSuppressed: false}); }); } catch (e) { this.writeLog('ERROR', `[Orchestrator] REM consolidation stall-alarm A2A broadcast failed: ${e.message}`); diff --git a/ai/mcp/server/memory-core/openapi.yaml b/ai/mcp/server/memory-core/openapi.yaml index 1b46cff15e..a5c0322605 100644 --- a/ai/mcp/server/memory-core/openapi.yaml +++ b/ai/mcp/server/memory-core/openapi.yaml @@ -2123,7 +2123,7 @@ paths: priority: type: string enum: [low, normal, high] - description: "Delivery priority. Omit to let the server set it from sender class: normal for an agent, high for an operator (turn-start drain ordering)." + description: "Delivery priority. Omit to let the server set it from sender class: normal for an agent, high for an operator (turn-start drain ordering). On an AGENT:* broadcast, high is rejected unless you also set wakeSuppressed: false — broadcasts are quiet by default, so high alone would claim urgency nothing wakes for." partOfThread: type: string taggedConcepts: @@ -2132,7 +2132,7 @@ paths: type: string wakeSuppressed: type: boolean - description: "Persist + deliver but emit NO wake: the recipient sees it on their next list_messages, not as an interrupt — in autonomous mode, not until they next list or boot. Use for awareness/FYI (PR-opened-you're-not-the-reviewer, lane-progress, acks) and sunset self-DM handovers. Never suppress actionable messages — you're the reviewer, a lane was claimed on you, REQUEST_CHANGES; those subjects are rejected. Omit and the server decides: claim-class AGENT:* broadcasts and operator-sent messages are quiet, everything else wakes." + description: "Persist + deliver but emit NO wake: the recipient sees it on their next list_messages, not as an interrupt — in autonomous mode, not until they next list or boot. Omit and the server decides: every AGENT:* broadcast is quiet, as are operator-sent messages; direct messages wake. To wake the whole fleet you must elect it with wakeSuppressed: false — reserve that for something every seat must act on now. On a direct message use true for awareness/FYI (PR-opened-you're-not-the-reviewer, lane-progress, acks) and sunset self-DM handovers; never for actionable ones — you're the reviewer, a lane was claimed on you, REQUEST_CHANGES; those subjects are rejected." task: type: object additionalProperties: true diff --git a/ai/services/memory-core/MailboxService.mjs b/ai/services/memory-core/MailboxService.mjs index ba880c2801..4358686a48 100644 --- a/ai/services/memory-core/MailboxService.mjs +++ b/ai/services/memory-core/MailboxService.mjs @@ -6,7 +6,6 @@ import {canonicalizeTaggedConceptIds} from '../graph/conceptSpineCanon import GraphService from './GraphService.mjs'; import PermissionService from './PermissionService.mjs'; import WakeSubscriptionService from './WakeSubscriptionService.mjs'; -import {collisionPreventionTag} from '../shared/a2aCollisionTags.mjs'; import { TASK_ASSIGNMENT_AUTHORITY, TASK_STATES, @@ -2405,15 +2404,26 @@ class MailboxService extends Base { // 'human' inverts the delivery defaults: durable-quiet (wake is the sender's per-message // election, never the default) and priority-high as turn-start drain-ordering metadata. const senderPrincipalClass = resolveSenderPrincipalClass(db, sentBy), - operatorSteering = senderPrincipalClass === 'human'; + operatorSteering = senderPrincipalClass === 'human', + // Captured BEFORE the defaults resolve below. The coherence gate has to tell "the + // sender elected a wake" apart from "the default left this loud", and after the `??` + // those two states are the same value. + wakeElectedBySender = wakeSuppressed === false; priority = priority ?? (operatorSteering ? 'high' : 'normal'); - // Claim-class broadcasts are quiet by default (operator-directed 2026-07-26, superseding the - // wake-mandatory polarity): a claim's collision defense lives at the claim surfaces (the - // `requireUnassigned` assignee gate + intake's claim-race re-check), while a forced wake - // taxed every active seat per claim. Explicit `wakeSuppressed: false` still wakes — the - // contested-lane escalation stays a sender election, never a default. Scoped to `AGENT:*` - // fan-out; direct messages keep the plain default. + // A broadcast cannot be action-required for everyone — if it were, it would be addressed to + // someone. So `AGENT:*` fan-out is quiet by DEFAULT, and a wake is the sender's election via + // explicit `wakeSuppressed: false`. That is the polarity `learn/agentos/A2A.md` already + // describes; what changed is that the implementation now covers it. + // + // This supersedes a four-tag allowlist that delivered the same sentence for claim-class + // subjects only. The routine broadcast vocabulary is far wider — `pr-merged`, + // `merge-readiness`, `PR-opened`, `handoff`, `defect-note`, `runtime-maintenance` — so every + // class outside those four woke every active seat unless its author remembered the flag, + // and authors remembered unevenly. The predecessor fixed a matcher that was too literal; + // the same failure then recurred one level up as a vocabulary that was too small. Addressing + // is the signal that needs no vocabulary to stay current, which is why the rule keys on it. + // Direct messages keep the plain default and still wake: the noise was never in 1:1 traffic. // // Both this line and the `priority` one above depend on the field arriving ABSENT, and that // is a contract with the tool schema, not a local property: `buildZodSchema` compiles a @@ -2422,7 +2432,17 @@ class MailboxService extends Base { // value is never nullish, and `??` cannot reach either branch — silently, since the injected // value is a legal one. So `add_message` declares no default for either field: a request // field may carry a schema default OR a service-side contextual default, never both. - wakeSuppressed = wakeSuppressed ?? (operatorSteering || (to === 'AGENT:*' && !!collisionPreventionTag({subject, taggedConcepts}))); + wakeSuppressed = wakeSuppressed ?? (operatorSteering || to === 'AGENT:*'); + + // `priority: 'high'` and a suppressed wake are contradictory instructions, and the pair was + // accepted silently: the message reads as urgent in every listing while nothing wakes for + // it. Under the quiet default that state stops being an authoring slip and becomes the + // common case, so the two knobs must agree on `AGENT:*` — `high` requires the sender to + // have elected the wake. Operator steering is exempt by construction: that class is + // durable-quiet, and its `high` is turn-start drain-ordering metadata, not urgency. + if (to === 'AGENT:*' && priority === 'high' && !wakeElectedBySender && !operatorSteering) { + throw new Error("Cannot send a 'high' priority AGENT:* broadcast without an explicit wake election: broadcasts are quiet by default, so 'high' would claim urgency nothing wakes for. Set wakeSuppressed: false to wake the fleet, or use priority: 'normal'."); + } // Canonicalize addressing to match the seeded AgentIdentity graph-node IDs. Upstream tool- // schema wording exposes the `'AGENT:@login'` prefixed form; the seed uses bare `@login`. diff --git a/ai/services/shared/a2aCollisionTags.mjs b/ai/services/shared/a2aCollisionTags.mjs index 94d8e7d915..8cea604b33 100644 --- a/ai/services/shared/a2aCollisionTags.mjs +++ b/ai/services/shared/a2aCollisionTags.mjs @@ -13,14 +13,22 @@ * copy-pasted into two services, and a fix in one place left the other wrong: the wake guard was * repaired while a fleet-activity copy of the old anchored regex kept undercounting live claims. * A tag that lives in two places drifts. + * + * **The mailbox wake seam is no longer a consumer.** `MailboxService` once used this reader to pick + * which broadcasts default to quiet; `AGENT:*` fan-out is now quiet unconditionally, so that seam + * needs no vocabulary and asks nothing here. This module is NOT dead: `fleetA2AActivityAdapter` + * still reads it to identify lane claims, which is a question about what a message IS, not about + * how loudly it should arrive. That is the durable use — the classification outlived the delivery + * policy that first needed it, which is the argument for centralizing it rather than inlining a + * matcher at the seam. */ /** * The collision-prevention tag vocabulary. PRIVATE to this module — the consumed contract is the * reader below, never the Set: an exported mutable Set lets any importer rewrite every consumer's * classifier at once (`.delete()` is a silent global veto). Consumers decide per-surface how WIDE - * their own question is (the mailbox quiet-by-default seam fires on any member; fleet activity counts only - * `lane-claim`) by comparing against the returned tag name. + * their own question is (fleet activity counts only `lane-claim`) by comparing against the returned + * tag name. * @type {Set} */ const COLLISION_PREVENTION_TAGS = new Set([ diff --git a/learn/agentos/A2A.md b/learn/agentos/A2A.md index 28187f1869..3934132926 100644 --- a/learn/agentos/A2A.md +++ b/learn/agentos/A2A.md @@ -57,12 +57,28 @@ and concept graph. The wake convention follows from that separation: quiet is the default for status broadcasts, and a wake is reserved for action the recipient must take. -Claim-class broadcasts (`lane-claim`, `review-claim`, `claim-corrected`, -`drive-claimed`) default to `wakeSuppressed` at the acceptance layer — peers -read them at their next natural intake, and claim collisions stay fail-closed -at the claim surfaces (the assignee gate plus intake's claim-race re-check). A -contested-lane resolution that genuinely must interrupt is a sender election -via explicit `wakeSuppressed: false`, never a default. +The acceptance layer implements that by address, not by vocabulary — **every +`AGENT:*` broadcast defaults to `wakeSuppressed`.** A broadcast cannot be +action-required for everyone; if it were, it would be addressed to someone. +Peers read them at their next natural intake, and claim collisions stay +fail-closed at the claim surfaces (the assignee gate plus intake's claim-race +re-check) rather than in the wake. + +Direct messages are untouched and still wake: the interrupt belongs to 1:1 +traffic, where the recipient is named because they have to act. + +Something that genuinely must interrupt the whole fleet — a contested-lane +resolution, a stalled-pipeline alarm — is a sender election via explicit +`wakeSuppressed: false`, never a default. Because that election is now the only +way a broadcast wakes anyone, `priority: 'high'` on an `AGENT:*` message is +rejected unless it accompanies one: a high-priority broadcast nothing wakes for +would read as urgent in every listing while reaching no one in time. + +*(This scoping was previously the four claim-class tags — `lane-claim`, +`review-claim`, `claim-corrected`, `drive-claimed`. That covered one class of +the routine broadcast vocabulary and left `pr-merged`, `merge-readiness`, +`PR-opened`, `handoff` and the rest waking every seat unless their author +remembered the flag.)* ## How A Message Moves diff --git a/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs b/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs index da5b3a237c..4451728092 100644 --- a/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs +++ b/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs @@ -21,6 +21,8 @@ import Neo from '../../../../../../src/Neo.mjs'; import * as core from '../../../../../../src/core/_export.mjs'; import '../../../../../../src/manager/Instance.mjs'; import RequestContextService from '../../../../../../ai/mcp/server/shared/services/RequestContextService.mjs'; +// Static import is safe: a pure classifier with no imports of its own, so it pulls in no Neo graph. +import {collisionPreventionTag} from '../../../../../../ai/services/shared/a2aCollisionTags.mjs'; test.describe.configure({ mode: 'serial' }); @@ -3694,6 +3696,140 @@ test.describe('Neo.ai.services.memory-core.MailboxService', () => { expect(GraphService.db.nodes.get(ids.directOmitted).properties.wakeSuppressed).toBe(false); }); + /** + * @summary The quiet default is scoped to `AGENT:*` fan-out, not to a tag vocabulary. + * + * The predecessor suppressed broadcasts only when `collisionPreventionTag` matched, so + * the routine lifecycle vocabulary — `pr-merged`, `merge-readiness`, `PR-opened`, `handoff`, + * `defect-note` — woke every active seat unless its author remembered the flag. Every subject + * below is chosen so the classifier returns `null`: an arm using a claim-class subject would + * pass under the OLD code too and would prove nothing about the change. + */ + test('#17646 an AGENT:* broadcast is quiet with the flag omitted WHATEVER its subject', async () => { + // Real lifecycle subjects from the live corpus, none of them collision-classified. + const lifecycleSubjects = [ + '[pr-merged][PR #17652 @ b909f8baa2][#17647 closed]', + '[merge-readiness][PR #17652][APPROVED cross-family · CLEAN] → @tobiu', + '[PR-opened][PR #17653 → #17533]', + '[handoff][claimable][PR #17650 / #17645]', + '[defect-note] manage_wake_subscription resync throws on an empty endpoint', + '[runtime-maintenance-complete][OC/MC/KB/Fleet][7f608560b0]' + ]; + + const ids = []; + + await RequestContextService.run({agentIdentityNodeId: '@alice'}, async () => { + for (const subject of lifecycleSubjects) { + // NON-VACUITY: this is the whole point of the arm. If any of these classified as a + // collision tag, the old default would already have quieted it. + expect(collisionPreventionTag({subject}), `must NOT be collision-classified: ${subject}`).toBe(null); + + const {messageId} = await MailboxService.addMessage({to: 'AGENT:*', subject, body: 'status'}); + ids.push([subject, messageId]); + } + }); + + for (const [subject, id] of ids) { + expect(GraphService.db.nodes.get(id).properties.wakeSuppressed, subject).toBe(true); + } + }); + + test('#17646 the sender election survives on a non-claim broadcast, and 1:1 traffic is untouched', async () => { + await RequestContextService.run({agentIdentityNodeId: '@bob'}, async () => { + await PermissionService.grantPermission({to: '@alice', scope: 'CAN_REPLY_TO'}); + }); + + const ids = {}; + + await RequestContextService.run({agentIdentityNodeId: '@alice'}, async () => { + // Explicit `false` still wakes the fleet — the inversion changed the DEFAULT, not the + // election. Verified red against a change that made broadcasts unconditionally quiet. + ({messageId: ids.elected} = await MailboxService.addMessage({ + to : 'AGENT:*', + subject : '[alert] the embed drain has stalled — semantic recall is degrading', + body : 'Fleet-wide alarm.', + wakeSuppressed: false + })); + + // The operator's boundary: a DIRECT message with the flag omitted still wakes. The + // noise was never in 1:1 traffic, and an inversion that leaked into it would be the + // mirror defect — a reviewer's REQUEST_CHANGES arriving at next boot. + ({messageId: ids.direct} = await MailboxService.addMessage({ + to : '@bob', + subject: '[review-result][PR #99999][REQUEST_CHANGES]', + body : 'One blocking RA.' + })); + }); + + expect(GraphService.db.nodes.get(ids.elected).properties.wakeSuppressed).toBe(false); + expect(GraphService.db.nodes.get(ids.direct).properties.wakeSuppressed).toBe(false); + }); + + /** + * @summary `priority: 'high'` and a suppressed wake cannot silently disagree. + * + * Before the quiet default this pair was an authoring slip; after it, it is what every + * high-priority broadcast becomes by default, so the message would read as urgent in every + * listing while nothing woke for it. The gate makes the sender resolve the contradiction. + */ + test('#17646 priority high on an AGENT:* broadcast is rejected unless the wake is elected', async () => { + // Self-contained: the direct arm below needs its own grant, since state does not carry + // across tests in this suite. + await RequestContextService.run({agentIdentityNodeId: '@bob'}, async () => { + await PermissionService.grantPermission({to: '@alice', scope: 'CAN_REPLY_TO'}); + }); + + await RequestContextService.run({agentIdentityNodeId: '@alice'}, async () => { + // Omitted flag → quiet by default → `high` claims urgency nothing wakes for. + await expect(MailboxService.addMessage({ + to : 'AGENT:*', + subject : '[pr-merged][PR #99999] shipped', + body : 'status', + priority: 'high' + })).rejects.toThrow(/high.*AGENT:\*.*wakeSuppressed: false|wakeSuppressed: false.*priority: 'normal'/s); + + // Explicit `true` is the same contradiction stated outright, and is rejected too. + await expect(MailboxService.addMessage({ + to : 'AGENT:*', + subject : '[pr-merged][PR #99998] shipped', + body : 'status', + priority : 'high', + wakeSuppressed: true + })).rejects.toThrow(/wakeSuppressed: false/); + + // The error has to name BOTH knobs, or the author cannot tell which one to move. + let message = ''; + try { + await MailboxService.addMessage({to: 'AGENT:*', subject: '[handoff] x', body: 'y', priority: 'high'}); + } catch (e) { + message = e.message; + } + expect(message).toContain('wakeSuppressed: false'); + expect(message).toContain("priority: 'normal'"); + + // ACCEPTED: the two knobs agree. This is the shape the orchestrator's stall alarms use. + const {messageId} = await MailboxService.addMessage({ + to : 'AGENT:*', + subject : '[alert] REM consolidation has stalled', + body : 'Fleet-wide alarm.', + priority : 'high', + wakeSuppressed: false + }); + + expect(GraphService.db.nodes.get(messageId).properties.wakeSuppressed).toBe(false); + + // UNAFFECTED: a high-priority DIRECT message needs no election — it already wakes. + const {messageId: directId} = await MailboxService.addMessage({ + to : '@bob', + subject : '[review-request][PR #99997] you are the cross-family seat', + body : 'Please review.', + priority: 'high' + }); + + expect(GraphService.db.nodes.get(directId).properties.wakeSuppressed).toBe(false); + }); + }); + /** * @summary The quiet-by-default seam covers the collision CLASS, and only where a tag is structural. * @@ -3725,22 +3861,36 @@ test.describe('Neo.ai.services.memory-core.MailboxService', () => { ]; for (const subject of collisionSubjects) { - // Flag OMITTED on purpose: the seam must derive quiet from the structural tag alone. + // The load-bearing assertion moved here. The mailbox seam below now quiets + // EVERY `AGENT:*` broadcast, so asserting only `wakeSuppressed === true` would stay + // green with `collisionPreventionTag` deleted outright — a control that cannot fail + // is not a control. The corpus keeps its value against the CLASSIFIER, which is + // where the "IS a claim vs MENTIONS one" question still has a consumer. + expect(collisionPreventionTag({subject}), subject).not.toBe(null); + + // Flag OMITTED on purpose. const {messageId} = await MailboxService.addMessage({ to : 'AGENT:*', subject, body: 'collision signal' }); + // Still quiet — but now because it is a broadcast, not because of the tag. expect(GraphService.db.nodes.get(messageId).properties.wakeSuppressed, subject).toBe(true); } }); }); - test('#15905 a message that MENTIONS a collision tag in prose is NOT default-suppressed (and stays suppressible)', async () => { - // The matcher's negative arm, now guarding the DEFAULT: a substring matcher would silently - // quiet every message *discussing* claims. All three subjects are real sends from the - // wake-routing divergence. Explicit suppression stays legal; omission must NOT flip. + test('#15905 a message that MENTIONS a collision tag in prose does not CLASSIFY as one (#17646 moved this off the wake seam)', async () => { + // The matcher's negative arm: a substring matcher would classify every message *discussing* + // claims as a claim. All three subjects are real sends from the wake-routing divergence. + // + // This arm used to assert that prose mentions were NOT default-suppressed at the mailbox. + // That reading was retired rather than the property: `AGENT:*` fan-out is now quiet + // unconditionally, so the wake seam cannot express the distinction any more and asserting + // `false` here would be asserting a policy that no longer exists. The distinction itself is + // still real and still consumed — `fleetA2AActivityAdapter` reads it to identify lane + // claims — so it is asserted against the classifier below, where it now lives. const metaSubjects = [ '[falsifier-positive][D#15904] the [lane-claim] guard is ^-anchored — 53% of LIVE lane-claims bypass #14100', '[evidence][wake-routing] the guard ALREADY exempts broadcasts — why [lane-claim] must never be suppressible', @@ -3774,7 +3924,11 @@ test.describe('Neo.ai.services.memory-core.MailboxService', () => { } for (const id of omitted) { - expect(GraphService.db.nodes.get(id).properties.wakeSuppressed, 'prose mention must not default-suppress').toBe(false); + expect(GraphService.db.nodes.get(id).properties.wakeSuppressed, 'a broadcast is quiet regardless of its subject').toBe(true); + } + + for (const subject of metaSubjects) { + expect(collisionPreventionTag({subject}), `prose mention must not classify as a claim: ${subject}`).toBe(null); } }); From 23ed9e96ee2e23865868c1e9f5e9693da3d88db4 Mon Sep 17 00:00:00 2001 From: Grace Date: Mon, 24 Aug 2026 02:16:16 +0200 Subject: [PATCH 2/3] fix(memory-core): the coherence gate fires on silence, not on explicit suppression (#17646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the gate rejecting a shape that is coherent, and doing it where the rejection would be swallowed. KbAlertingService.dispatchA2A builds priority from severity and wakeSuppressed from deliveryMode, so a critical alert with audit delivery on an a2a:AGENT:* channel produces high + suppressed + broadcast. The gate threw on it, and dispatchAlert catches and logs — so the durable alert was lost rather than surfaced. The same swallowed-throw shape as the orchestrator alarms, in a caller a literal-target grep could not see: that call site passes a runtime value, so the earlier census missed it. The narrowing is the real fix, not a producer change. Both EXPLICIT wake states are deliberate statements and neither is incoherent: false plus high is the all-hands interrupt, true plus high is durable-high — file it at the top of the queue and interrupt nobody, which is exactly what an audit delivery of a critical alert means, and the same semantic the operator-steering class already relies on. Only SILENCE plus high is the pair nobody chose. So the capture becomes three-state and the gate fires on the absent case alone, removing a false positive instead of bending a producer to satisfy an over-broad guard. Truth-folded across every durable surface, since the previous wording claimed universal rejection: the addMessage JSDoc, the OpenAPI priority description, A2A.md, peer-role-mode.md, and the ticket's ledger row and AC. Each now states the three-state rule and scopes the rejection to the agent classes, since operator steering carries high as drain-ordering metadata. Skill-loaded substrate nets -100 bytes after compression. Co-Authored-By: Grace --- .../peer-role/references/peer-role-mode.md | 2 +- ai/mcp/server/memory-core/openapi.yaml | 2 +- ai/services/memory-core/MailboxService.mjs | 51 +++++++++++++------ learn/agentos/A2A.md | 16 +++--- .../memory-core/MailboxService.spec.mjs | 40 +++++++++++++-- 5 files changed, 84 insertions(+), 27 deletions(-) diff --git a/.agents/skills/peer-role/references/peer-role-mode.md b/.agents/skills/peer-role/references/peer-role-mode.md index 67e05b9858..837c4b0c07 100644 --- a/.agents/skills/peer-role/references/peer-role-mode.md +++ b/.agents/skills/peer-role/references/peer-role-mode.md @@ -119,7 +119,7 @@ add_message({ }); ``` -**Wake-control (the `add_message` `wakeSuppressed` param; #12635 · #14576 · #15987 · #17646):** **the address decides, not the subject.** Every `AGENT:*` broadcast is quiet by default — no tag vocabulary to remember, and collision safety lives at the claim surfaces (assignee gate + intake re-check). Waking the fleet is an explicit election, `wakeSuppressed: false`, reserved for what every seat must act on now (contested-lane resolution, stalled-pipeline alarm); `priority: 'high'` on a broadcast is **rejected** without it. **Direct** messages still wake by default — that is where actionability lives: review / re-review, `REQUEST_CHANGES`, `[lane-override]`, lane-unblock, operator relay, owned-surface overlap. Suppress a direct message (`wakeSuppressed: true`) only for non-overlapping awareness — observer notes, lane-progress, acks; suppressed actionable direct lifecycle subjects are rejected mechanically. Keep `priority: 'high'` for act-now direct traffic; everything else stays `normal`. (Additive to the session-sunset self-DM suppression, which stays valid.) +**Wake-control (the `add_message` `wakeSuppressed` param; #12635 · #14576 · #15987 · #17646):** **the address decides, not the subject.** Every `AGENT:*` broadcast is quiet by default — no tag vocabulary, and collision safety lives at the claim surfaces (assignee gate + intake re-check). Waking the fleet is an explicit `wakeSuppressed: false`, for what every seat must act on now. On a broadcast `priority: 'high'` must state the wake — `false` to interrupt, `true` for durable-high (top of queue, nobody woken); **silence + `high` is rejected** (agent classes; operator `high` is drain-ordering). **Direct** messages wake by default — that is where actionability lives: review / re-review, `REQUEST_CHANGES`, `[lane-override]`, lane-unblock, operator relay, owned-surface overlap. Suppress a direct message only for non-overlapping awareness — observer notes, lane-progress, acks; suppressed actionable direct lifecycle subjects are rejected mechanically. Keep `'high'` for act-now direct traffic. (Additive to the session-sunset self-DM suppression, which stays valid.) ### 6.5.1 Lane-Override Protocol (`[lane-override]`) diff --git a/ai/mcp/server/memory-core/openapi.yaml b/ai/mcp/server/memory-core/openapi.yaml index a5c0322605..0ef67546c3 100644 --- a/ai/mcp/server/memory-core/openapi.yaml +++ b/ai/mcp/server/memory-core/openapi.yaml @@ -2123,7 +2123,7 @@ paths: priority: type: string enum: [low, normal, high] - description: "Delivery priority. Omit to let the server set it from sender class: normal for an agent, high for an operator (turn-start drain ordering). On an AGENT:* broadcast, high is rejected unless you also set wakeSuppressed: false — broadcasts are quiet by default, so high alone would claim urgency nothing wakes for." + description: "Delivery priority. Omit to let the server set it from sender class: normal for an agent, high for an operator (turn-start drain ordering). On an AGENT:* broadcast, high requires you to say what happens to the wake — wakeSuppressed: false to interrupt the fleet, or true for a durable-high broadcast nobody is woken for. High with no wakeSuppressed at all is rejected: broadcasts are presumed quiet, so it would claim urgency nothing wakes for." partOfThread: type: string taggedConcepts: diff --git a/ai/services/memory-core/MailboxService.mjs b/ai/services/memory-core/MailboxService.mjs index 4358686a48..9392cea4ec 100644 --- a/ai/services/memory-core/MailboxService.mjs +++ b/ai/services/memory-core/MailboxService.mjs @@ -2369,10 +2369,16 @@ class MailboxService extends Base { * @param {Boolean} [args.wakeSuppressed] Persist the message without emitting `SENT_TO_ME` * wake events. Intended for mailbox-only handovers such as session-sunset self-DMs that must be * consumed by the next boot, not injected back into the active sender harness. Known-actionable - * direct lifecycle messages reject wake suppression before persistence. Defaults per sender - * principal class: the `'human'` (operator-steering) class defaults to `true` — durable-quiet - * delivery, the sender electing a wake per message by passing `false` — every other class - * defaults to `false` (wake) exactly as before. + * direct lifecycle messages reject wake suppression before persistence. Defaults resolve from + * ADDRESS and sender class: every `AGENT:*` broadcast is quiet — broadcasts are presumed quiet, + * and an all-hands interrupt is the sender's explicit `false`. Direct messages keep the plain + * default and wake, except for the `'human'` (operator-steering) class, which is durable-quiet + * on every target and elects a wake per message by passing `false`. + * + * On an `AGENT:*` broadcast, `priority: 'high'` requires the sender to SAY something about the + * wake — `false` (interrupt) or `true` (durable-high, top of the queue, nobody woken). Silence + * plus `'high'` is the incoherent pair and is rejected; the rejection is scoped to the + * agent classes, since operator steering carries `high` as drain-ordering metadata. * @param {Object} [args.task] Optional A2A Task envelope payload. Caller fields are cloned, then * the server overwrites `task.assignee`: a direct AgentIdentity recipient is bound immediately; * a broadcast remains `null` until an eligible recipient wins the atomic claim. The top-level @@ -2405,10 +2411,11 @@ class MailboxService extends Base { // election, never the default) and priority-high as turn-start drain-ordering metadata. const senderPrincipalClass = resolveSenderPrincipalClass(db, sentBy), operatorSteering = senderPrincipalClass === 'human', - // Captured BEFORE the defaults resolve below. The coherence gate has to tell "the - // sender elected a wake" apart from "the default left this loud", and after the `??` - // those two states are the same value. - wakeElectedBySender = wakeSuppressed === false; + // Captured BEFORE the defaults resolve below, and kept as a THREE-state value: the + // coherence gate must tell "the sender elected a wake" and "the sender elected + // durable-quiet" apart from "the sender said nothing", and after the `??` all three + // collapse into one boolean. `null` is the silence the gate fires on. + wakeSuppressedBySender = wakeSuppressed === null || wakeSuppressed === undefined ? null : wakeSuppressed; priority = priority ?? (operatorSteering ? 'high' : 'normal'); // A broadcast cannot be action-required for everyone — if it were, it would be addressed to @@ -2434,14 +2441,26 @@ class MailboxService extends Base { // field may carry a schema default OR a service-side contextual default, never both. wakeSuppressed = wakeSuppressed ?? (operatorSteering || to === 'AGENT:*'); - // `priority: 'high'` and a suppressed wake are contradictory instructions, and the pair was - // accepted silently: the message reads as urgent in every listing while nothing wakes for - // it. Under the quiet default that state stops being an authoring slip and becomes the - // common case, so the two knobs must agree on `AGENT:*` — `high` requires the sender to - // have elected the wake. Operator steering is exempt by construction: that class is - // durable-quiet, and its `high` is turn-start drain-ordering metadata, not urgency. - if (to === 'AGENT:*' && priority === 'high' && !wakeElectedBySender && !operatorSteering) { - throw new Error("Cannot send a 'high' priority AGENT:* broadcast without an explicit wake election: broadcasts are quiet by default, so 'high' would claim urgency nothing wakes for. Set wakeSuppressed: false to wake the fleet, or use priority: 'normal'."); + // The incoherent pair is `priority: 'high'` on a broadcast whose quiet came from the + // DEFAULT — an author who set urgency without considering wake semantics, so the message + // reads urgent in every listing while nothing wakes for it. Under the quiet default that + // stops being a rare slip and becomes the common case, which is what makes it worth + // rejecting rather than tolerating. + // + // Both EXPLICIT wake states are deliberate statements and neither is incoherent: + // - `wakeSuppressed: false` + high — the all-hands interrupt (stall alarms). + // - `wakeSuppressed: true` + high — durable-high: file it at the top of the queue and + // do NOT interrupt. That is exactly `KbAlertingService`'s `deliveryMode: 'audit'` + // carrying a `critical` severity, and it is the same semantic the operator-steering + // class already relies on, where `high` is drain-ordering metadata rather than urgency. + // + // So the gate fires only on SILENCE about waking. Rejecting explicit suppression too would + // have made a real producer's durable alert unsendable — and its dispatcher catches and + // logs, so the alert would have been lost rather than surfaced. Caught in review by + // @neo-gpt-emmy; the narrower rule removes the false positive instead of bending the + // producer to satisfy an over-broad guard. + if (to === 'AGENT:*' && priority === 'high' && wakeSuppressedBySender === null && !operatorSteering) { + throw new Error("Cannot send a 'high' priority AGENT:* broadcast without saying what should happen to the wake: broadcasts are quiet by default, so 'high' alone claims urgency nothing wakes for. Set wakeSuppressed: false to interrupt the fleet, wakeSuppressed: true for a durable-high broadcast nobody is woken for, or use priority: 'normal'."); } // Canonicalize addressing to match the seeded AgentIdentity graph-node IDs. Upstream tool- diff --git a/learn/agentos/A2A.md b/learn/agentos/A2A.md index 3934132926..95ea0568b5 100644 --- a/learn/agentos/A2A.md +++ b/learn/agentos/A2A.md @@ -58,8 +58,8 @@ and concept graph. The wake convention follows from that separation: quiet is the default for status broadcasts, and a wake is reserved for action the recipient must take. The acceptance layer implements that by address, not by vocabulary — **every -`AGENT:*` broadcast defaults to `wakeSuppressed`.** A broadcast cannot be -action-required for everyone; if it were, it would be addressed to someone. +`AGENT:*` broadcast defaults to `wakeSuppressed`.** Broadcasts are presumed +quiet; an all-hands interrupt is the sender's explicit `wakeSuppressed: false`. Peers read them at their next natural intake, and claim collisions stay fail-closed at the claim surfaces (the assignee gate plus intake's claim-race re-check) rather than in the wake. @@ -69,10 +69,14 @@ traffic, where the recipient is named because they have to act. Something that genuinely must interrupt the whole fleet — a contested-lane resolution, a stalled-pipeline alarm — is a sender election via explicit -`wakeSuppressed: false`, never a default. Because that election is now the only -way a broadcast wakes anyone, `priority: 'high'` on an `AGENT:*` message is -rejected unless it accompanies one: a high-priority broadcast nothing wakes for -would read as urgent in every listing while reaching no one in time. +`wakeSuppressed: false`, never a default. Because a broadcast's wake is now +always an election, `priority: 'high'` on an `AGENT:*` message requires the +sender to state one — `false` to interrupt the fleet, or `true` for a +durable-high broadcast that sits at the top of the queue and wakes nobody. +Only SILENCE plus `'high'` is rejected, because that is the pair nobody chose: +it reads as urgent in every listing while reaching no one in time. The rejection +is scoped to the agent classes — operator steering is durable-quiet by design and +carries `high` as drain-ordering metadata, so it is exempt. *(This scoping was previously the four claim-class tags — `lane-claim`, `review-claim`, `claim-corrected`, `drive-claimed`. That covered one class of diff --git a/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs b/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs index 4451728092..a7e744e78d 100644 --- a/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs +++ b/test/playwright/unit/ai/services/memory-core/MailboxService.spec.mjs @@ -3788,14 +3788,18 @@ test.describe('Neo.ai.services.memory-core.MailboxService', () => { priority: 'high' })).rejects.toThrow(/high.*AGENT:\*.*wakeSuppressed: false|wakeSuppressed: false.*priority: 'normal'/s); - // Explicit `true` is the same contradiction stated outright, and is rejected too. - await expect(MailboxService.addMessage({ + // Explicit `true` + high is DURABLE-HIGH and is ACCEPTED: file it at the top of the + // queue, interrupt nobody. Narrowed in review — the first cut rejected this and would + // have made a real producer's alert unsendable (see the composed arm below). + const {messageId: durableHigh} = await MailboxService.addMessage({ to : 'AGENT:*', subject : '[pr-merged][PR #99998] shipped', body : 'status', priority : 'high', wakeSuppressed: true - })).rejects.toThrow(/wakeSuppressed: false/); + }); + + expect(GraphService.db.nodes.get(durableHigh).properties.wakeSuppressed).toBe(true); // The error has to name BOTH knobs, or the author cannot tell which one to move. let message = ''; @@ -3830,6 +3834,36 @@ test.describe('Neo.ai.services.memory-core.MailboxService', () => { }); }); + /** + * @summary The composed acceptance seam, using a real producer's exact argument shape. + * + * `KbAlertingService.dispatchA2A` builds `{to, subject, body, priority, wakeSuppressed}` where + * `priority` is `severity === 'critical' ? 'high' : 'normal'` and `wakeSuppressed` is + * `deliveryMode === 'audit'`. A rule with a `critical` severity, `audit` delivery and an + * `a2a:AGENT:*` channel therefore produces high + suppressed + broadcast. + * + * This arm exists because a capture stub cannot see the defect: the producer's own dispatcher + * catches and logs, so a rejection here does not surface as a failed send — the durable alert + * is simply LOST. Only the real acceptance seam can prove the message is accepted. RED before + * the gate was narrowed to fire on silence rather than on explicit suppression. + */ + test('#17646 a critical AUDIT broadcast in a real producer shape is ACCEPTED, not rejected', async () => { + await RequestContextService.run({agentIdentityNodeId: '@alice'}, async () => { + const {messageId} = await MailboxService.addMessage({ + to : 'AGENT:*', + subject : '[kb-alert][critical] retention sweep breached its floor', + body : 'Durable audit alert; nobody is woken for it.', + priority : 'high', // severity === 'critical' + wakeSuppressed: true // deliveryMode === 'audit' + }); + + const node = GraphService.db.nodes.get(messageId); + + expect(node.properties.wakeSuppressed).toBe(true); + expect(node.properties.priority).toBe('high'); + }); + }); + /** * @summary The quiet-by-default seam covers the collision CLASS, and only where a tag is structural. * From 38023da53f0cf80c14d42577e7ba02ad195df566 Mon Sep 17 00:00:00 2001 From: Grace Date: Mon, 24 Aug 2026 03:06:58 +0200 Subject: [PATCH 3/3] docs(memory-core): the universal framing survived in the comment that spawned it (#17646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 review found the truth-fold incomplete in the way truth-folds usually are: I corrected the surfaces I had listed and missed the ones I had not. The inline comment at the default line still carried "a broadcast cannot be action-required for everyone" verbatim — the sentence three other surfaces had quoted onward, corrected everywhere except its origin. It reads as a universal and is not one: the presumption is rebuttable per message, which is exactly what the sender election is for. Also folded: the PR body's pull-quote repeated it, the ticket's Fix section still asserted both the universal and the superseded "high requires explicit false", and the Post-Merge section restated a literal-only caller sweep of four that the corrected Deltas table already replaced with the full census including the two dynamic sites. Recorded at the origin comment rather than silently swapped, because the useful fact is not the new wording — it is that a sentence written once propagated into four surfaces before anyone measured whether it was true. Co-Authored-By: Grace --- ai/services/memory-core/MailboxService.mjs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ai/services/memory-core/MailboxService.mjs b/ai/services/memory-core/MailboxService.mjs index 9392cea4ec..515b65c617 100644 --- a/ai/services/memory-core/MailboxService.mjs +++ b/ai/services/memory-core/MailboxService.mjs @@ -2418,10 +2418,14 @@ class MailboxService extends Base { wakeSuppressedBySender = wakeSuppressed === null || wakeSuppressed === undefined ? null : wakeSuppressed; priority = priority ?? (operatorSteering ? 'high' : 'normal'); - // A broadcast cannot be action-required for everyone — if it were, it would be addressed to - // someone. So `AGENT:*` fan-out is quiet by DEFAULT, and a wake is the sender's election via - // explicit `wakeSuppressed: false`. That is the polarity `learn/agentos/A2A.md` already - // describes; what changed is that the implementation now covers it. + // Broadcasts are PRESUMED quiet; an all-hands interrupt is the sender's explicit + // `wakeSuppressed: false`. That is the polarity `learn/agentos/A2A.md` already describes; + // what changed is that the implementation now covers it. + // + // The earlier framing here — "a broadcast cannot be action-required for everyone" — read as + // a universal and is not one: the presumption is rebuttable per message, which is the whole + // point of the election. Corrected because the sentence was quoted onward into three other + // surfaces before anyone noticed it overstated the rule. // // This supersedes a four-tag allowlist that delivered the same sentence for claim-class // subjects only. The routine broadcast vocabulary is far wider — `pr-merged`,