# Data model reference

> Schemas, entities, fields, relationships, migrations, and validation rules.

- Repository: egoist/lorca
- GitHub: https://github.com/egoist/lorca
- Human docs: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6
- Complete Markdown: https://grok-wiki.com/public/docs/egoist-lorca-2cf67495e5e6/llms-full.txt

## Source Files

- `crates/cli/src/model.rs`
- `crates/relay/src/db.rs`
- `crates/agent/src/types.rs`
- `crates/cli/src/api.rs`

---

---
title: "Data model reference"
description: "Schemas, entities, fields, relationships, migrations, and validation rules."
---

Lorca stores plaintext domain objects on each Device (`crates/cli/src/model.rs`) and syncs them as encrypted blobs through the relay (`crates/relay/src/db.rs`). The relay SQL schema never sees bot text, credentials, or message bodies — only ciphertext, blob kinds, sequence numbers, and machine keys.

## Ownership map

```mermaid
erDiagram
  Identity ||--o{ Machine : registers
  Identity ||--o{ Blob : owns
  Identity ||--o{ Usage : tracks
  Blob }o--o| Machine : "sealed recipient"
  Blob }o--o| Group : "chat group_id"
  RosterBlob ||--o{ Bot : contains
  RosterBlob ||--o{ ChatMeta : contains
  RosterBlob ||--o{ Routine : contains
  ChatMeta ||--o{ Message : "local messages table"
  Bot }o--|| Device : "runner_id"
  Job }o--|| Bot : bot_id
  Job }o--|| ChatMeta : chat_id
```

| Layer | Path | What it holds |
| --- | --- | --- |
| Domain (plaintext) | `crates/cli/src/model.rs` | Devices, bots, chats, messages, jobs, credentials shapes |
| Local SQLite | `crates/cli/src/local_store.rs` | Decrypted roster + messages + outbox on each Device |
| Relay SQL | `crates/relay/src/db/{sqlite,postgres}.rs` | Identities, machines, opaque blobs, pairing, push tokens |
| Agent transcript | `crates/agent/src/types.rs` | LLM turn messages (Runner-local, not synced as-is) |

<Note>
Plaintext crosses the network only inside encrypted blobs. Field names in `model.rs` match what the macOS and mobile apps decode.
</Note>

## Constants and hard limits

| Constant | Value | Where |
| --- | --- | --- |
| `MAX_GROUP_BOTS` | `6` | `model.rs`, mobile `model.ts` |
| `MAX_BOT_HOPS` | `8` | bot-to-bot `message` jobs |
| `SNAPSHOT_MESSAGES` | `60` | newest messages in a bootstrap snapshot |
| `APP_TOOL_DETAIL_CHARS` | `400` | tool-row detail kept for apps |
| `APP_COMMAND_CHARS` | `8000` | shell command text on permission/command cards |
| `MAX_ATTACHMENT_BYTES` | `100 MiB` | Device accept limit (mobile) |
| `MAX_ATTACHMENTS` | `10` | per send (mobile) |
| Non-file blob max | `4 MiB` | relay `MAX_BLOB_BYTES` |
| File blob max | `100 MiB + 40` | relay envelope overhead |
| Blob page max | `8 MiB` | `GET /v1/blobs` / group pages |
| Sealed envelope TTL | `7 days` | relay hourly sweep |
| `quota_bytes` | CLI flag, `0` = unlimited | per-identity usage |

`relay_name(name)` maps slot/group ids to relay-safe names: 1–64 chars of `[A-Za-z0-9._-]`, else SHA-256 hex.

## Domain entities

### Device

Paired machine or phone. `os` of `macos` | `linux` | `windows` means Runner.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Machine signing pubkey, base64url |
| `name`, `model`, `os`, `os_version` | string | Host facts |
| `box_pubkey` | string | Sealing target for envelopes |
| `plugins` | `PluginStatus[]` | Advertised setup state; secrets stay on Runner |
| `updated_at` | i64 | Unix seconds |

`PluginStatus.state`: `ready` | `needs_setup` | `needs_auth` | `connecting` | `error`.

### Bot

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `name`, `description` | string | Description is the single behavioral field |
| `symbol_name`, `accent` | string | Avatar fallback |
| `avatar` | `Attachment?` | Image `file` blob |
| `runner_id` | string | Device that runs tools |
| `provider` | string | Default `"deepseek"` on create |
| `model`, `thinking` | string? | Provider defaults when absent |
| `legacy_instructions` | string | Wire name `instructions`; folded into `description` on read |
| `workdir` | string? | Default `<LORCA_HOME>/workspaces/<bot id>` |
| `created_at` | f64 | Unix seconds |

`thinking` values: `off` | `minimal` | `low` | `medium` | `high` | `xhigh` | `max` (same as `ThinkingLevel` in `lorca-agent`).

### Auto-review

Shared through the roster. Default: `is_enabled = true`, empty `rules`.

| Field | Type | Notes |
| --- | --- | --- |
| `AutoReviewRule.id` | string | |
| `text` | string | Plain-language rule |
| `behavior` | string | `allow` or `ask` (`ask` wins on conflict) |
| `tool` | string? | Exact `plugin_id/tool` for Always-allow |

### Chat and ChatMeta

`ChatMeta.kind`: `dm` | `group`.

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | Also the relay group when hashed via `relay_name` |
| `title` | string? | Groups only; DMs named after bot |
| `bot_ids` | string[] | Group cap `MAX_GROUP_BOTS` |
| `owner_bot_id` | string? | Receives unaddressed messages |
| `is_pinned` | bool | |
| `created_at` | f64 | |

`Chat` flattens `ChatMeta` and adds Runner-local fields that are **not** synced: `unread_count`, `usage` (`ChatUsage`), `compactions` (`Compaction[]`).

### Message

| Field | Type | Notes |
| --- | --- | --- |
| `id` | string | `msg-<uuid>` when created locally |
| `chat_id` | string | |
| `author` | `Author` | tagged `kind`: `you` \| `bot` \| `system` |
| `body` | `Body` | tagged `kind` (below) |
| `state` | `MessageState` | `thinking` \| `streaming` \| `complete` \| `failed` |
| `created_at` | f64 | Unix seconds |
| `promoted_at` | f64? | Mid-turn user message becomes model-visible later |

#### Body kinds

| `kind` | Purpose | Key fields |
| --- | --- | --- |
| `text` | User/bot text | `text`, `attachments[]`, `mentions[]` (bot ids) |
| `tool` | Tool row | `name`, `summary`, `detail`, `is_running`, `call_id`, `arguments`, `result`, `run?` |
| `handoff` | Owner change | `from`, `to`, `reason` |
| `notice` | System marker | `text`, `routine_id?` |
| `permission` | Ask before action | `plugin_id`, `tool`, `summary`, `decision`, `rule?`, `command?` |

Permission `decision`: `pending` | `allowed` | `always` | `denied` | `expired`.

#### CommandRun (`bash` card)

`state`: `checking` | `asking` | `running` | `waiting` | `exited` | `failed` | `stopped` | `denied` | `expired` | `dismissed`.

`decision` (Auto-review answer): `allowed` | `always` | `denied` | `expired` | `dismissed`.

`Message::for_app()` strips tool `arguments`/`result`, truncates `detail` to 400 chars, and for computer/`bash` permission cards copies the command into `command` then nulls `arguments`.

### Attachment

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `name`, `mime` | string | Bytes live as `file` blob + `~/.lorca/files/<id>` |
| `size` | u64 | |
| `width`, `height` | u32? | Images |

### Routine

Lives in the roster; Runner executes on schedule.

| Field | Type | Notes |
| --- | --- | --- |
| `id`, `bot_id`, `name`, `prompt` | string | |
| `schedule` | string | `every 30m` / `every 2h` / `every 1d` or 5-field cron |
| `is_enabled` | bool | |
| `enabled_at`, `created_at` | f64 | |
| `last_run_at` | f64? | |
| `last_outcome` | string? | `sent` \| `pass` \| `error` |
| `paused_reason` | string? | e.g. `away` |

### Credentials

`kind = credentials` blob under the account DEK (`crates/cli/src/credentials.rs`).

| Field | Type |
| --- | --- |
| `deepseek`, `anthropic`, `opencode`, `opencode_go` | `ApiKeyCredential?` (`api_key`, `base_url?`, `connected_at`) |
| `chatgpt`, `grok` | OAuth token blobs |
| `changed_at` | map kind → unix seconds; later change wins on merge |

Provider kinds: `deepseek`, `anthropic`, `opencode`, `opencode-go`, `chatgpt`, `grok`.

## Blob kinds and payloads

`KINDS` on the relay:

`roster` · `chat` · `job` · `job_cancel` · `job_result` · `request` · `response` · `machine` · `credentials` · `key` · `file`

**Sealed** (addressed to one machine; swept after 7 days if unconsumed): `job`, `job_cancel`, `job_result`, `request`, `response`.

| Kind | Payload type | Slot / group behavior |
| --- | --- | --- |
| `roster` | `RosterBlob` | Latest wins: bots, `ChatMeta[]`, routines, `auto_review` |
| `chat` | `ChatBlob` | Per-message slot; group = chat id |
| `machine` | `MachineBlob` | Device + live `turns[]` |
| `credentials` | `Credentials` | Account DEK; merge by `changed_at` |
| `file` | raw bytes | Object store; row may hold empty ciphertext + size |
| `job` | `Job` | Sealed to Runner box key |
| `job_result` | `JobResult` | Sealed to requester |
| `job_cancel` | `JobCancel` | `{ job_id }` |
| `request` / `response` | `Request` / `Response` | Memory read/write verbs |

### ChatBlob ops

```text
upsert  { message }           // insert/replace by message id
remove  { chat_id, message_id }
clear_unread { chat_id }
```

Tool rows without a `CommandRun` use `Slot::latest`; other upserts and removals use `first_and_latest` so the first seq keeps transcript place.

### Job

| Field | Notes |
| --- | --- |
| `kind` | `turn` \| `room_turn` \| `message` \| `routine` \| `command` |
| `trigger_message_id` | |
| `routine_id?`, `from_bot_id?` | |
| `hops` | capped by `MAX_BOT_HOPS` |
| `round`, `is_winding_down` | group exchange |
| `setup?` | marketplace first-turn `TemplateSetup` |

`JobResult.outcome`: `sent` | `pass` | `error`.

### Pairing envelopes

`PairRequest`: `machine_pubkey`, `box_pubkey`, `device`.  
`PairReply`: `identity_pubkey`, `content_pubkey`, `account_dek`, `relay_url`.

## Relay persistence schema

Backends share the same logical tables. Open path: `postgres://` / `postgresql://` → Postgres; otherwise SQLite file.

### Tables

| Table | Key columns |
| --- | --- |
| `identities` | `pubkey`, `content_pubkey`, `created_at` |
| `machines` | `machine_pubkey`, `identity_pubkey`, `box_pubkey`, `attestation`, `last_seen`, `created_at` |
| `revoked_machines` | `machine_pubkey`, `identity_pubkey`, `revoked_at` |
| `blobs` | `(identity_pubkey, id)`, `kind`, `recipient_machine_pubkey?`, `seq`, `ciphertext`, `size`, `slot?`, `group_id?`, `created_at` |
| `deleted_groups` | `(identity_pubkey, group_id)`, `deleted_at` |
| `sequences` | `identity_pubkey` → head `seq` |
| `usage` | `identity_pubkey` → `bytes` |
| `challenges` | `nonce`, `machine_pubkey`, `expires_at` |
| `pairings` | `nonce`, `identity_pubkey`, `request`/`reply` blobs, expiry |
| `push_tokens` | `machine_pubkey`, `platform`, `token`, `environment` (`sandbox`\|`production`) |
| `schema_version` | `version`, `applied_at` |

Postgres-only presence tables: `relay_instances`, `relay_sockets` (multi-process online state + `NOTIFY` for `Event`).

### Migrations

Both backends: `SCHEMA` is version 1; `MIGRATIONS: &[&str] = &[]` today. Rules in code:

1. Append-only steps (add table, nullable column, or index).
2. Never edit a shipped step.
3. SQLite re-runs `SCHEMA` (pragmas) every open; Postgres runs each step once under advisory lock `0x10ca_5c4e`, with deadlock retry.

Housekeeping: `tick` (expired challenges/pairings); `sweep` (stale sealed envelopes, old deleted-group marks, orphans in the file store, inactive identities).

## Device local store

`LocalStore` SQLite (`PRAGMA user_version = 1`) projects decrypted state for CLI and phone core:

| Table | Role |
| --- | --- |
| `metadata` | `auto_review_json`, `last_seq`, `machine_blob_hash`, `credentials_uploaded` |
| `devices` / `bots` / `chats` / `routines` | Ordered JSON rows |
| `messages` | Full `message_json` + denormalized author/body flags for search |
| `outbox` | Pending encrypted uploads; unique `slot_name` replaces in place |
| `chat_history` | `before_place` when older pages remain on relay |
| `device_turns` / `sent_jobs` | Live and cross-Runner wait state |
| `group_deletes` / `blob_deletes` / `applied_blobs` | Sync bookkeeping |

In-memory `State` mirrors these tables plus ephemeral `device_online` / `turns_online`.

## Agent transcript model

Runner-local types in `crates/agent/src/types.rs` (not the sync `Message` shape):

| Type | Role |
| --- | --- |
| `ContentPart` | `text` \| `image` |
| `AssistantPart` | `text`, `thinking`, `tool_call`, `server_block` |
| `LlmMessage` / `AgentMessage` | `user` \| `assistant` \| `tool_result` (+ `custom`) |
| `Usage` / `Cost` | Token and dollar totals |
| `StopReason` | `stop` \| `length` \| `tool_use` \| `error` \| `aborted` |
| `AgentEvent` | Loop lifecycle stream |

### Tool-argument validation

`lorca_agent::schema::validate_tool_arguments`:

1. Drop optional `null`s the model meant as “omit”.
2. Coerce common mistakes (`"true"` → bool, numeric strings → number/integer).
3. Validate with JSON Schema; failure returns an error string the model can read.
4. Uncompilable schema → arguments accepted as-is (tool author’s bug).

## Validation and lifecycle rules (API surface)

From `crates/cli/src/api.rs` and model helpers:

| Rule | Behavior |
| --- | --- |
| Group add bot | Only if `kind == group`, `< MAX_GROUP_BOTS`, id not already present |
| Group remove bot | Keep ≥ 1 bot; reassign `owner_bot_id` if owner removed |
| Set owner | Bot must already be in `bot_ids` |
| Avatar | Must be an image MIME; stored as `file` blob |
| Bot create | Always creates a DM chat in the same roster change |
| Search limit | Clamped `1..=50`, default `20` |
| Legacy `instructions` | Folded into `description` once via `normalize_description` |

```text
CommandRun open states:  checking → asking → running|waiting → terminal
Permission decision:     pending → allowed|always|denied|expired
```

## Sync kinds filter

Device poll kinds (`sync.rs`):

`roster,chat,machine,credentials,job,job_cancel,job_result,request,response`

First sync omits `chat` from the full log replay, then pages newest messages per roster chat.

## Related pages

<CardGroup>
  <Card title="Architecture" href="/architecture">
    Component boundaries and how blobs move between Devices and the relay.
  </Card>
  <Card title="HTTP API reference" href="/http-api-reference">
    Blob, file, machine, pairing, and sync routes that persist these rows.
  </Card>
  <Card title="crates/relay reference" href="/ref-crates-relay">
    Store trait, SQLite vs Postgres backends, sweep, and quotas.
  </Card>
  <Card title="crates/agent reference" href="/ref-crates-agent">
    Transcript types, thinking levels, and tool-argument validation.
  </Card>
</CardGroup>

Next: open `crates/cli/src/model.rs` and skim the `RosterBlob` / `ChatBlob` / `Job` section (about two minutes) before changing any wire field.
