Personas
The same person arrives as a chat handle on one edge, an email address on another, an API key on a third — and each edge forgets who they are. A persona is the one durable principal behind all of them: every turn is attributed to it, and permissions and memory accrue on it across every edge.
Edges already know their callers — a chat user id, an email sender, an authenticated API key — but without a principal, that knowledge dies at the edge. A persona is the stable internal identity behind all of those surfaces: the thing the event log attributes turns to, the thing permissions and quotas resolve against, and the thing memory follows from one conversation to the next. See the design record for the full decision.
The principal model
A persona is identified by a PersonaId — a UUIDv7 minted at creation and never derived from any external identifier, so linking, merging, and splitting never invalidate it. External identities are typed (provider, scope, id) tuples that link to a persona; the scope matters because many platform ids are only unique within one.
| Provider | Scope | Identifier |
|---|---|---|
slack | workspace / enterprise id | user id (U…) — workspace-scoped, so the bare id is not an identity |
telegram | — | numeric user id — usernames are mutable and never stored |
email | — | address |
api-key | — | key id, from the public API's bearer auth |
Creation is lazy and provisional: first contact on any edge creates a persona immediately, so attribution and memory accrue from message one, and the first response is never gated on a linking ceremony. A provisional persona holds the floor-level permission set until a verified link attaches — permissions attach to the persona; trust attaches to the link.
Attribution on every turn
Caller identity is the second pure identity mapping on the EdgeAdapter trait, next to conversation_id: caller() maps a native inbound event to an external identity, and the shared SDK applies one dedupe policy for multi-party participants. The wire carries it as AgentStart.caller plus participants.
The control plane resolves each identity to a durable persona before the turn runs — provisional on first contact, and never failing a turn over attribution. caller and participant events land inside the turn's atomic batch, so the signed event log records who started and who was present; participant ties upgrade to initiator idempotently, and an approval answered in a channel is attributed to the approver.
The forensics API reads it back:
GET /api/personas/resolve # external identity -> persona
GET /api/personas/{id} # the persona record
GET /api/personas/{id}/conversations # every conversation it touchedThe identity ledger
The directory lives in one authenticated Commonware qmdb store: identity rows and profile rows under domain-separated SHA-256 keys, committed in atomic batches so an identity mutation and its profile mutation share one root transition. Authenticated matters: the store keeps the full operation history behind a Merkle structure, which makes the identity ledger tamper-evident under the same signed-root story as the event log — and gives verified read-replica sync when scale calls for it.
The control plane is the single writer, hosting the store on a dedicated runtime thread the same way it hosts the event log. Recovery is replay, durability points are explicit commits, and the store rides the same shared storage backend as the event log — personas add no new infrastructure.
Data model
Six record families share the one store. Every key is a domain-separated SHA-256 over length-framed parts, so identifiers containing separators can never collide and one family can never shadow another. Mutations that must hold together — an identity repoint and its profile tombstone, a link and the nonce it consumes — stage into a single batch and commit as one root transition.
| Record | Keyed by | Holds | Lifecycle |
|---|---|---|---|
| Identity record | identity tuple | persona id, link method, verified_at, linked_by, first/last seen | Written at first contact; every record repoints to the survivor in the same batch as a merge, so a record never points at a tombstone |
| Profile | persona id | display name, linked identities, status, merge audit (merged_into, actor, evidence, timestamp) | Provisional at creation; becomes a permanent alias tombstone when absorbed — reads traverse it to the survivor |
| Participation | (persona, conversation) | role, first-tie time, via_persona_id provenance | Point row per tie — O(1) per attribution; moved under the survivor on merge, provenance preserved for split |
| Participation index | persona id | the conversation ids a persona is tied to | Touched only when a new tie lands — steady-state turns never rewrite it |
| Link nonce | digest of the code | minting persona, expiry | Minted by StartLink; deleted in the same batch that applies the link, so a code can never replay |
| Link attempts | completing identity | failure count, window start | Tallied on each failed CompleteLink; lockout past the budget; cleared atomically by a successful ceremony |
A persona's status moves one way: provisional (first contact, floor permissions) → linked (a verified ceremony attached) → merged (absorbed into a survivor; the id lives on as an alias). The store is half the picture — the other half is the conversation event log, where caller events persist on every turn and participant events on first tie only, derived from the committed log itself so an interrupted turn self-heals. The directory answers "who is this, now?"; the log answers "who did what, when?" — and they reconcile through alias traversal.
Linking and trust
Linking is deterministic only. Heuristics — a matching display name, a shared channel — may at most suggest a link; only a verified ceremony merges, because personas carry permissions and a false merge is privilege escalation, not a bookkeeping error. Merges append to the identity graph rather than rewriting it, the absorbed id remains a permanent alias, and split exists from day one: every fact carries the external identity it arrived through, so a wrong merge re-partitions by provenance instead of guessing.
The machinery is a dedicated PersonaService on the control plane's internal listener. StartLink mints a single-use six-digit code on the channel the user already holds — ten-minute TTL, stored only as a domain-separated digest, never in the clear. CompleteLink consumes it from the channel being claimed, in the same atomic batch that applies the link, so a code cannot be replayed even across a restart. If the completing identity already accrued its own provisional persona, that persona is absorbed into the minting one — the completed ceremony is the evidence.
The small code space is defended where it matters: wrong guesses are tallied against the guessing identity, and a handful of failures locks it out of ceremonies for the window — even with a correct code. Operator-gated Merge uses the same append-only mechanics, recording actor, evidence, and timestamp on the alias tombstone; reads traverse aliases to the survivor, so a persona id captured in an old event log keeps resolving forever.
On Slack the ceremony rides a native command, dispatched by a small command router: link mints a code and link <code> completes it from the channel being claimed (in a channel, prefix with a mention; in a DM just type it; help lists the commands). It is parsed deterministically before the model ever sees the message — the security path never routes through the LLM — and the caller’s identity is taken from the signed Slack event, so no one can claim someone else’s handle. Because the code is a credential, it is delivered only to a private DM and the request fails closed if no DM can be opened; it never lands on a channel surface, not even ephemerally. Only the short, non-secret outcomes (“invalid or expired”, “linked”) appear in the channel.
For one-click linking, the bot’s App Home tab shows your persona and its connected accounts with a “Connect another account” button — the same fail-closed ceremony, reachable without typing a command. The dashboard is a read-only render that refreshes on every open; the code still arrives only in a DM, never on the button or the view.
| Ceremony | Trust basis |
|---|---|
| Workspace-asserted email auto-link | The workspace admin asserts the email |
| Deep-link token (chat platforms) | Possession of both sessions |
| Cross-channel one-time code | Possession of both channels; works for any pair |
| API-key issuance | A key is born linked to the persona that requested it |
| Email magic link | Mailbox possession, on top of sender verification |
Memory
Persona memory is injection, not retrieval: turn assembly prepends a token-budgeted context block — a structured profile document, the active facts with validity timestamps, and a recent-conversation digest — rather than running search over transcripts. Facts are bi-temporal and carry provenance; contradictions invalidate the old fact's validity interval, never delete it. Extraction runs after the turn completes, off the hot path.
Scope is a hard predicate, not a relevance signal: a fact learned in a shared channel never surfaces into another party's context. An incognito flag suppresses extraction entirely, and each persona's memory lives in its own storage partition, so erasure is wholesale partition removal rather than a best-effort sweep.
Status
The design ships in five increments. The foundation is live, the linking and merge machinery is built; the rest is designed but not yet built.
| Increment | Scope | Status |
|---|---|---|
| Principal foundation | Persona directory, caller() on the edge contract, attribution events, forensics reads | Shipped — live-verified, including restart recovery |
| Linking and merge | PersonaService: cross-channel code ceremony with brute-force lockout, operator merge with permanent aliases and audit | Shipped |
| Slack command router | Deterministic addressed-command layer (link, help) plus a one-click Connect button and an App Home dashboard; code delivered by DM, fail-closed | Shipped |
| No-typing Telegram deep link | Slack “Connect Telegram” button DMs a tappable t.me/<bot>?start=<token> link; a single-use high-entropy token binds the verified Telegram sender on /start — no codes to type | Shipped |
| More affordances and split | Workspace-email auto-link, provenance-based split | Designed |
| Profile and permissions | Preferences, per-persona tool/approval/quota resolution | Designed |
| Memory | Per-persona journals, post-turn extraction, context block | Designed |
| Consolidation and hardening | Sleep-time consolidation, signed roots, usage rollups | Designed |