diff --git a/.agents/skills/peer-role/references/peer-role-mode.md b/.agents/skills/peer-role/references/peer-role-mode.md index 72e79e70b9..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):** 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, 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/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..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)." + 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: @@ -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..515b65c617 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, @@ -2370,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,15 +2410,31 @@ 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, 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'); - // 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. + // 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`, + // `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 +2443,29 @@ 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:*'); + + // 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- // 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..95ea0568b5 100644 --- a/learn/agentos/A2A.md +++ b/learn/agentos/A2A.md @@ -57,12 +57,32 @@ 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`.** 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. + +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 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 +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..a7e744e78d 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,174 @@ 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` + 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 + }); + + 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 = ''; + 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 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. * @@ -3725,22 +3895,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 +3958,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); } });