Data model
The entities behind the API, their fields, and which state transitions the implementation allows.
One schema, two runtimes
Twenty-three forward-only migrations build the canonical tables and two rebuildable FTS5
virtual tables. 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.
Subject directory
Migration 23 adds the canonical subjects and subject_references tables.
subjects records the subject type, display label, status, creation
actor, and timestamps within one organization.
Insert triggers register new observation and claim subjects as concept rows
when no richer directory entry exists. The migration also backfills existing
subjects.
subject_references maps one authorized namespace and value to one subject.
GET /v1/subjects and GET /v1/subjects/:id/references return only directory
entries backed by canonical records the caller can read.
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 stores typed configuration plus lifecycle version. claim_approvals binds a
decision to one claim version, policy version and independent actor. channels binds
audiences and minimum trust to one gateway service principal. The canonical
channel_releases table carries the immutable released text, source hash, exact claim
version and lifecycle draft, approved, active, suspended, replaced, expired or
revoked. customer_assertion_replays prevents reuse of a short-lived signed gateway
assertion.
legal_holds and retention_exclusions preserve the rule that an active direct or
dependent-claim hold wins over retention and purge. external_identity_mappings maps a
provider subject to a principal and retires rows without deleting audit history.
audit_log remains 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 |
Implemented in migration 23. Triggers create concept entries from observations and claims; references provide namespaced aliases. |
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 |
Implemented with audiences, minimum trust, status and gateway binding. |
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 |
Implemented in channel_releases.lifecycle_status; legacy status remains a compatibility projection. |
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. |