> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mesh.texturehq.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Data model

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

See [`perspective.md`](/perspective) for the normative epistemic-isolation boundary and [`runtime/work-graph.md`](/runtime/work-graph) 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 a `runtime_agent_id` column on `connectors`, `actors`, and
`connector_identities`, and it is part of the uniqueness rules rather than a
filter applied afterward:

* `connectors` is 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.
* `actors` carries 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_identities` carries 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:

* `id`
* `runtime_agent_id` — the owning perspective, `NOT NULL`
* `display_name`
* `status` (`active`, `disabled`, `pending`)
* `created_at`
* `updated_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 — see [`agent-provisioning.md`](/agent-provisioning). 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:

* `id`
* `slug`
* `display_name`
* `status`
* `model_primary`
* `model_fast`
* `model_heartbeat`
* `created_at`
* `updated_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:

* `id`
* `agent_id`
* `connector_type`
* `external_app_id`
* `external_team_id`
* `status`
* `created_at`

### Secret

An encrypted credential, owned by the thing that uses it.

Credentials are owned by an `AgentConnector`, 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:

* `id`
* `owner_type`
* `owner_id`
* `name`
* `ciphertext`
* `created_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:

* `id`
* `runtime_agent_id` — the owning perspective, `NOT NULL`
* `type` (`slack`, `telegram`, `github`, etc.)
* `name` — the conversation namespace; legible, not the isolation key
* `status`
* `created_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
in [`runtime/identity-linking.md`](/runtime/identity-linking).

Suggested fields:

* `id`
* `runtime_agent_id` — the owning perspective, `NOT NULL`
* `actor_id`
* `connector_id`
* `external_id`
* `external_handle`
* `metadata_json`
* `created_at`

Composite foreign keys require `(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:

* `id`
* `connector_id`
* `external_thread_id`
* `external_channel_id`
* `title`
* `status`
* `shape` (`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. See [`runtime/addressing.md`](/runtime/addressing).
* `engagement_override` (nullable: `require_mention`, `always`) — per-conversation
  operator escape hatch. Null means "use the shape default," which is the
  intended state.
* `created_at`
* `updated_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 rescanning
`message_events`.

Suggested fields:

* `conversation_id`
* `actor_id`
* `first_seen_at`
* `last_seen_at`

The pair `(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 is `NOT NULL` today.** 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:53` declares
  `actor_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. See [`roadmap/risks.md#r25` (repository)](https://github.com/TextureHQ/mesh/blob/main/docs/roadmap/risks.md#r25).
* `connector_identity_id` — who said it, at connector scope. Always present for
  inbound. This is the cheap identity; `actor_id` is the expensive one.
* `direction` (`inbound`, `outbound`)
* `external_message_id`
* `parent_external_message_id`
* `body`
* `body_format` — how to parse `body`. It differs by direction and that is the
  point: inbound Slack messages are `mrkdwn` (what Slack sent), outbound replies
  are `markdown` (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. `observe` messages 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`

A future storage optimization may deduplicate raw connector payload bytes beneath
these observations. It must not deduplicate the perspective-specific MessageEvent
semantics themselves.

### 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, in
`message_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_id`
* `external_id` — the connector's file id; `(message_event_id, external_id)` is
  unique, so a replayed delivery re-inserts nothing
* `name`, `mime_type`, `size_bytes`
* `source_url` — empty when the connector withheld it (Slack sends id-only stubs
  for files the app cannot see); recorded and named, never fetched
* `created_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`](/runtime/addressing).

Suggested fields:

* `id`
* `conversation_id`
* `trigger_message_event_id` — the *first* trigger. A turn's full trigger set is
  plural (see [`runtime/interrupt-model.md`](/runtime/interrupt-model)) and lives
  on edges.
* `target_actor_id`
* `agent_id`
* `status` (`queued`, `running`, `succeeded`, `failed`)
* `model_connector_id`
* `result_message_event_id`
* `created_at`
* `updated_at`

### Run

A durable execution of one Turn.

The harness owns Run semantics; see [`runtime/agent-harness.md`](/runtime/agent-harness). For graph engineering, the important addition is that a root Run has an execution shape:

* `loop` — one bounded agent loop directly owns the objective
* `graph` — a WorkGraph schedules bounded node executions for the objective

A WorkGraph is not a second conversational identity. It is execution state owned by this Run and RuntimeAgent.

Suggested additions:

* `execution_shape` (`loop`, `graph`)
* optional `work_graph_id`
* optional `parent_run_id` for child worker executions
* optional `work_node_id` identifying which graph node a child Run executes

A model-backed WorkNode may execute as a **child Run**. The child has fresh bounded execution context and may have its own workspace, but it remains inside the parent RuntimeAgent's perspective and authority. It is not a new RuntimeAgent or Actor.

### 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:

* `id`
* `run_id`
* `agent_id`
* `status` (`planning`, `running`, `blocked`, `succeeded`, `failed`, `cancelled`, `budget_exhausted`)
* `current_revision_id`
* aggregate budget fields
* `created_at`
* `updated_at`

A root Run has at most one WorkGraph.

### 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:

* `id`
* `work_graph_id`
* `revision_number`
* `proposed_by_run_step_id`
* `status` (`proposed`, `admitted`, `rejected`, `superseded`)
* structured `rejection_reasons`
* `created_at`

Exactly one admitted revision is current at a time.

### WorkNode

A stable logical job inside a WorkGraph.

Suggested fields:

* `id`
* `work_graph_id`
* stable `key`
* `kind` (`agent_loop`, `deterministic`, `verify`, `human_gate`, `join`)
* `objective`
* `effect_class`
* declared input contract
* declared output contract
* node budget
* status
* `created_at`
* `updated_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`](/perspective).

### WorkEdge

A dependency between WorkNodes in one admitted revision.

Suggested fields:

* `revision_id`
* `from_node_id`
* `to_node_id`
* optional runtime-defined typed condition
* expected artifact type/schema

Edges are scheduling/data dependencies, not conversational relationships. They do not belong in `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:

* `id`
* `work_node_id`
* optional `child_run_id` for `agent_loop` nodes
* `status` (`queued`, `running`, `succeeded`, `failed`, `blocked`, `cancelled`, `awaiting_input`, `awaiting_approval`)
* attempt number
* effect/commit state
* failure reason
* `started_at`
* `finished_at`

Retries create new attempts; they do not create graph cycles.

### WorkArtifact

A durable typed hand-off produced by one node and consumed by downstream nodes.

Suggested fields:

* `id`
* `work_graph_id`
* `producer_node_id`
* `producer_attempt_id`
* artifact type/schema
* inline value or external reference
* provenance metadata
* `created_at`

Artifacts, not whole worker transcripts, are the normal data plane between graph nodes. A downstream context may receive exactly the artifacts declared by its incoming edges plus relevant perspective context resolved under [`runtime/context-resolution.md`](/runtime/context-resolution).

### 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 reaches `succeeded`, 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)](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/commitments.md) for the normative liveness contract.

Suggested fields:

* `id`
* `runtime_agent_id` — the owning perspective, `NOT NULL`; the authority a reconcile Run assumes, independent of `lease_owner`
* `conversation_id` — where it was promised and where escalation returns
* `origin_run_id` — the turn that created it
* `objective`
* `objective_hash` — with `origin_run_id`, a **database-enforced `UNIQUE` constraint** (both columns `NOT NULL`, immutable after insert) that makes creation idempotent under replay; the step-2 upsert uses this composite as its conflict target. `objective_hash` is 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 resume
* `status` (`open`, `in_progress`, `completed`, `stalled`, `escalated`, `cancelled`, `failed`)
* `desired_state` — the target terminal state; `completed` is the only success target and the default, present so a time-boxed or best-effort commitment can declare a different honest terminal
* `deadline_at` — nullable wall-clock deadline, distinct from `next_reconcile_at` (retry cadence); when passed unmet, the reconciler drives to `failed`/`escalated` rather 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 before `desired_state`
* `lease_owner`, `lease_expires_at` — the E1/B2.5 single-flight idiom; who currently drives, not what authority they hold
* `frontier_fingerprint`, `frontier_changed_at`, `attempts` — progress tracking
* `next_reconcile_at` — due-time for the next attempt, backed off for `stalled`/`escalated`
* `created_at`
* `updated_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](#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)](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/commitments.md).

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)](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/commitments.md)) 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

Suggested fields:

* `id`
* `from_type`
* `from_id`
* `edge_type`
* `to_type`
* `to_id`
* `metadata_json`
* `created_at`

`ThreadEdge` is deliberately not reused for WorkGraph dependencies. Conversation topology and execution topology have different ownership, invariants, and lifecycle semantics.

## Identity rules

1. 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.
2. Cross-connector identity is an explicit mapping inside one perspective, not an assumption.
3. Multiple actors may participate in the same conversation.
4. The system must preserve who said what, and who it was for, from the owning perspective.
5. A reply should never lose attribution just because the thread is busy.
6. RuntimeAgent identity is control-plane data, not an Actor classification and not deployment configuration.
7. Seeing an identity is not the same as knowing an actor. Connector identities
   are created on sighting; actors are promoted on interaction.
8. Co-residency never implies Actor equivalence, shared memory, or shared conversation state.
9. Self-recognition is privileged; peer-recognition is not.
10. WorkGraph workers inherit one RuntimeAgent perspective; they do not create new participant identities.

## Execution graph rules

1. A root Run may execute as one loop or one WorkGraph.
2. V1 WorkGraphs are DAGs; bounded loops live inside `agent_loop` node executions.
3. A model may propose graph topology but may not directly schedule unadmitted topology.
4. Graph revisions are append-only; completed node history is immutable.
5. WorkEdges carry dependencies and typed artifacts, never hidden conversational identity.
6. Child worker authority is a subset of root Run authority.
7. Node budgets compose under the root Run budget; fan-out does not create budget.
8. Parallel mutable work must be isolated; one join owns reconciliation.
9. A worker that wants more decomposition requests replanning from the root graph planner rather than recursively spawning invisible work.
10. An external side effect in any WorkNodeAttempt commits the root Run under the interrupt-model contract.
11. A promise that outlives its turn is a Commitment, not a Run. Its terminal `completed` state is validated against declared evidence, and a reconciler — not the promising agent — owns driving it there. See [`runtime/commitments.md` (repository)](https://github.com/TextureHQ/mesh/blob/main/docs/runtime/commitments.md).

## 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

Memory written by one RuntimeAgent is never an implicit retrieval source for
another RuntimeAgent, even when the control plane can correlate their external
participants.

WorkGraph artifacts are not automatically durable long-term memory. They are execution records. Promotion from a WorkArtifact into remembered state is an explicit memory write with provenance, not a side effect of a worker having produced it.

## First database choice

PostgreSQL is the obvious first choice because both early domains are relational with graph edges: the perspective-scoped conversation graph and the run-scoped WorkGraph.

If we later need specialized retrieval, graph scheduling infrastructure, or event search, that should be layered on top of the canonical stores, not replace their ownership and auditability contracts.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.