# Database

## Connection and Init

`db::init_pool(path)` opens a SQLite connection pool with `create_if_missing=true`, then calls `create_tables()`. All `CREATE TABLE IF NOT EXISTS` statements are idempotent — safe to run on every startup.

The pool is opened in **WAL journal mode** (`synchronous=NORMAL`) with a **5 s `busy_timeout`**. The DB is written concurrently (chat loop, cron, and plugins such as the mobile-connector persisting its E2E `send_counter` before each send). Without `busy_timeout`, a contended write returns `SQLITE_BUSY` ("database is locked") immediately and the operation is lost — which silently dropped outbound mobile messages (the `inbox_update` never reached the device). WAL lets readers run alongside the single writer; `busy_timeout` makes a writer wait for the lock instead of failing.

---

## Table Schemas

### chat_sessions

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `title` | TEXT | nullable |
| `source` | TEXT | NOT NULL DEFAULT `'web'` |
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` (added via ALTER) |
| `is_interactive` | INTEGER | NOT NULL DEFAULT `1` (added via ALTER) |
| `is_ephemeral` | INTEGER | NOT NULL DEFAULT `0` (added via ALTER) |
| `run_context` | TEXT | nullable — `RunContext` JSON blob (e.g. `{"security_group":"admin"}`); formerly `run_context_id` (renamed in migration 10) |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

**`is_interactive`** — `1` when a real user is actively participating in the session (web, Telegram). `0` for fully automated sessions where all user-role messages are generated by the application (cron, tic).

**`is_ephemeral`** — `1` for short-lived task sessions (cron, tic) with no long-term conversational value. Memory sinks (e.g. Honcho) should skip ephemeral sessions.

Index: `idx_stack_session ON chat_sessions_stack(session_id)`

---

### chat_sessions_stack

Represents one agent call frame. Root frames have `depth=0`; sub-agent frames increment depth.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `session_id` | INTEGER | NOT NULL REFERENCES `chat_sessions(id)` |
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` |
| `agent_prompt` | TEXT | nullable (the `call_agent` prompt) |
| `depth` | INTEGER | NOT NULL DEFAULT 0 |
| `parent_tool_call_id` | INTEGER | nullable (links to `chat_llm_tools.id`) |
| `terminated_at` | TEXT | nullable; set when `dispatch_sub_agent` finishes |
| `created_at` | TEXT | NOT NULL |

---

### chat_history

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `session_stack_id` | INTEGER | NOT NULL REFERENCES `chat_sessions_stack(id)` |
| `role` | TEXT | NOT NULL CHECK(`user` \| `assistant` \| `agent`) |
| `content` | TEXT | NOT NULL DEFAULT `''` |
| `status` | TEXT | NOT NULL DEFAULT `ok` CHECK(`ok` \| `failed`) |
| `input_tokens` | INTEGER | nullable |
| `output_tokens` | INTEGER | nullable |
| `duration_ms` | INTEGER | nullable |
| `model_db_id` | INTEGER | nullable REFERENCES `llm_models(id)` |
| `is_synthetic` | INTEGER | NOT NULL DEFAULT `0` — `1` when the message was system-generated |
| `reasoning_content` | TEXT | nullable — chain-of-thought from reasoning models |
| `cost` | REAL | nullable — turn cost in USD, when the provider reports it (OpenRouter `usage.cost`) |
| `metadata` | TEXT | nullable — generic JSON metadata bag (added v17); currently `{ "attachments": [...] }` |
| `created_at` | TEXT | NOT NULL |

- `role = 'agent'` is a sub-agent prompt message; sent as `user` to the LLM but hidden in the UI
- `status = 'failed'` rows are excluded from `for_stack()` (not sent to LLM)
- `model_db_id` is set only on `role = 'assistant'` rows; `null` for user/agent messages and for rows created before this column was added
- `is_synthetic = 1` marks messages injected by the system (ChatHub notification assistant messages with tool-call injection, or legacy synthetic user turns). They are included in the LLM context but excluded from the UI history (not shown on page reload). Currently used for `role = 'assistant'` rows that carry the synthetic `read_notification` tool call and reasoning trace.
- `reasoning_content` is populated only for `role = 'assistant'` rows from providers that return chain-of-thought (currently DeepSeek thinking mode); echoed back in the assistant message on subsequent turns
- `cost` is set on `role = 'assistant'` rows only when the provider returns a per-request price (OpenRouter exposes it under `usage.cost`); `null` for providers that don't bill per-call. Extracted via the `ChatbotClient::extract_cost` trait method (see [llm-clients.md](llm-clients.md))
- `metadata` is a generic JSON bag (type `MessageMetadata` in `core-api`), set on `role = 'user'` rows that carry file attachments. The raw `[SYSTEM INFO]` block the LLM sees is **never** stored here — it is generated on the fly from `metadata.attachments` by the message builder, while the UI renders the same list as chips. `content` always stays the clean typed text. See [frontend.md](frontend.md) (attachments) and [session.md](session.md).

Index: `idx_history_stack ON chat_history(session_stack_id)`

---

### chat_summaries

Persisted conversation summaries generated by the **context compactor** (see
[compaction.md](compaction.md)). Each row covers all `chat_history` messages up
to and including `covers_up_to_message_id`. `build_openai_messages` loads only
messages *after* this boundary and injects the summary as context.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `stack_id` | INTEGER | NOT NULL REFERENCES `chat_sessions_stack(id)` |
| `content` | TEXT | NOT NULL — the summary text |
| `covers_up_to_message_id` | INTEGER | NOT NULL — boundary: messages with `id > this` are loaded raw |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Index: `idx_chat_summaries_stack ON chat_summaries(stack_id)`

Multiple rows can exist per stack (one per compaction cycle). Only the most
recent row is used; older rows are retained for historical reference.

---

### chat_llm_tools

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `message_id` | INTEGER | NOT NULL REFERENCES `chat_history(id)` |
| `name` | TEXT | NOT NULL |
| `arguments` | TEXT | nullable (JSON string) |
| `result` | TEXT | nullable |
| `status` | TEXT | NOT NULL DEFAULT `running` CHECK(`running` \| `pending` \| `done` \| `failed` \| `cancelled` \| `rejected`) |
| `created_at` | TEXT | NOT NULL |

- `running` → tool is executing (or app crashed mid-execution — re-run on resume)
- `pending` → blocked on a human approval / clarification (re-gate / re-ask on resume)
- `done` → result is in `result` column
- `failed` → tool/runtime error message is in `result` column
- `cancelled` → stopped by the user via `/stop` (terminal, **not** re-run on resume) — distinct from `failed`
- `rejected` → denied by an approval policy or a human (terminal) — distinct from `failed`

The richer in-memory `ToolExecutionState` maps onto these strings (see [tools.md](tools.md#tool-execution-lifecycle)). The table is created directly with the full 6-value `CHECK` constraint; `cancelled`/`rejected` (distinct from `failed`) were historically added by a table rebuild in schema versions 15–16, now folded into the baseline (see [Migration Pattern](#migration-pattern)).

Index: `idx_tools_message ON chat_llm_tools(message_id)`

---

### mcp_servers

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `name` | TEXT | NOT NULL UNIQUE |
| `transport` | TEXT | NOT NULL DEFAULT `'stdio'` |
| `command` | TEXT | nullable |
| `args_json` | TEXT | nullable (JSON array) |
| `env_json` | TEXT | nullable (JSON object) |
| `url` | TEXT | nullable |
| `api_key` | TEXT | nullable |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
| `created_at` | TEXT | NOT NULL |

---

### llm_providers

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `name` | TEXT | NOT NULL UNIQUE |
| `type` | TEXT | NOT NULL (`lm_studio`, `ollama`, `open_ai`, `anthropic`) |
| `api_key` | TEXT | nullable |
| `base_url` | TEXT | nullable |
| `description` | TEXT | nullable |
| `removed_at` | TEXT | nullable; set on soft-delete (api_key is also cleared) |
| `created_at` | TEXT | NOT NULL |

Providers are **never hard-deleted**. `DELETE /api/llm/providers/{id}` sets `removed_at` and clears `api_key`, and cascades soft-delete to all models belonging to that provider. Rows with `removed_at IS NOT NULL` are excluded from all queries.

---

### llm_models

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` ON DELETE CASCADE |
| `model_id` | TEXT | NOT NULL (provider's internal model name) |
| `name` | TEXT | NOT NULL UNIQUE (display name, used as client key) |
| `strength` | TEXT | nullable (`very_low` \| `low` \| `average` \| `high` \| `very_high`) |
| `scope` | TEXT | NOT NULL DEFAULT `'[]'` (JSON array of scope strings) |
| `is_default` | INTEGER | NOT NULL DEFAULT 0 |
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by AUTO) |
| `extra_params` | TEXT | nullable (JSON object, merged into request body at call time) |
| `context_length` / `max_output_tokens` / `knowledge_cutoff` / `capabilities` | INTEGER / INTEGER / TEXT / TEXT | nullable catalog metadata (see `docs/llm-clients.md`) |
| `reasoning` | TEXT | nullable; selected reasoning value (JSON string for a `ValueSet` mode, JSON number for a `Range` mode; NULL = off) — see *Reasoning* in `docs/llm-clients.md` |
| `removed_at` | TEXT | nullable; set on soft-delete |
| `created_at` | TEXT | NOT NULL |

The only uniqueness constraint is `name` (also the resolution key). There is **no** `UNIQUE(provider_id, model_id)` — the same underlying model may be registered several times under one provider with different aliases/reasoning (e.g. `glm-4.6` vs `glm-4.6-thinking`). Migration **v20** dropped the historical `(provider_id, model_id)` constraint by rebuilding the table (preserving `id` values, which are FK targets in `llm_requests.model_db_id` / `chat_history.model_db_id`) and added the `reasoning` column.

Models are **never hard-deleted**. `DELETE /api/llm/models/{id}` sets `removed_at`. Rows with `removed_at IS NOT NULL` are excluded from all queries, including the AUTO selector. The `id` column remains a valid FK target for `chat_history.model_db_id` even after removal. Re-adding a model with the same `name` **revives** the soft-deleted row (`insert_model` upserts on `name`).

---

### transcribe_models

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
| `model_id` | TEXT | NOT NULL (provider's internal model name, e.g. `openai/whisper-1`) |
| `name` | TEXT | NOT NULL UNIQUE (display name) |
| `language` | TEXT | nullable; BCP-47 code (e.g. `"it"`, `"en"`) or NULL for auto-detect |
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by `TranscribeManager`) |
| `removed_at` | TEXT | nullable; soft-delete |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Only providers that declare `ServiceType::Transcribe` in `ApiProvider::supported_types()` should have rows here (OpenAI, OpenRouter). See [providers/transcribe.md](providers/transcribe.md).

---

### image_generate_models

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
| `model_id` | TEXT | NOT NULL (provider's internal model name, e.g. `x-ai/grok-2-vision`) |
| `name` | TEXT | NOT NULL UNIQUE (display name, also used as `provider_id` in the `image_generate` LLM tool) |
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first) |
| `removed_at` | TEXT | nullable; soft-delete |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Only providers that declare `ServiceType::ImageGenerate` in `ApiProvider::supported_types()` should have rows here (OpenRouter, OpenAI). See [providers/image.md](providers/image.md).

---

### secrets

| Column | Type | Constraints |
| --- | --- | --- |
| `key` | TEXT | PRIMARY KEY (uppercase by convention, e.g. `HUGGINGFACE_TOKEN`) |
| `value` | TEXT | NOT NULL — stored in plain text, never log |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` — updated on upsert |

Managed via `SecretsStore` (`src/core/secrets.rs`). See [secrets.md](secrets.md).

---

### tts_models

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `provider_id` | INTEGER | NOT NULL REFERENCES `llm_providers(id)` |
| `model_id` | TEXT | NOT NULL (generation model, e.g. `tts-1-hd`, `eleven_multilingual_v2`) |
| `voice_id` | TEXT | nullable; speaker voice ID — required for ElevenLabs, NULL for OpenAI (added in schema v2) |
| `name` | TEXT | NOT NULL UNIQUE (display name, also used as the synthesiser `id()`) |
| `description` | TEXT | nullable; human-readable description shown in UI |
| `instructions` | TEXT | nullable; default voice style / tone / speed (overridable per call) |
| `response_format` | TEXT | nullable; audio format `mp3`/`opus`/`aac`/`flac`/`wav`/`pcm` sent to the provider — NULL ⇒ `mp3` (added in schema v18) |
| `priority` | INTEGER | NOT NULL DEFAULT 100 (lower = tried first by `TtsManager`) |
| `removed_at` | TEXT | nullable; soft-delete |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

See [providers/tts.md](providers/tts.md).

---

### scheduled_jobs

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `title` | TEXT | NOT NULL |
| `description` | TEXT | NOT NULL DEFAULT `''` |
| `cron` | TEXT | NOT NULL (7-field format) |
| `prompt` | TEXT | NOT NULL |
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` (column default; in practice always set explicitly — `agent_id` is required at task creation) |
| `enabled` | INTEGER | NOT NULL DEFAULT 1 |
| `last_run_at` | TEXT | nullable (RFC3339 datetime) |
| `next_run_at` | TEXT | nullable (RFC3339); pre-computed next fire time; used by `list_due()` (added via ALTER) |
| `single_run` | INTEGER | NOT NULL DEFAULT 0; if 1 the job is disabled after its first execution (added via ALTER) |
| `running_session_id` | INTEGER | nullable; non-null while a run is in-flight; cleared by `finish_run()`; used by `list_interrupted()` for restart recovery (added via ALTER) |
| `kind` | TEXT | NOT NULL DEFAULT `'cron'` — `'cron'` scheduled, `'sync'` run-now-blocking, `'async'` run-now fire-and-forget |
| `parent_session_id` | INTEGER | nullable FK → `chat_sessions(id)`; set for `kind='async'` tasks to route the result back to the calling session |
| `run_context` | TEXT | nullable; `RunContext` JSON blob inherited from the creating session (or overridden via the Tasks UI); applied to the ephemeral child session at run time. Formerly `run_context_id` (renamed in migration 10). |
| `origin_ref` | TEXT | nullable; free-form reference to the entity that originated this job (e.g. `"PROJECT_TASK:42"`); used by `TaskManager` for post-completion callbacks (added in migration 11) |
| `created_at` | TEXT | NOT NULL |

**`next_run_at`** is set at job creation (first upcoming fire time after now), advanced after each successful run, cleared on disable, and recalculated by `toggle_item` (kind=cron) when re-enabling. `list_due(pool, now)` queries `WHERE kind='cron' AND enabled=1 AND next_run_at <= now AND running_session_id IS NULL`. Sync and async tasks run immediately on creation and are never picked up by the scheduler tick.

**`origin_ref`** is set only for jobs created by `ProjectTicketManager.start()`. When the job completes, `run_job()` checks this field and dispatches `ticket_manager.on_job_completed()` to update the ticket's status and result.

**`running_session_id`** is set by `set_running()` before `handle_message()` starts and cleared by `finish_run()` after the run completes. At startup, `recover_interrupted()` queries `list_interrupted()` and re-spawns any jobs still in-flight from a crash.

---

### job_runs

Audit trail of every cron job and immediate task execution. One row per run, written after each execution by `TaskManager::run_job()`.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `job_id` | INTEGER | NOT NULL REFERENCES `scheduled_jobs(id)` |
| `session_id` | INTEGER | nullable — the ephemeral session that ran the job |
| `started_at` | TEXT | NOT NULL (RFC3339) |
| `completed_at` | TEXT | nullable (RFC3339) |
| `duration_ms` | INTEGER | nullable |
| `status` | TEXT | NOT NULL (`success` \| `failed`) |
| `final_response` | TEXT | nullable — last assistant message from the session |
| `error` | TEXT | nullable — error message on failure |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Index: `idx_job_runs_job_id ON job_runs(job_id)`

DAO in `src/core/db/job_runs.rs`: `insert(pool, ...)`, `list_for_job(pool, job_id, limit)`.

---

### sources

One row per source (e.g. `"web"`, `"telegram"`). Tracks the active session for each source.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | TEXT | PRIMARY KEY |
| `active_session_id` | INTEGER | nullable REFERENCES `chat_sessions(id)` |
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

`ChatHub::get_or_create_session()` reads this table; `sources::upsert()` writes it.

---

### relay_clients

Created and owned by the `mobile-connector` plugin (`crates/plugin-mobile-connector/src/db.rs`), not the main crate. One row per paired mobile device. Counters MUST persist across restarts to prevent AES-GCM nonce reuse / replay (see [mobile-connector.md](mobile-connector.md)).

| Column | Type | Constraints |
| --- | --- | --- |
| `ed25519_pub` | BLOB | PRIMARY KEY (32 B) |
| `x25519_pub` | BLOB | NOT NULL (32 B, for ECDH) |
| `state` | TEXT | NOT NULL (`pending` \| `authorized`) |
| `platform` | TEXT | nullable (`ios` \| `android`) |
| `device_info` | TEXT | nullable JSON (from the E2E `hello`) |
| `send_counter` | INTEGER | NOT NULL DEFAULT 0 (dir agent→client) |
| `recv_counter` | INTEGER | NOT NULL DEFAULT 0 (last seen, dir client→agent) |
| `authorized_at` | INTEGER | nullable unix ms |
| `last_seen` | INTEGER | nullable unix ms |

---

### config

Global key/value store for runtime configuration.

| Column | Type | Constraints |
| --- | --- | --- |
| `key` | TEXT | PRIMARY KEY |
| `value` | TEXT | NOT NULL |
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Currently used keys:

| Key | Owner | Description |
| --- | --- | --- |
| `source_home` | `ChatHub` | Source ID that receives background agent notifications. Default: `"web"` |
| `tic.run_context` | `TicManager` | Run context ID applied to each TIC session tick. Empty = no run context. |
| `tic.interval_minutes` | `TicManager` | Override tick interval in minutes. Empty = use `tic.interval_secs` from `config.yml`. |

Keys are managed via `GET /api/config` + `PUT /api/config/:key`. Registered properties are declared by each subsystem via `core_api::ConfigProperty` and collected in `Skald::config_properties`.

---

### mcp_events

Persistent store for push notifications received from MCP servers via JSON-RPC notifications (stdout, no `id` field). Written by `McpManager::notification_consumer`; consumed by `TicManager` at a configurable interval (`tic.interval_secs` in `config.yml`, default 900 s).

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `source` | TEXT | NOT NULL — MCP server name (e.g. `"whatsapp"`, `"gmail"`, `"gcal"`) |
| `method` | TEXT | NOT NULL — notification method (e.g. `"event/new_email"`) |
| `payload` | TEXT | NOT NULL — JSON string of the notification `params` field |
| `processed` | INTEGER | NOT NULL DEFAULT 0 — set to 1 by `TicManager` before running the agent |
| `processed_at` | TEXT | nullable — timestamp set when `processed` is flipped |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Index: `idx_mcp_events_pending ON mcp_events(id) WHERE processed = 0` — partial index for efficient pending-event queries.

DAO in `src/core/db/mcp_events.rs`: `insert`, `pending_limited(limit)`, `pending`, `mark_processed(ids)`, `all_recent(limit)`.

---

### session_mcp_grants

**Root-agent** record of which tool groups have been activated via `activate_tools` — MCP server names and/or the reserved keyword `config` (which unlocks the built-in `Config`-category tools). Grants survive restarts and persist for the whole session lifetime. Tools for granted groups are included in the LLM's tool list on every subsequent turn via the dynamic `all_tool_defs()` call.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `session_id` | INTEGER | NOT NULL (FK to `chat_sessions.id`) |
| `mcp_name` | TEXT | NOT NULL — MCP server name (e.g. `"gmail"`) |
| `granted_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Unique constraint: `(session_id, mcp_name)` — granting the same server twice is a no-op (INSERT OR IGNORE).

DAO in `src/core/db/session_mcp_grants.rs`: `grant(pool, session_id, mcp_name)`, `list_for_session(pool, session_id)`.

> **Sub-agents do not write here.** They use `stack_mcp_grants` instead.

---

### stack_mcp_grants

**Sub-agent** record of which MCP servers have been activated by a specific stack frame. Grants are:

- **Isolated** from the parent session — they do not affect `session_mcp_grants`.
- **Persistent** across restarts — if a sub-agent is interrupted, `dispatch_sub_agent` re-loads these grants from DB when execution resumes.
- **Cleaned up** automatically when the stack frame terminates: `dispatch_sub_agent` calls `delete_for_stack(pool, stack_id)` before returning.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `stack_id` | INTEGER | NOT NULL (FK to `chat_sessions_stack.id`) |
| `mcp_name` | TEXT | NOT NULL — MCP server name |
| `granted_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Unique constraint: `(stack_id, mcp_name)` — granting the same server twice is a no-op (INSERT OR IGNORE).

DAO in `src/core/db/stack_mcp_grants.rs`: `grant(pool, stack_id, mcp_name)`, `list_for_stack(pool, stack_id)`, `delete_for_stack(pool, stack_id)`.

---

### llm_requests

Debug log of every `chat_with_tools` call. Populated by [`LoggingChatbotClient`](../src/chatbot/logging.rs) (fire-and-forget, invisible to the LLM loop). Enabled via `config.yml` — see `llm.request_log`.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `session_id` | INTEGER | nullable — `chat_sessions.id` of the calling session |
| `stack_id` | INTEGER | nullable — `chat_sessions_stack.id` of the calling frame |
| `model_name` | TEXT | NOT NULL — display name of the model (e.g. `"claude"`) |
| `request_json` | TEXT | NOT NULL — full HTTP request body sent to the provider (compact JSON) |
| `request_headers` | TEXT | nullable — HTTP request headers as JSON object (api-key redacted) |
| `response_json` | TEXT | nullable — full HTTP response body (compact JSON); NULL on error |
| `response_headers` | TEXT | nullable — HTTP response headers as JSON object |
| `error_text` | TEXT | nullable — error message when the HTTP call failed |
| `input_tokens` | INTEGER | nullable |
| `output_tokens` | INTEGER | nullable |
| `duration_ms` | INTEGER | NOT NULL DEFAULT 0 — wall-clock round-trip time |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |

Index: `idx_llm_requests_created ON llm_requests(created_at)` — used by the retention cleanup.

Rows are purged automatically by a background task (runs at boot + every hour) via `db::llm_requests::cleanup(pool, retention_days)`. Default retention: 14 days.

DAO in `src/core/db/llm_requests.rs`: `insert(pool, LlmRequestRow)`, `cleanup(pool, retention_days)`.

**Config** (`config.yml`):

```yaml
llm:
  request_log:
    enabled: true          # default false
    retention_days: 14     # default 14
```

---

### session_scratchpad

Per-session key-value store used by the `update_scratchpad` tool. Shared by all agents in the same chat session (including async sub-tasks, which are transparently redirected to the parent session's scratchpad at runtime); not persisted across sessions (tied to `chat_sessions.id`).

| Column | Type | Constraints |
| --- | --- | --- |
| `session_id` | INTEGER | NOT NULL REFERENCES `chat_sessions(id)` |
| `key` | TEXT | NOT NULL |
| `value` | TEXT | NOT NULL |

Primary key: `(session_id, key)`.

`upsert()` uses `INSERT ... ON CONFLICT DO UPDATE` — writing the same key twice overwrites the previous value. Contents are injected into every agent's system context via `build_openai_messages`.

---

## Schema Creation

The database has no migration system. `create_tables()` builds every table directly at its final shape on first boot (`CREATE TABLE IF NOT EXISTS`). There is no `schema_version` tracking, no incremental migration logic — the schema is a single flat baseline.

To change the schema, edit the relevant `CREATE TABLE` in `create_tables()` (`src/core/db/mod.rs`). Since the system has no existing users, schema changes simply start from a fresh DB (delete the old `data/skald.db` if needed).

---

### projects

Filesystem-linked projects, each with an independent Kanban board of tickets. `updated_at` is refreshed on every project or ticket operation (via `db::projects::touch`) so the UI can order by recency.

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `name` | TEXT | NOT NULL |
| `path` | TEXT | NOT NULL — absolute filesystem path for the project folder |
| `description` | TEXT | NOT NULL DEFAULT `''` |
| `run_context` | TEXT | nullable — `RunContext` JSON blob applied to tickets when they have no own run_context |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
| `updated_at` | TEXT | NOT NULL DEFAULT `datetime('now')` — refreshed on every project/ticket operation |

DAO in `src/core/db/projects.rs`: `list(pool)` (ORDER BY updated_at DESC), `get(pool, id)`, `create(...)`, `update(...)`, `touch(pool, id)`, `delete(pool, id)` (CASCADE deletes tickets).

---

### project_tickets

Individual work items belonging to a project. Each ticket can be executed by a background agent.

**Lifecycle**: `todo` → `pending` (start() called) → `in_progress` (job starts) → `done` / `failed`

| Column | Type | Constraints |
| --- | --- | --- |
| `id` | INTEGER | PRIMARY KEY AUTOINCREMENT |
| `project_id` | INTEGER | NOT NULL REFERENCES `projects(id)` ON DELETE CASCADE |
| `title` | TEXT | NOT NULL |
| `description` | TEXT | NOT NULL DEFAULT `''` |
| `status` | TEXT | NOT NULL DEFAULT `'todo'` CHECK(`todo` \| `pending` \| `in_progress` \| `done` \| `failed`) |
| `agent_id` | TEXT | NOT NULL DEFAULT `'main'` |
| `run_context` | TEXT | nullable — `RunContext` JSON blob; stores only static config set at creation time (e.g. `security_group`). Runtime fields (`working_directory`, `allow_fs_writes`, `system_prompt`) are computed by `ProjectTicketManager.start()` and never persisted here. Falls back to `projects.run_context` if NULL. |
| `job_id` | INTEGER | nullable FK → `scheduled_jobs(id)` (no `ON DELETE` action); set when `start()` creates the job. Callers that delete a `scheduled_jobs` row (`scheduled_jobs::delete()`, cron's `cleanup_expired_single_runs`) must null this first, or the delete fails with a FK constraint error. |
| `result` | TEXT | nullable — final agent output (populated when status = `done`) |
| `error` | TEXT | nullable — error message (populated when status = `failed`) |
| `created_at` | TEXT | NOT NULL DEFAULT `datetime('now')` |
| `started_at` | TEXT | nullable — set when the job begins execution |
| `completed_at` | TEXT | nullable — set when the job finishes |

DAO in `src/core/db/project_tickets.rs`: `list_for_project(pool, project_id)`, `get(pool, id)`, `create(...)`, `delete(pool, id)`, `set_status(pool, id, status)`, `start(pool, id, job_id)`, `complete(pool, id, result, error)`, `reset(pool, id)`.

---

## Soft-Failure Pattern

On `handle_message` start, two recovery operations run:

1. **Orphaned user message check** — if the last `chat_history` row for the stack has `role='user'` or `role='agent'` (no following assistant message), it is marked `status='failed'`. This prevents sending a user→user sequence to strict LLM APIs (e.g. OpenRouter).

2. **`resume_pending_tools(stack_id, config, tx)`** — finds all `chat_llm_tools` rows still in `status='pending'` for the stack. For each one:
   - Re-runs it through the `ApprovalManager` gate (rules may have changed since the interruption).
   - `Deny` → marks the tool call `failed` immediately.
   - `Require` → emits an `ApprovalRequired`/`PendingWrite` event and waits for human input, just like a live turn. If the WS is disconnected, returns early leaving the calls still pending.
   - `Allow` → executes the tool directly; marks it `done` or `failed` depending on outcome.

   After all pending calls are resolved, `run_agent_turn` runs normally and the LLM sees a complete history.

> **Note:** `fail_pending_for_stack()` exists in `db/chat_llm_tools.rs` but is not called from the session handler. Pending tool calls are re-executed (not hard-failed) so the LLM receives real results rather than synthetic errors.

---

## When to Update This File

- Any table schema changes (new column, new table, removed table)
- Migration approach changes
- A new soft-failure recovery step is added to `handle_message`
