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.

ProviderScopeIdentifier
slackworkspace / enterprise iduser id (U…) — workspace-scoped, so the bare id is not an identity
telegramnumeric user id — usernames are mutable and never stored
emailaddress
api-keykey 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 touched

The 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.

RecordKeyed byHoldsLifecycle
Identity recordidentity tuplepersona id, link method, verified_at, linked_by, first/last seenWritten at first contact; every record repoints to the survivor in the same batch as a merge, so a record never points at a tombstone
Profilepersona iddisplay 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 provenancePoint row per tie — O(1) per attribution; moved under the survivor on merge, provenance preserved for split
Participation indexpersona idthe conversation ids a persona is tied toTouched only when a new tie lands — steady-state turns never rewrite it
Link noncedigest of the codeminting persona, expiryMinted by StartLink; deleted in the same batch that applies the link, so a code can never replay
Link attemptscompleting identityfailure count, window startTallied 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.

CeremonyTrust basis
Workspace-asserted email auto-linkThe workspace admin asserts the email
Deep-link token (chat platforms)Possession of both sessions
Cross-channel one-time codePossession of both channels; works for any pair
API-key issuanceA key is born linked to the persona that requested it
Email magic linkMailbox 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.

IncrementScopeStatus
Principal foundationPersona directory, caller() on the edge contract, attribution events, forensics readsShipped — live-verified, including restart recovery
Linking and mergePersonaService: cross-channel code ceremony with brute-force lockout, operator merge with permanent aliases and auditShipped
Slack command routerDeterministic addressed-command layer (link, help) plus a one-click Connect button and an App Home dashboard; code delivered by DM, fail-closedShipped
No-typing Telegram deep linkSlack “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 typeShipped
More affordances and splitWorkspace-email auto-link, provenance-based splitDesigned
Profile and permissionsPreferences, per-persona tool/approval/quota resolutionDesigned
MemoryPer-persona journals, post-turn extraction, context blockDesigned
Consolidation and hardeningSleep-time consolidation, signed roots, usage rollupsDesigned