diff --git a/docs/role-vs-agent-id.md b/docs/role-vs-agent-id.md new file mode 100644 index 0000000..7214a5d --- /dev/null +++ b/docs/role-vs-agent-id.md @@ -0,0 +1,79 @@ +# Design note: Agent ID vs. Agent Role (role-addressed CMBs) + +**Status:** proposal · **Author:** CMO (claude-research-win) · **Reviewer:** CTO +**Lane:** core-lib (`@sym-bot/sym` identity + addressing) + MMP spec §3/§4.4 +**Related:** PR #31 (lib-level name resolver), `sym-mesh-channel` server.js + +--- + +## Problem + +A mesh node's **`name` does double duty.** It is simultaneously: + +1. the **agent ID** — a unique handle for one running process (one Claude Code session), and +2. the **addressing handle** you target with `sym_send { to: }`. + +There is no separate notion of **role**. So a logical role that is served by *more than one* session — the common case the README itself sells ("one per repo, one planning while another codes") — cannot be expressed or addressed. You must know each ephemeral session ID to reach "the coding agents." + +### Evidence in current code + +- `sym-mesh-channel/server.js → defaultNodeName()` mints a per-session ID: `claude--`. Correct — IDs *should* be 1:1 with a session. +- The same file then hardcodes `cognitiveProfile: "Engineering node. Code, architecture, debugging, technical decisions."` for **every** install. This is a vestigial, non-configurable, **non-addressable** role field: every plugin user is silently "Engineering," and you still can't target by it. +- `@sym-bot/sym` targeted-CMB addressing (MMP §4.4.4) resolves `to` against a node **name/ID**. No role selector exists. + +**Net:** the mesh can address a *session* but not a *function*. + +## Principle + +> **Agent ID : Agent Role = N : 1.** +> One role is served by zero-or-many agent IDs. One agent ID has exactly one role (optional). + +IDs are ephemeral and unique (a session). Roles are stable and shared (a function: `cmo`, `coder`, `reviewer`, `planner`). + +## Where role belongs — NOT in CAT7 + +Role is an **identity / addressing-layer** concept, not a cognition-payload concept. It belongs in MMP **§3 (identity)** and **§4.4 (addressing)** — *not* as an 8th CAT7 field. CAT7 stays at 7 (6 semantic + 1 mood); adding `role` there would violate the killer-ontology discipline and conflate "who/what function emitted this" with "what the cognition *is*." Keep them orthogonal. + +## Proposed model + +### 1. Identity gains an optional `role` +- New node attribute `role` (kebab-case, e.g. `coder`, `cmo`), set via — in precedence order, mirroring how group is resolved today: + `SYM_NODE_ROLE` env → persisted `~/.sym/role` (CLI-settable, `sym role `) → unset. +- `name` (the ID) stays 1:1 with the session and auto-generated by default. **The README should stop implying users name nodes** — they tag *roles*, not IDs. +- Validation reuses `groups.js` `KEBAB_CASE_RE`. `role` is optional; absent ⇒ node is addressable only by ID (today's behavior — fully backward compatible). + +### 2. Role is advertised in discovery + handshake +- Bonjour/loopback advertisement carries `role` in the TXT record (alongside `nodeId`, `name`), and the SymFrame handshake includes it, so receivers can build a `role → {liveIDs}` map without a central registry. + +### 3. Addressing extends (MMP §4.4.4) +- `sym_send { to_role: "coder", … }` → **fan-out** to every live ID currently advertising `role=coder`. Delivery semantics: at-least-one of the live set; no holder ⇒ same "peer not connected" surface as today. +- `sym_send { to: }` → unchanged (specific session). +- `to` and `to_role` are mutually exclusive per call. +- SVAF still runs per-receiver (a role-addressed CMB is still admitted/remixed independently by each ID). + +### 4. Orthogonal to groups +- **Group** = mDNS isolation boundary (`_._tcp`) — *who can discover whom*. +- **Role** = addressable function *within* a group — *who you mean*. +- They compose: `to_role: "coder"` resolves within the node's current group only. Different concepts; don't model role as a group (that would isolate roles from each other — the opposite of what we want). + +## Onboarding / README implications + +This is what makes the "real-time comms between Claude agents" demo land for a *new* user: +- A user opens N sessions (throwaway IDs), tags each by role (`planner`, `coder`, `reviewer`), and they talk **by role**. +- The README must distinguish: **ID = your session (auto, ephemeral)** vs. **role = the hat it wears (you set, shared)**. Every current doc spot that says "each session gets its own mesh identity" should add that identity is an *address*, not a *role*, and show role tagging. + +## Strategic note (ontology wedge) + +Role-addressed delivery is **semantic addressing** — you target by *function*, not by a tenant-specific node handle. That is precisely the cross-tenant lever in the CAT7/MMP thesis: intra-tenant systems address by object/handle; the mesh can address by *shared semantic role across IDs and tenants*. Worth a line in the MMP paper's addressing section. + +## Open questions for CTO + +1. **Set-where:** `SYM_NODE_ROLE` env + `~/.sym/role` + `sym role ` — agree with the precedence, or role-per-project (like `--project` installs) too? +2. **Plugin surface:** expose `role` (and `node_name`) in the plugin `userConfig` so plugin users set a role without env vars? (Closes the same gap that blocked the CMO dogfood.) +3. **Fan-out delivery guarantee:** at-least-one vs. all-live vs. caller-choice (`to_role` + `mode: "any"|"all"`)? +4. **Wire format:** new TXT key + handshake field — version-gate, or additive-ignore for older peers? +5. **Default role:** should the plugin stop hardcoding `cognitiveProfile: "Engineering node"` and leave role unset (addressable by ID only) until the user tags one? + +## Backward compatibility + +All additive. `role` unset ⇒ identical to today. Older peers that don't advertise/parse `role` remain addressable by ID and simply never match a `to_role` query. No CAT7 change, no break to existing targeted or broadcast CMBs. diff --git a/lib/frame-handler.js b/lib/frame-handler.js index f943d60..f264b96 100644 --- a/lib/frame-handler.js +++ b/lib/frame-handler.js @@ -19,6 +19,13 @@ const { encode, DIM, processHeuristicSVAF, decryptFields, remixKey } = require('@sym-bot/core'); +// Receive-path dedup window (MMP §4.2 O2 — rejoin-without-replay convergence). +// A CMB whose content-hash key we have already processed within this window is +// suppressed rather than re-evaluated/re-remixed/re-emitted. Bounds the dedup +// cache so a long-lived node does not grow it without limit. +const SEEN_CMB_TTL_MS = 60 * 60 * 1000; // 1 hour +const SEEN_CMB_MAX = 10000; + /** * Processes inbound peer frames for a SymNode. * @@ -39,6 +46,31 @@ class FrameHandler { constructor(node, opts = {}) { this._node = node; this._cliHostMode = opts.cliHostMode || false; + // Receive-path dedup cache: CMB content-hash key -> last-seen timestamp. + // Distinct from the local store (which holds *remix* keys); this tracks the + // raw *incoming* keys so the same CMB re-sent on reconnect/resync converges + // instead of cycling. See _handleMemoryShare and MMP §4.2 O2. + this._seenCmbKeys = new Map(); + } + + /** + * Record an incoming CMB key in the receive-path dedup cache, pruning to + * stay within TTL and size bounds. Expired entries are dropped first; if + * still over the cap, the oldest-inserted keys are evicted. + * @private + */ + _recordSeenCmbKey(key, now) { + this._seenCmbKeys.set(key, now); + if (this._seenCmbKeys.size > SEEN_CMB_MAX) { + for (const [k, ts] of this._seenCmbKeys) { + if (this._seenCmbKeys.size <= SEEN_CMB_MAX) break; + if (now - ts >= SEEN_CMB_TTL_MS) this._seenCmbKeys.delete(k); + } + while (this._seenCmbKeys.size > SEEN_CMB_MAX) { + const oldest = this._seenCmbKeys.keys().next().value; + this._seenCmbKeys.delete(oldest); + } + } } /** @@ -198,6 +230,30 @@ class FrameHandler { const originTs = msg.originTimestamp || msg.timestamp || now; const ageSeconds = (now - originTs) / 1000; + // Receive-path dedup (MMP §4.2 O2 — rejoin-without-replay convergence). + // The send path already drops *local* duplicates (node.remember skips + // re-broadcast when the key is already stored). But a CMB *received* from a + // peer had no such guard: the anchor-CMB replay every node sends on each + // Bonjour reconnect — plus the same CMB arriving via multiple peers — was + // reprocessed each time (re-run through SVAF, remixed under a fresh key, + // re-emitted as 'cmb-accepted'). With several flapping/zombie instances each + // replaying accumulated memory, that produced a sustained replay storm. + // `_cmbKey` (sym-core, MMP §8.2) is a content hash over the CMB fields, so an + // identical re-send carries an identical key. Suppress a key already seen + // within the TTL window: converge instead of cycle. A genuine new remix has + // a new key and is unaffected; a first-seen anchor CMB still bootstraps a + // fresh peer — it simply processes once. + const incomingKey = msg.cmb?.key || msg.key; + if (incomingKey) { + const lastSeen = this._seenCmbKeys.get(incomingKey); + if (lastSeen !== undefined && (now - lastSeen) < SEEN_CMB_TTL_MS) { + this._seenCmbKeys.set(incomingKey, now); // refresh recency + this._node._log(`Duplicate CMB ${String(incomingKey).slice(0, 16)} from ${peerName} — seen within TTL, skipping (convergence)`); + return; + } + this._recordSeenCmbKey(incomingKey, now); + } + // CLI-host mode: forward only, do NOT persist. The node is just the // local IPC surface for sym CLI commands — it doesn't participate in // mesh cognition. Storage lives in the local agent stores; sym recall