Data model
This document describes the first-pass conceptual model for Mesh. The system should not start as “messages in, messages out.” It should start as a graph of actors, conversations, and events. That graph is perspective-scoped: there is no deployment-global conversational worldview shared by every RuntimeAgent. Mesh also has a second, deliberately separate graph domain for execution: a WorkGraph describes how one RuntimeAgent decomposes and executes a complex objective. The conversation graph answers what does this agent know?; the WorkGraph answers what work remains and what depends on what? Do not collapse the two because both happen to contain nodes and edges. Seeperspective.md for the normative epistemic-isolation boundary and runtime/work-graph.md for the normative execution-graph contract.
Perspective ownership
A RuntimeAgent owns a perspective: the actors, connector identities, conversations, message events, memory, and derived context through which that agent knows the world. This is a data-model boundary, not merely prompt filtering. A graph query that cannot establish which RuntimeAgent’s perspective it is reading is incomplete by definition. These invariants hold mechanically:- graph identity never silently crosses RuntimeAgent perspectives
- two RuntimeAgents may hold distinct Actor rows for the same real-world person
- cross-connector identity links exist only inside the perspective that established them
- memory and conversation history from one perspective are not retrieval sources for another
- co-residency in one Mesh deployment does not collapse graph identities
How the physical schema expresses it
Ownership is aruntime_agent_id column on connectors, actors, and
connector_identities, and it is part of the uniqueness rules rather than a
filter applied afterward:
connectorsis unique on(runtime_agent_id, type, name). Two RuntimeAgents observing the same external workspace under the same connector namespace get two connector rows, and therefore two conversation graphs.actorscarries ownership directly, because an Actor is reachable without any connector — message events, participation edges, and subject-attributed memory all point at it — and because an Actor may hold identities on several connectors, which is what cross-connector linking means.connector_identitiescarries it so that composite foreign keys can require an identity’s Actor and Connector to belong to the same perspective. That is what makes “a linkage learned by Lyra does not bootstrap Daedalus’s identity graph” a constraint violation rather than a rule to remember.
conversations and message_events deliberately carry no ownership column:
conversations.connector_id is NOT NULL, so their perspective is total and one
join away, and duplicating it would create a second version of the same fact that
could disagree with the first. The write path resolves both sides of every edge
under one perspective, so an observation cannot join rows from two.
An earlier iteration expressed ownership only through the connector’s
conversation namespace. That was insufficient rather than merely indirect: the
namespace resolves from per-install configuration, two installs could resolve to
the same string, and the resulting shared connectors row silently merged both
agents’ conversations, identities, and actors. Perspective ownership must not
depend on configuration values happening not to collide.
The conversation namespace still exists and still prefixes conversation keys. It
is a legible per-install key, not the isolation boundary.
Core concepts
Actor
A participant as known within one RuntimeAgent’s perspective. Mesh deliberately does not classify what an Actor “really is.” A participant may be a human, an agent, a service, or something else; that ontology is not part of the conversation graph.Actor.kind therefore does not belong in the model.
An actor is not the same thing as a connector account. A single Actor may have
identities on multiple platforms within the owning perspective. Another
RuntimeAgent may independently know the same real-world participant as a
different Actor.
Suggested fields:
idruntime_agent_id— the owning perspective,NOT NULLdisplay_namestatus(active,disabled,pending)created_atupdated_at
RuntimeAgent
A configured agent this install hosts. Agents are rows, created at runtime through the UI. None is privileged and none comes from process configuration — seeagent-provisioning.md. An install
with zero rows here is valid.
A RuntimeAgent is a control-plane entity: which credentials it uses, which
models it runs on, which connectors it owns, and which perspective belongs to it.
It is not a special class of Actor in the conversation graph.
When a RuntimeAgent emits through a connector, another perspective observes that
connector identity exactly as it would any other participant. The fact that the
control plane happens to host the sender is not conversational context.
Suggested fields:
idslugdisplay_namestatusmodel_primarymodel_fastmodel_heartbeatcreated_atupdated_at
AgentConnector
One RuntimeAgent’s presence on one connector installation. For Slack that is one app installed in one workspace, keyed by(api_app_id, team_id). An agent may have many of these — the same agent in
several workspaces — and each owns its own credentials.
AgentConnector is control-plane configuration. It is also the root from which
Mesh can establish which RuntimeAgent perspective an inbound observation belongs
to. It must never become a backdoor for telling one agent that another observed
Actor is also a RuntimeAgent.
Suggested fields:
idagent_idconnector_typeexternal_app_idexternal_team_idstatuscreated_at
Secret
An encrypted credential, owned by the thing that uses it. Credentials are owned by anAgentConnector, not by the process. They are
encrypted with AES-GCM under a master key supplied from outside the database,
since a key stored next to its own ciphertext protects nothing.
Suggested fields:
idowner_typeowner_idnameciphertextcreated_at
Connector
An integration surface within a perspective, such as Slack or Telegram. A connector owns the protocol details for ingress and egress. It belongs to exactly one RuntimeAgent, and its(runtime_agent_id, type, name) triple is
unique. Two co-resident agents observing the same external workspace — even under
the same namespace — hold two connector rows and do not share one conversational
graph.
Suggested fields:
idruntime_agent_id— the owning perspective,NOT NULLtype(slack,telegram,github, etc.)name— the conversation namespace; legible, not the isolation keystatuscreated_at
ConnectorIdentity
A connector-specific identity for an Actor inside one perspective. This is what lets that perspective know that a Slack user, a Telegram user, and a GitHub handle may all refer to the same Actor. The mapping is explicit and perspective-scoped. A mapping learned by Lyra does not bootstrap Daedalus’s graph. How a cross-connector mapping is established — the out-of-band possession challenge, its provenance columns, and the abuse-resistance rules — is specified inruntime/identity-linking.md.
Suggested fields:
idruntime_agent_id— the owning perspective,NOT NULLactor_idconnector_idexternal_idexternal_handlemetadata_jsoncreated_at
(runtime_agent_id, actor_id) and
(runtime_agent_id, connector_id) to resolve inside one perspective, so a link
between one agent’s Actor and another agent’s Connector is not storable.
Conversation
A logical conversation boundary as observed by one RuntimeAgent. This may map to a Slack channel thread, a Telegram chat, a GitHub discussion, or a synthetic Mesh thread. The same external Slack thread may legitimately have separate Conversation rows in multiple RuntimeAgent perspectives. That is intentional. They are separate observations of the same external room, not duplicate canonical state that should be normalized together. Suggested fields:idconnector_idexternal_thread_idexternal_channel_idtitlestatusshape(direct,small_group,channel) — derived from the participant set plus connector-native topology. It is the property that lets engagement policy differ between a DM, where everything is addressed to the agent, and a busy channel, where almost nothing is. Seeruntime/addressing.md.engagement_override(nullable:require_mention,always) — per-conversation operator escape hatch. Null means “use the shape default,” which is the intended state.created_atupdated_at
ConversationParticipant
The explicit relationship between a perspective-scoped Conversation and an Actor who has participated in it. This edge is maintained by the canonical message write path; readers do not reconstruct participation by rescanningmessage_events.
Suggested fields:
conversation_idactor_idfirst_seen_atlast_seen_at
(conversation_id, actor_id) is unique. Replayed or out-of-order
connector deliveries update the observed time range without creating duplicate
participants.
MessageEvent
An immutable observation of a message that entered or left one RuntimeAgent’s perspective. The same external connector event may produce one MessageEvent in Lyra’s perspective and another in Daedalus’s. Their bodies and external ids may match, but their derived meaning may differ. For example,"@Lyra check this" may be
engage for Lyra and observe for Daedalus. This is intentional perspective
semantics, not accidental duplication.
Suggested fields:
-
id -
conversation_id -
actor_id— must become nullable; it isNOT NULLtoday. A message must be persistable with a connector identity and no actor row. Dropping an agent into a 10,000-member channel should not materialize 10,000 actors; actors are promoted on first real interaction, not on first sighting. The applied schema does not do this yet:0001_initial.sql:53declaresactor_id uuid NOT NULL REFERENCES actors(id) ON DELETE RESTRICT, and no later migration alters it. So the sentence above is the target state, and relaxing the constraint is the first step of Track A2’s actor materialization — a migration over the largest table in the system, exactly the cost this field note was written to avoid. Seeroadmap/risks.md#r25(repository). -
connector_identity_id— who said it, at connector scope. Always present for inbound. This is the cheap identity;actor_idis the expensive one. -
direction(inbound,outbound) -
external_message_id -
parent_external_message_id -
body -
body_format— how to parsebody. It differs by direction and that is the point: inbound Slack messages aremrkdwn(what Slack sent), outbound replies aremarkdown(Mesh’s canonical form, since rendering happens at the send boundary and the rendered copy is never stored). A reader that ignores this column and assumes one dialect will show one of the two with its markup visible. -
engagement(observe,engage,ignore) — this perspective’s addressing verdict.observemessages are context and never trigger a turn. -
engagement_signal— which tier and which signal produced that verdict, so “why didn’t you answer me?” is answerable without a debugger. -
metadata_json -
created_at
Attachment
A file that rode with a MessageEvent: a screenshot pasted into a question, a document dropped into a thread. One row per file per event, inmessage_attachments, immutable and deleted with the event.
An Attachment is metadata about a file, never the file. Mesh stores the
connector’s id for it, its name, MIME type, size, and the connector-authenticated
address the bytes can be fetched from (Slack’s url_private, usable only with
that install’s own token). The bytes are fetched by the turn that answers the
message, bounded, shown to the model, and discarded. Mesh keeps no user files.
It is its own table rather than a key in message_events.metadata because
metadata is the operator’s provenance stamp and is documented as never read
back into a prompt — and attachments are: the current turn hands the images to
the model, and history lines carry the names so a later turn knows an earlier
message had a picture it is not seeing. A fact a prompt depends on gets a schema.
Fields:
message_event_idexternal_id— the connector’s file id;(message_event_id, external_id)is unique, so a replayed delivery re-inserts nothingname,mime_type,size_bytessource_url— empty when the connector withheld it (Slack sends id-only stubs for files the app cannot see); recorded and named, never fetchedcreated_at
Turn
A processing unit for a RuntimeAgent response. A turn is the unit the router schedules and the worker executes. It always belongs to the RuntimeAgent whose perspective contains its Conversation. Turn identity derives from(agent_id, conversation_id), not from message
arrival, and at most one turn per pair may be in flight. That is a uniqueness
constraint on active turns, not a policy — it is the difference between a busy
agent and ten concurrent tool loops fighting over one channel until the host dies.
See runtime/addressing.md.
Suggested fields:
idconversation_idtrigger_message_event_id— the first trigger. A turn’s full trigger set is plural (seeruntime/interrupt-model.md) and lives on edges.target_actor_idagent_idstatus(queued,running,succeeded,failed)model_connector_idresult_message_event_idcreated_atupdated_at
Run
A durable execution of one Turn. The harness owns Run semantics; seeruntime/agent-harness.md. For graph engineering, the important addition is that a root Run has an execution shape:
loop— one bounded agent loop directly owns the objectivegraph— a WorkGraph schedules bounded node executions for the objective
execution_shape(loop,graph)- optional
work_graph_id - optional
parent_run_idfor child worker executions - optional
work_node_ididentifying which graph node a child Run executes
WorkGraph
The durable execution topology for one graph-mode root Run. A WorkGraph is a DAG in V1. Loops remain inside bounded worker Runs; retries become node attempts; replanning creates a new graph revision rather than mutating or cycling the admitted graph. Suggested fields:idrun_idagent_idstatus(planning,running,blocked,succeeded,failed,cancelled,budget_exhausted)current_revision_id- aggregate budget fields
created_atupdated_at
WorkGraphRevision
An immutable proposed topology for a WorkGraph. The planner may propose revisions, but the runtime admits them deterministically before any new topology executes. Completed history is never edited in place. Suggested fields:idwork_graph_idrevision_numberproposed_by_run_step_idstatus(proposed,admitted,rejected,superseded)- structured
rejection_reasons created_at
WorkNode
A stable logical job inside a WorkGraph. Suggested fields:idwork_graph_id- stable
key kind(agent_loop,deterministic,verify,human_gate,join)objectiveeffect_class- declared input contract
- declared output contract
- node budget
- status
created_atupdated_at
agent_loop nodes do not point at other RuntimeAgents by default. They are bounded worker contexts for the owning RuntimeAgent. Persistent peer-agent collaboration still happens through ordinary connector-visible interaction under perspective.md.
WorkEdge
A dependency between WorkNodes in one admitted revision. Suggested fields:revision_idfrom_node_idto_node_id- optional runtime-defined typed condition
- expected artifact type/schema
thread_edges.
The runtime admits a revision only if its WorkEdges form a valid DAG and satisfy graph depth, width, budget, authority, and effect-policy constraints.
WorkNodeAttempt
One execution attempt for a WorkNode. Suggested fields:idwork_node_id- optional
child_run_idforagent_loopnodes status(queued,running,succeeded,failed,blocked,cancelled,awaiting_input,awaiting_approval)- attempt number
- effect/commit state
- failure reason
started_atfinished_at
WorkArtifact
A durable typed hand-off produced by one node and consumed by downstream nodes. Suggested fields:idwork_graph_idproducer_node_idproducer_attempt_id- artifact type/schema
- inline value or external reference
- provenance metadata
created_at
runtime/context-resolution.md.
Commitment
A durable promise owned by one RuntimeAgent that outlives the Run that made it. A Run is one turn; a Commitment is an objective. When an agent replies “on it” and the turn reachessucceeded, the promise must not evaporate with the turn. A Commitment is the row that keeps it alive and the thing a reconciler drives to a verified terminal state. See runtime/commitments.md (repository) for the normative liveness contract.
Suggested fields:
idruntime_agent_id— the owning perspective,NOT NULL; the authority a reconcile Run assumes, independent oflease_ownerconversation_id— where it was promised and where escalation returnsorigin_run_id— the turn that created itobjectiveobjective_hash— withorigin_run_id, a database-enforcedUNIQUEconstraint (both columnsNOT NULL, immutable after insert) that makes creation idempotent under replay; the step-2 upsert uses this composite as its conflict target.objective_hashis a hash of the normalized objective (trimmed, case-folded), so a replayed turn collides rather than forking a duplicate. Application-level check-then-insert is insufficient under concurrent resumestatus(open,in_progress,completed,stalled,escalated,cancelled,failed)desired_state— the target terminal state;completedis the only success target and the default, present so a time-boxed or best-effort commitment can declare a different honest terminaldeadline_at— nullable wall-clock deadline, distinct fromnext_reconcile_at(retry cadence); when passed unmet, the reconciler drives tofailed/escalatedrather than retrying forever- optional
work_graph_id— set when the plan decomposes; the Commitment (not a single Run) durably owns this WorkGraph so successive reconcile Runs resolve the same graph evidence_contract— the typed proof required beforedesired_statelease_owner,lease_expires_at— the E1/B2.5 single-flight idiom; who currently drives, not what authority they holdfrontier_fingerprint,frontier_changed_at,attempts— progress trackingnext_reconcile_at— due-time for the next attempt, backed off forstalled/escalatedcreated_atupdated_at
completed is never self-asserted; it is a transition validated against evidence_contract. A Commitment is control-plane execution state owned by one perspective — like the WorkGraph tables, it is not part of the conversation graph and is not a cross-perspective retrieval source.
A WorkGraph is otherwise scoped to one graph-mode root Run via run_id. A graph-mode Commitment outlives any single Run, so the durable owner of its WorkGraph is the Commitment: work_graph_id lives on the Commitment, each reconcile Run resolves the same WorkGraph through it, and the WorkGraph’s run_id records the proposing root Run for audit rather than the sole Run allowed to advance it. See runtime/commitments.md (repository).
Resolving work_graph_id is not authority to execute it. Perspective ownership (the constraint discipline above) must hold across the continuation edge: Commitment.runtime_agent_id, WorkGraph.agent_id, and the agent of any reconcile Run that advances the graph must be the same RuntimeAgent. This is a composite-FK / transactional invariant, not a runtime check — the same shape that makes a cross-perspective identity link unstorable rather than merely discouraged. A reconcile Run may not load a WorkGraph whose agent_id differs from its Commitment’s owner, so a stale or malicious work_graph_id cannot execute another agent’s graph under the wrong authority. An authorized ownership handoff (Rule 4 of runtime/commitments.md (repository)) atomically updates the Commitment’s and the WorkGraph’s ownership together, or it does not happen.
ThreadEdge
A relationship in one perspective’s conversation graph. Examples:- message replies to message
- actor participates in conversation
- conversation belongs to connector
- turn targets actor
idfrom_typefrom_idedge_typeto_typeto_idmetadata_jsoncreated_at
ThreadEdge is deliberately not reused for WorkGraph dependencies. Conversation topology and execution topology have different ownership, invariants, and lifecycle semantics.
Identity rules
- Identity is RuntimeAgent-perspective-scoped first and connector-scoped within
it.
(runtime_agent_id, connector_id, external_id)is the identity key; the same external account seen by two RuntimeAgents is two identities and two Actors. - Cross-connector identity is an explicit mapping inside one perspective, not an assumption.
- Multiple actors may participate in the same conversation.
- The system must preserve who said what, and who it was for, from the owning perspective.
- A reply should never lose attribution just because the thread is busy.
- RuntimeAgent identity is control-plane data, not an Actor classification and not deployment configuration.
- Seeing an identity is not the same as knowing an actor. Connector identities are created on sighting; actors are promoted on interaction.
- Co-residency never implies Actor equivalence, shared memory, or shared conversation state.
- Self-recognition is privileged; peer-recognition is not.
- WorkGraph workers inherit one RuntimeAgent perspective; they do not create new participant identities.
Execution graph rules
- A root Run may execute as one loop or one WorkGraph.
- V1 WorkGraphs are DAGs; bounded loops live inside
agent_loopnode executions. - A model may propose graph topology but may not directly schedule unadmitted topology.
- Graph revisions are append-only; completed node history is immutable.
- WorkEdges carry dependencies and typed artifacts, never hidden conversational identity.
- Child worker authority is a subset of root Run authority.
- Node budgets compose under the root Run budget; fan-out does not create budget.
- Parallel mutable work must be isolated; one join owns reconciliation.
- A worker that wants more decomposition requests replanning from the root graph planner rather than recursively spawning invisible work.
- An external side effect in any WorkNodeAttempt commits the root Run under the interrupt-model contract.
- A promise that outlives its turn is a Commitment, not a Run. Its terminal
completedstate is validated against declared evidence, and a reconciler — not the promising agent — owns driving it there. Seeruntime/commitments.md(repository).
Memory rules
Memory should not be one blob. It should be queryable against the owning RuntimeAgent’s graph:- actor-centric
- conversation-centric
- time-bounded
- platform-aware
- perspective-scoped