Data model
The entities behind the API, their fields, and which state transitions the implementation allows.
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.
Top-level tenant records carry org_id. Link tables — claim_sources, context_run_items, channel_release_items, federation_filters — reach the tenant through their parent.
Organization comes from the credential. No route accepts a tenant field in a request body.
subject_id, agent_id, project_id and run_id narrow retrieval; they never grant access.
Every route resolves a record with an org_id filter before touching it, even when the primary key alone would find the row.
Evidence
observations is append-only. There is no content update path and no delete route.
| 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) |
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.
A claim stores a statement, not a copy of the observation content it came from, so
supersession can rewrite a claim without rewriting history.
Claim states
| 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 |
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
context_runs what was asked and under what budget: task_hash rather than the raw prompt, max_tokens, used_tokens, policy_snapshot, degraded
context_run_items the ordered selection: position, claim_id, score, score_components — why each claim was chosen, without copying its content
context_feedback one outcome per run or item: used, useful, irrelevant, incorrect, harmful, with an optional reason_code and client_mutation_id
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
| 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. |
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
| 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 |
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:
| 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. |