Skip to content

Data model and durability

This note explains the parts of kata's data model that are easy to get wrong and hard to reconstruct from the schema alone: how identity works, why the audit log is the spine of the system, and the cursor invariant that lets a client miss a purge without missing data. It complements the architecture principles and the federation design notes, which build on the event model described here.

Two identities per issue

Every issue (and project) has a stable ULID uid that never changes. The uid is the authoritative identity: it survives schema cutovers, it is unique across instances, and it is what event payloads and links reference. Agents and humans, however, work with a short_id — derived from the lowercased suffix of the ULID and extended only as far as needed to stay unique within the project. The short ID is a display label, not the identity; legacy numeric references no longer resolve. See issue identity for the reference forms.

Keeping the two separate is what makes the rest of the model portable. Links store both endpoint UIDs and the integer foreign keys used for hot-path joins, and database triggers reject any drift between them, so the UID view and the local-id view can never disagree. When an issue is purged, its short_id is retained in a tombstone so external references stay meaningful and a future issue whose ULID suffix would collide is steered to a different short ID.

The event log is the spine

State changes are not just stored; they are recorded. Three rules follow:

  • The events table is append-only and is the authoritative record of every state change. Each event carries the actor, a stable event uid, the originating instance UID, and a payload with the field-level diff.
  • The comments table stores current comment bodies. Comment bodies can be edited in place for redaction while preserving UID, author, and creation time; there is no comment delete short of issue purge.
  • Issues themselves are mutable current-state rows, but every mutation emits an event, so the current row is always reconstructable from history.

This is what makes kata auditable by construction, and it is the foundation the federation fold engine later relies on to converge replicas.

The destructive ladder

Removal is staged so that the reversible and irreversible steps are clearly separated:

  1. Close — the issue is closed but fully visible.
  2. Delete (--force) — sets deleted_at; the issue is hidden but recoverable. Emits issue.soft_deleted.
  3. Restore — clears deleted_at. Emits issue.restored.
  4. Purge (--force --confirm) — irreversible. In one transaction it cascade-deletes the issue's comments, links, labels, and events, then writes a purge_log row that is intentionally outside the cascade so the audit of the deletion survives the data it describes.

Both destructive verbs require an exact confirmation string (or an interactive prompt with a TTY), so neither can fire by accident or from a careless script.

The purge cursor reservation invariant

Events are broadcast over SSE only after the database commit, and each event's monotonic id doubles as the stream cursor. Purge complicates this: deleting events leaves a hole in the id sequence, and a client reconnecting with an old cursor must not silently skip the fact that history changed underneath it.

kata solves this by reserving a synthetic cursor at purge time. In the same transaction that deletes events, the daemon advances the events table's autoincrement sequence by one without inserting a row, and stores that reserved value as purge_log.purge_reset_after_event_id. The reserved value is therefore strictly greater than every real event id that existed at the moment of the purge, and the next genuine event continues from reserved + 1, so the synthetic cursor is unique and can never be assigned to a real event.

On reconnect (over SSE or polling), the daemon computes the maximum purge_reset_after_event_id greater than the client's cursor. If one exists, the client's cursor predates a purge: the daemon sends a single sync.reset_required control signal carrying that maximum value and the client drops its cache, refetches state, and adopts the reserved id as its new cursor. Using the maximum collapses any number of accumulated purge gaps into one reset.

Two details make this correct rather than merely plausible:

  • Strict > is exactly right. Because the reserved value is strictly greater than every event id that existed at purge time, even a client sitting at the maximum real id when the purge happened is still below the reserved value and is correctly reset. There is no off-by-one and no need for >=.
  • Resets are scoped per project. A per-project stream adds the project predicate when searching for reset boundaries, so a purge in one project cannot invalidate a client following another. The cross-project stream omits the predicate and sees every boundary.

Polling and streaming stay in parity

The non-streaming event endpoints apply the identical purge-invalidation rule. A polling response sets reset_required: true with the new baseline after_id when the caller's cursor has fallen inside a purged range, and an empty event list. An agent that polls therefore cannot silently miss events any more than a TUI tailing the SSE stream can: both paths honor the same reset boundary, so the choice between polling and streaming is a performance decision, never a correctness one.

Idempotency and duplicate avoidance

Two independent mechanisms keep agents from creating duplicate issues.

Idempotency keys. A create request may carry an idempotency key. kata stores a fingerprint alongside it that covers every creation-affecting field — title, body, owner, labels, and initial links, each canonicalized so cosmetic whitespace differences do not matter. Replaying the same key with the same fingerprint returns the existing issue and emits no new event; replaying the same key with a different fingerprint is an error rather than a silent reuse, because the inputs materially disagree. This means a retried request is safe but a mistaken reuse is caught.

Look-alike soft-block. Independently, create runs a full-text and similarity search and refuses when an existing issue is too close, returning the candidates so the caller can comment instead. This check is bypassable with force_new.

Where the two interact, idempotency wins: an idempotent reuse never emits a duplicate even when force_new is set. The retry-safety guarantee takes precedence over the force-new escape hatch.

Schema evolution by JSONL cutover

kata does not evolve its schema with in-place table-rebuild migrations. Instead it exports the current database to JSONL exactly as stored, imports that JSONL into a fresh database at the binary's current schema, applies deterministic fill rules for anything an older export version lacked, validates, and atomically swaps the database files into place.

This choice does several jobs at once. The JSONL export is git-friendly and doubles as the supported backup and restore format. Importing into a fresh database sidesteps the fragility of rewriting live tables. And the fill rules are deterministic — backfilled identifiers are derived from a stable seed rather than generated randomly — so the same legacy record produces the same UID on every machine and every re-run, which is precisely what lets independently upgraded instances later federate without identity conflicts.

A single instance_uid written at first initialization identifies the installation, and every event and purge-log row carries its own uid plus the origin_instance_uid that produced it. That provenance is recorded from day one, in single-user installs that may never federate, so the audit log is sync-ready without a later disruptive migration.

Storage backends

The durable domain contract lives in internal/db as db.Storage: domain types, parameter/result structs, sentinel errors, and backend-neutral helpers. Concrete SQL implementations live beside it (internal/db/sqlitestore, internal/db/pgstore), and production entry points select a backend through internal/db/storeopen from the resolved DSN.

This boundary is intentionally narrow in production code: daemon and CLI paths should hold db.Storage after opening. Concrete store types are appropriate inside backend packages, SQLite-specific JSONL cutover code, and tests that assert SQL details.

SQLite bootstraps a fresh database from its canonical schema.sql and upgrades older files through the JSONL cutover path described above. Postgres also installs fresh databases from a canonical current schema.sql. Because no Postgres backend predates its first public release, development-only versions are not represented as compatibility migrations. The first released Postgres schema is the migration floor. Later releases add immutable forward migration assets from released versions; each asset names its exact source and target versions, and its version stamp is written in the same transaction as its SQL.

Postgres never uses the ambient public schema. A standalone open defaults to the dedicated kata schema; configured opens accept one restricted lowercase identifier and replace any DSN-provided search_path with that single quoted schema. Bootstrap mode creates an empty target schema, installs the canonical schema or applies registered forward migrations, and holds a transaction-scoped advisory lock. Schema creation, schema SQL, and the version stamp therefore become visible together, even when several processes start concurrently. A pre-existing non-empty schema without migration metadata is treated as a conflict rather than adopted.

Validation mode performs no DDL and requires the exact binary schema version. It is the runtime-role path after a separate migration role runs kata storage postgres migrate. Older versions require a complete registered migration chain; newer versions fail closed until a matching binary is used. This keeps schema ownership with kata without requiring the serving credential to hold schema-creation privileges. The production ceremony and grants are documented in PostgreSQL operations.

Postgres transport treats the database as a separate high-trust system, not as an extension of federation's explicitly enrolled peer trust. Every remote primary or fallback connection therefore requires TLS with server identity verification before dialing. Unix sockets and exact loopback hosts may use plaintext. A separate explicit insecure-storage opt-in exists for isolated labs; it is never inferred from a private address or federation setting.

Postgres mutation helpers retry the complete transaction only for serialization failure, deadlock, or explicit lock-unavailable SQLSTATEs. Connection failures are not retried because their commit outcome may be ambiguous. Serializable and repeatable-read helpers always roll back a failed attempt before retrying, and identity-sequence reservations discover Postgres's owned sequence rather than assuming a generated name. Constraint classification maps only query-owned constraint names to db.Storage sentinels; other server errors retain SQLSTATE and constraint diagnostics while omitting row values, hints, and query text.

Backend parity is measured behaviorally rather than inferred from interface satisfaction or schema shape. internal/db/dbtest runs the same observable storage scenarios against fresh SQLite and Postgres stores. The Postgres expected-failure manifest is empty: all db.Storage methods are implemented and covered by shared conformance scenarios. The pgstore generator still parses every Storage method and classifies it as implemented or sentinel-backed, so future interface growth cannot silently bypass the inventory. Every method classified as implemented must also be named by a shared conformance scenario. New storage behavior belongs in the shared suite when it is backend-neutral; backend-package tests remain appropriate for SQL-specific details.

JSONL replay is an atomic whole-database replacement on both backends, not a merge. SQLite imports into a temporary file set before swapping it into place; Postgres truncates every kata-owned table except meta inside the same locked replay transaction, restores the snapshot, rebuilds derived token state, and reconciles identity sequences before commit. This lets kata import --target postgres://... create a fresh kata schema or replace an initialized one with --force without exposing partial restored state.