# Data model

> The entities behind the API, their fields, and which state transitions the implementation allows.

Section: Reference · Source: https://titen.dev/docs/data-model
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/reference/data-model.md

---
## One schema, two runtimes

Nine forward-only migrations build 28 tables and two FTS5 virtual tables, plus a
`titen_migrations` bookkeeping table. The same SQL runs on `bun:sqlite` and on
Cloudflare D1, which constrains how migrations are written: one statement per array
item, no `PRAGMA` (D1 rejects most of them), parents before children so foreign keys
hold, and nothing destructive without its own work item.

`GET /readyz` compares the applied version against the expected one and refuses to
report ready when they differ, so old code against a new database is caught at startup
rather than at the first failed query.

## Identifiers, tenancy, authority

Every external id is a prefix plus 32 lowercase hex characters: `obs_`, `claim_`,
`ctx_`, `fb_`, `org_`, `key_`, and `req_` for request ids. The prefix is a reading
aid, not authority.

<div class="rules">

<p>Top-level tenant records carry <code>org_id</code>. Link tables — <code>claim_sources</code>, <code>context_run_items</code>, <code>channel_release_items</code>, <code>federation_filters</code> — reach the tenant through their parent.</p>
<p>Organization comes from the credential. No route accepts a tenant field in a request body.</p>
<p><code>subject_id</code>, <code>agent_id</code>, <code>project_id</code> and <code>run_id</code> narrow retrieval; they never grant access.</p>
<p>Every route resolves a record with an <code>org_id</code> filter before touching it, even when the primary key alone would find the row.</p>

</div>

## Evidence

`observations` is append-only. There is no content update path and no delete route.

<div class="table-wrap">

| Field group | Columns |
| --- | --- |
| identity | `id`, `org_id`, `actor_id` |
| scope | `subject_id`, `project_id`, `agent_id`, `run_id` |
| evidence | `kind`, `content`, `content_hash`, `source_type`, `source_ref` |
| authority | `trust`, `visibility` |
| time | `occurred_at` (when it happened, nullable), `ingested_at` (when Titen received it) |

</div>

`kind` is checked in the database: `user_statement`, `tool_result`, `imported_source`,
`decision`, `system_event`. `trust` is `unverified`, `asserted`, `verified` or
`policy_approved`, and `visibility` is `private`, `team` or `organization`. Both are
enforced by `CHECK` constraints, not only at the API boundary.

Sameness is decided by the idempotency key and a hash of the request body, never by
text similarity: the same tool output at two different times is two facts.

## Claims and their sources

A `claims` row is the current head: `kind`, `statement`, `confidence` (greater than 0
and at most 1, enforced by `CHECK`), `trust`, `visibility`, `status`, `version`,
`valid_from`, `valid_to`, and `superseded_by`. Kinds are `semantic_fact`,
`episodic_event`, `preference`, `procedural`, `decision`, `relationship`.

`claim_sources` is the append-only link to evidence, keyed on
(`claim_id`, `observation_id`, `relation`) with `relation` in `supports`,
`contradicts`, `qualifies`. Because `relation` is part of the key, one observation can
carry more than one relation to the same claim and every link survives. At least one
`supports` source is mandatory.

A claim defaults to the highest trust among its supporting observations and to the
narrowest visibility among all its sources, and a request may lower either but never
raise it. `confidence` defaults to 0.8 when omitted.

<div class="callout">
<strong>WHY THE STATEMENT IS NOT THE EVIDENCE</strong>

A claim stores a `statement`, not a copy of the observation content it came from, so
supersession can rewrite a claim without rewriting history.
</div>

## Claim states

<div class="table-wrap">

| From | To | Trigger | Retrievable after? |
| --- | --- | --- | --- |
| — | `active` | consolidation with only `supports` / `qualifies` sources | yes |
| — | `disputed` | consolidation where any source relation is `contradicts` | yes |
| `active`, `disputed` | `superseded` | `POST /v1/claims/:id/supersede` with an **active** replacement | no |
| `active`, `disputed` | `expired` | `POST /v1/claims/:id/expire`, which also sets `valid_to` | no |
| `active`, `disputed`, `expired` | `revoked` | `POST /v1/claims/:id/revoke` | no |

</div>

Retrieval selects `status IN ('active','disputed')` and applies the temporal window, so
the three terminal states drop out of compiled context without anything being deleted.
Every transition bumps `version` and appends a `record_history` row.

Nothing in the implementation moves a claim back to `active`. A dispute is resolved by
writing a better claim and superseding the old one. Revoking a `superseded` claim is
rejected with a message telling you to revoke the replacement instead.

## Context and feedback

<div class="kv">
<strong>THREE TABLES, ONE COMPILE</strong>

<p><code>context_runs</code> what was asked and under what budget: <code>task_hash</code> rather than the raw prompt, <code>max_tokens</code>, <code>used_tokens</code>, <code>policy_snapshot</code>, <code>degraded</code></p>
<p><code>context_run_items</code> the ordered selection: <code>position</code>, <code>claim_id</code>, <code>score</code>, <code>score_components</code> — why each claim was chosen, without copying its content</p>
<p><code>context_feedback</code> one outcome per run or item: <code>used</code>, <code>useful</code>, <code>irrelevant</code>, <code>incorrect</code>, <code>harmful</code>, with an optional <code>reason_code</code> and <code>client_mutation_id</code></p>

</div>

The task is stored hashed. Storing the prompt would turn the context log into a second
copy of everything an agent was ever told.

Feedback influences ranking utility. It never mutates evidence, trust, visibility or
authorization: a `harmful` outcome makes a claim less likely to be selected.

## Execution state

<div class="table-wrap">

| Table | Key | Lifecycle |
| --- | --- | --- |
| `checkpoints` | one row per (`org_id`, `subject_id`, `agent_id`, `kind`) | Upserted in place with a `state_hash` and `expires_at`. Reads filter on `expires_at > now`. `DELETE` removes the row — the one place a record is physically deleted, because task state is not evidence. |
| `leases` | one unreleased row per (`org_id`, `resource_type`, `resource_id`) | Live while `released_at IS NULL`. A competing acquire is `409` until `expires_at` passes; then the stale row is released and the new holder takes it. |
| `handoffs` | `pending` → `accepted` or `rejected` | Only `to_principal` may resolve. `GET /v1/handoffs` lists just the caller's pending ones. |
| `memberships` | unique on (`org_id`, `workspace_id`, `principal_id`) | Removal sets `removed_at`. Listings skip it; provenance and audit entries stay intact. |
| `api_keys` | unique on `key_hash` | Revocation sets `revoked_at`. Authentication reads the row every request, so revocation takes effect immediately. |

</div>

There is no `status` column on `checkpoints` or `leases`. Expiry is a timestamp
comparison, so a crashed agent's lease releases itself with no cleanup job.

## Governance and delivery

`policies` records `retention`, `approval_required`, `visibility_default` or
`trust_ceiling` with a JSON `config`. `channel_releases` is versioned per
(`org_id`, `channel`, `audience`) and moves `draft` → `active` → `revoked`;
`channel_release_items` holds the claim ids and an optional `redacted_statement`, so
what a channel serves can differ from what the claim says internally.
`customer_assertions` is created by migration 4, but no route reads or writes it yet.
`audit_log` is metadata only: actor, action, resource type and id,
optional detail, `ip_hint`, timestamp.

`webhooks` moves `active` → `paused` → `active`, and reaches `disabled` after 10
consecutive failures. `webhook_deliveries` carries `pending` → `success` or `failed`,
with a unique index on (`webhook_id`, `event_id`) added in migration 9. Before that,
`event_id` held the event *kind*, so deduplication matched on kind and only the first
event of each kind was ever delivered. `federation_peers` moves `active` →
`suspended`.

Five enum values are reserved by `CHECK` constraints but never written by any route
today: `expired` on `handoffs` and on `webhook_deliveries`, `revoked` on
`federation_peers`, `failed` on `index_outbox`, and `error` on `federation_log`.

## Derived, and therefore rebuildable

<div class="table-wrap">

| Table | Holds | If you lose it |
| --- | --- | --- |
| `observations_fts`, `claims_fts` | FTS5 search text, written in the same transaction as the canonical row | Rebuild from `observations` / `claims` |
| `index_outbox` | pending embedding work: record type, id, operation, `state`, `attempts` | Re-queue; `POST /v1/index/drain` consumes it |
| the vector store | opaque claim ids and embeddings — no canonical content | Re-embed. Vectors are an index, never the source of truth |
| `events` | metadata-only domain history, written in the canonical batch | Not reconstructible, but nothing reads it as authority |
| `record_history` | `record_type`, `record_id`, `version`, `change_kind`, `actor_id`, `snapshot_hash` | Not reconstructible; it is the provenance trail, and it stores a hash rather than content |
| `idempotency` | (`org_id`, `endpoint`, `key_hash`) → stored status and response | Losing it means a replayed key writes again |

</div>

## Where the design document runs ahead of the schema

`docs/reference/data-model.md` is the logical design and names entities the migrations
have not created. The mapping:

<div class="table-wrap">

| Design document | Today |
| --- | --- |
| `principals` | No table. Principal identity lives on `api_keys` (`principal_id`, `principal_kind`) and `memberships`. |
| `subjects`, `subject_references` | No table. `subject_id` is a scope column; there is no alias management. |
| `claim_versions` | No table. `record_history` carries the version, change kind, actor and snapshot hash. |
| `record_events` | Implemented as `record_history`; `audit_events` as `audit_log`. |
| `vector_outbox`, `event_outbox` | Implemented as `index_outbox` and `events` + `webhook_deliveries`. |
| `webhook_subscriptions` | Implemented as `webhooks`. |
| `channels` | No table. `channel` and `audience` are columns on `channel_releases`. |
| `tags`, `record_tags` | Not created. Tag filtering has not entered a shipped slice. |
| `schema_meta` | Implemented as `titen_migrations`, holding the applied version only. |
| claim kind `procedural_guidance` | The enforced kind is `procedural`. |
| release states `approved`, `suspended`, `replaced`, `expired` | The `CHECK` allows `draft`, `active`, `revoked`. |
| checkpoint states `open`, `in_progress`, `blocked` … | No `status` column. A checkpoint is current until `expires_at`. |
| handoff states `offered`, `declined`, `completed` | The `CHECK` uses `pending`, `accepted`, `rejected`, `expired`. |

</div>

## Next

<div class="cards">

<a href="/docs/api"><strong>HTTP API</strong><span>The 58 routes that read and write these tables.</span></a>
<a href="/docs/claim-lifecycle"><strong>Claim lifecycle</strong><span>The three terminal transitions in practice, and what survives each one.</span></a>
<a href="/docs/backup-export"><strong>Backup &amp; export</strong><span>Which of these tables leave in an export, and the order they must return in.</span></a>

</div>