Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/role-vs-agent-id.md
Original file line number Diff line number Diff line change
@@ -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: <name> }`.

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-<repo>-<sid>`. 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 <name>`) → 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: <id> }` → 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 (`_<group>._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 <name>` — 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.
56 changes: 56 additions & 0 deletions lib/frame-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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);
}
}
}

/**
Expand Down Expand Up @@ -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
Expand Down
Loading