03 Data model (PostgreSQL)¶
Status: draft M0, integrated after the feedback from all chapters. Normative for migrations, sqlc queries and repository ports. Terms: see 00-glossary.md. Sync and events: see 04-sync-protocol.md. Actions: see 05-actions.md. Retention and deletion: see 20-privacy.md. Search: see 14-search.md. Tables of a later milestone carry the note "Milestone"; their migration comes with that milestone, the contract already applies now.
1. Principles¶
- IDs. Every table with its own identity has
id uuidas its primary key. The value is a UUIDv7 (RFC 9562). The application generates it, not the database. Reason: the same logic on every PostgreSQL version, and clients can generate IDs in advance (client_msg_id). UUIDv7 is sorted by time and keeps B-tree indexes compact. - Time. All points in time are
timestamptzin UTC. Durations arebigintin milliseconds (suffix_ms). The server clock is the only time source for orderings; order, however, never comes from timestamps, but from sequence numbers. - Enumerations. As
textwithCHECK (... IN (...)), not as a PostgreSQLENUM. Reason: new values are an additive migration without a lock on the type. - Amounts of money.
bigintin micro-units of the currency (suffix_micros) pluscurrency text(ISO 4217). Reason: exact, fast to sum, no rounding error. - Sort keys. Freely sortable lists (spaces, channels, tags) use
sort_key text COLLATE "C"as a fractional index. Moving an item changes exactly one row. - Upper and lower case. Unique names (handles, slugs) are protected by a
unique index on
lower(...). Nocitextextension is needed. - Deletion. Principals, channels, threads and messages are never hard-deleted. They
get
deleted_atorarchived_at; content is cleared immediately on deletion (tombstone). Reason: sequence numbers, the audit chain and authorship stay consistent. Hard deletion exists only for dependent detail rows (ON DELETE CASCADE) and through the retention jobs from 20-privacy.md. The row of a deleted file (fileswithdeleted_at) is removed by the jobfiles.purge_objectsonce its objects are gone; its detail rows go byON DELETE CASCADE(R-DM-46). - JSON.
jsonbonly for data with its own versioned schema (manifest, run metrics, component definition, event payload). Every such field names its schema in the column "Rules". Fields used for filtering or joining are never only injsonb. - Secrets. No secrets in plain text. Tokens only as a hash
(HMAC-SHA-256 with a server pepper from the secret provider). Third-party credentials
(webhook secrets, OIDC client secrets) only as a reference
secret_refto the secret provider. Claude.ai credentials or subscription tokens do not exist in the model (B10, I5). - Optimistic locking. Mutable, synchronized objects have
version bigint. Every change increments it by one. Writing requests carry the expected version as the HTTP preconditionIf-Match; if it does not match, the server answers412 version_conflict(RFC 9110, 06-api.md). - Isolation level. Writing transactions run in
READ COMMITTED. The counter patterns from section 6 assume this. - Access. Only the module that owns a table writes it. Other modules read through
its port. Queries that filter by permissions get the set of visible channels from
authzand never joingrantsthemselves (one truth, B7). The module register records table ownership, a gate checks it (ADR 0050). - No
nullto the outside. A column with NULL appears in the API and in events as a missing field, never as JSONnull(04-sync-protocol.md, section 2.2; ADR 0053). Reason: generated clients (dart-dio) know no null type.
2. Modules¶
| Module | Responsibility | Tables |
|---|---|---|
instance |
Instance, settings, branding, feature flags, signing keys, counters, sync epoch | yes |
identity |
Principals, people, identities, passkeys, local credentials, enrollment codes, login flows, challenges (step-up, passkeys), device pairing, devices, sessions, tokens, owners | yes |
authz |
Groups, grants, permission version, authorize |
yes |
chat |
Spaces, channels, threads, tags, project binding, sidebar state | yes |
messaging |
Messages, revisions, mentions, reactions, pins, templates | yes |
files |
Files, upload sessions, thumbnails, attachments, virus scan, transcripts, file text extracts | yes |
agents |
Agents, agent sessions, consumer cursors of the agents, slash commands, dev environments | yes |
runs |
Runs, steps, follow-ups, components, interactions | yes |
apps |
Apps, installations, incoming and outgoing webhooks | yes |
billing |
Cost entries, price table | yes |
sync |
Event log, cursors of the worker consumers, idempotency keys, read state, drafts, settings | yes |
notify |
Notification preferences, quiet hours, push registrations | yes |
audit |
Audit log, checkpoints, keys of the IP pseudonyms | yes |
privacy |
Privacy requests | yes |
importer |
Import sources and imported items | yes |
status |
Status page, backup runs | yes |
gateway |
WebSocket endpoint, gateway sessions, focus, typing, ephemeral events, shared presence state of several nodes | yes |
search |
Search coordinator over the read ports of messaging, chat and files (14-search.md) |
none |
stats |
Statistics of the console from the read ports of other modules, own measurement as minute aggregates (25-visualization.md) | yes |
terminal |
Hosts, pairing, session users per person, terminal sessions, terminal session tokens, relay of the streams (24-terminal.md) | yes |
The two modules without tables are separate modules because they have their own ports, dependencies and performance targets (02-architecture.md, section 4). Technical tables outside the modules are in section 4.13. The assignment of the tables to modules is recorded in machine-readable form in the module register (ADR 0050).
3. Retention classes¶
Every table names exactly one class. 20-privacy.md sets the duration, deletion path and exceptions (for example legal retention) per class. This chapter only sets the assignment.
| Class | Content |
|---|---|
config |
Configuration of the instance. Lives until it is changed. |
account |
Master data of principals and groups. Lives until deletion or pseudonymization. |
security |
Sessions, tokens, devices, keys. Deletable shortly after expiry or revocation. |
content |
User content: messages, files, threads, runs. Deletion by the user, an admin or a privacy request. |
personal_sync |
Personal sync data: read state, drafts, settings. Follows the account. |
event_log |
Event log. Window sync.event_retention. |
transient |
Short-lived technical data: idempotency keys, delivery attempts. |
audit |
Audit log. Its own period, separate from telemetry (B11). |
cost |
Costs and prices. Period for billing records. |
import |
Import state. Deletable after the import is complete. |
privacy_job |
Privacy requests and export packages. |
ops |
Status page and backup runs. |
stats |
Minute aggregates of the own measurement, without reference to people. Period retention.stats_minutes. |
4. Tables¶
Notation: "Null" = yes means NULL is allowed. An empty "Default" means no default value.
Foreign keys name their ON DELETE behavior. RESTRICT is the normal case, because
principals and content are not hard-deleted.
4.1 Instance (instance)¶
instance¶
Purpose: exactly one row with the identity of this installation. Module: instance.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
smallint |
no | 1 |
CHECK (id = 1); enforces one row |
instance_id |
uuid |
no | UUIDv7, generated on the first bothy migrate up (the row is created there, through the port of instance); issuer (iss) in tokens |
|
setup_completed_at |
timestamptz |
yes | NULL until the first owner has been created by server command | |
schema_version |
bigint |
no | last applied migration, for display only | |
min_server_version |
text |
yes | SemVer; lowest server version that may run with this schema; set by bothy migrate. A server with a lower version reports not ready (16-operations.md, section 10) |
|
sync_epoch |
uuid |
no | UUIDv7 of the current timeline of the event log; generated on the first bothy migrate up, new after every restore (section 6.7) |
|
pg_system_identifier |
text |
yes | system_identifier of pg_control_system() of the database cluster, as decimal text (the value is an unsigned 64-bit number); set by bothy migrate up while empty and by bothy restore finalize (section 6.7, R-DM-50) |
|
pg_timeline_id |
bigint |
yes | timeline_id of pg_control_checkpoint(), read together with pg_system_identifier (R-DM-50) |
|
created_at |
timestamptz |
no | now() |
PK: id. FK: none. Unique: instance_id. Indexes: no others.
Retention: config. bothy migrate up creates the row if it is missing
(INSERT ... ON CONFLICT (id) DO NOTHING), and then sets schema_version and
min_server_version; it never changes instance_id, sync_epoch and setup_completed_at, and it sets pg_system_identifier and pg_timeline_id only while they are empty. Reason:
migrate is the first step of every installation, and the readiness check needs
min_server_version before there is an owner.
instance_settings¶
Purpose: typed settings of the instance, including branding, as key-value pairs.
Module: instance.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
key |
text |
no | for example branding.product_name, branding.accent_color, branding.logo_file_id, locale.default; keys and schema in the configuration reference (../operations/configuration.md, section 6). branding.accent_color is a key of the category colors of the design system, not a free color value |
|
value |
jsonb |
no | checked against the schema of the key | |
version |
bigint |
no | 1 |
optimistic locking |
updated_by |
uuid |
yes | principal; NULL at setup | |
updated_at |
timestamptz |
no | now() |
PK: key. FK: updated_by -> principals(id) ON DELETE RESTRICT. Unique: no others.
Indexes: no others (few rows, fully in the cache). Retention: config.
server_signing_keys¶
Purpose: public parts of the server keys for access tokens, invocation tokens (I3),
outgoing webhooks and audit checkpoints. The private part is only in the secret provider.
Module: instance.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
kid |
text |
no | key ID in the JWS header | |
purpose |
text |
no | CHECK (purpose IN ('access_token','invocation','terminal','webhook_out','audit_checkpoint')); terminal signs terminal session tokens (24-terminal.md, section 5.3) |
|
alg |
text |
no | CHECK (alg IN ('EdDSA','ES256')); with purpose IN ('invocation','terminal','webhook_out') only EdDSA (check), because invocation tokens and terminal session tokens allow exactly one algorithm and Standard Webhooks v1a only knows Ed25519 |
|
public_jwk |
jsonb |
no | JWK per RFC 7517, without the private part | |
private_key_ref |
text |
no | reference to the secret provider | |
state |
text |
no | 'active' |
CHECK (state IN ('next','active','retiring','retired')) |
created_at |
timestamptz |
no | now() |
|
not_after |
timestamptz |
yes | after this point in time nothing more is signed |
PK: kid. FK: none. Unique: partial index (purpose) WHERE state = 'active' (exactly one active
key per purpose). Indexes: (purpose, state) for the JWKS output.
Retention: security.
feature_flags¶
Purpose: switches for features, global or for groups and people. Module: instance.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
key |
text |
no | for example dm.enabled, voice.enabled |
|
enabled |
boolean |
no | false |
global value |
rollout |
jsonb |
no | '{}' |
schema feature_flag_rollout.v1: lists of user_ids and group_ids for which the switch applies differently |
version |
bigint |
no | 1 |
|
updated_by |
uuid |
yes | ||
updated_at |
timestamptz |
no | now() |
PK: key. FK: updated_by -> principals(id) ON DELETE RESTRICT. Indexes: no others.
Retention: config. Kind, description, default, module and remove_by are in the
registration in the code, not in the table (ADR 0064); a registered key without a row has
its default and, in the API, the version 0; a key without a registration is off and
orphaned. The same applies to instance_settings: a registered key without a row has its
default value and the version 0.
sequence_counters¶
Purpose: counter rows whose row lock enforces the commit order. Module: instance
(read and written only through the functions from section 6).
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
name |
text |
no | CHECK (name IN ('event_seq','audit_seq','permission_version')) |
|
value |
bigint |
no | 0 |
only increasing |
PK: name. FK: none. Indexes: no others. Retention: config.
The three rows are created by the migration that creates the table
(00002_instance_counters.sql). permission_version is the
permission version counter (see 4.3 and 04-sync-protocol.md).
4.2 Identity (identity)¶
principals¶
Purpose: common identity of all acting principals (person, agent, app). Foreign keys
for "who did it" always point here. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7 | |
kind |
text |
no | CHECK (kind IN ('user','agent','app')); never changeable |
|
status |
text |
no | 'active' |
CHECK (status IN ('pending','active','disabled','deleted')) |
handle |
text |
no | for mentions, CHECK (handle ~ '^[a-z0-9][a-z0-9._-]*$'), length up to principal.handle_max_chars |
|
display_name |
text |
no | not empty and not only whitespace, at most principal.display_name_max_chars characters, without control and bidi characters (19-security.md, SEC-IN-4); pseudonymized after deletion |
|
avatar_file_id |
uuid |
yes | ||
created_at |
timestamptz |
no | now() |
|
disabled_at |
timestamptz |
yes | ||
deleted_at |
timestamptz |
yes | pseudonymization by a privacy request |
PK: id. FK: avatar_file_id -> files(id) ON DELETE SET NULL. Unique: lower(handle).
Indexes: (kind, status) for directory lists. Retention: account.
users¶
Purpose: additional data for people. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
principal_id |
uuid |
no | kind = 'user' checked by trigger |
|
email |
text |
yes | for display only; never for matching, permissions or notifications; never set from a provider's data, because the value is unique and a provider could otherwise block logins (08-identity.md, section 3.1) | |
locale |
text |
no | 'de' |
BCP 47 |
time_zone |
text |
no | 'UTC' |
IANA name, for quiet hours |
last_active_at |
timestamptz |
yes | coarse, written at most once per identity.last_active_granularity |
PK: principal_id. FK: principal_id -> principals(id) ON DELETE RESTRICT.
Unique: lower(email) as a partial index WHERE email IS NOT NULL. Indexes: no others.
Retention: account.
owners¶
Purpose: list of owners. At least one. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | people only | |
granted_by |
uuid |
yes | NULL only for the first owner (server command) | |
granted_at |
timestamptz |
no | now() |
|
revoked_at |
timestamptz |
yes | ||
revoked_by |
uuid |
yes | ||
reason |
text |
no | required, recorded in the audit |
PK: id. FK: user_id -> users(principal_id) ON DELETE RESTRICT; granted_by,
revoked_by -> principals(id) ON DELETE RESTRICT. Unique: partial index (user_id) WHERE
revoked_at IS NULL. Indexes: via the partial index. Retention: account.
Invariant (SR8 in 07-permissions.md): a transaction that revokes an owner
or disables or deletes the principal of an owner (owner.remove, user.disable, GDPR deletion)
checks before the commit that at least one owner with principals.status = 'active' remains (code
last_owner), and checks I1 (section 6.4).
identity_providers¶
Purpose: configured login drivers (B9). Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
kind |
text |
no | CHECK (kind IN ('oidc','trusted_header','local')) |
|
name |
text |
no | display, for example "Company SSO" | |
config |
jsonb |
no | schema identity_provider.<kind>.v1 (08-identity.md, section 2.6); secrets only as references (client_secret_ref, client_key_ref) |
|
enabled |
boolean |
no | true |
|
allow_provisioning |
boolean |
no | false |
false: unknown subjects get no access (no self-registration) |
created_at |
timestamptz |
no | now() |
|
updated_at |
timestamptz |
no | now() |
PK: id. FK: none. Unique: lower(name); partial index (kind) WHERE kind IN ('trusted_header',
'local') (one each). Indexes: no others. Retention: config.
user_identities¶
Purpose: link of a person to a subject at a provider. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
provider_id |
uuid |
no | ||
subject |
text |
no | OIDC sub or header login or local login name |
|
issuer |
text |
yes | OIDC iss, otherwise NULL |
|
email_at_link |
text |
yes | snapshot, display only | |
linked_at |
timestamptz |
no | now() |
|
last_login_at |
timestamptz |
yes | ||
unlinked_at |
timestamptz |
yes |
PK: id. FK: user_id -> users(principal_id) ON DELETE RESTRICT; provider_id ->
identity_providers(id) ON DELETE RESTRICT. Unique: partial index (provider_id, subject) WHERE
unlinked_at IS NULL. Indexes: (user_id) for the account view. Retention: account.
passkey_credentials¶
Purpose: WebAuthn passkeys for the login of local accounts and for step-up. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
credential_id |
bytea |
no | WebAuthn credential ID | |
public_key_cose |
bytea |
no | public key (COSE) | |
sign_count |
bigint |
no | 0 |
a regression is logged and reported in the audit |
aaguid |
uuid |
yes | ||
transports |
text[] |
no | '{}' |
|
backup_eligible |
boolean |
no | false |
|
backup_state |
boolean |
no | false |
|
name |
text |
no | assigned by the person | |
created_at |
timestamptz |
no | now() |
|
last_used_at |
timestamptz |
yes | ||
revoked_at |
timestamptz |
yes |
PK: id. FK: user_id -> users(principal_id) ON DELETE RESTRICT. Unique: credential_id.
Indexes: (user_id) WHERE revoked_at IS NULL for the login challenge. Retention: security.
devices¶
Purpose: an installed client of a person. New devices wait for confirmation.
Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | generated by the client at registration (UUIDv7) | |
user_id |
uuid |
no | ||
name |
text |
no | for example "Alex's iPhone" | |
platform |
text |
no | CHECK (platform IN ('ios','android','macos','windows','web','cli')) |
|
state |
text |
no | 'pending' |
CHECK (state IN ('pending','active','revoked')) |
app_version |
text |
yes | ||
approved_by |
uuid |
yes | ||
approved_at |
timestamptz |
yes | ||
revoked_by |
uuid |
yes | ||
revoked_at |
timestamptz |
yes | ||
revoke_reason |
text |
yes | CHECK (revoke_reason IN ('revoked_by_self','revoked_by_owner','user_disabled','inactive','verification_failed','pending_expired','pairing_expired')); a code, never free text; the reason of a request is only in the audit |
|
created_at |
timestamptz |
no | now() |
|
last_seen_at |
timestamptz |
yes |
PK: id. FK: user_id -> users(principal_id) ON DELETE RESTRICT; approved_by,
revoked_by -> principals(id) ON DELETE RESTRICT. Unique: no others.
Indexes: (user_id, state) for the device list; (state) WHERE state = 'pending' for the
approval queue of the admin console. Retention: security. Checks: (state = 'revoked') =
(revoked_at IS NOT NULL); revoke_reason IS NULL OR state = 'revoked'.
device_keys¶
Purpose: public device keys from the secure storage. A native device has two: the
DPoP key (binds tokens, signs without user confirmation) and the step-up key
(signs only after biometrics or the device PIN) (08-identity.md,
section 6.1). Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
device_id |
uuid |
no | ||
purpose |
text |
no | CHECK (purpose IN ('dpop','step_up')) |
|
jkt |
text |
no | JWK thumbprint (RFC 7638); for the purpose dpop it is in the token as cnf.jkt |
|
public_jwk |
jsonb |
no | ||
alg |
text |
no | CHECK (alg IN ('ES256','EdDSA')); whether Windows Hello delivers ES256 is open (08-identity.md, section 6.1); an extension by PS256 is an additive migration |
|
attestation |
jsonb |
yes | platform attestation, if present | |
created_at |
timestamptz |
no | now() |
|
revoked_at |
timestamptz |
yes | device revocation; there is no key change on an existing device (08-identity.md, section 5.4) |
PK: id. FK: device_id -> devices(id) ON DELETE CASCADE. Unique: jkt; partial index
(device_id, purpose) WHERE revoked_at IS NULL (one active key per device and purpose).
Indexes: via the unique indexes. Retention: security.
The web console and tools without secure storage have no step-up key; their step-up
runs via passkey, a new OIDC login or password with TOTP.
auth_sessions¶
Purpose: login session of a person on a device. Every access token carries its ID as
sid; revocation therefore takes effect immediately. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
device_id |
uuid |
no | ||
client_component |
text |
no | 'app' |
CHECK (client_component IN ('app','share_ext','console')); the share extension has its own child session with its own refresh family; the Notification Service Extension has no session, it uses the preview token (13-push-notifications.md, section 6.4) |
parent_session_id |
uuid |
yes | set for child sessions of extensions; revocation of the parent session revokes all children with the same reason | |
identity_id |
uuid |
yes | what was used to log in; NULL for a local login without a provider identity | |
auth_method |
text |
no | CHECK (auth_method IN ('oidc','trusted_header','passkey','password_totp','enrollment_code','device_pairing')) |
|
cookie_hash |
bytea |
yes | console only (client_component = 'console'): HMAC-SHA-256 of the session cookie; new value at login and after step-up |
|
created_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | absolute end, created_at + auth.session_max_age (console: auth.console_session_max_age) |
|
step_up_at |
timestamptz |
yes | last fresh confirmation by the person; valid for auth.step_up_max_age |
|
step_up_method |
text |
yes | CHECK (step_up_method IN ('passkey','device_key','password_totp','oidc_reauth')); set together with step_up_at |
|
step_up_consumed_at |
timestamptz |
yes | set when terminal.open or terminal.attach has consumed the step-up with step_up_at; a consumed step-up (step_up_consumed_at >= step_up_at) allows no further terminal opening (08-identity.md, section 6.3) |
|
view_as_user_id |
uuid |
yes | console only: the person whose view an owner looks at with "View as person" (07-permissions.md, section 9.2); set by access.view_as, cleared by access.view_as.end or by the first request after authz.view_as_max_duration (R-DM-49) |
|
view_as_started_at |
timestamptz |
yes | start of the stored view; set and cleared together with view_as_user_id |
|
last_seen_at |
timestamptz |
yes | ||
last_ip |
inet |
yes | only for the security view, class security |
|
revoked_at |
timestamptz |
yes | ||
revoke_reason |
text |
yes | CHECK (revoke_reason IN ('logout','admin','device_revoked','refresh_reuse','user_disabled','expired','inactive','step_up_failed','trusted_header_mismatch')); logout when the person ends their own session, admin when someone else does it |
PK: id. FK: user_id -> users(principal_id) ON DELETE RESTRICT; device_id ->
devices(id) ON DELETE RESTRICT; identity_id -> user_identities(id) ON DELETE SET NULL;
parent_session_id -> auth_sessions(id) ON DELETE CASCADE; view_as_user_id -> users(principal_id) ON DELETE RESTRICT.
Checks: (client_component = 'console') = (cookie_hash IS NOT NULL); parent_session_id IS NULL OR
client_component = 'share_ext'; (step_up_at IS NULL) = (step_up_method IS
NULL); step_up_consumed_at IS NULL OR step_up_at IS NOT NULL; (view_as_user_id IS NULL) =
(view_as_started_at IS NULL); view_as_user_id IS NULL OR client_component = 'console'. Unique: cookie_hash as a partial index WHERE cookie_hash IS NOT NULL.
Indexes: (user_id) WHERE revoked_at IS NULL for "end all sessions"; (device_id);
(parent_session_id) WHERE parent_session_id IS NOT NULL; (expires_at) for the cleanup job.
Retention: security.
refresh_token_families¶
Purpose: family of rotating refresh tokens of a session; reuse revokes the family.
Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
session_id |
uuid |
no | ||
dpop_jkt |
text |
no | thumbprint of the DPoP key; every renewal requires a DPoP proof with exactly this key (RFC 9449, section 5) | |
current_generation |
integer |
no | 0 |
number of the only valid token |
rotated_at |
timestamptz |
yes | time of the last rotation; basis of the grace period auth.refresh_reuse_grace |
|
created_at |
timestamptz |
no | now() |
|
revoked_at |
timestamptz |
yes | ||
revoke_reason |
text |
yes | CHECK (revoke_reason IN ('reuse_detected','logout','session_revoked','expired')) |
PK: id. FK: session_id -> auth_sessions(id) ON DELETE CASCADE. Unique: partial index
(session_id) WHERE revoked_at IS NULL. Indexes: via the unique index.
Retention: security. Console sessions have no family (cookie instead of refresh token).
refresh_tokens¶
Purpose: individual refresh tokens, only as a hash. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
family_id |
uuid |
no | ||
generation |
integer |
no | ||
token_hash |
bytea |
no | HMAC-SHA-256 | |
issued_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | issued_at + auth.refresh_token_ttl |
|
used_at |
timestamptz |
yes | set on rotation |
PK: id. FK: family_id -> refresh_token_families(id) ON DELETE CASCADE.
Unique: token_hash; (family_id, generation). Indexes: via the unique indexes.
Retention: security.
Rotation: in one transaction SELECT ... FOR UPDATE on the family, with a DPoP proof with
dpop_jkt as a precondition.
- If
generation = current_generationandused_at IS NULL: setused_at, create a new token withgeneration + 1, incrementcurrent_generation,rotated_at = now(). - Grace period: if the token is the immediately preceding generation
(
generation = current_generation - 1) androtated_atis less thanauth.refresh_reuse_gracein the past, the server rotates once more as in item 1 (the never received generation expires). Reason: a crash between the server rotation and local storage (app or extension) must not log the person out; the DPoP proof shows that the same device key is asking. - Otherwise it is reuse: family and session are revoked (
refresh_reuse), an audit entry is created, the request fails.
api_tokens¶
Purpose: long-lived tokens for agents, apps and (via feature flag) personal tools.
Only hash and prefix. Format bothy_<kind>_<random>_<checksum> with kind agt, app or
pat (08-identity.md, section 12; ADR 0061; incoming webhooks use the same
format with kind whi in incoming_webhooks.token_hash); the fixed prefix allows the rule
bothy-api-token in the secret scanner (.gitleaks.toml). Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
principal_id |
uuid |
no | holder of the token | |
name |
text |
no | ||
token_prefix |
text |
no | bothy_<kind>_ and the first 6 characters of the random part, for recognition in the console (ADR 0061) |
|
token_hash |
bytea |
no | HMAC-SHA-256 | |
scopes |
text[] |
no | '{}' |
subset of the manifest scopes or agent scopes |
dpop_jkt |
text |
yes | set: requests must be DPoP-bound to this key | |
expires_at |
timestamptz |
no | required, at most auth.api_token_max_ttl or auth.personal_token_max_ttl after creation (08-identity.md, section 12) |
|
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
last_used_at |
timestamptz |
yes | coarse, at most once per identity.last_active_granularity |
|
revoked_at |
timestamptz |
yes | ||
revoked_by |
uuid |
yes |
PK: id. FK: principal_id, created_by, revoked_by -> principals(id) ON DELETE RESTRICT.
Unique: token_hash. Indexes: (principal_id) WHERE revoked_at IS NULL for the token list.
Retention: security.
local_credentials¶
Purpose: password with TOTP as a fallback for local accounts that can be switched off (baseline R-Passkeys,
auth.local_password_enabled). One row per local identity. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
identity_id |
uuid |
no | identity at the driver local |
|
password_hash |
text |
no | Argon2id in PHC format; parameters from auth.argon2id_* (08-identity.md) |
|
password_changed_at |
timestamptz |
no | now() |
|
totp_secret_ciphertext |
bytea |
yes | TOTP secret, AEAD-encrypted; NULL until TOTP is set up | |
totp_key_ref |
text |
yes | reference to the key in the secret provider; set exactly together with totp_secret_ciphertext |
|
totp_last_step |
bigint |
yes | last accepted time step; every digit sequence is valid only once (RFC 6238, section 5.2) | |
totp_enabled_at |
timestamptz |
yes | ||
failed_count |
integer |
no | 0 |
failed attempts in the current window auth.login_fail_window |
fail_window_started_at |
timestamptz |
yes | start of the current window; NULL without a failed attempt. A failed attempt after fail_window_started_at + auth.login_fail_window starts a new window |
|
locked_until |
timestamptz |
yes | lockout of the password path only |
PK: identity_id. FK: identity_id -> user_identities(id) ON DELETE CASCADE.
Checks: (totp_secret_ciphertext IS NULL) = (totp_key_ref IS NULL); (failed_count = 0) = (fail_window_started_at IS NULL). Indexes: via the PK.
Retention: security. The TOTP secret is one of the two exceptions to principle 9 (the
other: audit_ip_keys): the server needs it for checking; therefore it is stored encrypted in the
database and the key only in the secret provider.
enrollment_codes¶
Purpose: one-time enrollment codes for the first device, the linking of an identity and
recovery (08-identity.md, section 3.2). Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | person for whom the code applies | |
purpose |
text |
no | CHECK (purpose IN ('first_login','identity_link','recovery')) |
|
code_hash |
bytea |
no | HMAC-SHA-256 of the code; the code appears exactly once | |
expires_at |
timestamptz |
no | created_at + auth.enrollment_code_ttl |
|
attempts |
integer |
no | 0 |
failed attempts with this code (a rejected redemption whose code matched); invalid from auth.enrollment_code_max_attempts on |
created_by |
uuid |
yes | NULL when issued by server command | |
created_at |
timestamptz |
no | now() |
|
used_at |
timestamptz |
yes | ||
used_by_device_id |
uuid |
yes | device that redeemed the code; it thereby becomes active |
PK: id. FK: user_id -> users(principal_id) ON DELETE CASCADE; created_by ->
principals(id) ON DELETE RESTRICT; used_by_device_id -> devices(id) ON DELETE SET NULL.
Unique: code_hash. Indexes: (user_id) WHERE used_at IS NULL; (expires_at) for the
cleanup job. Retention: security. At most one code applies per person: issuing a code sets
expires_at = now() on every other unused code of the person (08-identity.md,
section 3.2).
auth_login_flows¶
Purpose: state of an OIDC login or link flow between the start, the redirect back from the
provider and the redemption of the server code by the app (08-identity.md,
section 2.2). In the API the ID is called login_attempt_id. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
provider_id |
uuid |
no | ||
mode |
text |
no | CHECK (mode IN ('login','link')) |
|
user_id |
uuid |
yes | set for link (who is linking) |
|
device_id |
uuid |
yes | device ID, if the app already names it at the start; the device is created only at redemption | |
oidc_state_hash |
bytea |
no | HMAC of the state sent to the provider |
|
oidc_nonce_hash |
bytea |
no | HMAC of the nonce in the ID token |
|
client_challenge |
text |
no | S256 of the app's client_verifier; binds the server code to this app instance |
|
app_redirect_uri |
text |
no | from the fixed list (loopback or private scheme) | |
login_code_hash |
bytea |
yes | HMAC of the one-time server code (login_code) after the redirect back |
|
login_code_expires_at |
timestamptz |
yes | auth.server_code_ttl after the redirect back |
|
enrollment_code_id |
uuid |
yes | enrollment code presented and checked at the start; it is redeemed only at completion | |
external_identity |
jsonb |
yes | schema external_identity.v1 (08-identity.md, section 2.2): the provider identity checked at the callback; set together with status = 'code_issued', NULL again at completion or failure |
|
status |
text |
no | 'started' |
CHECK (status IN ('started','code_issued','completed','failed')) |
error |
text |
yes | code without content | |
created_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | created_at + auth.login_flow_ttl |
PK: id. FK: provider_id -> identity_providers(id) ON DELETE CASCADE; user_id ->
users(principal_id) ON DELETE CASCADE; enrollment_code_id -> enrollment_codes(id) ON DELETE
SET NULL. Checks: (status = 'code_issued') = (external_identity IS NOT NULL); mode = 'link'
has no enrollment_code_id. Unique: oidc_state_hash; login_code_hash as a partial index
WHERE login_code_hash IS NOT NULL. Indexes: (expires_at) for the cleanup job.
Retention: transient. The server's PKCE verifier towards the provider is not
stored, but derived from id and the token pepper (HMAC-SHA-256); this way no
further secret is in the table. The callback changes no account: matching, linking and
provisioning happen only at completion, which only the app with the client_verifier can
trigger (R-DM-30).
auth_challenges¶
Purpose: one-time challenges for step-up (device key and passkey) and for the
WebAuthn ceremonies login, first setup with an enrollment code and registration
(08-identity.md, sections 2.5 and 6.2). In the API the ID is called
challenge_id. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7 | |
purpose |
text |
no | CHECK (purpose IN ('step_up','passkey_login','passkey_enroll','passkey_register')) |
|
session_id |
uuid |
yes | session for which it applies; set exactly for step_up and passkey_register |
|
enrollment_code_id |
uuid |
yes | checked enrollment code; set exactly for passkey_enroll |
|
challenge_hash |
bytea |
no | HMAC-SHA-256 of the random value with the token pepper; the value appears only in the response | |
webauthn_session |
jsonb |
yes | schema webauthn_session.v1: RP ID, required user verification, allowed credential IDs, user handle; without the challenge and without secrets; NULL for step-up with a device key |
|
status |
text |
no | 'issued' |
CHECK (status IN ('issued','used','failed')) |
created_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | created_at + auth.step_up_challenge_ttl for step_up, otherwise created_at + auth.login_flow_ttl |
|
finished_at |
timestamptz |
yes | set together with used or failed |
PK: id. FK: session_id -> auth_sessions(id) ON DELETE CASCADE; enrollment_code_id ->
enrollment_codes(id) ON DELETE CASCADE. Checks: (status = 'issued') = (finished_at IS NULL);
(session_id IS NOT NULL) = (purpose IN ('step_up','passkey_register'));
(enrollment_code_id IS NOT NULL) = (purpose = 'passkey_enroll'). Unique: challenge_hash.
Indexes: (session_id, finished_at) WHERE status = 'failed' for the failed attempts at step-up;
(expires_at) for the cleanup job. Retention: transient.
Redemption: in one transaction SELECT ... FOR UPDATE on the row; only issued and not
expired is valid, afterwards used or, on a wrong confirmation, failed. Reason for a table instead of
derived values: one-time use and counted failed attempts thus apply on every node without
depending on synchronized clocks (R-DM-31).
device_pairings¶
Purpose: device pairing (08-identity.md, section 5.3). The new device
registers without a login with its public keys and shows a pairing code;
an active device of the same person confirms with this code and step-up. Module: identity.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | pairing_id in the API |
|
pairing_code_hash |
bytea |
no | HMAC-SHA-256 of the pairing code; the code appears only on the new device | |
device_registration |
jsonb |
no | schema device_registration.v1: name, platform, app version, public keys (dpop, optionally step_up); no secrets |
|
status |
text |
no | 'requested' |
CHECK (status IN ('requested','approved','completed','rejected','expired')) |
user_id |
uuid |
yes | person whose active device confirmed | |
approved_by_device_id |
uuid |
yes | confirming device | |
device_id |
uuid |
yes | device created on completion (active) |
|
created_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | created_at + auth.pairing_code_ttl |
|
completed_at |
timestamptz |
yes |
PK: id. FK: user_id -> users(principal_id) ON DELETE CASCADE; approved_by_device_id,
device_id -> devices(id) ON DELETE SET NULL. Checks: status NOT IN ('approved','completed')
OR user_id IS NOT NULL; status <> 'completed' OR device_id IS NOT NULL. Unique:
pairing_code_hash as a partial index WHERE status = 'requested'. Indexes: (expires_at) for the
cleanup job. Retention: transient. Completion requires a DPoP proof with the
registered dpop key; so only the new device can fetch the tokens.
4.3 Permissions (authz)¶
groups¶
Purpose: named set of people as the subject of grants. Module: authz.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
name |
text |
no | for example "Support" | |
description |
text |
no | '' |
|
source |
text |
no | 'local' |
CHECK (source IN ('local','oidc_claim')) |
provider_id |
uuid |
yes | OIDC provider whose group claim determines the members; set exactly for oidc_claim |
|
external_ref |
text |
yes | claim value for oidc_claim |
|
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
deleted_at |
timestamptz |
yes |
PK: id. FK: created_by -> principals(id) ON DELETE RESTRICT; provider_id ->
identity_providers(id) ON DELETE RESTRICT. Checks: (source = 'oidc_claim') = (provider_id IS
NOT NULL); (source = 'oidc_claim') = (external_ref IS NOT NULL). Unique: partial index lower(name)
WHERE deleted_at IS NULL; partial index (provider_id, external_ref) WHERE source = 'oidc_claim' AND
deleted_at IS NULL. Indexes: covered by the unique indexes. Retention: account. name at most
group.name_max_chars, description at most group.description_max_chars characters, both without
control and bidi characters. Reason for provider_id: without the binding, a second provider could
assert claim values that are meant for the first one.
group_memberships¶
Purpose: membership of a person in a group, with history. Module: authz.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
group_id |
uuid |
no | ||
user_id |
uuid |
no | people only (decision R-DM-07) | |
added_by |
uuid |
no | ||
added_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
yes | checked on every authorize |
|
expiry_reminded_at |
timestamptz |
yes | expiry reminder written (access.expiring) |
|
expiry_reported_at |
timestamptz |
yes | expiry reported (access.changed, system action group.member.expire); exactly once per expiry time (the extension by group.member.sync clears the column) |
|
removed_at |
timestamptz |
yes | ||
removed_by |
uuid |
yes |
PK: id. FK: group_id -> groups(id) ON DELETE RESTRICT; user_id ->
users(principal_id) ON DELETE RESTRICT; added_by, removed_by -> principals(id) ON DELETE
RESTRICT. Unique: partial index (group_id, user_id) WHERE removed_at IS NULL.
Indexes: (user_id) WHERE removed_at IS NULL for "groups of a person" when building permissions;
(expires_at) WHERE removed_at IS NULL AND expires_at IS NOT NULL for the reminder and the
expiry report.
Retention: account.
Only the sync at login changes memberships in groups with source = 'oidc_claim'
(system action group.member.sync, 08-identity.md, section 3.4); there
added_by and removed_by are the person whose login carried the claim.
The sync extends an existing row (expired or not) by setting expires_at and clearing
expiry_reminded_at and expiry_reported_at: reminder and expiry report are written once per
expiry time, so the new expiry is reminded and reported again (M1-42, docs/DECISIONS.md M1-42).
grants¶
Purpose: one grant: subject x target x level x capabilities x expiry x reason. Revocation sets
revoked_at, the row stays. Module: authz.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
subject_kind |
text |
no | CHECK (subject_kind IN ('user','group','agent','app')) |
|
subject_principal_id |
uuid |
yes | set for user, agent, app |
|
subject_group_id |
uuid |
yes | set for group |
|
target_kind |
text |
no | CHECK (target_kind IN ('instance','space','channel','host')) |
|
target_space_id |
uuid |
yes | set exactly for space |
|
target_channel_id |
uuid |
yes | set exactly for channel |
|
target_host_id |
uuid |
yes | set exactly for host (24-terminal.md, section 8.1) |
|
level |
text |
yes | CHECK (level IN ('read','write','trigger')); NULL exactly when target_kind = 'host', because levels do not apply on hosts |
|
capabilities |
text[] |
no | '{}' |
subset of approve_actions, upload, export, manage, terminal; terminal only and exactly on the target host |
expires_at |
timestamptz |
yes | checked on every authorize; the job authz.expiry_sweep only reports the expiry (section 6.4) |
|
expiry_reminded_at |
timestamptz |
yes | expiry reminder written (access.expiring) |
|
expiry_reported_at |
timestamptz |
yes | expiry reported (access.changed, system action grant.expire); exactly once per grant |
|
reason |
text |
no | required | |
granted_by |
uuid |
no | ||
granted_at |
timestamptz |
no | now() |
|
revoked_at |
timestamptz |
yes | ||
revoked_by |
uuid |
yes | ||
revoke_reason |
text |
yes |
PK: id.
FK: subject_principal_id -> principals(id) ON DELETE RESTRICT; subject_group_id ->
groups(id) ON DELETE RESTRICT; target_space_id -> spaces(id) ON DELETE RESTRICT;
target_channel_id -> channels(id) ON DELETE RESTRICT; target_host_id -> hosts(id) ON DELETE
RESTRICT; granted_by, revoked_by -> principals(id) ON DELETE RESTRICT.
Checks: exactly one of subject_principal_id and subject_group_id is set, matching
subject_kind; target columns matching target_kind; capabilities <@ ARRAY['approve_actions',
'upload','export','manage','terminal']; for subject_kind IN ('agent','app'), level <> 'trigger' AND
capabilities <@ ARRAY['upload'] holds (R-DM-08, R-DM-20); (target_kind = 'host') = (level IS NULL);
target_kind <> 'host' OR (capabilities = ARRAY['terminal'] AND subject_kind IN ('user','group'));
target_kind = 'host' OR NOT ('terminal' = ANY(capabilities)) (R-DM-35).
Unique: none (several grants on the same pair are allowed; the highest valid one applies).
Indexes: (subject_principal_id) WHERE revoked_at IS NULL and (subject_group_id) WHERE
revoked_at IS NULL for building the permissions of a principal; (target_space_id) WHERE revoked_at
IS NULL and (target_channel_id) WHERE revoked_at IS NULL for "Who has access?" and the
I1 check; (target_host_id) WHERE revoked_at IS NULL for the I6 check and SR10; (expires_at) WHERE revoked_at IS NULL AND expires_at IS NOT NULL for the reminder and the
expiry report. Retention: account.
Grants are never changed, only revoked and created anew (R-DM-09). The only allowed updates are
the revocation columns and the expiry bookkeeping (expiry_reminded_at,
expiry_reported_at). The job authz.expiry_sweep reports every expired row not yet reported
(expires_at <= now() and expiry_reported_at IS NULL) and sets the column in the same
transaction; so after an outage of the worker, every expiry is reported exactly once. Every transaction
that changes grants, group_memberships, owners, operator assignments, agents.billing_mode,
agents.credential_holder_id, agents.tool_class, the state of a dev environment, a host's
state, assigned_user_id, dev_environment_id, llm_credentials, credential_holder_id or
declared_server_host or reported_session_users, the rows in host_session_users or the status of a principal increments sequence_counters.permission_version (section 6.3). This
also applies to the job authz.expiry_sweep, which reports expired grants and memberships.
4.4 Agents and apps (agents, apps)¶
agents¶
Purpose: additional data for agent principals: operations, backend policy and reported state.
Module: agents.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
principal_id |
uuid |
no | kind = 'agent' checked by a trigger |
|
adapter_kind |
text |
no | free-form identifier of the adapter, for example hermes, code_runner, custom |
|
description |
text |
no | '' |
|
operator_config |
jsonb |
no | '{}' |
schema agent_operator_config.v1 (09-agents.md, section 2.2): default model (default_model) and allowed adapter features (supports_followups, supports_components, supports_voice, supports_title, supports_files_out); a missing key allows; no secrets. An adapter feature is effective only when the adapter also reports it (09-agents.md, section 2.2) |
billing_mode |
text |
no | 'organization' |
CHECK (billing_mode IN ('personal','organization')) |
credential_holder_id |
uuid |
yes | required for personal, otherwise NULL |
|
billing_mode_changed_at |
timestamptz |
yes | ||
billing_mode_changed_by |
uuid |
yes | ||
tool_class |
text |
no | 'chat' |
CHECK (tool_class IN ('chat','scoped','host')); configured tool class, order chat < scoped < host (07-permissions.md, section 2.11) |
reported_tool_class |
text |
yes | reported value from the heartbeat, same values; authorize uses the higher of configured and reported |
|
reported_runtime_state |
text |
no | 'unknown' |
CHECK (reported_runtime_state IN ('unknown','online','degraded','offline')) |
reported_billing_mode |
text |
yes | last reported mode from the heartbeat, personal or organization; NULL without a heartbeat or for adapters that follow the configured mode per run (then reported_details.billing_modes_supported names the modes that can be proven; the check per run is binding) |
|
reported_runtime_version |
text |
yes | ||
reported_details |
jsonb |
no | '{}' |
schema agent_heartbeat.v1 (09-agents.md, section 4.8): capabilities, billing_modes_supported, billing_evidence (provider, key_type, method, never a value), active_runs, degraded_reasons, rate_limit; without secrets and without content |
last_heartbeat_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
PK: principal_id. FK: principal_id -> principals(id) ON DELETE RESTRICT;
credential_holder_id -> users(principal_id) ON DELETE RESTRICT; billing_mode_changed_by ->
principals(id) ON DELETE RESTRICT.
Checks: (billing_mode = 'personal') = (credential_holder_id IS NOT NULL).
Indexes: (credential_holder_id) WHERE billing_mode = 'personal' for the I1 check on
changes to a person; (last_heartbeat_at) for the presence evaluation.
Retention: account.
Heartbeat fields are written at most once per agent.heartbeat_persist_interval;
in between, the server keeps the state in memory. Exception: a change of
reported_billing_mode or reported_tool_class is stored and audited at once
(agent.heartbeat, 05-actions.md), because the reported mode is the evidence for I3.
agent_consumer_cursors¶
Purpose: durable read position of an agent adapter in the event log. Module: agents.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
agent_id |
uuid |
no | ||
consumer_name |
text |
no | 'default' |
several consumers per agent possible |
acked_seq |
bigint |
no | highest acknowledged global sequence | |
holder_session_id |
uuid |
yes | gateway session that currently holds the consumer (fencing across nodes, 04-sync-protocol.md, section 15.1) | |
created_at |
timestamptz |
no | now() |
starting point; catch-up never before this time |
updated_at |
timestamptz |
no | now() |
PK: (agent_id, consumer_name). FK: agent_id -> agents(principal_id) ON DELETE CASCADE.
Indexes: covered by the PK. Retention: security.
acked_seq is stored as UPDATE ... WHERE holder_session_id = $own_session;
if a node gets no row back, another node has taken over the consumer, and it closes
its connection with 4015.
dev_environments¶
Purpose: isolated dev environment of a person for a runner agent
(21-dev-isolation.md, section 6). Milestone: multi-user dev environment.
Module: agents.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | person who owns the environment | |
runner_agent_id |
uuid |
no | runner that operates the environment | |
isolation_kind |
text |
no | CHECK (isolation_kind IN ('macos_vm','linux_vm','cloud_vm')) |
|
environment_ref |
text |
no | opaque reference that the runner resolves; no path and no hostname of the server | |
image_ref |
text |
no | version of the clean image | |
billing_mode |
text |
no | 'organization' |
CHECK (billing_mode IN ('organization')); a personal backend policy per environment needs its own decision on I1 (R-DM-24) |
state |
text |
no | 'provisioning' |
CHECK (state IN ('provisioning','verifying','ready','suspended','retired')) |
checks |
jsonb |
no | '{}' |
schema dev_environment_checks.v1: result and time per check |
verified_at |
timestamptz |
yes | last fully passed check | |
red_team_passed_at |
timestamptz |
yes | red team passed for the kind and the image | |
red_team_ref |
text |
yes | reference to the report | |
approved_by |
uuid |
yes | owner with step-up | |
approved_at |
timestamptz |
yes | ||
suspended_reason |
text |
yes | for example check_failed, user_disabled, manual |
|
created_at |
timestamptz |
no | now() |
|
retired_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
PK: id. FK: user_id -> users(principal_id), runner_agent_id -> agents(principal_id),
approved_by -> principals(id), all ON DELETE RESTRICT. Unique: partial index (user_id,
runner_agent_id) WHERE state <> 'retired'. Indexes: covered by the unique index.
Retention: security. When an environment is ready is defined in
21-dev-isolation.md, section 6.2; it is checked on every
trigger (guard rule SR3 in 07-permissions.md).
apps¶
Purpose: additional data for app principals (integrations) with a manifest. Module: apps.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
principal_id |
uuid |
no | kind = 'app' checked by a trigger |
|
slug |
text |
no | ||
manifest |
jsonb |
no | schema app_manifest.v1: scopes, event subscriptions, slash commands, components, webhook URLs |
|
manifest_version |
text |
no | SemVer of the manifest | |
manifest_sha256 |
bytea |
no | ||
publisher |
text |
no | '' |
|
homepage_url |
text |
yes | ||
created_at |
timestamptz |
no | now() |
|
updated_at |
timestamptz |
no | now() |
PK: principal_id. FK: principal_id -> principals(id) ON DELETE RESTRICT.
Unique: lower(slug). Indexes: covered by the unique index. Retention: account.
app_installations¶
Purpose: installation of an app in this instance with granted scopes. Module: apps.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
app_id |
uuid |
no | ||
granted_scopes |
text[] |
no | '{}' |
subset of the manifest scopes |
event_subscriptions |
text[] |
no | '{}' |
event types from 05-actions.md |
state |
text |
no | 'active' |
CHECK (state IN ('active','suspended','uninstalled')); app.suspend sets suspended (emergency stop without losing the granted scopes), back with app.resume |
installed_by |
uuid |
no | ||
installed_at |
timestamptz |
no | now() |
|
uninstalled_at |
timestamptz |
yes | ||
uninstalled_by |
uuid |
yes |
PK: id. FK: app_id -> apps(principal_id) ON DELETE RESTRICT; installed_by,
uninstalled_by -> principals(id) ON DELETE RESTRICT. Unique: partial index (app_id) WHERE
state <> 'uninstalled'. Indexes: covered by the unique index. Retention: config.
commands¶
Purpose: slash commands with options, registered by agents or apps. Module: agents.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
owner_principal_id |
uuid |
no | agent or app | |
name |
text |
no | CHECK (name ~ '^[a-z0-9][a-z0-9_-]*$') |
|
description |
text |
no | ||
scope_kind |
text |
no | 'instance' |
CHECK (scope_kind IN ('instance','space','channel')) |
scope_space_id |
uuid |
yes | ||
scope_channel_id |
uuid |
yes | ||
options |
jsonb |
no | '[]' |
schema command_options.v1: name, type (string, integer, boolean, choice, user, file), required, choice values, autocomplete |
required_level |
text |
no | 'trigger' |
CHECK (required_level IN ('write','trigger')) |
control |
boolean |
no | false |
true: control command (for example stop, status), never goes to the model as a prompt |
version |
bigint |
no | 1 |
|
created_at |
timestamptz |
no | now() |
|
removed_at |
timestamptz |
yes |
PK: id. FK: owner_principal_id -> principals(id) ON DELETE RESTRICT; scope_space_id ->
spaces(id) ON DELETE CASCADE; scope_channel_id -> channels(id) ON DELETE CASCADE.
Checks: scope columns matching scope_kind.
Unique: partial index (owner_principal_id, lower(name), scope_kind, coalesce(scope_space_id,
scope_channel_id)) WHERE removed_at IS NULL.
Indexes: (owner_principal_id) WHERE removed_at IS NULL for the command list of an agent;
lower(name) text_pattern_ops WHERE removed_at IS NULL for the suggestion list while typing.
Retention: config.
The display resolves name conflicts between agents: commands of the channel's operator come
first; equal names of other agents are qualified with the agent handle.
4.5 Structure (chat)¶
spaces¶
Purpose: space, top-level group of the sidebar. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
name |
text |
no | for example "Support"; at most space.name_max_chars characters, without control and bidi characters |
|
icon |
text |
yes | glyph name from the list space-icons of the design tokens (kernel/designkeys), no emoji |
|
color |
text |
yes | key of a category color of the design system, no hex value | |
sort_key |
text COLLATE "C" |
no | global order set by an admin | |
default_operator_agent_id |
uuid |
yes | default operator; NULL = none | |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
archived_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
PK: id. FK: default_operator_agent_id -> agents(principal_id) ON DELETE RESTRICT;
created_by -> principals(id) ON DELETE RESTRICT. Unique: partial index lower(name) WHERE
archived_at IS NULL. Indexes: (sort_key) WHERE archived_at IS NULL for the sidebar;
(default_operator_agent_id) for the I1 check. Retention: content.
user_space_states¶
Purpose: personal sidebar state per space (collapsed, own order),
across devices. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
user_id |
uuid |
no | ||
space_id |
uuid |
no | ||
collapsed |
boolean |
no | false |
|
sort_key |
text COLLATE "C" |
yes | NULL = global order | |
version |
bigint |
no | 1 |
for the rule see 04-sync-protocol.md |
updated_by_device_id |
uuid |
yes | ||
updated_at |
timestamptz |
no | now() |
PK: (user_id, space_id). FK: user_id -> users(principal_id) ON DELETE CASCADE;
space_id -> spaces(id) ON DELETE CASCADE; updated_by_device_id -> devices(id) ON DELETE
SET NULL. Indexes: covered by the PK. Retention: personal_sync.
channels¶
Purpose: channel (text, forum or direct message) with operator and behavior. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
space_id |
uuid |
yes | NULL exactly when kind = 'dm' (R-DM-10) |
|
kind |
text |
no | CHECK (kind IN ('text','forum','dm')) |
|
name |
text |
no | at most channel.name_max_chars characters, without control and bidi characters; empty for dm, display from the participants |
|
topic |
text |
no | '' |
at most channel.topic_max_chars characters, without control and bidi characters |
sort_key |
text COLLATE "C" |
no | order within the space | |
operator_inherit |
boolean |
no | true |
true: operator = spaces.default_operator_agent_id |
operator_agent_id |
uuid |
yes | only with operator_inherit = false; NULL = no operator |
|
require_mention |
boolean |
no | true |
main thread: operator only on a mention |
thread_require_mention |
boolean |
no | false |
in threads: operator only on a mention |
free_response |
boolean |
no | false |
true: the operator answers everywhere without a mention; overrides both fields above |
auto_thread |
boolean |
no | true |
a trigger in the main thread creates a thread |
allow_mentioned_agents |
boolean |
no | false |
further agents allowed by mention (only if their grants match) |
history_backfill_limit |
integer |
no | number of earlier messages as context at thread start; default from channel.history_backfill_limit_default; CHECK (history_backfill_limit BETWEEN 0 AND channel.history_backfill_limit_max) by the application |
|
prompt |
text |
no | '' |
channel prompt for the operator; length up to channel.prompt_max_bytes |
forum_default_sort |
text |
yes | forum only: CHECK (forum_default_sort IN ('last_activity','created')) |
|
default_notification_level |
text |
yes | CHECK (default_notification_level IN ('all','mentions','mute')); channel preset for people without their own setting (report channels); set with channel.update |
|
read_only |
boolean |
no | false |
read-only for all principals, owners included; only import.apply writes (05-actions.md, rule 1.8); set with channel.read_only.set |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
archived_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
|
settings_version |
bigint |
no | 1 |
separate counter for behavior, prompt, skills, project; events name only this number |
PK: id. FK: space_id -> spaces(id) ON DELETE RESTRICT; operator_agent_id ->
agents(principal_id) ON DELETE RESTRICT; created_by -> principals(id) ON DELETE RESTRICT.
Checks: (kind = 'dm') = (space_id IS NULL); NOT operator_inherit OR operator_agent_id IS
NULL; for kind = 'dm': operator_inherit = false AND operator_agent_id IS NULL AND NOT
read_only.
Unique: partial index (space_id, lower(name)) WHERE archived_at IS NULL AND kind <> 'dm'.
Indexes: (space_id, sort_key) WHERE archived_at IS NULL for the sidebar;
(operator_agent_id) WHERE operator_agent_id IS NOT NULL for "which channels does this agent
operate" (I1). Retention: content.
Effective operator: CASE WHEN operator_inherit THEN spaces.default_operator_agent_id ELSE
operator_agent_id END. Mention rule: the operator is triggered when an operator exists
and one of these holds: free_response, or (main thread and not require_mention), or (thread and not
thread_require_mention), or the operator is mentioned. All further conditions are listed under
agent.invoke in 05-actions.md; the whole decision per message is in
09-agents.md, section 3.4.
channel_skill_bindings¶
Purpose: ordered list of skills that the operator loads in this channel. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
channel_id |
uuid |
no | ||
position |
integer |
no | 0, 1, 2 ... | |
skill_ref |
text |
no | identifier that the adapter understands; the server does not interpret it |
PK: (channel_id, position). FK: channel_id -> channels(id) ON DELETE CASCADE.
Unique: (channel_id, skill_ref). Indexes: covered by the PK. Retention: config.
project_bindings¶
Purpose: project binding of a forum for dev projects (runner agent as operator).
Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
channel_id |
uuid |
no | only kind = 'forum' |
|
workspace_ref |
text |
no | opaque reference that the runner resolves (for example a project name); never an absolute path of the server | |
repository_url |
text |
yes | for display; the server never loads this URL | |
default_branch |
text |
yes | ||
runner_config |
jsonb |
no | '{}' |
schema project_runner_config.v1: model, allowed tools of the runtime within agents.tool_class; no secrets |
ready |
boolean |
no | false |
reported by the runner via heartbeat: work environment ready |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
version |
bigint |
no | 1 |
PK: channel_id. FK: channel_id -> channels(id) ON DELETE CASCADE; created_by ->
principals(id) ON DELETE RESTRICT. Unique: lower(workspace_ref). Indexes: covered by the PK and the unique index.
Retention: config.
dm_participants¶
Purpose: participants of a direct message channel. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
channel_id |
uuid |
no | only kind = 'dm' |
|
user_id |
uuid |
no | people only | |
joined_at |
timestamptz |
no | now() |
|
left_at |
timestamptz |
yes |
PK: (channel_id, user_id). FK: channel_id -> channels(id) ON DELETE CASCADE; user_id
-> users(principal_id) ON DELETE RESTRICT. Indexes: (user_id) WHERE left_at IS NULL for the
DM list. Retention: content.
forum_tags¶
Purpose: tags of a forum. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
channel_id |
uuid |
no | forum only | |
name |
text |
no | ||
color |
text |
yes | key of a category color of the design system, no hex value | |
sort_key |
text COLLATE "C" |
no | ||
restricted |
boolean |
no | false |
true: can be set only with manage |
deleted_at |
timestamptz |
yes |
PK: id. FK: channel_id -> channels(id) ON DELETE CASCADE. Unique: partial index
(channel_id, lower(name)) WHERE deleted_at IS NULL. Indexes: covered by the unique index.
Retention: content. name at most forum_tag.name_max_chars characters. Every change to
tags increments channels.version and writes channel.updated; clients then reload the tags
(05, section 6).
threads¶
Purpose: unit of conversation and of agent session with a fixed ID. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | fixed, never changes, not even on a move | |
channel_id |
uuid |
no | ||
kind |
text |
no | CHECK (kind IN ('main','thread')); main = continuous history of a text or DM channel (R-DM-11) |
|
parent_message_id |
uuid |
yes | message in the main thread from which the thread arose; stays on a move and then points into the source channel (retrievable only with read permission there) | |
title |
text |
no | '' |
length up to thread.title_max_chars |
title_source |
text |
no | 'auto' |
CHECK (title_source IN ('auto','manual')); once manual, it stays manual |
state |
text |
no | 'open' |
CHECK (state IN ('open','closed')) |
closed_at |
timestamptz |
yes | ||
closed_by |
uuid |
yes | ||
operator_agent_id |
uuid |
yes | only kind = 'thread': operator at the time of creation, fixed afterwards (R-DM-12); on import from the owner's mapping (22-import-migration.md). Always NULL for the main thread: there the effective operator of the channel at the time of the trigger applies (R-DM-21) |
|
last_message_seq |
bigint |
no | 0 |
counter of the thread sequence |
unread_floor_seq |
bigint |
no | 0 |
messages up to this thread_seq count as unread for nobody; the import sets it to last_message_seq at the end of each batch |
last_activity_at |
timestamptz |
no | now() |
sorting of the post list; on import from the source |
message_count |
integer |
no | 0 |
excluding deleted ones |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
on import from the source |
moved_at |
timestamptz |
yes | last move | |
version |
bigint |
no | 1 |
|
search_language |
regconfig |
no | 'simple' |
text search configuration from the creator's language; for the choice see 14-search.md |
title_search |
tsvector |
no | generated | see section 5 |
PK: id. FK: channel_id -> channels(id) ON DELETE RESTRICT; parent_message_id ->
messages(id) ON DELETE RESTRICT (deferred, DEFERRABLE INITIALLY DEFERRED);
operator_agent_id -> agents(principal_id) ON DELETE RESTRICT; closed_by, created_by ->
principals(id) ON DELETE RESTRICT.
Checks: (state = 'closed') = (closed_at IS NOT NULL); kind = 'main' has neither
parent_message_id nor operator_agent_id.
Unique: partial index (channel_id) WHERE kind = 'main' (exactly one main thread per text and
DM channel); (parent_message_id) WHERE parent_message_id IS NOT NULL (a message starts
at most one thread).
Indexes: (channel_id, state, last_activity_at DESC) for the post list with the
Done section; (operator_agent_id, channel_id) WHERE operator_agent_id IS NOT NULL for the
I1 targets, without a condition on state, because a new message reopens a done thread
(section 6.4); GIN (title_search) for search. Retention: content.
A move changes channel_id. It is allowed only for kind = 'thread' (the main thread belongs
permanently to its channel, R-DM-11) and only when the effective operator of the target channel equals
operator_agent_id (Baseline section 4). The same transaction changes the copies
messages.channel_id, message_attachments.channel_id, read_states.channel_id and
drafts.channel_id of this thread.
cost_entries and event_log keep the channel at the time of the booking or of the event, respectively.
Attached files follow their message (05-actions.md, section 2.2);
files.scope_channel_id applies only before attaching and stays unchanged.
thread_tags¶
Purpose: assignment of forum tags to threads. Module: chat.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
thread_id |
uuid |
no | ||
tag_id |
uuid |
no | the tag must belong to the channel of the thread (application) | |
added_by |
uuid |
no | ||
added_at |
timestamptz |
no | now() |
PK: (thread_id, tag_id). FK: thread_id -> threads(id) ON DELETE CASCADE; tag_id ->
forum_tags(id) ON DELETE CASCADE; added_by -> principals(id) ON DELETE RESTRICT.
Indexes: (tag_id) for the tag filter of the post list. Retention: content.
At most thread.max_tags tags per thread.
When a thread is moved to another channel, its tags are removed, because a tag belongs to the channel of the thread.
agent_sessions¶
Purpose: mapping of a thread to an external session of an agent. Older rows of the same
thread are the earlier session references. Module: agents.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
thread_id |
uuid |
no | ||
agent_id |
uuid |
no | ||
external_session_ref |
text |
no | for example the session key of an adapter; the server does not interpret it | |
is_current |
boolean |
no | true |
|
started |
boolean |
no | false |
true as soon as the external session exists (reported by the adapter with agent_session.report) |
model |
text |
yes | last reported model | |
cumulative_cost_micros |
bigint |
no | 0 |
sum of the cost_entries of this session |
cost_baseline_micros |
bigint |
no | 0 |
cumulative amount of the external session before the binding (import, rebinding); if a runtime reports totals per session, the server books only the part above it |
currency |
text |
no | 'USD' |
currency of both amounts |
cost_is_estimate |
boolean |
no | true |
until reconciliation |
dev_environment_id |
uuid |
yes | dev environment in which the first run of the session ran; NULL outside dev isolation (milestone, SR3) | |
created_at |
timestamptz |
no | now() |
|
ended_at |
timestamptz |
yes | set when the session ends or was never current | |
end_reason |
text |
yes | CHECK (end_reason IN ('reset','rebind','import','agent_changed')), meaning below |
|
version |
bigint |
no | 1 |
compare-and-swap on rebinding and on agent_session.report |
PK: id. FK: thread_id -> threads(id) ON DELETE RESTRICT; agent_id ->
agents(principal_id) ON DELETE RESTRICT; dev_environment_id -> dev_environments(id) ON
DELETE RESTRICT.
Checks: is_current = (ended_at IS NULL).
Unique: partial index (thread_id) WHERE is_current (one current session per thread); partial index
(agent_id, external_session_ref) WHERE is_current (an external session belongs to at most
one thread).
Indexes: (thread_id, created_at DESC) for the session history. Retention: content.
Meaning of end_reason:
| Value | Meaning |
|---|---|
reset |
new session via agent_session.reset (/new) |
rebind |
replaced by agent_session.bind, or because resuming a bound session failed and the adapter reported a new one |
import |
earlier session reference from an import; was never current in this product. On the first trigger without a current session, the server binds it exactly once as a new current row with started = true (22-import-migration.md, section 9) |
agent_changed |
operator change in the main thread; the session ends at the next run |
4.6 Messages (messaging)¶
messages¶
Purpose: a message in a thread, with thread sequence and idempotency key.
Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | created by the server | |
thread_id |
uuid |
no | ||
channel_id |
uuid |
no | copy of threads.channel_id for the permission filter; changed along with a move |
|
thread_seq |
bigint |
no | from threads.last_message_seq, gapless per thread |
|
author_principal_id |
uuid |
no | ||
client_msg_id |
uuid |
no | UUIDv7 from the client; for server messages created by the server | |
client_payload_sha256 |
bytea |
no | hash of the canonical request; detects reuse with different content | |
origin |
text |
no | CHECK (origin IN ('human','agent','app','import','system')); only human triggers agents |
|
kind |
text |
no | 'text' |
CHECK (kind IN ('text','voice','run','card','system')); run = anchor of the live box; card = message with components (question, approval) |
system_code |
text |
yes | CHECK (system_code IN ('thread.reopened','agent_session.resume_failed')); set exactly when kind = 'system'; clients build the line in their language from code and author |
|
content_md |
text |
no | '' |
Markdown; size up to message.max_content_bytes; empty after deletion and for system lines |
content_format |
smallint |
no | 1 |
version of the Markdown dialect |
reply_to_message_id |
uuid |
yes | reply with quote; same thread | |
run_id |
uuid |
yes | for kind = 'run': the run whose live box stands here |
|
trigger_state |
text |
yes | CHECK (trigger_state IN ('received','queued','working','done','failed','cancelled','followup_delivered')); NULL = no triggering message; transitions in 09-agents.md, section 6 |
|
trigger_state_reason |
text |
yes | reason code for failed and cancelled, for example billing_mode_personal_not_holder, run_active_other_agent, user_cancelled; also set on a rejection already at acceptance, when no run is created |
|
trigger_state_at |
timestamptz |
yes | ||
silent |
boolean |
no | false |
no notification (for example import) |
has_attachments |
boolean |
no | false |
true exactly when the message was created with at least one attachment and is not deleted. Attachments arise only with the message and disappear only with its deletion (editing does not change them, file.delete applies only to files that are not attached), so messaging sets the value in the transaction of the creation; deletion sets it to false. Filter has_file of the search (14-search.md, section 4.4); R-DM-43 |
import_item_id |
uuid |
yes | origin on import | |
version |
bigint |
no | 1 |
rises on edit, deletion, state change; reactions and pinning do not raise it (04-sync-protocol.md, section 9) |
edited_at |
timestamptz |
yes | ||
deleted_at |
timestamptz |
yes | ||
deleted_by |
uuid |
yes | ||
created_at |
timestamptz |
no | now() |
on import: original time |
search_language |
regconfig |
no | 'simple' |
text search configuration; choice see 14-search.md |
search_vector |
tsvector |
no | generated | see section 5 |
PK: id.
FK: thread_id -> threads(id) ON DELETE RESTRICT; channel_id -> channels(id) ON DELETE
RESTRICT; author_principal_id, deleted_by -> principals(id) ON DELETE RESTRICT;
reply_to_message_id -> messages(id) ON DELETE SET NULL; run_id -> runs(id) ON DELETE
SET NULL (deferred); import_item_id -> import_items(id) ON DELETE SET NULL.
Checks: (kind = 'run') = (run_id IS NOT NULL); trigger_state IS NULL OR origin = 'human';
trigger_state_reason IS NULL OR trigger_state IN ('failed','cancelled');
deleted_at IS NULL OR content_md = ''; deleted_at IS NULL OR NOT has_attachments;
(kind = 'system') = (system_code IS NOT NULL);
kind <> 'system' OR (origin = 'system' AND silent AND content_md = '').
Unique: (thread_id, thread_seq); (author_principal_id, client_msg_id); partial index (run_id)
WHERE run_id IS NOT NULL (one live box anchor per run).
Indexes:
(thread_id, thread_seq DESC)for loading the history page by page (via the unique index).(channel_id, created_at DESC)for "newest messages of a channel" and backfill context.(author_principal_id, created_at DESC)for GDPR export and "own messages".(trigger_state) WHERE trigger_state IN ('received','queued','working')for resumption after a restart and "Running now".- GIN
(search_vector)for full-text search.
Retention: content.
Deletion empties content_md, sets has_attachments to false, deletes attachments, revisions,
mentions, components, reactions and the pin, and redacts the payload of the related events
(section 6.5). The row and
thread_seq remain. The server writes a system line thread.reopened when a message
reopens a thread that is done; the author is the person whose message reopened it. If the server
accepts a report agent_session.report with bind_failed (the session of the run is missing in
the runtime; fail open according to 09-agents.md, section 9.5 item 4, and
22-import-migration.md, section 9 step 5), it writes in the same
transaction exactly one system line agent_session.resume_failed; the author is the agent of the
run. A system line does not reopen a done thread (R-DM-42).
Content format content_format = 1: Markdown according to CommonMark 0.31.2
(https://spec.commonmark.org/0.31.2/, retrieved 2026-10-10) [verified], without raw HTML
(19-security.md, SEC-OUT-1). Extensions such as tables get a new
content_format, because they change the rendering of existing texts. Mentions stand as text in
the content:
- A mention token is
@followed by a handle ([a-z0-9][a-z0-9._-]*, case-insensitive). Before the@stands the start of the text or a character that is neither a letter nor a digit nor one of_ . - @ /;alex@example.comis therefore not a mention. Trailing.,-and_do not belong to the handle (end of sentence). - Inline code and code blocks (fenced and indented, according to CommonMark) contain no mentions.
- On send and edit, the server resolves each distinct handle once among active
principals of the kind
useroragent. Who can be mentioned is governed by 07-permissions.md, section 4.6; a token that cannot be resolved stays text.@everyoneand@herehave no special meaning. - Each resolved principal creates one row in
mentions;Message.mention_idslists them sorted. If the client sendsmention_ids, the set must equal the resolved one, otherwise400 validation_failedwith codemention_mismatch. The server is the source; clients show a token as a mention only when its principal is inmention_ids. - More than
message.max_mentionsdistinct handles result in400 validation_failedwith codetoo_many_mentions. Reason: the resolution is one query per message and stays bounded.
message_revisions¶
Purpose: earlier versions of edited messages. Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
message_id |
uuid |
no | ||
revision |
integer |
no | 1 = original version | |
content_md |
text |
no | version before the change | |
edited_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
PK: id. FK: message_id -> messages(id) ON DELETE CASCADE; edited_by ->
principals(id) ON DELETE RESTRICT. Unique: (message_id, revision). Indexes: via the
unique index. Retention: content.
Live box anchors (kind = 'run') create no revisions; their history is in run_steps.
mentions¶
Purpose: resolved mentions of people and agents (no roles, no @everyone).
Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
message_id |
uuid |
no | ||
principal_id |
uuid |
no | person or agent | |
thread_id |
uuid |
no | copy for counters | |
created_at |
timestamptz |
no | now() |
PK: (message_id, principal_id). FK: message_id -> messages(id) ON DELETE CASCADE;
principal_id -> principals(id) ON DELETE RESTRICT; thread_id -> threads(id) ON DELETE
CASCADE. Indexes: (principal_id, created_at DESC) for "my mentions"; (principal_id,
thread_id) for unread mentions per thread. Retention: content. Editing replaces the
rows of a message, deletion removes them; a stored counter therefore does not have to follow
(read_states, R-DM-32).
A mention of a principal without read permission is stored, but creates no
notification and reveals nothing (P3).
reactions¶
Purpose: free Unicode reactions. Status is not a reaction. Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
message_id |
uuid |
no | ||
principal_id |
uuid |
no | ||
emoji |
text |
no | exactly one Unicode grapheme cluster, at most reaction.emoji_max_bytes bytes, checked in the application |
|
created_at |
timestamptz |
no | now() |
PK: (message_id, principal_id, emoji). FK: message_id -> messages(id) ON DELETE CASCADE;
principal_id -> principals(id) ON DELETE RESTRICT. Indexes: via the PK (count per
message). Retention: content. Per message at most reaction.max_distinct_per_message
distinct emoji; another new emoji results in 400 validation_failed with code
limit_reached.
pins¶
Purpose: pinned messages per thread. Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
message_id |
uuid |
no | ||
thread_id |
uuid |
no | ||
pinned_by |
uuid |
no | ||
pinned_at |
timestamptz |
no | now() |
PK: message_id. FK: message_id -> messages(id) ON DELETE CASCADE; thread_id ->
threads(id) ON DELETE CASCADE; pinned_by -> principals(id) ON DELETE RESTRICT.
Indexes: (thread_id, pinned_at DESC) for the list of pinned messages.
Retention: content. Per thread at most pin.max_per_thread pins (otherwise 400
validation_failed, code limit_reached).
message_templates¶
Purpose: templates for frequent inputs, personal or per channel. Module: messaging.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
owner_user_id |
uuid |
yes | personal template | |
channel_id |
uuid |
yes | channel template | |
name |
text |
no | ||
content_md |
text |
no | ||
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
deleted_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
PK: id. FK: owner_user_id -> users(principal_id) ON DELETE CASCADE; channel_id ->
channels(id) ON DELETE CASCADE; created_by -> principals(id) ON DELETE RESTRICT.
Checks: exactly one of owner_user_id and channel_id. Indexes: (owner_user_id) WHERE
deleted_at IS NULL; (channel_id) WHERE deleted_at IS NULL. Retention: content. name
has at most template.name_max_chars characters without control and bidi characters; per
person or per channel at most template.max_per_scope templates (otherwise
400 validation_failed, code limit_reached).
Permissions: personal templates through the person (resource user, self), channel
templates through the channel (manage), see template.manage in 05-actions.md.
4.7 Files (files)¶
files¶
Purpose: an uploaded file in object storage with checksum, scan and transcript.
Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
object_key |
text |
no | key in object storage (B4); contains no names or IDs of channels | |
sha256 |
bytea |
no | over the stored bytes | |
size_bytes |
bigint |
no | up to file.max_upload_bytes |
|
mime_type |
text |
no | detected by the server (sniffing), not taken from the client | |
declared_mime_type |
text |
yes | the client's statement, for diagnosis only | |
original_name |
text |
no | sanitized | |
uploaded_by |
uuid |
no | ||
purpose |
text |
no | 'attachment' |
CHECK (purpose IN ('attachment','avatar','branding','gdpr_export')); determines the read path (05-actions.md, section 2.2) |
scope_channel_id |
uuid |
yes | only attachment: channel for which the file was uploaded; governs access before attaching, after that the channel of the message applies |
|
upload_state |
text |
no | 'pending' |
CHECK (upload_state IN ('pending','complete','failed')) |
scan_status |
text |
no | 'pending' |
CHECK (scan_status IN ('pending','clean','infected','error','skipped')) |
scan_engine |
text |
yes | ||
scanned_at |
timestamptz |
yes | ||
width |
integer |
yes | images and videos | |
height |
integer |
yes | ||
duration_ms |
bigint |
yes | audio and video | |
transcript_status |
text |
no | 'none' |
CHECK (transcript_status IN ('none','pending','done','failed')) |
transcript_text |
text |
yes | ||
transcript_language |
text |
yes | BCP 47 | |
transcript_source |
text |
yes | CHECK (transcript_source IN ('agent','service')); files has no version column: the ETag of a transcript (If-Match of file.transcript.submit) is derived as a strong hash over transcript_status, transcript_source, transcript_language and transcript_text (R-DM-48) |
|
created_at |
timestamptz |
no | now() |
|
deleted_at |
timestamptz |
yes | object is removed by the cleanup job | |
search_vector |
tsvector |
no | generated | name and transcript, see section 5 |
PK: id. FK: uploaded_by -> principals(id) ON DELETE RESTRICT; scope_channel_id ->
channels(id) ON DELETE RESTRICT. Checks: (purpose = 'attachment') = (scope_channel_id IS NOT
NULL). Unique: object_key. Indexes: (uploaded_by, created_at DESC) for export and
quota; (scan_status) WHERE scan_status = 'pending' for the scan queue;
(upload_state, created_at) WHERE upload_state = 'pending' for cleaning up aborted
uploads; GIN (search_vector). Retention: content.
No deduplication across files (R-DM-13). Download only through the server after
file.read, also for media players through short-lived file tickets instead of presigned GET
(12-files.md, section 9;
ADR 0048); served
only when scan_status IN ('clean','skipped'). For JPEG and PNG, sha256, size_bytes,
width and height describe the stored original after the lossless removal of the metadata
(12-files.md, section 7).
file_thumbnails¶
Purpose: thumbnails of a file. Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
file_id |
uuid |
no | ||
variant |
text |
no | CHECK (variant IN ('small','medium','large','poster')) |
|
object_key |
text |
no | ||
mime_type |
text |
no | ||
width |
integer |
no | ||
height |
integer |
no | ||
size_bytes |
bigint |
no |
PK: (file_id, variant). FK: file_id -> files(id) ON DELETE CASCADE. Unique:
object_key. Indexes: via the PK. Retention: content.
message_attachments¶
Purpose: attachments of a message in a fixed order. Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
message_id |
uuid |
no | ||
file_id |
uuid |
no | ||
position |
smallint |
no | up to message.max_attachments |
|
thread_id |
uuid |
no | copy of messages.thread_id; fixed, because a message never changes its thread |
|
channel_id |
uuid |
no | copy of messages.channel_id for permission filters (search); changed along on a move |
PK: (message_id, position). FK: message_id -> messages(id) ON DELETE CASCADE; file_id
-> files(id) ON DELETE RESTRICT. Unique: (file_id) (a file is attached to at most one
message, R-DM-22); the index also serves "where is this file attached" for file.read. Indexes:
(thread_id) for following a move. Retention: content.
A file may only be attached when purpose = 'attachment', its scope_channel_id is the
channel of the message and its upload session is completed (application,
12-files.md, section 10).
files writes thread_id and channel_id on attaching from the message that messaging creates
in the same transaction. On moving a thread, files changes channel_id in the transaction of
the move (see threads; in-transaction event chat.ThreadMoved as for messaging and sync,
02-architecture.md, section 4.3). So files filters attached files by
channel and thread of their message with channel_id = ANY($visible) in its own tables
(07-permissions.md, section 7.4; 14-search.md, section 4.3),
without reading messages (principle 12); files.scope_channel_id does not count for this. The
copies deliberately have no foreign key of their own: the row hangs on its message through
message_id and goes with it (ON DELETE CASCADE), and thread and channel of the message hold the
foreign keys of messages (ON DELETE RESTRICT); a foreign key to threads or channels would
only check existence. That the copies equal the values of the message is ensured by attaching and
by the transaction of the move, as for messages.channel_id (R-DM-43).
file_upload_sessions¶
Purpose: resumable upload of a file in parts, the same for both storage drivers
(12-files.md, section 3). Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
file_id |
uuid |
no | one session per file | |
mode |
text |
no | 'direct' |
CHECK (mode IN ('direct','presigned')) |
state |
text |
no | 'open' |
CHECK (state IN ('open','completed','aborted','expired')) |
declared_size_bytes |
bigint |
no | announced by the client | |
declared_sha256 |
bytea |
no | announced by the client; is kept after the removal of the metadata | |
part_size_bytes |
bigint |
no | file.upload_part_bytes |
|
part_count |
integer |
no | declared_size_bytes / part_size_bytes, rounded up |
|
storage_upload_id |
text |
yes | multipart upload ID with the S3 driver | |
failure_reason |
text |
yes | code without contents | |
created_at |
timestamptz |
no | now() |
|
completed_at |
timestamptz |
yes | ||
expires_at |
timestamptz |
no | created_at + file.upload_session_ttl |
PK: file_id. FK: file_id -> files(id) ON DELETE CASCADE. Indexes: (expires_at) WHERE state
= 'open' for cleanup. Retention: transient.
file_upload_parts¶
Purpose: confirmed parts of an upload session. Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
file_id |
uuid |
no | ||
part_number |
integer |
no | from 1 | |
size_bytes |
bigint |
no | ||
sha256 |
bytea |
no | from Content-Digest; the same number with a different digest is a conflict |
|
etag |
text |
yes | ETag of the S3 part | |
received_at |
timestamptz |
no | now() |
PK: (file_id, part_number). FK: file_id -> file_upload_sessions(file_id) ON DELETE CASCADE.
Indexes: via the PK. Retention: transient.
file_text_extracts¶
Purpose: text extracted from a file for search (14-search.md, section 5).
Module: files.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
file_id |
uuid |
no | ||
state |
text |
no | 'pending' |
CHECK (state IN ('pending','done','failed','unsupported')) |
extractor |
text |
yes | name and version of the extractor | |
text_content |
text |
yes | at most search.extract.max_text_bytes |
|
truncated |
boolean |
no | false |
|
search_language |
regconfig |
no | 'simple' |
language of the instance |
extracted_at |
timestamptz |
yes | ||
error |
text |
yes | code without contents | |
search_vector |
tsvector |
no | generated | see section 5 |
PK: file_id. FK: file_id -> files(id) ON DELETE CASCADE. Indexes: GIN (search_vector);
(state) WHERE state = 'pending' for the job. Retention: content.
4.8 Runs and interactions (runs)¶
runs¶
Purpose: one agent turn; at the same time the live box. Module: runs.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
thread_id |
uuid |
no | ||
agent_id |
uuid |
no | ||
agent_session_id |
uuid |
yes | set at start or at the first agent_session.report; NULL for guest runs |
|
trigger_kind |
text |
no | CHECK (trigger_kind IN ('message','command','interaction')) |
|
triggering_message_id |
uuid |
yes | set when trigger_kind = 'message'; for a run from deferred follow-ups the first of these messages |
|
triggering_command_id |
uuid |
yes | set exactly when trigger_kind = 'command' |
|
triggering_interaction_id |
uuid |
yes | set exactly when trigger_kind = 'interaction' (interaction without a waiting run) |
|
triggered_by |
uuid |
no | person who triggered | |
state |
text |
no | 'queued' |
CHECK (state IN ('queued','running','waiting_input','completed','failed','cancelled')) |
state_reason |
text |
yes | reason code from 09-agents.md, section 5.2, for example billing_mode_mismatch, access_revoked, stale, user_cancelled, channel_read_only |
|
version |
bigint |
no | 1 |
rises with every stored change; basis of the patches |
billing_mode_expected |
text |
no | configured mode from agents.billing_mode at the last delivery; every delivery sets it anew |
|
billing_mode_used |
text |
yes | reported mode, as reported by the adapter per run | |
billing_evidence |
jsonb |
no | '{}' |
schema billing_evidence.v1 from run.start: provider, key_type, method; never a value, never a prefix |
invocation_jti |
uuid |
no | jti of the token last issued with a delivery that allows a start (agent.invocation; in waiting_input also agent.followup and interaction.created); deliveries during running do not change it |
|
started_jti |
uuid |
yes | jti of the token of the last successful run.start; a repetition with the same token returns the result again instead of 409 as long as the run is running; in waiting_input the token is used up and is rejected |
|
renewed_from_jti |
uuid |
yes | jti of the token that the last run.token.renew replaced; a repetition with it issues again (lost response). Every delivery that replaces invocation_jti and every successful run.start set NULL (09-agents.md, section 4.5.1) |
|
dev_environment_id |
uuid |
yes | dev environment of the run (milestone, SR3); also stands in the token | |
model |
text |
yes | ||
status_line |
text |
yes | current status line | |
last_text_excerpt |
text |
yes | last text of the agent, shortened to run.excerpt_max_chars |
|
plan |
jsonb |
no | '{}' |
schema run_plan.v1: items (id, text, status) for the live box |
metrics |
jsonb |
no | '{}' |
schema run_metrics.v1: context_pct, tokens_in, tokens_out, tokens_cache_read, tokens_cache_write, tokens_cache_write_1h, subagents, background_jobs, api_retries, last_api_retry (attempt, max_retries, retry_delay_ms, error), progress_events (count of runtime progress messages without a visible change, addendum of 2026-10-24) |
cost_micros |
bigint |
no | 0 |
|
currency |
text |
no | 'USD' |
|
cost_is_estimate |
boolean |
no | true |
|
summary |
text |
yes | collapsed line after the end | |
stall_warned_at |
timestamptz |
yes | stall notice after run.stall_after |
|
last_progress_at |
timestamptz |
yes | server time of the last successful run.start or stored patch; runs.stall_check compares it with run.stall_after (09-agents.md, section 5.5; addendum of 2026-10-24, run visualization, R-DM-53) |
|
queued_at |
timestamptz |
no | now() |
|
invocation_held_until |
timestamptz |
yes | set exactly when the acceptance holds the event agent.invocation of the run because an attachment of the triggering message is not final, or because the server transcript of a voice message (kind = voice in transcription mode service) is still pending (R-DM-47) (04-sync-protocol.md, section 15.3 step 1); the value is the latest time of the release (acceptance plus file.invocation_hold_max or voice.transcript_hold_max); ReleaseInvocation sets NULL in the transaction that writes the event (version rises, no run.state_changed); a run that ends before keeps the value and is never released (R-DM-44) |
|
started_at |
timestamptz |
yes | ||
finished_at |
timestamptz |
yes | ||
duration_ms |
bigint |
yes |
PK: id. FK: thread_id -> threads(id) ON DELETE RESTRICT; agent_id ->
agents(principal_id) ON DELETE RESTRICT; agent_session_id -> agent_sessions(id) ON DELETE
SET NULL; triggering_message_id -> messages(id) ON DELETE SET NULL;
triggering_command_id -> commands(id) ON DELETE SET NULL; triggering_interaction_id ->
interactions(id) ON DELETE SET NULL; triggered_by -> principals(id) ON DELETE RESTRICT;
dev_environment_id -> dev_environments(id) ON DELETE RESTRICT.
Checks: billing_mode_expected IN ('personal','organization'); final states have
finished_at; at most one of the three columns triggering_* is set, and only the one matching
trigger_kind (it may later become empty through ON DELETE SET NULL).
Unique: invocation_jti; partial index (thread_id) WHERE state IN ('queued','running',
'waiting_input') (at most one active run per thread, R-DM-14).
Indexes: (thread_id, queued_at DESC) for the run history; (state, agent_id) WHERE state
IN ('queued','running','waiting_input') for "Running now" and resumption;
(agent_id, finished_at DESC) for statistics. Retention: content.
State transitions (everything else is rejected): queued -> running | failed | cancelled;
running -> waiting_input | completed | failed | cancelled; waiting_input -> running |
failed | cancelled. Every transition is a compare-and-swap on state and version. Both
transitions to running are run.start with a valid token
(04-sync-protocol.md, section 15.3).
run_steps¶
Purpose: ordered steps of a run (tool, status, subagent, workflow,
background job). Module: runs.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | created by the adapter (UUIDv7), so that repetitions are idempotent | |
run_id |
uuid |
no | ||
ordinal |
integer |
no | order in the run, assigned by the server | |
parent_step_id |
uuid |
yes | nesting (tool of a subagent, task under the tool call that started it); set at add, never replaced; the run graph is built from it (09-agents.md, section 5.6) |
|
kind |
text |
no | CHECK (kind IN ('tool_call','status','subagent','workflow','background_job')) |
|
name |
text |
no | for example tool name | |
title |
text |
no | '' |
short description for the line |
status |
text |
no | 'running' |
CHECK (status IN ('pending','running','completed','failed','cancelled')) |
detail |
jsonb |
no | '{}' |
schema run_step_detail.v1; shortened to run.step_detail_max_bytes; optional member usage of a task step (09-agents.md, section 5.6) |
started_at |
timestamptz |
no | now() |
|
finished_at |
timestamptz |
yes |
PK: id. FK: run_id -> runs(id) ON DELETE CASCADE; parent_step_id -> run_steps(id) ON
DELETE CASCADE. Unique: (run_id, ordinal). Indexes: via the unique index (loading in
order); (run_id) WHERE status = 'running' for open steps. Retention: content.
At most run.max_steps_persisted steps per run; after that the server combines older
finished steps into one status step per parent (addendum of 2026-10-24, R-DM-53;
09-agents.md, section 5.3).
run_followups¶
Purpose: follow-ups to a running run and their delivery state. Module: runs.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
run_id |
uuid |
no | ||
message_id |
uuid |
no | ||
state |
text |
no | 'queued' |
CHECK (state IN ('queued','delivered','absorbed','deferred','discarded')); deferred = becomes the next run |
created_at |
timestamptz |
no | now() |
|
updated_at |
timestamptz |
no | now() |
PK: (run_id, message_id). FK: run_id -> runs(id) ON DELETE CASCADE; message_id ->
messages(id) ON DELETE CASCADE. Unique: (message_id) (a follow-up belongs to one run).
Indexes: (run_id, state) for open follow-ups. Retention: content.
message_components¶
Purpose: interactive elements on a message with state and single-use rule. Module: runs.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
message_id |
uuid |
no | ||
custom_id |
text |
no | assigned by the agent, comes back in the interaction | |
type |
text |
no | CHECK (type IN ('button','select','form')) |
|
definition |
jsonb |
no | schema component.v1 (09-agents.md, section 11.1): label, style, options, form fields. Button style only a role of the design system (primary, secondary, success, danger, ghost), no free color |
|
position |
smallint |
no | order | |
single_use |
boolean |
no | true |
all single_use components of a message form a group with exactly one decision |
required_capability |
text |
yes | for example approve_actions |
|
restricted_to |
text |
no | 'any' |
CHECK (restricted_to IN ('any','run_starter','owner')); run_starter = only the person who triggered the run, or the owner; owner = only the owner |
run_id |
uuid |
yes | run to which the question belongs | |
state |
text |
no | 'active' |
CHECK (state IN ('active','used','expired','disabled')) |
expires_at |
timestamptz |
yes | default component.default_ttl |
|
used_by |
uuid |
yes | ||
used_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
PK: id. FK: message_id -> messages(id) ON DELETE CASCADE; run_id -> runs(id) ON
DELETE SET NULL; used_by -> principals(id) ON DELETE RESTRICT. Unique: (message_id,
custom_id). Indexes: (expires_at) WHERE state = 'active' for the expiry job; (run_id) WHERE
state = 'active' for disabling at the end of the run. Retention: content.
Single-use rule per message: in one transaction SELECT ... FROM message_components WHERE
message_id = $m AND single_use AND state = 'active' ORDER BY id FOR UPDATE. If the chosen
component is among them and not expired, it becomes used (used_by, used_at,
version + 1), and all other locked rows become disabled. Otherwise 409 component_used.
Only whoever finds the chosen component active has triggered the interaction. Components
without single_use stay active.
interactions¶
Purpose: one use of a component by a principal. Module: runs.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
component_id |
uuid |
no | ||
message_id |
uuid |
no | ||
principal_id |
uuid |
no | ||
client_interaction_id |
uuid |
no | UUIDv7 from the client, idempotency | |
values |
jsonb |
no | '{}' |
selection or form values, schema from the component |
state |
text |
no | 'received' |
CHECK (state IN ('received','dispatched','acknowledged','failed','rejected')) |
response |
jsonb |
yes | response of the agent (for example a note text) | |
created_at |
timestamptz |
no | now() |
|
updated_at |
timestamptz |
no | now() |
PK: id. FK: component_id -> message_components(id) ON DELETE CASCADE; message_id ->
messages(id) ON DELETE CASCADE; principal_id -> principals(id) ON DELETE RESTRICT.
Unique: (principal_id, client_interaction_id). Indexes: (component_id); (state) WHERE
state IN ('received','dispatched') for delivery. Retention: content.
4.9 Costs (billing)¶
price_table¶
Purpose: prices per provider and model with source and retrieval time. Module: billing.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
provider |
text |
no | ||
model |
text |
no | ||
unit |
text |
no | CHECK (unit IN ('input_mtok','output_mtok','cache_read_mtok','cache_write_mtok','cache_write_1h_mtok','request')); cache_write_mtok is a cache write with 5 minutes, cache_write_1h_mtok with 1 hour |
|
price_micros |
bigint |
no | per unit | |
currency |
text |
no | 'USD' |
|
valid_from |
timestamptz |
no | ||
valid_to |
timestamptz |
yes | ||
source_url |
text |
no | public pricing page | |
retrieved_at |
timestamptz |
no | time of retrieval | |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
PK: id. FK: created_by -> principals(id) ON DELETE RESTRICT.
Unique: (provider, model, unit, valid_from); in addition the exclusion constraint
EXCLUDE USING gist (provider WITH =, model WITH =, unit WITH =, tstzrange(valid_from,
valid_to) WITH &&) (extension btree_gist) against overlapping validity.
Indexes: (provider, model, unit, valid_from DESC) for "price at time t".
Retention: cost.
A row is no longer changed after its first use in a cost entry; a correction
closes it with valid_to and creates a new one. This makes every computed entry recomputable from
provider, model and occurred_at (R-DM-23).
cost_entries¶
Purpose: cost entry per run, as an estimate or reconciled. Module: billing.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
run_id |
uuid |
no | ||
agent_id |
uuid |
no | copy for analysis | |
thread_id |
uuid |
no | copy | |
channel_id |
uuid |
no | copy | |
space_id |
uuid |
yes | copy; NULL for a DM | |
agent_session_id |
uuid |
yes | ||
billing_mode |
text |
no | CHECK (billing_mode IN ('personal','organization')); reported mode of the run |
|
provider |
text |
no | ||
model |
text |
no | ||
tokens_in |
bigint |
no | 0 |
|
tokens_out |
bigint |
no | 0 |
|
tokens_cache_read |
bigint |
no | 0 |
|
tokens_cache_write |
bigint |
no | 0 |
cache write with 5 minutes (or without a stated duration) |
tokens_cache_write_1h |
bigint |
no | 0 |
cache write with 1 hour, counted separately |
amount_micros |
bigint |
no | 0 for unpriced |
|
currency |
text |
no | 'USD' |
|
is_estimate |
boolean |
no | true |
|
source |
text |
no | CHECK (source IN ('adapter_reported','computed','unpriced','reconciled')); unpriced = neither a price nor an amount from the adapter, display "Price missing" |
|
occurred_at |
timestamptz |
no | for computed also the point in time whose price rows apply |
|
reconciled_at |
timestamptz |
yes | ||
supersedes_id |
uuid |
yes | a correction entry replaces an earlier one |
PK: id. FK: run_id -> runs(id) ON DELETE RESTRICT; agent_id -> agents(principal_id)
ON DELETE RESTRICT; thread_id, channel_id, space_id, agent_session_id -> the respective
table ON DELETE RESTRICT; supersedes_id -> cost_entries(id) ON DELETE RESTRICT.
A calculation uses up to five price rows (input, output, cache read, cache write
with 5 minutes and with 1 hour); therefore the entry does not reference a single row, but
the price set for provider, model and occurred_at (see price_table).
Unique: partial index (supersedes_id) WHERE supersedes_id IS NOT NULL.
Indexes: (space_id, occurred_at), (agent_id, occurred_at), (thread_id), (run_id) for
costs per space, agent, thread and run; (is_estimate, occurred_at) WHERE is_estimate for
reconciliation. Retention: cost.
Valid total = entries without a successor (no other entry points to them via supersedes_id).
With billing_mode = 'personal' amounts are information only, never an invoice.
4.10 Sync and personal data (sync, notify)¶
event_log¶
Purpose: durable events with a global, commit-ordered sequence (B8). Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
seq |
bigint |
no | from sequence_counters('event_seq'), gapless |
|
id |
uuid |
no | UUIDv7, dedupe key for clients | |
type |
text |
no | event type from 05-actions.md | |
schema_version |
smallint |
no | 1 |
version of the payload of this type |
occurred_at |
timestamptz |
no | now() |
time of the transaction |
actor_kind |
text |
yes | CHECK (actor_kind IN ('user','agent','app','system')); envelope actor.kind. NULL: no actor (job without a principal) |
|
actor_principal_id |
uuid |
yes | set exactly when actor_kind is user, agent or app; NULL for system (system action without a principal, for example grant.expire) and without an actor |
|
space_id |
uuid |
yes | scope for the filter | |
channel_id |
uuid |
yes | ||
thread_id |
uuid |
yes | ||
resource_kind |
text |
no | for example message, thread, run |
|
resource_id |
uuid |
yes | ||
resource_version |
bigint |
yes | version of the object after the change | |
audience_principal_id |
uuid |
yes | set: personal event, only for this principal | |
receive_action |
text |
no | action that a receiver needs on the resource (05-actions.md, section 6). Personal events: self when 05 names only "self"; if 05 also names an action, exactly that action stands here (for example agent.events.consume for agent.invocation, interaction.created and command.invoked, agent.config.read for agent.config_changed). If the receiver is an app, delivery checks the scope events:subscribe instead of agent.events.consume (05, section 6). One value per type; the source is the action catalogue (ADR 0049) |
|
payload |
jsonb |
no | schema <type>.v<schema_version>; size up to sync.event_payload_max_bytes |
|
truncated |
boolean |
no | false |
envelope truncated: the producer built a reduced payload because the object exceeds sync.event_payload_max_bytes; the client reloads via REST |
redacted_at |
timestamptz |
yes | payload redacted (deletion) | |
correlation_id |
uuid |
yes | W3C trace ID of the triggering request (16 bytes, written as a UUID); if a request has no traceparent, the server creates one. NULL only for jobs without a request |
|
trace_context |
text |
yes | W3C traceparent of the triggering request, only when it was recorded; goes to adapters so that their span becomes a child of the request (17-observability.md) |
|
client_msg_id |
uuid |
yes | echo for the sender | |
permission_version |
bigint |
no | permission version at the time of the commit |
PK: seq. Partitioning: PARTITION BY RANGE (seq), per partition
sync.event_log_partition_size numbers (R-DM-15), no default partition. FK: none (deliberately;
the log outlives deleted objects and may detach partitions freely). Unique: (id, seq)
(must contain the partition key). Check: COALESCE(actor_kind IN ('user','agent','app'), false) =
(actor_principal_id IS NOT NULL) as a table check, so that a principal never stands without a
kind and system never stands with a principal (R-DM-29); receive_action <> 'self' OR
audience_principal_id IS NOT NULL. The appender rejects an oversized payload; only the producer
can shorten it, because JSON cannot be shortened in general.
Indexes:
(channel_id, seq)for catch-up filtered by channels.(space_id, seq)for catch-up filtered by fully visible spaces (04-sync-protocol.md, section 7.2).(audience_principal_id, seq) WHERE audience_principal_id IS NOT NULLfor personal events.(resource_kind, resource_id)for redaction on deletion.- BRIN
(occurred_at)for retention per partition.
Retention: event_log (window sync.event_retention). A partition is detached and
dropped when its largest occurred_at is older than the window, except the partition with the
current head (max(seq)): it always stays. Reason: otherwise, on an idle instance,
max(seq) would no longer be the counter value, and gap detection and min_retained_seq
(04-sync-protocol.md, section 7.3) would break. The job
sync.partition_maintenance creates partitions in advance
(02-architecture.md, section 12.3); bothy migrate up calls the same
idempotent routine, so that a fresh database has partitions before the first serve. Because
bothy_app may only run DML, creating, attaching and detaching run through two
SECURITY DEFINER functions of bothy_owner (16-operations.md, section 7.1).
Ephemeral events (typing, live box patches, presence, notifications, autocomplete
requests) never stand in this table. Delivery fields (invocation token,
redelivered) are created per delivery and are never stored.
event_consumer_cursors¶
Purpose: durable read position of the consumers in the worker that create external effects
from the event log (push, outgoing webhooks, deletion journal; 02-architecture.md,
section 11.5). Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
name |
text |
no | CHECK (name IN ('push','webhooks','privacy.journal')); new consumers are an additive migration (privacy.journal: M7-07, deletion journal of 20-privacy.md, section 4.6) |
|
last_seq |
bigint |
no | 0 |
highest processed global sequence |
updated_at |
timestamptz |
no | now() |
PK: name. FK: none. Indexes: via the PK. Retention: config.
A round locks the row with FOR UPDATE SKIP LOCKED, reads from last_seq, creates the jobs with
InsertTx and advances last_seq in the same transaction. This way jobs are created exactly once,
even with more than one worker.
idempotency_keys¶
Purpose: idempotency for writing requests without their own client key (everything except
messages and interactions). Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
principal_id |
uuid |
no | ||
key |
uuid |
no | header Idempotency-Key, UUIDv7 |
|
action |
text |
no | x-authz-action of the route |
|
request_sha256 |
bytea |
no | hash of method, path and canonical body | |
state |
text |
no | 'in_progress' |
CHECK (state IN ('in_progress','completed')) |
response_status |
smallint |
yes | ||
response_body |
jsonb |
yes | ||
resource_id |
uuid |
yes | ||
created_at |
timestamptz |
no | now() |
|
expires_at |
timestamptz |
no | created_at + sync.idempotency_key_ttl |
PK: (principal_id, key). FK: principal_id -> principals(id) ON DELETE CASCADE.
Indexes: (expires_at) for the cleanup job. Retention: transient.
The same key with a different request_sha256 yields 422 idempotency_key_reuse.
in_progress on a retry yields 409 request_in_progress.
read_states¶
Purpose: read state per person and thread. Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
user_id |
uuid |
no | ||
thread_id |
uuid |
no | ||
channel_id |
uuid |
no | copy for counters per channel, updated along with a move | |
last_read_seq |
bigint |
no | 0 |
highest read thread_seq |
version |
bigint |
no | 1 |
|
updated_by_device_id |
uuid |
yes | ||
updated_at |
timestamptz |
no | now() |
PK: (user_id, thread_id). FK: user_id -> users(principal_id) ON DELETE CASCADE;
thread_id -> threads(id) ON DELETE CASCADE; channel_id -> channels(id) ON DELETE
CASCADE; updated_by_device_id -> devices(id) ON DELETE SET NULL.
Indexes: (user_id, channel_id) for unread counters per channel.
Retention: personal_sync.
Unread = threads.last_message_seq - GREATEST(coalesce(last_read_seq, 0),
threads.unread_floor_seq), only for visible threads. Unread mentions = rows in
mentions of the person whose message is not deleted and whose thread_seq lies above
GREATEST(last_read_seq, threads.unread_floor_seq). The reader computes both counters
(snapshot, listMyReadStates, event read_state.updated, badge); neither is stored
(R-DM-32). Only the person themselves writes
(read_state.update, read_state.mark_unread). So that imported content does not
appear as unread, the import sets threads.unread_floor_seq instead of creating rows for all
readers; this costs one row per batch instead of one per person.
drafts¶
Purpose: a person's draft per thread or per channel (new forum post), versioned.
Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
thread_id |
uuid |
yes | NULL for a new forum post | |
channel_id |
uuid |
no | with thread_id a copy of threads.channel_id, updated along with a move |
|
content_md |
text |
no | '' |
|
reply_to_message_id |
uuid |
yes | ||
file_ids |
uuid[] |
no | '{}' |
uploaded attachments that are not sent yet |
version |
bigint |
no | 1 |
|
updated_by_device_id |
uuid |
yes | ||
updated_at |
timestamptz |
no | now() |
PK: id. FK: user_id -> users(principal_id) ON DELETE CASCADE; thread_id ->
threads(id) ON DELETE CASCADE; channel_id -> channels(id) ON DELETE CASCADE;
reply_to_message_id -> messages(id) ON DELETE SET NULL; updated_by_device_id ->
devices(id) ON DELETE SET NULL. Unique: partial index (user_id, thread_id) WHERE thread_id IS
NOT NULL; partial index (user_id, channel_id) WHERE thread_id IS NULL. Indexes: covered by the
unique indexes. Retention: personal_sync. An empty draft is deleted.
user_settings¶
Purpose: synchronized personal settings, versioned per key. Module: sync.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
user_id |
uuid |
no | ||
key |
text |
no | for example appearance.theme, composer.enter_sends; keys, values and defaults are normative in 01-product.md, section 11, the keys notify.* in 13-push-notifications.md, section 3.3; the server rejects unknown keys |
|
value |
jsonb |
no | ||
version |
bigint |
no | 1 |
|
updated_by_device_id |
uuid |
yes | ||
updated_at |
timestamptz |
no | now() |
PK: (user_id, key). FK: user_id -> users(principal_id) ON DELETE CASCADE;
updated_by_device_id -> devices(id) ON DELETE SET NULL. Indexes: covered by the PK.
Retention: personal_sync.
notification_preferences¶
Purpose: notification level per space, channel or thread. Module: notify.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
user_id |
uuid |
no | ||
target_kind |
text |
no | CHECK (target_kind IN ('space','channel','thread')) |
|
target_id |
uuid |
no | polymorphic; checked in the application | |
level |
text |
no | CHECK (level IN ('all','mentions','mute')) |
|
muted_until |
timestamptz |
yes | temporarily muted | |
version |
bigint |
no | 1 |
|
updated_at |
timestamptz |
no | now() |
PK: (user_id, target_kind, target_id). FK: user_id -> users(principal_id) ON DELETE
CASCADE; target polymorphic without FK (R-DM-16). Indexes: (target_kind, target_id) for
finding the recipients of a push. Retention: personal_sync.
Effective level, first existing value: own setting for thread, channel, space; then
channels.default_notification_level; for DM channels all; then
user_settings['notify.default_level']; then notify.user_default_level
(13-push-notifications.md, section 3.1).
quiet_hours¶
Purpose: quiet hours of a person. Module: notify.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
weekdays |
smallint |
no | bit mask, bit 0 = Monday | |
start_local |
time |
no | ||
end_local |
time |
no | may be less than start_local (across midnight) |
|
time_zone |
text |
yes | NULL = users.time_zone |
|
enabled |
boolean |
no | true |
|
version |
bigint |
no | 1 |
PK: id. FK: user_id -> users(principal_id) ON DELETE CASCADE. Checks: weekdays BETWEEN 1
AND 127. Indexes: (user_id) WHERE enabled. Retention: personal_sync.
push_registrations¶
Purpose: push target of a device. Push carries no content. Module: notify.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
user_id |
uuid |
no | ||
device_id |
uuid |
no | ||
auth_session_id |
uuid |
no | session that registered; logging out deactivates the registration | |
provider |
text |
no | CHECK (provider IN ('apns','fcm','unifiedpush','wns')) |
|
token |
text |
no | push token or endpoint of the provider | |
environment |
text |
no | 'production' |
CHECK (environment IN ('production','sandbox')) |
app_id |
text |
no | bundle or package ID | |
preview_token_hash |
bytea |
yes | HMAC of the preview token of the Notification Service Extension; rotated with every new registration (13-push-notifications.md, section 6.4) | |
created_at |
timestamptz |
no | now() |
|
last_success_at |
timestamptz |
yes | ||
failure_count |
integer |
no | 0 |
deactivated from push.max_consecutive_failures on |
disabled_at |
timestamptz |
yes | ||
disabled_reason |
text |
yes | CHECK (disabled_reason IN ('token_invalid','device_revoked','logout','topic_mismatch','sender_mismatch','repeated_failures')) |
PK: id. FK: user_id -> users(principal_id) ON DELETE CASCADE; device_id ->
devices(id) ON DELETE CASCADE; auth_session_id -> auth_sessions(id) ON DELETE CASCADE.
Checks: (disabled_at IS NULL) = (disabled_reason IS NULL). Unique: (provider, token);
preview_token_hash as a partial index WHERE preview_token_hash IS NOT NULL. Indexes: (user_id)
WHERE disabled_at IS NULL for sending; (auth_session_id). Retention: security.
4.11 Integrations (apps, importer)¶
webhook_subscriptions¶
Purpose: outgoing webhooks for subscribed event types, bound to an app installation or to an
agent (webhook as the third agent transport, 09-agents.md,
section 4.2). Module: apps.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
app_installation_id |
uuid |
yes | set for subscriptions of an app | |
agent_id |
uuid |
yes | set for subscriptions of an agent; every delivery carries a fresh invocation token, 2xx counts as an ack up to this seq |
|
url |
text |
no | https only; the host must be listed in webhook.allowed_hosts (R-DM-17) |
|
event_types |
text[] |
no | for apps a subset of app_installations.event_subscriptions; for agents the personal events from 04-sync-protocol.md, section 15.1, and the events of its grants |
|
signing_key_kid |
text |
no | server key with purpose webhook_out (signature v1a, Ed25519) |
|
hmac_enabled |
boolean |
no | false |
additional signature v1 (HMAC); the secret is not stored but derived from a master key, id and hmac_generation (../api/webhooks.md) |
hmac_generation |
integer |
no | 0 |
increases with every rotation of the HMAC secret |
hmac_rotated_at |
timestamptz |
yes | until webhook.secret_rotation_overlap after this time the server signs with the old and the new generation |
|
state |
text |
no | 'active' |
CHECK (state IN ('active','paused','disabled')) |
consecutive_failures |
integer |
no | 0 |
disabled from webhook.max_consecutive_failures on |
last_delivered_seq |
bigint |
no | 0 |
highest global sequence accepted with 2xx; the next event goes out only after it (strictly ordered per subscription, 11-extensions.md, section 7); set to head_seq after a restore (section 6.7) |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
disabled_at |
timestamptz |
yes |
PK: id. FK: app_installation_id -> app_installations(id) ON DELETE CASCADE; agent_id ->
agents(principal_id) ON DELETE CASCADE; signing_key_kid -> server_signing_keys(kid) ON
DELETE RESTRICT; created_by -> principals(id) ON DELETE RESTRICT.
Checks: exactly one of app_installation_id and agent_id is set; hmac_enabled OR
hmac_generation = 0. Indexes: (state) for the delivery worker; (agent_id) WHERE agent_id IS NOT
NULL. Retention: config.
webhook_deliveries¶
Purpose: delivery attempts per event and subscription. Module: apps.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
subscription_id |
uuid |
no | ||
event_seq |
bigint |
no | ||
event_id |
uuid |
no | in the header, dedupe at the receiver | |
attempt |
integer |
no | 0 |
|
status |
text |
no | 'pending' |
CHECK (status IN ('pending','succeeded','failed','dead')) |
response_code |
smallint |
yes | ||
last_error |
text |
yes | without the response body | |
next_attempt_at |
timestamptz |
yes | backoff from webhook.retry_backoff_base and webhook.retry_backoff_cap |
|
created_at |
timestamptz |
no | now() |
|
delivered_at |
timestamptz |
yes |
PK: id. FK: subscription_id -> webhook_subscriptions(id) ON DELETE CASCADE.
Unique: (subscription_id, event_seq). Indexes: (next_attempt_at) WHERE status = 'pending'
for the worker. Retention: transient.
incoming_webhooks¶
Purpose: incoming URL through which an external system writes into a channel as an app.
Module: apps.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | in the URL | |
app_id |
uuid |
no | writing principal; needs write on the channel |
|
channel_id |
uuid |
no | ||
thread_id |
uuid |
yes | fixed thread, otherwise a new thread or the main thread | |
name |
text |
no | ||
token_hash |
bytea |
no | HMAC-SHA-256 | |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
last_used_at |
timestamptz |
yes | ||
revoked_at |
timestamptz |
yes |
PK: id. FK: app_id -> apps(principal_id) ON DELETE RESTRICT; channel_id ->
channels(id) ON DELETE RESTRICT; thread_id -> threads(id) ON DELETE RESTRICT;
created_by -> principals(id) ON DELETE RESTRICT. Unique: token_hash. Indexes:
(channel_id) WHERE revoked_at IS NULL. Retention: config.
Messages from incoming webhooks have origin = 'app' and never trigger agents.
import_sources¶
Purpose: an import source (for example a Discord server) with its state. Module: importer.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
kind |
text |
no | CHECK (kind IN ('discord','archive')) |
|
name |
text |
no | ||
config |
jsonb |
no | schema import_source.<kind>.v1; credentials only as secret_ref |
|
state |
text |
no | 'draft' |
CHECK (state IN ('draft','running','paused','completed','failed')); a dry run writes nothing and does not change the state |
cursor |
jsonb |
no | '{}' |
progress per source channel |
stats |
jsonb |
no | '{}' |
counters, without content |
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
started_at |
timestamptz |
yes | ||
finished_at |
timestamptz |
yes |
PK: id. FK: created_by -> principals(id) ON DELETE RESTRICT. Indexes: (state).
Retention: import.
import_items¶
Purpose: an imported item with a unique origin key. Module: importer.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
source_id |
uuid |
no | ||
origin_key |
text |
no | for example message:<source-id>:<part>; stable across runs |
|
item_kind |
text |
no | CHECK (item_kind IN ('space','channel','forum_tag','thread','message','file','user','reaction','pin')) |
|
target_kind |
text |
yes | ||
target_id |
uuid |
yes | created object | |
state |
text |
no | 'pending' |
CHECK (state IN ('pending','posted','skipped','failed')) |
content_sha256 |
bytea |
yes | detects changes at the source | |
error |
text |
yes | without content or secrets | |
created_at |
timestamptz |
no | now() |
|
updated_at |
timestamptz |
no | now() |
PK: id. FK: source_id -> import_sources(id) ON DELETE CASCADE.
Unique: (source_id, origin_key). Indexes: (source_id, state) for resuming;
(target_kind, target_id) for "where does this object come from". Retention: import.
pending is written before the object is created, posted in the same transaction as the target
object. This makes a restart without duplicates possible.
What the import sets differently from normal writes (only through import.apply, only in
channels of the running source; 22-import-migration.md):
| Column | Value on import |
|---|---|
messages.origin, messages.silent |
import, true; no trigger_state, no mentions |
messages.created_at, threads.created_at, threads.last_activity_at |
times from the source |
threads.operator_agent_id |
planned operator from the owner's mapping, not the effective operator of the (read-only) channel |
agent_sessions |
earlier references with is_current = false, started = true, end_reason = 'import', optionally cost_baseline_micros |
threads.unread_floor_seq |
end of the batch; imported content counts as unread for nobody |
4.12 Audit, privacy, status (audit, privacy, status)¶
audit_events¶
Purpose: append-only, hash-chained audit log (B11). Module: audit.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
seq |
bigint |
no | from sequence_counters('audit_seq'), gapless |
|
id |
uuid |
no | ||
occurred_at |
timestamptz |
no | now() |
|
actor_principal_id |
uuid |
yes | NULL for the system and for a server command | |
actor_device_id |
uuid |
yes | ||
actor_session_id |
uuid |
yes | ||
action |
text |
no | action ID from 05-actions.md | |
decision |
text |
no | CHECK (decision IN ('allow','deny','error')) |
|
deny_reason |
text |
yes | reason code from the closed list in 07-permissions.md, section 4.1, for example no_grant, step_up_required, i1_violation, billing_mode_mismatch, malware_detected |
|
resource_kind |
text |
yes | ||
resource_id |
uuid |
yes | ||
details |
jsonb |
no | '{}' |
schema audit_details.v1 (19-security.md, SEC-AUD-5); never content, never secrets; the only free text is the required reason of an administrative action (reason); an IP only as ip_hmac with ip_key_id (the key changes every privacy.audit_ip_key_period, 20-privacy.md) |
correlation_id |
uuid |
yes | trace ID of the triggering request, as in event_log |
|
prev_hash |
bytea |
no | hash of the row seq - 1; first row 32 zero bytes |
|
hash |
bytea |
no | SHA-256(prev_hash || canonical_json(row without hash)); canonical_json is JCS per RFC 8785 |
PK: seq. FK: none (the audit log outlives everything; IDs remain evidence). Unique: id; hash.
Indexes: (actor_principal_id, occurred_at DESC), (resource_kind, resource_id), (action,
occurred_at DESC) for the filterable log view; BRIN (occurred_at) for retention.
Retention: audit.
Permissions: The application role has only INSERT and SELECT; UPDATE and DELETE are
blocked by REVOKE and a trigger. Truncation at the end of the period runs through a separate
maintenance role and writes an anchor entry (last deleted seq and hash) so that the chain
stays verifiable. deny_reason deliberately has no CHECK: an audit entry must never fail
because of a new code. A test against 07-permissions.md keeps the list closed.
audit_checkpoints¶
Purpose: signed checkpoints of the hash chain that are also stored outside the database
(ADR 0021, 19-security.md). Module: audit.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
seq |
bigint |
no | last covered audit_events.seq |
|
hash |
bytea |
no | audit_events.hash of this row |
|
previous_checkpoint_hash |
bytea |
yes | SHA-256 of the canonical payload of the previous checkpoint; NULL for the first one | |
signature |
bytea |
no | the complete compact JWS (UTF-8) over the payload {instance_id, seq, hash, occurred_at, previous_checkpoint_hash} per JCS; occurred_at is the time of the covered row seq; header and format in 19-security.md, section 12 |
|
kid |
text |
no | server key with purpose audit_checkpoint |
|
created_at |
timestamptz |
no | now() |
|
exported_at |
timestamptz |
yes | stored at audit.checkpoint_sink |
PK: seq. FK: kid -> server_signing_keys(kid) ON DELETE RESTRICT. Indexes: (exported_at)
WHERE exported_at IS NULL for the export. Retention: audit. Permissions as for audit_events;
in addition the application role may set exported_at once (column grant UPDATE (exported_at)
and trigger, M1-50).
audit_ip_keys¶
Purpose: one HMAC key per period privacy.audit_ip_key_period for the IP pseudonyms in the
audit log (audit_events.details.ip_hmac, 20-privacy.md section 4.5).
Module: audit.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7; appears as ip_key_id in audit_events.details |
|
valid_from |
timestamptz |
no | start of the period, aligned to privacy.audit_ip_key_period |
|
valid_until |
timestamptz |
no | valid_from + privacy.audit_ip_key_period |
|
key_ciphertext |
bytea |
yes | 32 random bytes, AEAD-encrypted with the key from key_ref; NULL after destruction |
|
key_ref |
text |
no | reference to the encryption key in the secret provider (privacy.audit_ip_kek_ref at creation time) |
|
created_at |
timestamptz |
no | now() |
|
destroyed_at |
timestamptz |
yes | set as soon as valid_until + privacy.audit_ip_key_retention has passed |
PK: id. FK: none. Unique: valid_from. Checks: valid_until > valid_from;
(key_ciphertext IS NULL) = (destroyed_at IS NOT NULL). Indexes: covered by PK and Unique.
Retention: audit (the row stays as evidence, only the key is destroyed).
Permissions: The application role may SELECT, INSERT and UPDATE only on key_ciphertext and
destroyed_at. The server creates the row of a period when it is first needed
(ON CONFLICT (valid_from) DO NOTHING, then read again). Like the TOTP secret, the key is
an exception to principle 9: it must be destructible, but the file driver of the secret provider
cannot create keys per period; therefore it is stored encrypted in the database, and the key for it
only in the secret provider.
gdpr_requests¶
Purpose: privacy requests (export, deletion, rectification) as jobs. Module: privacy.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
subject_user_id |
uuid |
no | data subject | |
kind |
text |
no | CHECK (kind IN ('export','deletion','rectification')) |
|
state |
text |
no | 'requested' |
CHECK (state IN ('requested','approved','running','completed','failed','rejected','cancelled')) |
requested_by |
uuid |
no | ||
approved_by |
uuid |
yes | NULL with approved means: approved by the system (export of one's own data with privacy.export_requires_approval = false) |
|
reason |
text |
no | ||
due_at |
timestamptz |
no | statutory deadline per 20-privacy.md | |
result_file_id |
uuid |
yes | export package | |
result_expires_at |
timestamptz |
yes | ||
progress |
jsonb |
no | '{}' |
steps and counters, without content |
error |
text |
yes | ||
created_at |
timestamptz |
no | now() |
|
completed_at |
timestamptz |
yes |
PK: id. FK: subject_user_id -> users(principal_id) ON DELETE RESTRICT; requested_by,
approved_by -> principals(id) ON DELETE RESTRICT; result_file_id -> files(id) ON
DELETE SET NULL. Unique: partial index (subject_user_id, kind) WHERE state IN ('requested',
'approved','running'). Indexes: (state, due_at) for the job worker and deadline warnings.
Retention: privacy_job.
deletion_journal_state¶
Purpose: the numbering of the deletion journal outside the database (20-privacy.md,
section 4.6). The object storage port has no listing, so journal objects carry dense numbers, and
this single row says which number comes next and which is the lowest one not yet removed. Module:
privacy.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
smallint |
no | 1 |
CHECK (id = 1): exactly one row |
next_number |
bigint |
no | 1 |
number of the next journal object; moves in the transaction that moves the cursor privacy.journal |
low_number |
bigint |
no | 1 |
lowest number whose object the retention has not removed yet; the retention removes an object only when it is older than privacy.deletion_journal_retention and its number is below the deletion_journal.next_number of every backup that is still kept, so a restore of any kept backup finds its first object |
updated_at |
timestamptz |
no | now() |
PK: id. FK: none. Indexes: via the PK. Retention: config. After a restore, bothy restore
finalize reads the journal from the restored next_number up to the first missing number and
stores the number after the last object found (R-DM-52).
status_components¶
Purpose: components of the status page. Module: status.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
name |
text |
no | for example "Messages", "Push", "Agent Support" | |
description |
text |
no | '' |
|
sort_key |
text COLLATE "C" |
no | ||
state |
text |
no | 'operational' |
CHECK (state IN ('operational','degraded','partial_outage','major_outage','maintenance')) |
health_source |
text |
yes | key of an automatic check; NULL = manual only | |
agent_id |
uuid |
yes | component for an agent (presence) | |
updated_at |
timestamptz |
no | now() |
PK: id. FK: agent_id -> agents(principal_id) ON DELETE SET NULL. Unique: lower(name).
Indexes: (sort_key). Retention: ops.
status_incidents¶
Purpose: incidents and maintenance windows. Module: status.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
title |
text |
no | ||
kind |
text |
no | 'incident' |
CHECK (kind IN ('incident','maintenance')) |
impact |
text |
no | CHECK (impact IN ('none','minor','major','critical')) |
|
state |
text |
no | 'investigating' |
CHECK (state IN ('scheduled','investigating','identified','monitoring','resolved')) |
started_at |
timestamptz |
no | ||
resolved_at |
timestamptz |
yes | ||
created_by |
uuid |
yes | NULL when created automatically | |
hidden |
boolean |
no | false |
true once its last component was the component of an agent and is gone; shown to nobody then (D-M7-09-5) |
PK: id. FK: created_by -> principals(id) ON DELETE RESTRICT. Indexes: (state,
started_at DESC). Retention: ops.
status_incident_updates¶
Purpose: history of an incident. Module: status.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
incident_id |
uuid |
no | ||
state |
text |
no | as status_incidents.state |
|
body_md |
text |
no | ||
created_by |
uuid |
yes | ||
created_at |
timestamptz |
no | now() |
PK: id. FK: incident_id -> status_incidents(id) ON DELETE CASCADE; created_by ->
principals(id) ON DELETE RESTRICT. Indexes: (incident_id, created_at). Retention: ops.
status_incident_components¶
Purpose: affected components of an incident. Module: status.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
incident_id |
uuid |
no | ||
component_id |
uuid |
no |
PK: (incident_id, component_id). FK: incident_id -> status_incidents(id) ON DELETE
CASCADE; component_id -> status_components(id) ON DELETE CASCADE. Indexes:
(component_id). Retention: ops.
backup_runs¶
Purpose: runs of the backup and of the automatic restore test, with the result per check
(16-operations.md, section 8). The admin console, the status page and alerts read
them. Module: status. Written by the program bothy-backupd through the port of the module
status with a separate database role that may write only this table.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | ||
kind |
text |
no | CHECK (kind IN ('pg_full','pg_diff','logical','objects','restore_test')) |
|
state |
text |
no | 'running' |
CHECK (state IN ('running','succeeded','failed')) |
started_at |
timestamptz |
no | now() |
|
finished_at |
timestamptz |
yes | ||
size_bytes |
bigint |
yes | ||
manifest |
jsonb |
no | '{}' |
schema backup_manifest.v1: row count per table, head_seq, last audit seq and hash, schema and server version, sync_epoch, count and sum of the file sizes; never content |
checks |
jsonb |
no | '{}' |
schema restore_test_checks.v1: result and duration per check (only for restore_test) |
error |
text |
yes | code without content or secrets |
PK: id. FK: none. Checks: (state = 'running') = (finished_at IS NULL). Indexes: (kind,
started_at DESC) for "last successful run per kind". Retention: ops.
4.13 Technical tables¶
These tables belong to no domain module; the respective tool defines their schema.
| Tables | Owner | Schema | Retention | Reason |
|---|---|---|---|---|
River (river_migration, river_job, river_leader, river_queue and others) |
internal/platform/jobs |
own schema jobs.schema, migrated with rivermigrate |
transient |
job arguments contain only IDs (02-architecture.md, section 12) |
| Version table of goose | bothy migrate |
schema of the domain tables | config |
state of the migrations; instance.schema_version shows it |
Domain modules never read or write these tables directly.
4.14 Terminal (terminal)¶
Normative together with 24-terminal.md. Milestone M2.
hosts¶
Purpose: a connected host with the host service, its public keys, its declaration of the
LLM login (I6) and its reported state. Module: terminal.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7; host ID in the prologue and in aud of the terminal session token |
|
name |
text |
no | not empty, at most host.name_max_chars characters, without control and bidi characters |
|
state |
text |
no | 'pairing' |
CHECK (state IN ('pairing','confirming','active','disabled','retired')) (24, section 3.1) |
platform |
text |
yes | CHECK (platform IN ('macos','linux','windows')); reported at host.pair |
|
assigned_user_id |
uuid |
yes | host assignee (SR10); NULL means owner only | |
dev_environment_id |
uuid |
yes | dev environment that this host is (milestone "Multi-user dev environment") | |
llm_credentials |
text |
no | 'none' |
CHECK (llm_credentials IN ('none','personal','organization')) (24, section 7.2) |
credential_holder_id |
uuid |
yes | holder of the login for personal (I6) |
|
llm_credentials_declared_by |
uuid |
yes | owner who made the last declaration | |
llm_credentials_declared_at |
timestamptz |
yes | ||
declared_server_host |
boolean |
no | false |
the owner's declaration: the server runs on this host (24, section 2.3) |
pairing_code_hash |
bytea |
yes | HMAC-SHA-256 of the pairing code (bothy_hpc_...); set exactly when state = 'pairing' |
|
pairing_expires_at |
timestamptz |
yes | terminal.pairing_code_ttl after issuing |
|
noise_public_key |
bytea |
yes | X25519, exactly 32 bytes (CHECK (octet_length(noise_public_key) = 32)) |
|
auth_public_jwk |
jsonb |
yes | Ed25519 as JWK (kty = OKP, crv = Ed25519), without the private part |
|
auth_jkt |
text |
yes | JWK thumbprint (RFC 7638) of the authentication key | |
key_fingerprint |
text |
yes | 24, section 4.3 | |
key_generation |
integer |
no | 0 |
increases by one with every pairing |
fingerprint_failures |
integer |
no | 0 |
failed attempts of the current confirmation |
paired_at |
timestamptz |
yes | last successful host.pair |
|
confirmed_by |
uuid |
yes | owner who confirmed the fingerprint | |
confirmed_at |
timestamptz |
yes | ||
reported_profiles |
jsonb |
no | '[]' |
schema host_profiles.v1: per profile name, display_name, kind (shell, claude, command); never command lines, paths or environment |
reported_session_users |
jsonb |
no | '[]' |
schema host_reported_session_users.v1: per name from host_service.session_users, os_user and workdirs, the display names of its working folders of the profile claude from host_service.claude_workdirs after HD7 (a folder shared by two session users is missing from both lists), in their order; the host service resolves wdi in exactly this list; reported at host.pair and with every report (24, section 2.2); never paths |
reported_doctor |
jsonb |
no | '[]' |
schema host_doctor.v1: list with result and code per check HD1 to HD11 (24, section 2.2); per account that the rule for names from 24, section 2.2 names, a separate entry with its name (session_user): for HD6, HD10 (wrong peer) and HD11 the affected session user, for HD9 every former session user with a readable home, for other users an entry without a name; check and name together are unique; never content, paths or values |
reported_is_server_host |
boolean |
yes | report of the host service (24, section 2.3) | |
reported_require_local_confirm |
boolean |
yes | report whether host_service.require_local_confirm is on |
|
reported_os_version |
text |
yes | ||
reported_host_version |
text |
yes | SemVer of the host service | |
last_seen_at |
timestamptz |
yes | coarse, at most once per identity.last_active_granularity |
|
created_by |
uuid |
no | ||
created_at |
timestamptz |
no | now() |
|
disabled_at |
timestamptz |
yes | ||
disabled_reason |
text |
yes | CHECK (disabled_reason IN ('owner','duplicate_connection')); the server sets duplicate_connection on a second live control connection (24, section 3.4) |
|
retired_at |
timestamptz |
yes | ||
version |
bigint |
no | 1 |
optimistic lock |
PK: id. FK: assigned_user_id, credential_holder_id -> users(principal_id) ON DELETE
RESTRICT; dev_environment_id -> dev_environments(id) ON DELETE RESTRICT;
llm_credentials_declared_by, confirmed_by, created_by -> principals(id) ON DELETE RESTRICT.
Checks: (llm_credentials = 'personal') = (credential_holder_id IS NOT NULL);
(state = 'pairing') = (pairing_code_hash IS NOT NULL); state NOT IN ('confirming','active',
'disabled') OR (noise_public_key IS NOT NULL AND auth_public_jwk IS NOT NULL AND auth_jkt IS NOT
NULL AND key_fingerprint IS NOT NULL); state NOT IN ('active','disabled') OR confirmed_at IS NOT
NULL; (state = 'retired') = (retired_at IS NOT NULL); (state = 'disabled') = (disabled_reason IS
NOT NULL).
Unique: auth_jkt as a partial index WHERE auth_jkt IS NOT NULL; pairing_code_hash as a partial
index WHERE pairing_code_hash IS NOT NULL; partial index lower(name) WHERE state <> 'retired'.
Indexes: (assigned_user_id) WHERE state = 'active' for SR10; (credential_holder_id) WHERE
llm_credentials = 'personal' for I6; (pairing_expires_at) WHERE state = 'pairing' for the
cleanup job terminal.sweep (02-architecture.md, section 12.3). Retention: security.
The server keeps the connection state (connected, disconnected) only in memory; it appears as the
ephemeral event host.presence_changed (05).
host_session_users¶
Purpose: assignment of a person to their own session user (operating system user) on
a host, ruling T12 (24-terminal.md, section 2.2). A session user
belongs to at most one person, also after the assignment has ended. Module: terminal.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7 | |
host_id |
uuid |
no | ||
user_id |
uuid |
no | person who owns the session user | |
os_user |
text |
no | name of the session user as in host_service.session_users; not empty, at most host.os_user_max_chars characters, only letters, digits, _, . and - (CHECK (os_user ~ '^[A-Za-z0-9_][A-Za-z0-9_.-]*$')); never a path |
|
created_by |
uuid |
no | owner who made the assignment | |
created_at |
timestamptz |
no | now() |
|
revoked_by |
uuid |
yes | owner who ended the assignment | |
revoked_at |
timestamptz |
yes | end of the assignment; the row stays, the name stays taken for this host |
PK: id. FK: host_id -> hosts(id) ON DELETE RESTRICT; user_id -> users(principal_id) ON
DELETE RESTRICT; created_by, revoked_by -> principals(id) ON DELETE RESTRICT.
Checks: (revoked_at IS NULL) = (revoked_by IS NULL). A rule of the application, not of the
database, because the report may change later: on creation, os_user must be listed in
hosts.reported_session_users; otherwise host.manage rejects with 400 validation_failed
(errors[].code = session_user_not_on_host, 24 section 2.2).
Unique: (id, host_id, user_id) as the target of the composite foreign key of
terminal_sessions; (host_id, lower(os_user)) across all rows, including ended ones (never
shared, not even over time; operating system names are case-insensitive on macOS and Windows);
(host_id, user_id) WHERE revoked_at IS NULL (at most one valid assignment per person and
host). A violation of either of the last two yields 409 conflict with reason:
session_user_taken.
Indexes: (user_id) WHERE revoked_at IS NULL for ending assignments when the person is disabled.
Retention: security (period retention.security_after_end after retired_at of the host).
Rows are never deleted as long as the host is not retired; otherwise the name would become free again.
terminal_sessions¶
Purpose: metadata of a terminal session. Never content, never commands. Module: terminal.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
id |
uuid |
no | UUIDv7; session ID in the prologue and in sid of the token |
|
host_id |
uuid |
no | ||
user_id |
uuid |
no | person of the session; only people | |
host_session_user_id |
uuid |
no | session user of the person on this host at opening (SR12); source of osu in every token of the session |
|
opened_by_device_id |
uuid |
no | device at opening | |
device_id |
uuid |
no | device of the last opening or the last reattach | |
auth_session_id |
uuid |
no | login session of the last opening or the last reattach; its revocation ends the session | |
profile |
text |
no | name of the profile from hosts.reported_profiles |
|
state |
text |
no | 'opening' |
CHECK (state IN ('opening','active','detached','closed')) (24, section 6.6) |
token_jti |
uuid |
no | jti of the currently valid terminal session token; older tokens are worthless |
|
key_generation |
integer |
no | hosts.key_generation at opening |
|
opened_at |
timestamptz |
no | now() |
start; basis of terminal.max_session_duration |
started_at |
timestamptz |
yes | PTY started (session.started of the host service) |
|
detached_at |
timestamptz |
yes | last detach; NULL as long as a device is connected | |
last_input_at |
timestamptz |
yes | coarse, from the relay; basis of the server's idle check | |
closed_at |
timestamptz |
yes | ||
closed_by |
uuid |
yes | person for client_closed and closed_by_owner; otherwise NULL |
|
close_reason |
text |
yes | CHECK (close_reason IN ('client_closed','closed_by_owner','process_exited','idle_timeout','max_duration','detach_expired','access_revoked','i6_violation','host_disabled','host_retired','host_rekeyed','host_restarted','user_disabled','device_revoked','session_revoked','logout','open_failed','host_policy_violation')) (24, section 10.3) |
|
close_detail |
text |
yes | code from the closed list in 24, section 10.3; never free text | |
exit_code |
integer |
yes | exit code of the process, if reported | |
bytes_in |
bigint |
no | 0 |
bytes of the transport messages from the device, counted in the relay |
bytes_out |
bigint |
no | 0 |
bytes of the transport messages to the device, counted in the relay |
version |
bigint |
no | 1 |
PK: id. FK: host_id -> hosts(id) ON DELETE RESTRICT; user_id -> users(principal_id) ON
DELETE RESTRICT; (host_session_user_id, host_id, user_id) -> host_session_users(id, host_id,
user_id) ON DELETE RESTRICT (this way the database holds that the session user belongs to the host
and the person of the session; a bug in the code cannot write an osu of another person into a
token);
opened_by_device_id, device_id -> devices(id) ON DELETE RESTRICT;
auth_session_id -> auth_sessions(id) ON DELETE RESTRICT; closed_by -> principals(id) ON
DELETE RESTRICT.
Checks: (state = 'closed') = (closed_at IS NOT NULL); (state = 'closed') = (close_reason IS NOT
NULL); close_detail IS NULL OR state = 'closed'; (state = 'detached') = (detached_at IS NOT
NULL).
Unique: token_jti. Indexes: (user_id) WHERE state <> 'closed' for terminal.max_sessions_per_person
and for ending sessions on revocation; (host_id) WHERE state <> 'closed'; (auth_session_id) WHERE state <>
'closed'; (device_id) WHERE state <> 'closed'; BRIN (opened_at) for retention.
Retention: security (period retention.security_after_end after closed_at).
4.15 Statistics (stats)¶
Normative together with 25-visualization.md. Milestone M6.
stats_minutes¶
Purpose: minute aggregates of the server's own measurement (usage, runs, costs, SLI counters) for
the live view, the timeline and the SLO traffic light (25-visualization.md,
section 3). No people, no content. Module: stats. Every row is an aggregate without an identity
of its own; therefore there is no column id and no primary key, but a unique key
over all dimensions.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
bucket_start |
timestamptz |
no | start of the minute; CHECK (bucket_start = date_trunc('minute', bucket_start)); partition key |
|
metric |
text |
no | CHECK (metric IN ('messages_created','threads_created','runs_started','runs_finished','tool_calls','approval_cards','cost','invocations_denied','billing_mode_switches','catchups','resyncs','slow_consumer_closes','sli.api_availability','sli.delivery_latency','sli.reconnect','sli.push_acceptance','sli.run_start','sli.turn_duration')) (25, section 3.2) |
|
channel_id |
uuid |
yes | channel of the measurement; NULL for values of the whole instance | |
agent_id |
uuid |
yes | agent of the measurement, where the metric has one | |
dimension |
text |
no | '' |
code from the closed list per metric (25, section 3.2), schema stats_dimension.v1; never free text, never tool names |
currency |
text |
yes | ISO 4217; set exactly when metric = 'cost' |
|
count |
bigint |
no | 0 |
number of events |
sum |
bigint |
no | 0 |
sum of the values: milliseconds for durations, micro-units for cost, otherwise 0 |
good |
bigint |
no | 0 |
good events of an SLI; 0 for other metrics |
updated_at |
timestamptz |
no | now() |
last flush into this row |
PK: none. FK: none (evidence like event_log; an archived channel keeps its numbers).
Checks: count >= 0 AND sum >= 0 AND good >= 0; good <= count; (currency IS NOT NULL) = (metric
= 'cost'); metric LIKE 'sli.%' OR good = 0.
Unique: (bucket_start, metric, channel_id, agent_id, dimension, currency) NULLS NOT DISTINCT
(target of the additive upsert, 25 section 3.4). Indexes: (metric, bucket_start) for the timeline
and the traffic light; (channel_id, bucket_start) WHERE channel_id IS NOT NULL for the permission
filter.
Partitioning: by bucket_start per day (PARTITION BY RANGE); the job stats.retention creates
partitions in advance and detaches expired ones.
Retention: stats (period retention.stats_minutes).
4.16 Gateway (gateway)¶
gateway_presence¶
Purpose: the shared presence state of the counted live connections of humans, so that a worker
and other api nodes see who is connected where (13-push-notifications.md,
section 12 point 4; 02-architecture.md, section 16). Module: gateway.
UNLOGGED: the contents are lost after a crash of the database, and the nodes write them again.
| Field | Type | Null | Default | Rules / notes |
|---|---|---|---|---|
gateway_session_id |
uuid |
no | gateway session of the connection | |
node_id |
uuid |
no | process instance of the api node that holds the connection; new at every start |
|
principal_id |
uuid |
no | the human of the connection | |
device_id |
uuid |
no | the device of the connection | |
focus_thread_ids |
uuid[] |
no | '{}' |
threads in focus after the checks of the gateway (op focus, 04-sync-protocol.md, section 6.2); only thread_ids, never background_thread_ids (addendum of 2026-10-24, R-SY-27) |
idle |
boolean |
no | false |
the client reported idle |
updated_at |
timestamptz |
no | now() |
last write or refresh by the node |
PK: gateway_session_id. FK: none (a row lives only as long as its connection; it is not
evidence). Indexes: (principal_id); (node_id). Retention: transient (the node deletes the row
when the connection ends and all its rows on an orderly shutdown; readers ignore rows whose
updated_at is older than cluster.presence_ttl, and the gateway deletes them). Only processes in
role api, and in role all with cluster.mode = multi, write the table; a process in role all
with cluster.mode = single keeps the state in memory (R-DM-51).
5. Search vectors¶
Full-text search uses PostgreSQL tsvector as stored, generated columns. This keeps the
vector always consistent with the content, without a trigger and without a job. Every vector
contains stems of the language and unchanged words (simple) with their own weight; one GIN query
is enough (14-search.md, section 3.2).
| Table | Column | Expression |
|---|---|---|
messages |
search_vector |
GENERATED ALWAYS AS (setweight(to_tsvector(search_language, content_md), 'B') \|\| setweight(to_tsvector('simple'::regconfig, content_md), 'D')) STORED |
threads |
title_search |
GENERATED ALWAYS AS (setweight(to_tsvector(search_language, title), 'A') \|\| setweight(to_tsvector('simple'::regconfig, title), 'A')) STORED |
files |
search_vector |
GENERATED ALWAYS AS (setweight(to_tsvector('simple', original_name), 'A') \|\| setweight(to_tsvector('simple', coalesce(transcript_text, '')), 'B')) STORED |
file_text_extracts |
search_vector |
GENERATED ALWAYS AS (setweight(to_tsvector(search_language, coalesce(text_content, '')), 'C') \|\| setweight(to_tsvector('simple'::regconfig, coalesce(text_content, '')), 'D')) STORED |
to_tsvector(regconfig, text) is IMMUTABLE and may therefore appear in generated columns.
PostgreSQL 18 creates generated columns without STORED as virtual columns; every migration
writes STORED explicitly, and a migration lint checks this.
Every column has a GIN index. Permission filter: every search query is combined with
channel_id = ANY($visible); authz builds the list per request (P2, 07-permissions.md section 7.4). Deleted
messages have empty content and therefore an empty vector. Language choice, ranking,
highlighting and possible further indexes (for example trigrams) are defined in 14-search.md;
a change to an expression is a migration that regenerates the column, and it is carried over into
this table.
6. Transaction rules¶
6.1 Global event sequence¶
The exact SQL pattern and the rationale are in
04-sync-protocol.md, section 3. In short: the row
sequence_counters('event_seq') is locked and incremented by UPDATE ...
RETURNING as the last step before the commit; the events get these numbers. The same applies to
audit_seq.
6.2 Thread sequence¶
UPDATE threads SET last_message_seq = last_message_seq + 1, last_activity_at = now(),
message_count = message_count + 1, version = version + 1 WHERE id = $1 RETURNING
last_message_seq. The row lock on the thread orders all messages of a thread
strictly. A rolled-back transaction also gives back the number; no gaps arise.
6.3 Lock order¶
Every write transaction takes its locks in this order; otherwise deadlocks can occur:
sequence_counters('permission_version'), only for permission changes, and then first.- Domain rows; for several rows of the same table in ascending
id. sequence_counters('event_seq').sequence_counters('audit_seq').
Permission changes are therefore fully serialized among each other. This makes the I1 check
(6.4) correct without SERIALIZABLE.
6.4 I1 check¶
In every transaction that increments the permission version, authz computes, after the change
and before the commit, for every affected active agent (principal status active) with
billing_mode = 'personal' the set of
principals with effective level trigger on any target that the agent serves (including
owners, group members, inheritance, unexpired grants). The targets of agent A are:
- channels whose effective operator is A (main thread and new threads),
- channels with at least one thread with
threads.operator_agent_id = A, open or done, even if the channel has another operator by now; a new message reopens a done thread, and there A still triggers (R-DM-12), - the spaces of these channels and every space whose default operator is A, even without a channel;
otherwise
channel.create(not an action with an I1 check) would later make A the operator of a channel on whose space someone else already hastrigger, - the instance.
Guest channels (allow_mentioned_agents) do not count, because an agent with personal is never a
guest agent (05-actions.md, section 5). If the set is not exactly
{credential_holder_id}, the transaction is rolled back with 409 i1_violation (decision
invariant_violation) and the hint "switch to organization first", and an
audit entry with decision = 'deny' is written in a separate transaction. Affected are: all
agents with personal whose served targets lie in the path of the changed grant, and, for
owner and group changes, all agents with personal. Expiry and revocation only shrink the
set; the check runs anyway, because it is cheap.
Consequence: an agent with personal cannot be the operator of a target on which another
principal has trigger. Because every owner has trigger everywhere, personal works only as long
as the credential_holder is the only owner. This is intended (Baseline I1, ruling L22): there is
no owner exception. The guided flow "switch first, then grant" in one transaction
is described in 05-actions.md, rule 1.6 (resolve_i1).
6.5 Redaction on deletion¶
Deleting a message or a file writes in the same transaction: a tombstone, an
event message.deleted, and UPDATE event_log SET payload = <redacted payload>,
redacted_at = now() WHERE resource_kind = 'message' AND resource_id = $1. The envelope fields
stay. Events are therefore append-only in the envelope and redactable in the payload (R-DM-18).
Deleting a file redacts in the same transaction the events with resource_kind = 'file' and
resource_id = the file, in the shape of 04-sync-protocol.md, section 2.2
rule 5 (R-DM-45).
6.6 Notifying the gateway processes¶
Every transaction with new events calls pg_notify('event_log', <highest seq>).
PostgreSQL delivers the notification only after the commit. Every process with the role api
then reads event_log WHERE seq > <own position> ORDER BY seq. No further broker.
With several api nodes (cluster.mode = multi, 02-architecture.md,
section 11.6), a second channel pg_notify('ephemeral', ...) distributes ephemeral events
between the nodes. It carries only IDs and versions (thread, principal, run, agent, host,
terminal session), never content; the receiving node reads content from the database. With one node the channel
is off, because every NOTIFY takes an instance-wide lock at commit.
6.7 Sync epoch and restore¶
Decision: ADR 0059.
A restore from a backup resets the global sequence. Without a mitigation, the new timeline overtakes old cursors after the first writes, and a client would silently skip other events with the same numbers. Therefore:
instance.sync_epochnames the timeline. After every restore,bothy restore finalizesets a new UUIDv7, setsagent_consumer_cursors.acked_seq,webhook_subscriptions.last_delivered_seqandevent_consumer_cursors.last_seqto the newhead_seqand writes an audit entry, all in one transaction.- Clients store the epoch together with the cursor. If it differs, the server requires a resync (04-sync-protocol.md, section 7.5).
- A move without data loss (16-operations.md, section 11) keeps the epoch.
bothy restore finalizealso stores the identity of the restored database ininstance.pg_system_identifierandinstance.pg_timeline_idin the same transaction. A server whose current database identity differs from the stored one does not become ready untilbothy restore finalizehas run, because a restore without it would mix two timelines; a move without data loss stores the new identity without a new epoch (R-DM-50; the readiness reason and the operator switch are in 16-operations.md, section 8.4).
6.8 I6 check¶
Applies to grant.create with target_kind = 'host' and to host.manage when it sets llm_credentials,
credential_holder_id or the state of a host (24-terminal.md,
section 7.3). The transaction first locks permission_version (6.3), then the row in hosts
(SELECT ... FOR UPDATE), writes the change and checks before the commit: with
llm_credentials = 'personal', no valid grant on the host has a subject other than
subject_kind = 'user' with subject_principal_id = credential_holder_id. Otherwise rollback with
409 i6_violation (decision invariant_violation) and an audit entry deny in a separate
transaction. Owners do not count, because I6 removes their implicit authorization on this host.
Group grants are always rejected, because a group can change without a permission check at the
host. On a switch to personal or a change of the credential holder, the same transaction ends all
sessions of other people that are not closed (close_reason = 'i6_violation').
7. ERD¶
The ERDs exist only in ../architecture/ as Mermaid sources with SVG,
one diagram per module. They show every table of sections 4.1 to 4.12 and 4.14 to 4.16 exactly
once with its columns and every foreign key relation except pure log columns. The technical
tables of section 4.13 are left out on purpose. The comparison with this chapter is a gate
(make diagrams, tools/docs/erd_check.py). This chapter stays normative; on a difference,
section 4 applies, and the gate fails.
| Diagram | Modules |
|---|---|
| erd-1-instance-identity.mmd | instance, identity |
| erd-2-permissions-structure.mmd | authz, chat |
| erd-3-agents-apps.mmd | agents, apps |
| erd-4-messages-files.mmd | messaging, files |
| erd-5-runs-costs.mmd | runs, billing |
| erd-6-sync.mmd | sync |
| erd-7-terminal.mmd | terminal |
| erd-8-audit-privacy.mmd | audit, privacy |
| erd-9-import-status-statistics.mmd | importer, status, stats |
| erd-10-notification.mmd | notify, gateway |
8. Decisions of this chapter¶
| No. | Decision | Reason |
|---|---|---|
| R-DM-01 | The application generates UUIDv7; no DB defaults for IDs. | Independent of the PostgreSQL version; clients generate IDs offline. |
| R-DM-02 | Enumerations as text with CHECK. |
Additive migrations without a type lock. |
| R-DM-03 | Shared table principals for person, agent and app, with subtables. |
One foreign key for author, grant subject and audit actor. |
| R-DM-04 | Money as bigint micro-units plus currency. |
Exact and fast. |
| R-DM-05 | Counter rows in one table sequence_counters with fixed names. |
One pattern for events, audit and permission version; fixed lock order. |
| R-DM-06 | Third-party credentials only as secret_ref, tokens only as HMAC. |
B10, I5; a DB dump reveals no usable tokens. |
| R-DM-07 | Groups contain only people. | Agents and apps get grants directly; keeps I1 manageable. |
| R-DM-08 | Agents and apps get at most write, never trigger. |
Bot messages never trigger agents (study B); prevents loops and keeps the trigger set of I1 human. |
| R-DM-09 | Grants are immutable; a change = revocation plus a new grant. | Complete history without a revision table. |
| R-DM-10 | DM channels have no space and no grants; access only through dm_participants. |
Direct messages belong to no space; grants on spaces must not open DMs. |
| R-DM-11 | Every text and DM channel has exactly one thread kind = 'main' as its main thread. |
Every message has a thread; one sequence mechanism for everything. |
| R-DM-12 | threads.operator_agent_id is fixed when a thread (kind = 'thread') is created. |
One agent session per thread; an operator change on the channel does not switch a running session. |
| R-DM-13 | No file deduplication across files. | Deletion is simple; the existence of a hash reveals nothing. |
| R-DM-14 | At most one active run per thread (partial index). | Further messages become follow-ups; no parallel runs on one session. |
| R-DM-15 | event_log is partitioned by seq. |
Retention by detaching whole partitions instead of an expensive DELETE. |
| R-DM-16 | notification_preferences with a polymorphic target without FK. |
Three target types in one table; the application handles the cleanup on archiving. |
| R-DM-17 | Outgoing webhooks only to https hosts from webhook.allowed_hosts. |
The baseline forbids server-side loading of third-party URLs (SSRF); webhooks are allowed only to allowed targets. |
| R-DM-18 | Event payloads are redactable, envelopes are not. | Deletion must remove content; order and catch-up stay intact. |
| R-DM-19 | No materialization of effective permissions in the database. | authz holds a cache per principal and permission version; one truth, no second permission logic in SQL. |
| R-DM-20 | Agents and apps get at most upload as a capability (check in grants). |
approve_actions would mean self-approval, manage granting permissions and reading prompts, export mass extraction; a bot needs none of them (07-permissions.md, section 2.1). |
| R-DM-21 | The main thread has no fixed operator; the effective operator of the channel at the time of triggering applies. | A main thread is not a single conversation; a frozen operator would silence a channel after the switch. |
| R-DM-22 | A file is attached to at most one message (unique index). | Deleting a message deletes its files unambiguously, without reference counting; forwarding creates a new file. |
| R-DM-23 | Cost entries refer to the price rate for provider, model and occurred_at, not to a price row; used price rows are immutable. |
One calculation uses several units; it stays recomputable only with unchanged rows. |
| R-DM-24 | In this milestone, dev environments run only with billing_mode = 'organization'. |
A personal backend policy per environment would need I1 per environment instead of per agent; that is a separate decision of the "dev environment" milestone (ruling of the lead). |
| R-DM-25 | Read-only channels (channels.read_only) as a column, not as a grant. |
Owners have trigger everywhere; only a state on the channel can also keep owners from writing, as the read mirror of an import needs. |
| R-DM-26 | Sync epoch in instance, new after every restore (ADR 0059). |
A cursor is valid only within one timeline; without an epoch, the new timeline silently overtakes old cursors. |
| R-DM-27 | Cursors of the worker consumers in a separate table (event_consumer_cursors) instead of inserting jobs in the write path. |
The write path does not need to know the consequences; a new consumer changes no producer (ADR 0051). |
| R-DM-28 | audit_events.deny_reason without CHECK, the list closed by a test. |
An audit entry must never fail because of a new reason code. |
| R-DM-29 | event_log.actor_kind as a separate column instead of deriving it from actor_principal_id. |
NULL cannot tell system apart from "no actor", and sync must not ask identity for the kind (02, section 4.4). |
| R-DM-30 | The OIDC callback stores only the verified external identity in the login flow (auth_login_flows.external_identity); assignment, linking through an enrollment code and provisioning happen only at completion. |
The authorization_url is not bound to a browser; otherwise anyone who receives it could link their own provider account with someone else's code (login CSRF). Only the app with the client_verifier can complete the flow. |
| R-DM-31 | Challenges for step-up and WebAuthn are kept in auth_challenges instead of as derived values without storage. |
Single use and counted failed attempts thus apply on every node, without depending on synchronized clocks; a table needs only one cleanup job. |
| R-DM-32 | Unread and mention counters are never stored but computed on read. | A stored counter would have to follow every edit, deletion and move and would otherwise silently go stale; mentions and threads already carry everything needed. |
| R-DM-33 | Groups from claims are bound to their provider (groups.provider_id). |
Claim values are unique only within one provider; without the binding, a second provider would determine memberships of the first. |
| R-DM-34 | System rows carry a code (messages.system_code) instead of text. |
Clients show them in their language; a text in the content would be bound to one language and searchable. |
| R-DM-35 | Hosts are a separate target in grants (target_kind = 'host') without a level, with exactly the capability terminal, only for people and groups (checks). |
One grant table for all permissions keeps authz the single truth (B7); levels have no meaning on a host, and agents and apps never open a terminal (24-terminal.md, section 8.1). |
| R-DM-36 | terminal_sessions holds only metadata; content does not exist in the server. |
End-to-end encryption without storage in the server; evidence through audit and metadata (24-terminal.md, section 10.4). |
| R-DM-37 | Consumption of a step-up for the terminal as the column auth_sessions.step_up_consumed_at. |
A confirmation applies to exactly one opening; a column on the session is enough, because the confirmation applies per session anyway (08-identity.md, section 6.3). |
| R-DM-38 | Own measurement as minute aggregates in stats_minutes without a primary key, with a unique key over all dimensions (NULLS NOT DISTINCT), additive upsert and daily partitions. |
Several processes and nodes add into the same row without a coordinator; retention by detaching whole partitions like R-DM-15; the statistics never read from telemetry (R-OB-08, ruling T7). |
| R-DM-39 | Session users per person in host_session_users, the name unique per host across all rows, including ended ones. |
Ruling T12: the credentials of a session user belong to exactly one person; a reassigned name would give the next person the logins and long-running processes of the previous one (24-terminal.md, section 2.2). |
| R-DM-40 | terminal_sessions refers with (host_session_user_id, host_id, user_id) to host_session_users(id, host_id, user_id). |
A foreign key only on id would allow a session user of another host or of another person; the host service would then bind a new name to the wrong person (D-W-66). |
| R-DM-41 | hosts.reported_session_users holds the reported names of the session users with the display names of their working folders. |
The server assigns only names that the host knows, and offers each person only their own working folders (rulings T12 and T13; D-W-62, D-W-63). |
| R-DM-42 | If the server accepts a report agent_session.report with bind_failed, it writes in the same transaction exactly one system line agent_session.resume_failed with the agent of the run as author; system lines do not reopen a done thread. |
09, section 9.5, and 22, section 9, require exactly one system message on fail open, according to R-DM-34 with a code instead of text; the session belongs to the agent, and reopening would write a second system line thread.reopened for the same notice (D-W-104). |
| R-DM-43 | message_attachments carries the copies thread_id and channel_id of its message (channel_id changed along on a move, both without their own foreign key), messages the column has_attachments (created with at least one attachment and not deleted). |
Search filters files by channel and thread of their message and messages by has_file in SQL before the candidate limit (14-search.md, sections 4.3 to 4.5); files must not read messages and messaging must not read message_attachments (principle 12). Attachments arise only with the message and disappear only with its deletion, so a flag that messaging sets on creation and resets on deletion suffices. A foreign key from the copies to threads or channels would only check existence, which message_id with the foreign keys of messages already ensures; that the copies equal the values of the message is ensured by attaching and moving in their transaction (D-W-104). |
| R-DM-44 | A held agent invocation is recorded in runs.invocation_held_until. |
Without a flag runs cannot tell whether the invocation of a waiting run has already been written; the release on files.FileFinalized and the job runs.invocation_hold_timeout serialize on the run row and write it exactly once (04-sync-protocol.md, section 15.3 step 1, R-SY-20, D-K-G6; D-W-104). |
| R-DM-45 | Deleting a file redacts the stored events of resource kind file of that file in the same transaction; file.updated keeps only the members that 04 section 2.2 rule 5 names, with empty name, size zero, a neutral type and no thumbnails. |
20 sections 4.2 and 4.3 and DP-T01 require the name and digest of a deleted file to be gone, and every file.updated carries them; 6.5 named only the resource kind message. The kept members are IDs and states, the redacted payload stays valid against FileUpdatedEvent (D-W-108). |
| R-DM-46 | The job files.purge_objects removes the row of a deleted file after its objects are gone; the detail rows go by ON DELETE CASCADE, principals.avatar_file_id by ON DELETE SET NULL. |
The row keeps original_name, sha256, transcript_text and the search vector, which 20 sections 4.2 and 4.3 require to be gone; nothing references a deleted file, because its attachment row went with the message, so a tombstone would serve nobody and need a second redaction of every content column (D-W-108). |
| R-DM-47 | runs.invocation_held_until is also set while the server transcript of a triggering voice message is pending; the release follows the same rules with voice.transcript_hold_max. |
04 section 15.3 step 1 and R-SY-20 hold agent.invocation until a server transcript is final, and the column already names voice.transcript_hold_max, but its rule named only attachments (D-W-108). |
| R-DM-48 | The ETag of a transcript is derived from its four transcript columns; files gets no version column. |
05 section 4.8 requires If-Match for file.transcript.submit; a derived strong ETag (as for other rows without a version, 06 section 8 point 1) detects a lost update without a new column that every file write would have to raise (D-W-108). |
| R-DM-49 | auth_sessions stores "View as person" of a console session in view_as_user_id and view_as_started_at (console sessions only, both set or both empty); access.view_as from a console session sets them in the transaction of its audit entry, access.view_as.end or the first request after authz.view_as_max_duration clears them with an audit entry of the end. |
07-permissions.md section 9.2 requires state on the console session (target person, start) and operations to start and end it, added to the core contract before PLAN M6-01 builds the console session (D-W-100 extended to M5 and M6). |
| R-DM-50 | instance stores the identity of the database cluster (pg_system_identifier, pg_timeline_id); bothy migrate up fills it while empty, bothy restore finalize replaces it after a restore or a move, and a differing current identity keeps the server not ready (section 6.7). |
ADR 0059 leaves the start lock open; a point-in-time restore raises the timeline and a logical restore changes the system identifier, so both are detected without a value outside the database. |
| R-DM-51 | gateway_presence (module gateway, UNLOGGED) holds the counted live connections of humans with device, focus and idle; processes in role api (and in role all with cluster.mode = multi) write it, the worker reads it through the presence port; rows of a node that stopped refreshing are ignored after cluster.presence_ttl. |
With separate roles or several api nodes, the presence state of one process is not visible to the worker that decides about hints (13, section 12 point 4; RK-37); a table read in the decision's transaction needs no broker, and UNLOGGED keeps the write cost low for data that is worthless after a crash. |
| R-DM-52 | The deletion journal numbers its objects densely in deletion_journal_state (module privacy): the consumer privacy.journal writes object next_number and moves the number with its cursor, the retention removes objects from low_number upward, and bothy restore finalize continues from the restored next_number to the first missing number. |
The object storage port has no listing and no conditional write (core S3, ADR 0010); dense numbers make the journal readable and deletable without either. |
| R-DM-53 | runs.last_progress_at holds the server time of the last start or stored patch, and the server keeps one summary step per parent in run_steps (addendum of 2026-10-24, run visualization). |
The stall notice needs a server fact instead of a client clock; merging old steps at the root would flatten the run graph (09-agents.md, sections 5.3, 5.5 and 5.6). |
9. Configuration values of this chapter¶
The values are defaults. Other chapters refer only to the name.
| Name | Default | Meaning |
|---|---|---|
principal.handle_max_chars |
32 |
length of a handle |
principal.display_name_max_chars |
64 |
length of a display name in Unicode characters |
branding.product_name_max_chars |
64 |
length of the product name in the branding (instance_settings, branding.product_name) in Unicode characters |
group.name_max_chars |
100 |
length of a group name in Unicode characters |
group.description_max_chars |
500 |
length of a group description in Unicode characters |
host.name_max_chars |
100 |
length of a host name in Unicode characters |
host.os_user_max_chars |
64 |
length of the name of a session user in host_session_users.os_user |
space.name_max_chars |
100 |
length of a space name in Unicode characters |
channel.name_max_chars |
100 |
length of a channel name in Unicode characters |
channel.topic_max_chars |
1024 |
length of a channel topic in Unicode characters |
forum_tag.name_max_chars |
50 |
length of a tag name in Unicode characters |
identity.last_active_granularity |
5m |
last_active_at or last_used_at is written at most this often |
auth.session_max_age |
30d |
absolute end of a login session |
auth.refresh_token_ttl |
14d |
lifetime of a refresh token |
auth.step_up_max_age |
5m |
a step-up confirmation counts as fresh for this long |
auth.refresh_reuse_grace |
30s |
grace period for the immediately preceding refresh generation with the same DPoP key |
agent.heartbeat_persist_interval |
60s |
heartbeat fields are stored at most this often |
channel.history_backfill_limit_default |
20 |
default for history_backfill_limit |
channel.history_backfill_limit_max |
200 |
upper limit for history_backfill_limit |
channel.prompt_max_bytes |
32768 |
size of a channel prompt |
thread.title_max_chars |
200 |
length of a thread title |
thread.max_tags |
5 |
forum tags per thread |
message.max_content_bytes |
262144 |
size of a message (long answers without splitting) |
message.max_attachments |
10 |
attachments per message |
message.max_mentions |
50 |
distinct mention handles per message (content format, messages) |
reaction.emoji_max_bytes |
64 |
largest length of a reaction emoji in bytes (otherwise a grapheme cluster has no limit) |
reaction.max_distinct_per_message |
20 |
distinct emoji per message; limits Message.reactions and the payload of the events |
pin.max_per_thread |
50 |
pins per thread; listPins is not paginated |
template.name_max_chars |
100 |
length of a template name in Unicode characters |
template.max_per_scope |
100 |
templates per person or per channel; listTemplates is not paginated |
file.max_upload_bytes |
104857600 |
size of a file |
run.excerpt_max_chars |
500 |
length of last_text_excerpt |
run.step_detail_max_bytes |
16384 |
size of run_steps.detail |
run.max_steps_persisted |
2000 |
stored steps per run |
run.stall_after |
10m |
without progress: stall notice |
component.default_ttl |
24h |
expiry of a component without its own value |
push.max_consecutive_failures |
5 |
after that, a push registration is deactivated |
webhook.allowed_hosts |
empty | allowed target hosts for outgoing webhooks |
webhook.max_consecutive_failures |
20 |
after that, a subscription is deactivated |
webhook.retry_backoff_base |
10s |
base of the backoff |
webhook.retry_backoff_cap |
1h |
largest interval |
sync.event_log_partition_size |
1000000 |
sequence numbers per partition |