Files
claudemesh/docs/member-profile-spec.md
Alejandro Gutiérrez 0661e6223a
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled
fix(web): correct LinkedIn URL on about page
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 13:17:24 +01:00

20 KiB
Raw Blame History

Member Profile: Persistent Identity & Dashboard Management

Spec for moving member identity (role tag, groups, display name, message mode) from ephemeral CLI flags to persistent server-side state, editable from the dashboard with configurable self-edit permissions.


Problem

Today, launching a claudemesh session requires re-declaring your identity:

claudemesh launch --name Alice --role lead --groups eng,review --message-mode push

Every. Single. Time. These values live on the ephemeral presence row (per-WS connection) and peerState row (cross-session, but CLI-written only). There's no way for:

  • An admin to assign someone's role/groups from the dashboard
  • A user to set their profile once and forget about it
  • An invite to pre-configure a new member's identity
  • The dashboard to show/manage who belongs to which groups

This creates friction for daily users and makes managed teams impossible.


Design

Move identity to member (persistent, server-side)

Field Current location New location Source of truth
displayName presence (ephemeral) member (persistent) Server, CLI flag overrides per-session
roleTag nowhere (CLI --role flag only) member (persistent) Server, CLI flag overrides per-session
groups peerState (CLI-written) member (persistent) Server, CLI flag overrides per-session
messageMode config.json (local file) member (persistent) Server, CLI flag overrides per-session
status presence presence (no change) Ephemeral, changes per-minute
summary presence presence (no change) Ephemeral, changes per-task
cwd, pid presence presence (no change) Literal session metadata

Three-layer model

member (persistent, server-side)
  │  Source of truth for identity. Set via dashboard, CLI profile command,
  │  or invite presets. Survives everything.
  │
  ├── peerState (cross-session, server-side)
  │     Cumulative stats, visibility toggle, last-seen metadata.
  │     Still CLI-written. Not promoted — these are operational, not identity.
  │
  └── presence (ephemeral, per-connection)
        Runtime snapshot. Copies member defaults on connect.
        CLI flags override for this session only.
        Status, summary, cwd, pid — all transient.

Schema changes

Extend mesh.member

ALTER TABLE mesh.member
  ADD COLUMN role_tag TEXT,                          -- free-text label (lead, backend-dev, observer)
  ADD COLUMN default_groups JSONB DEFAULT '[]',      -- [{name: string, role?: string}]
  ADD COLUMN message_mode TEXT DEFAULT 'push',       -- push | inbox | off
  ADD COLUMN dashboard_user_id TEXT;                 -- links to Payload CMS user.id (for CLI sync)

CREATE INDEX member_dashboard_user_idx
  ON mesh.member(dashboard_user_id)
  WHERE dashboard_user_id IS NOT NULL;

Note: member.displayName already exists. member.role stays as the permission level enum (admin/member). role_tag is the new free-text label.

Rename for clarity

The existing member.role (admin/member enum) controls permissions. The new member.role_tag is a label visible to peers. To avoid confusion in code and UI:

member.role       → member.permission   -- "admin" | "member" (access control)
member.role_tag   → member.roleTag      -- "backend-dev", "lead", etc. (display label)

DB migration: rename the column for clarity:

ALTER TABLE mesh.member RENAME COLUMN role TO permission;
-- Also rename the enum type if feasible, or keep as-is (DB enum name is internal)

Impact: Update all broker code that references member.role to member.permission. The meshRoleEnum values stay the same (admin/member).

Extend mesh.mesh — self-edit policy

ALTER TABLE mesh.mesh
  ADD COLUMN self_editable JSONB DEFAULT '{
    "displayName": true,
    "roleTag": true,
    "groups": true,
    "messageMode": true
  }';

Controls what members can edit about themselves. Admins can always edit anyone. Mesh creator configures this on the dashboard.

Extend mesh.invite — presets

ALTER TABLE mesh.invite
  ADD COLUMN preset JSONB DEFAULT '{}';

Preset schema:

interface InvitePreset {
  displayName?: string;    // rarely set — joiner usually picks their own
  roleTag?: string;        // "backend-dev", "observer", etc.
  groups?: Array<{ name: string; role?: string }>;
  messageMode?: "push" | "inbox" | "off";
}

When a member joins via this invite, preset values are applied to the member row as defaults. The joiner can change them later (if self-editable).


Permission model

Who can edit what

Action Who Condition
Edit your own displayName You mesh.selfEditable.displayName is true
Edit your own roleTag You mesh.selfEditable.roleTag is true
Edit your own groups You mesh.selfEditable.groups is true
Edit your own messageMode You mesh.selfEditable.messageMode is true
Edit any member's profile fields Mesh admins Always
Change permission (admin ↔ member) Mesh admins Always
Revoke a member Mesh admins Always
Change selfEditable policy Mesh admins Always

Default policy by tier

Field free pro team enterprise
displayName self self self self
roleTag self self admin-only admin-only
groups self self admin-only admin-only
messageMode self self self self

These are defaults — the mesh creator can override any of them on the dashboard regardless of tier.


Broker changes

New HTTP endpoints

PATCH /mesh/:meshId/member/:memberId

Update a member's profile fields. Used by dashboard and CLI.

// Request
PATCH /mesh/:meshId/member/:memberId
Authorization: Bearer <dashboard-session-token> OR X-Pubkey + X-Signature
{
  "displayName": "Alice",
  "roleTag": "lead",
  "groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
  "messageMode": "push"
}

// Response
{
  "ok": true,
  "member": {
    "id": "member_123",
    "displayName": "Alice",
    "roleTag": "lead",
    "groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
    "messageMode": "push",
    "permission": "admin"
  }
}

Authorization logic:

if (caller is dashboard admin OR caller.memberId == targetMemberId with admin permission):
  → allow all fields
elif (caller.memberId == targetMemberId):
  → check mesh.selfEditable for each field
  → reject fields that are admin-only: 403 "field X is admin-managed in this mesh"
else:
  → 403 "not authorized"

Side effect: If the target member has active WebSocket connections, push a profile_updated event to all their sessions:

{
  "type": "profile_updated",
  "memberId": "member_123",
  "changes": {
    "roleTag": "lead",
    "groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }]
  }
}

The CLI handles this by updating its in-memory state for the current session.

GET /mesh/:meshId/members

List all members with their profiles. Used by dashboard and CLI.

// Response
{
  "ok": true,
  "members": [
    {
      "id": "member_123",
      "displayName": "Alice",
      "roleTag": "lead",
      "groups": [{ "name": "eng", "role": "lead" }],
      "messageMode": "push",
      "permission": "admin",
      "dashboardUserId": "user_abc123",
      "joinedAt": "2026-04-01T10:00:00Z",
      "lastSeenAt": "2026-04-08T14:30:00Z",
      "online": true,
      "sessionCount": 2
    },
    {
      "id": "member_456",
      "displayName": "Bob",
      "roleTag": "backend-dev",
      "groups": [{ "name": "eng" }],
      "messageMode": "inbox",
      "permission": "member",
      "dashboardUserId": null,
      "joinedAt": "2026-04-03T09:00:00Z",
      "lastSeenAt": "2026-04-07T18:00:00Z",
      "online": false,
      "sessionCount": 0
    }
  ]
}

online and sessionCount are derived from active presence rows (disconnectedAt IS NULL) for each member.

PATCH /mesh/:meshId/settings

Update mesh settings including self-edit policy. Dashboard only, admin only.

// Request
PATCH /mesh/:meshId/settings
{
  "selfEditable": {
    "displayName": true,
    "roleTag": false,
    "groups": false,
    "messageMode": true
  }
}

hello_ack changes

When a peer connects, the hello_ack now includes the member's persistent profile so the CLI can apply defaults:

{
  "type": "hello_ack",
  "presenceId": "pres_789",
  "memberDisplayName": "Alice",
  "memberProfile": {
    "roleTag": "lead",
    "groups": [{ "name": "eng", "role": "lead" }, { "name": "review" }],
    "messageMode": "push"
  },
  "meshPolicy": {
    "selfEditable": { "displayName": true, "roleTag": false, "groups": false, "messageMode": true }
  },
  "restored": { ... }
}

Presence creation changes

When creating a presence row on hello, the broker now merges:

1. Start with member defaults (displayName, roleTag → groups, messageMode)
2. Override with CLI hello payload (if flags were provided)
3. Write to presence row

This means presence.groups is populated from member.default_groups if the CLI didn't send explicit groups in the hello. No more blank sessions.

Join flow changes

When a member joins via /join, the broker applies invite presets:

// In handleJoinPost, after creating the member row:
if (invite.preset) {
  const preset = invite.preset;
  await db.update(meshMember)
    .set({
      roleTag: preset.roleTag ?? null,
      defaultGroups: preset.groups ?? [],
      messageMode: preset.messageMode ?? "push",
      // displayName already set from the join request
    })
    .where(eq(meshMember.id, newMemberId));
}

CLI changes

Launch flow (simplified)

// After config loaded and mesh selected:

// 1. Connect to broker (existing flow)
// 2. Receive hello_ack with memberProfile + meshPolicy

// 3. Apply member defaults, CLI flags override
const effectiveName = flags.name ?? helloAck.memberDisplayName;
const effectiveRole = flags.role ?? helloAck.memberProfile.roleTag;
const effectiveGroups = flags.groups ?? helloAck.memberProfile.groups;
const effectiveMode = flags.messageMode ?? helloAck.memberProfile.messageMode;

// 4. No prompts. Flags or server defaults. Done.

claudemesh profile command

New command to view/edit your member profile from the CLI:

# View current profile
claudemesh profile
#   Name:     Alice
#   Role:     lead
#   Groups:   eng (lead), review
#   Messages: push
#   Mesh:     dev-team (admin)

# Edit fields (sends PATCH to broker)
claudemesh profile --role-tag fullstack
claudemesh profile --groups eng,frontend,review
claudemesh profile --message-mode inbox
claudemesh profile --name "Alice M."

# Edit another member (admin only)
claudemesh profile --member Bob --role-tag junior-dev --groups onboarding

Fields that are admin-managed show a lock icon:

claudemesh profile
#   Name:     Alice
#   Role:     lead 🔒 (admin-managed)
#   Groups:   eng (lead), review 🔒 (admin-managed)
#   Messages: push

Attempting to edit a locked field:

claudemesh profile --role-tag senior
# Error: roleTag is admin-managed in this mesh. Ask a mesh admin to change it.

First launch stores displayName

When --name Alice is provided on first launch (or sync), the CLI sends it to the broker which persists it on the member row. Future launches don't need --name:

# First time
claudemesh launch --name Alice
# → broker stores displayName="Alice" on member row

# Every subsequent launch
claudemesh launch
# → hello_ack returns displayName="Alice", no flag needed

Invite presets

Creating an invite with presets (dashboard)

Create invite link — dev-team

  Permission:   [member ▾]  (admin/member)

  Profile presets (applied to new members):
    Role tag:     [backend-dev    ]
    Groups:       [eng ×] [review ×]  [+ Add]
    Message mode: (●) Push  ( ) Inbox  ( ) Off

  Link settings:
    Max uses:     [10  ]
    Expires:      [7 days ▾]

  [Generate link]

  ────────────────────

  ic://join/eyJhbGciOi...
  https://claudemesh.com/join/eyJhbGciOi...

  [Copy link]

Creating an invite with presets (CLI)

claudemesh invite create \
  --role-tag backend-dev \
  --groups eng,review \
  --message-mode push \
  --max-uses 10 \
  --expires 7d

Invite payload extension

The signed invite payload gains a preset field:

{
  "v": 1,
  "mesh_id": "mesh_xyz",
  "mesh_slug": "dev-team",
  "broker_url": "wss://ic.claudemesh.com/ws",
  "expires_at": 1713100000,
  "mesh_root_key": "...",
  "role": "member",
  "preset": {
    "roleTag": "backend-dev",
    "groups": [{ "name": "eng" }, { "name": "review" }],
    "messageMode": "push"
  },
  "owner_pubkey": "...",
  "signature": "..."
}

The preset is included in the canonical signed bytes (appended to the existing canonical format) so it can't be tampered with.


Dashboard views

Mesh members page

dev-team — Members

  ┌───────────────────────────────────────────────────────────────┐
  │ Name          Role tag       Groups          Status  Access   │
  │─────────────────────────────────────────────────────────────── │
  │ ● Alice       lead           eng, review     idle    admin ▾  │
  │ ● Bob         backend-dev    eng             working member ▾ │
  │ ○ Carol       designer       design, ux      —       member ▾ │
  │ ○ Dave        —              —               —       member ▾ │
  └───────────────────────────────────────────────────────────────┘

  ● = online (has active session)   ○ = offline

  [Invite member]

Clicking a member opens an edit panel:

  Edit member — Bob

    Display name:  [Bob            ]
    Role tag:      [backend-dev    ]
    Groups:        [eng ×]  [+ Add]
    Message mode:  (●) Push  ( ) Inbox  ( ) Off
    Permission:    [member ▾]

    Joined: Apr 3, 2026
    Last seen: 2 hours ago
    Sessions: 0 (offline)

    [Save]                         [Revoke access]

Mesh settings page

  dev-team — Settings

  General:
    Name:        [dev-team       ]
    Visibility:  [private ▾]

  Member self-edit permissions:
    What can members edit about themselves?

      Display name:    [✓]
      Role tag:        [ ]  ← only admins can assign
      Groups:          [ ]  ← only admins can assign
      Message mode:    [✓]

  [Save]

Live presence view

  dev-team — Live

  ┌────────────────────────────────────────────────────────────────┐
  │ ● Alice (lead)                          idle                   │
  │   eng (lead), review                                           │
  │   Session 1: ~/Desktop/claudemesh — "Working on auth sync"     │
  │   Session 2: ~/Desktop/cuidecar — "Reviewing PR #47"           │
  │                                                                │
  │ ● Bob (backend-dev)                     working                │
  │   eng                                                          │
  │   Session 1: ~/Desktop/api — "Fixing migration bug"            │
  └────────────────────────────────────────────────────────────────┘

  Auto-refreshes every 5s via WebSocket.

Real-time profile push

When an admin (or self) updates a member's profile via the dashboard or CLI, all active sessions for that member receive a push:

Dashboard: admin changes Bob's groups
  → PATCH /mesh/:meshId/member/:memberId { groups: [{name: "ops"}] }
  → Broker updates member row
  → Broker finds all active presence rows for this memberId
  → Broker sends to each WS connection:
    { type: "profile_updated", changes: { groups: [{name: "ops"}] } }
  → Bob's CLI receives push, updates in-memory groups
  → Bob's next list_peers / join_group reflects the change
  → No restart needed

Migration from peerState

The existing peerState table stores groups, profile, visible, lastDisplayName, and cumulativeStats. After this change:

peerState field Migration
groups Copy to member.default_groups for existing members. peerState.groups becomes a session-level overlay (for CLI join_group/leave_group within a session).
lastDisplayName Already on member.displayName. Drop from peerState.
profile (avatar, title, bio) Keep on peerState for now. These are presentation, not identity. Could move to member later.
visible Keep on peerState. Session-scoped toggle.
cumulativeStats Keep on peerState. Operational data, not identity.

The peerState table is NOT removed. It still serves its purpose for cross-session operational state. The member table absorbs identity fields only.


Implementation order

  1. DB migration: Add columns to member (role_tag, default_groups, message_mode, dashboard_user_id), mesh (self_editable), invite (preset). Rename member.rolemember.permission.
  2. Broker: PATCH /mesh/:meshId/member/:memberId endpoint with self-edit permission checks and real-time push.
  3. Broker: GET /mesh/:meshId/members endpoint with online status.
  4. Broker: PATCH /mesh/:meshId/settings endpoint.
  5. Broker: Update handleHello to include memberProfile + meshPolicy in hello_ack. Update presence creation to merge member defaults.
  6. Broker: Update /join to apply invite presets to new members.
  7. CLI: Update launch to read memberProfile from hello_ack, skip prompts when server has defaults, flags override.
  8. CLI: claudemesh profile command.
  9. CLI: Update invite creation to accept preset flags.
  10. Web: Member management page (list, edit, revoke).
  11. Web: Mesh settings page (self-edit policy).
  12. Web: Invite creation with presets.
  13. Web: Live presence view.

What stays the same

  • Ed25519 keypairs remain the mesh identity
  • E2E encryption unchanged (crypto_box with peer keys)
  • presence table stays ephemeral — status, summary, cwd, pid
  • peerState keeps operational data — stats, visibility, session profile
  • list_peers MCP tool still works (reads from presence, now enriched with member defaults)
  • CLI --role, --groups, --message-mode flags still work as per-session overrides
  • join_group / leave_group WS messages still work for session-scoped group changes (these update presence, not member)

Open questions

  1. Session-scoped group changes vs member-level groups. If member has groups: [eng] and the CLI does join_group("review") mid-session, does that add to the member row or just the presence? Proposal: just presence. Session-scoped join/leave is temporary. Use claudemesh profile --groups or dashboard for permanent changes.

  2. Profile conflicts across devices. If Alice has two CLI devices with different keypairs (different member rows), they have independent profiles. This is correct — they're different identities in the mesh. But if she syncs from the same dashboard account, should her profile sync across devices? Proposal: no, not in v1. Each member row is independent. Dashboard shows all members linked to your account.

  3. Audit trail for profile changes. Should profile edits go in the audit log? Proposal: yes. Event type: member_profile_updated, payload includes who changed what. Useful for managed teams.