State schema
kiki state lives in two SQLite databases. All tables are versioned via migrations. Recovery on daemon restart reads from sqlite; threads survive crashes and reboots.
Database split
| Path | Scope | Contents |
|---|---|---|
~/.config/kiki/state.db | per-user | repo registry, daemon meta, presenters, unscoped audit |
~/.config/kiki/repos/<repo_id>/state.db | per-repo | threads, agent sessions, transcripts, cascades, audit |
Both databases live under ~/.config/kiki/ — kiki's centralized state directory. The source repo's own filesystem holds no kiki state; the per-repo state.db lives at ~/.config/kiki/repos/<repo_id>/state.db, where <repo_id> is a UUID assigned at kk init and recorded in the per-user repos table. The user-level database knows which repos kkd watches; the per-repo database holds everything that lives or dies with the repo.
The <repo_id> UUID gives the centralized directory a stable, opaque name during one registration; it is not an identity that survives the source repo being moved out of band. With no breadcrumb inside the source repo, kiki's only handle on a registered repo is its canonical_path. If the user mvs the tree, kk invoked from the new location reports the repo as unregistered (because no canonical_path matches). The prior <repo_id> and its centralized state remain in ~/.config/kiki/repos/<old_repo_id>/ until kk repo unregister is run against the original path. The v1 recoveries are: move the source tree back so the registered canonical_path matches; or kk repo unregister followed by kk init at the new location, which starts a fresh registration with a new repo_id. By default, kk repo unregister removes both the registry row and the centralized state directory; --keep-state retains the directory on disk for manual inspection but offers no kiki-managed recovery — a subsequent kk init at the same canonical path mints a fresh <repo_id> with no link to the preserved bytes. See Commands · Repo registry. A move-aware kk repo relocate command is not in v1.
Tables
The list below is table-level. Column-level definitions are derived from the behavioral chapters.
Per-user (~/.config/kiki/state.db)
repos—(repo_id, canonical_path, registered_at, opt_in_metadata).repo_idis a UUID assigned atkk initand keys the per-repo state directory at~/.config/kiki/repos/<repo_id>/.canonical_pathis the realpath of the registered source repo at registration time and is the only handle kiki has for matching a future invocation back to this row.repo_idis stable for the life of the registration (so the centralized directory's name does not churn while kkd is running); it is not preserved acrosskk repo unregister+kk init, which is a fresh registration with a new UUID. An out-of-bandmvof the source tree cannot be auto-detected — see the move semantics in the section above.daemon_meta— daemon startup info, last-known socket path, schema version.approval_presenters— foreground-channel enrollments(presenter_id, frontend_kind, credential_hash, channel_binding, enrolled_at, last_seen_at, revoked_at?). Enrollment requires bootstrap Admin, but enrollment is not approval for an operation. A presenter proves that a confirmation came from a registered interactive CLI or TUI channel; it cannot mint a capability without completing a persisted challenge.user_approval_challengesanduser_human_approvals— the same two-phase challenge and one-use-capability records described below, for operations with no repository target. Repository-scoped confirmations are stored only in the target repository database.user_approved_operationsanduser_approved_operation_steps— durable claim envelopes and idempotent step journals for approved operations whose authority must survive without a repo database, including registration and unregister. Their shape matches the per-repo tables below; unregister completes its user-level journal only after the target repo state is deleted.user_audit_log— append-only attempts that have no valid repository target, including bootstrap,ListRepos, registration, malformed repo selectors, and authorization failures before a repo can be resolved. Its row shape matches per-repoaudit_logand permits null credential, approval, and presenter identities.
Per-repo (~/.config/kiki/repos/<repo_id>/state.db)
threads— per-thread row carrying stable identity; bookmark, workspace, tmux, and harness projection references;thread_head_commit_id;checkpoint_commit_id;owned_stack_base_commit_id; and alifecyclecolumn with valuesCreating | Active | CreateFailed | ClosePreflight | CloseCommit | CloseFailed | Closed | ProjectionDiverged | DestroyCommit | DestroyFailed | Destroyed(see Threads).projection_resume_lifecycleis null except inProjectionDiverged, where it records whether successful repair must restoreActiveorClosedinvariants. For an active coherent thread, the managed workspace's exact@is the live head andthread_head_commit_idis its last-observed exact value.checkpoint_commit_idis the expected bookmark target used for publish and reopen; it may legitimately trail the live head between explicit checkpoints.owned_stack_base_commit_idsurvives follows-edge deletion and advances only with a completed reconciliation, so detach and later destroy validation retain an exact structural base. Cascade progress is not projected onto this row; it is derived fromsync_intents.thread_links— directed follows edges, DAG-validated at insert time, includinglast_synchronized_parent_commit_id. The initial parent anchor comes from the parent's exact live head in a pinned, revalidated operation view, never its bookmark or a stale cache. Immediately before explicit rebase, every commit after the thread's exact owned-stack base through its exact live head must form one strict single-parent chain and be owned by that child. Merge commits, multiple roots, conflicts, or foreign commits produceTopologyDiverged; kiki never infers a broader source revset or trusts a remembered “root” identity in place of validating the path.thread_lifecycle_ops— durable creation, close, reopen, checkpoint, repair, and later destroy sagas. Each row carries(lifecycle_op_id, thread_id, kind, state, started_at, updated_at, completed_at?, last_error?, context_json).context_jsoncontains pinned ids and fingerprints needed to retry but is not a substitute for normalized thread or step state.thread_lifecycle_steps— append/update journal keyed by(lifecycle_op_id, ordinal)carrying(name, state: Pending | Started | Completed | Compensating | Compensated | Failed, attempt, started_at?, completed_at?, input_fingerprint?, result_json?, error?). A step is markedStartedbefore its external side effect and records the stable external identity required to detect already-completed work. Creation and reopen compensation run completed steps in reverse order. Close records freeze, final proof, checkpoint, each child reconcile/checkpoint/detach, session kill, workspace forget, and directory delete separately; child work completes while the parent remains frozen and before session kill. Destroy separately records bookmark deletion, optional exact revision abandonment, projection cleanup, and tombstoning so failure after an irreversible step cannot masquerade as an intact lifecycle.thread_projection_issues— repair ledger(issue_id, thread_id, kind, expected_json, observed_json, detected_at, notified_at?, resolved_at?, resolution?). Kinds includeWorkspaceDirectoryMissing | WorkspaceRecordMissing | WorkspacePathMismatch | BookmarkMissing | BookmarkMoved | SessionMissing. An unresolved issue places an otherwise coherent active or closed thread inProjectionDiverged, except for a temporary mismatch owned by an in-progress lifecycle journal. Issue effects are derived fromkind: bookmark-only issues block checkpoint, publish, and close but not unambiguous workspace-head observation or follows classification;SessionMissingpermits head observation but blocks managed boundary mutation; workspace identity or path ambiguity blocks both. Only identity-preserving, unique normalization may resolve automatically; adopt/recreate/move/restart choices require a recordedkk repairresolution.agent_sessions— one row per runtime process incarnation carrying(agent_session_id, harness_session_id, started_at, retired_at?, launch_gate_id?, gate_activated_at?, harness_started_at?, ready_fingerprint?, exclusive_hook_fingerprint?, exclusive_hook_proven_at?, delivered_intent_id?, startup_intent_id?, startup_intent_epoch?, startup_delivery_id?, startup_payload_sha256?, startup_proven_at?).agent_session_idchanges on every process start even when--resumereusesharness_session_id; every incarnation must independently prove exclusive hook control before it is ready or may mutate a managed workspace. During creation, a launcher blocks onlaunch_gate_id; activation authorizes exec but leaves the threadCreating. A validated ready handshake records the harness and hook fingerprints, and only that transaction completes the saga and entersActive. Delivery pointers name a synthetic result awaiting acknowledgement either through a soft batch barrier or as the byte-stable, one-use-tagged first startup message of a replacement process.metadata_writes— kk-ownership content-hash ledger for descriptions and bookmark names.sync_intents— the sole durable reconciliation, embedded-outbox, and delivery authority. Each row carries(intent_id, thread_id, seq, kind, from_base_commit_id, to_base_commit_id, classification_op_id, state, planned_op_id?, result_op_id?, result_workspace_commit_id?, payload?, anchor_change_id?, anchor_commit_id?, anchor_op_id?, prepared_at?, delivery_mode?, delivered_at?, acknowledged_at?, transcript_row_id?, barrier_incarnation_id?, barrier_model_turn_id?, barrier_tool_batch_id?, barrier_bound_at?, batch_completed_at?, soft_redelivery_count, restart_incarnation_id?, restart_startup_delivery_id?, restart_startup_proven_at?, delivery_exhausted_at?, delivery_epoch, delivery_rearmed_at?, recovery_reason?, recovery_bundle_path?, recovery_fingerprint?, recovery_details?), where kind isNativeRewrite | ParentAdvance,delivery_modeisSoftBatch | RestartStartup, and state isDetected | Reconciling | Materialized | Delivered | Acknowledged | RecoveryRequired | TopologyDiverged | Superseded. Soft-barrier and restart-startup proof fields are embedded on the intent rather than stored in another progress table. Delivery-attempt bounds are durable on the same row:soft_redelivery_count(default 0) counts barrier rebinds after contrary delivery receipts, replacement attempts are counted from the persistedagent_sessionsrows whose(startup_intent_id, startup_intent_epoch)match the intent and its currentdelivery_epoch, anddelivery_exhausted_atfreezes the intent for human attention.UNIQUE (thread_id, seq)orders intents; sequence allocation and insertion occur in one transaction. Recovery bundles live under~/.config/kiki/repos/<repo_id>/recovery/<intent_id>/, outside the source workspace. The runtime lock remains an in-memorytokio::sync::Mutexkeyed bythread_id; restart recovery resumes from intent state, exact operation ids, and durable delivery proof. See Cascade and the embedded-outbox design note.sync_intent_triggers— normalized(intent_id, op_id)rows withUNIQUE (intent_id, op_id). Coalescing adds observed operation ids here and updatesclassification_op_idto the exact view used for reclassification only while the intent remains pre-materialization; it does not impose a false linear “through” order on jj's operation DAG.op_attribution— kk-initiated op-id dedupe so the watcher does not react to its own ops.op_history— operation-node cache(repo_id, op_id, committed_at, observed_at, processed_at?)withUNIQUE (repo_id, op_id). A row is processed only after every relevant workspace transition for that node has been classified durably.op_history_parents— normalized operation-DAG edges(repo_id, op_id, parent_op_id)withUNIQUE (repo_id, op_id, parent_op_id). No code path may collapse an operation with multiple parents to one cursor predecessor.op_workspace_heads— per-view workspace projection(repo_id, op_id, workspace_id, change_id, commit_id)withUNIQUE (repo_id, op_id, workspace_id). The workspace key is load-bearing because@is workspace-local.op_watch_frontier— the current known set of operation heads(repo_id, op_id, observed_at)withUNIQUE (repo_id, op_id). Restart catch-up walks every not-yet-processed ancestor reachable from this frontier in topological order; it is not a singular “last cursor to head” scan.pr_links— thread → PR.thread_config— per-thread config layer key/value/updated_at.credentials—(cred_id, kind: 'Admin' | 'ThreadScoped' | 'RegistrationScoped', thread_id?, repo_id?, issued_at, revoked_at?). Admin is accepted only by bootstrap and approval-presenter enrollment endpoints; operational calls cannot substitute it for human approval.approval_challenges— pending foreground confirmations(challenge_id, requester_cred_id, presenter_id?, method, repo_id, thread_id?, args_digest, rendered_plan, nonce, created_at, expires_at, confirmed_at?, confirmed_terminal_binding?, cancelled_at?).BeginApprovalpersists the exact plan before returning it for display. Only an enrolled foreground presenter may bind itself and confirm the nonce and digest; presenter binding, confirmation evidence, and capability issuance occur in one target-database transaction.human_approvals— one-use capabilities(approval_id, challenge_id, requesting_cred_id, approving_presenter_id, method, repo_id?, thread_id?, args_digest, issued_at, expires_at, claimed_at?, operation_id?, consumed_at?, consuming_audit_id?). Claim and creation of the exactapproved_operationsenvelope occur atomically before external effects. A claimed approval can resume only that operation id and cannot authorize another plan; completion marks it consumed. Expired, replayed, method-mismatched, target-mismatched, argument-drifted, unconfirmed, or channel-mismatched approvals fail closed.approved_operations— generic approval-claim envelope(operation_id, approval_id UNIQUE, kind, thread_id?, plan_digest, state: Claimed | Running | Completed | Failed | OutcomeUnknown, claimed_at, updated_at, completed_at?, result_digest?, last_error?). Lifecycle operations link their envelope tothread_lifecycle_ops; publish, parent-merge remote updates, sensitive snapshots, and other consequential methods use this envelope directly. One approval creates one immutable envelope, and recovery may resume only its stored plan.approved_operation_steps— idempotent external-effect journal(operation_id, ordinal, name, state, input_fingerprint, result_json?, error?), unique by(operation_id, ordinal). Remote push and PR-base mutation are separate steps; an unknown remote result becomesOutcomeUnknownuntil observation proves success or failure. Read-only approved snapshots record their repository observation and result digest as one completed step rather than leaving an unjournaled reusable capability.audit_log— append-only(audit_id, timestamp, cred_id?, approval_id?, presenter_id?, transport_peer, declared_scope?, method, args_summary, outcome). Null identities explicitly represent unidentified or rejected callers. Parseable attempts with a resolved repo go here; attempts without a resolvable repo go to per-useruser_audit_log, so there is exactly one authoritative row per attempt.thread_messages— per-thread transcript rows.(thread_id, change_id?, commit_id?, op_id?, session_id, seq, captured_at, author, direction, text, dedup_key, synthesized, anchor_unknown)withUNIQUE (thread_id, dedup_key)and an FTS5 virtual table overtext. CHECK constraint bindinganchor_unknownto nullability of the(change_id, commit_id, op_id)triple. See Transcript.thread_changes— per-(thread, change_id) row carryingtombstoned_at?(abandon) andredirected_to?(squash).transcript_offsets— per-(thread, session_id)(byte_offset, last_row_uuid)for crash-safe tail resumption. Both fields advance in the same transaction as the correspondingthread_messagesinsert.pending_kkd_prepends— TTL-bounded(prepend_id PRIMARY KEY, thread_id, text_sha256, inserted_at). Duplicate(thread_id, text_sha256)rows are allowed and consumed FIFO. Match key omitssession_idso fresh-session reopens match correctly.provider_egress_consents— remembered, revocable grants(consent_id, thread_id, provider_identity, purpose: 'catch_up' | 'transcript_mcp', disclosure_version, granted_at, granted_by_presenter_id, revoked_at?). Consent is valid only for the exact thread, configured provider identity, purpose, and disclosed policy version. Local preview is not provider egress; returning transcript-derived text through a hosted-model MCP call is and must check an activetranscript_mcpgrant before reading rows.
v2-only
causal_chains,causal_chain_visits— deferred until the broader MCP substrate ships. See Roadmap.
Constraints and transactional invariants worth calling out
threads:thread_head_commit_id,checkpoint_commit_id, andowned_stack_base_commit_idare required inActive,ClosePreflight,CloseCommit,CloseFailed,Closed, andProjectionDiverged; destroy states retain every still-valid id until the corresponding journaled deletion or abandonment step. They may be null only while the corresponding creation step has not completed or after its exact destructive step is recorded.checkpoint_commit_idis the expected bookmark target, but need not equalthread_head_commit_idwhile active. EnteringClosedrequires equality because close checkpoints before forgetting the workspace.Activerequires no unresolvedthread_projection_issuesand no unfinished lifecycle operation.Closedmay have one unfinished or failedReopenoperation because reopen does not expose an active thread until its ready handshake commits; other lifecycle kinds are disallowed there.ProjectionDivergedrequires at least one unresolved projection issue andprojection_resume_lifecycle IN ('Active', 'Closed'); other states require it to be null.CreateFailed,CloseFailed, orDestroyFailedrequires the corresponding failed unfinished lifecycle operation. A thread cannot return toActivefrom failed close until the journal records a verified process/session resume or an explicit restart repair; after destroy's first irreversible step, it cannot return toActive,Closed, orProjectionDivergedby inference. Projection mismatches caused insideCreating,CloseCommit, reopen, orDestroyCommitbelong to that lifecycle journal and fail the owning saga rather than manufacturing an invalid projection-resume state.thread_lifecycle_steps:(lifecycle_op_id, ordinal)is unique and step transitions are monotonic except that a failed idempotent step may return toStartedwith an incremented attempt. A lifecycle operation may have only one in-flight step.Completedrequiresresult_jsonsufficient to recognize or compensate the external effect. Gate activation requires the credential, hook configuration, workspace head, bookmark checkpoint, and tmux projections to be complete, but does not enterActive. Only a ready-handshake transaction with a matching incarnation and settings fingerprint completes creation and entersActive; an activated gate alone is never success evidence.thread_messages:(anchor_unknown=FALSE AND change_id IS NOT NULL AND commit_id IS NOT NULL AND op_id IS NOT NULL) OR (anchor_unknown=TRUE AND change_id IS NULL AND commit_id IS NULL AND op_id IS NULL). The two states cannot drift apart.approved_operations:approval_idis unique and immutable;plan_digestmust equal the claimed approval's argument/rendered-plan digest. External steps cannot start beforestate = Claimed, and completion consumes the approval in the same transaction that commits the final result digest.FailedandOutcomeUnknownretain the claim and can only retry or observe the same step plan; they never release the approval for reuse.sync_intents: both exact base commits are required.Materialized,Delivered, andAcknowledgedrequire a result workspace commit, byte-stable payload, complete anchor triple, andprepared_at. Before any soft-path workspace mutation, the transaction setsdelivery_mode = SoftBatchand the complete(barrier_incarnation_id, barrier_model_turn_id, barrier_tool_batch_id, barrier_bound_at)tuple; partial tuples are invalid.batch_completed_atrequires that tuple and may be written only by an idempotent completion report carrying the same identities. Soft acknowledgement requires both that completion and a later model-turn identity. IfMarkDeliveredwas lost, that exact later-turn proof may atomically repairdelivered_atand advanceMaterialized -> Acknowledged; a different batch or stdout alone is insufficient.RestartStartupclears the retired soft tuple without claiming acknowledgement, requires the complete(restart_incarnation_id, restart_startup_delivery_id)pair, and acknowledges only when that replacement's first managed boundary causally echoes the one-use id attached to the byte-stable startup message.RecoveryRequiredrequiresrecovery_reason,recovery_bundle_path, andrecovery_fingerprint. Only a pre-materialization intent may becomeSuperseded. A barrier tuple may be rebound to a new batch after a contrary delivery receipt only in a transaction that incrementssoft_redelivery_count; retry bounds are evaluated against that column and the persisted replacement-incarnation rows, never an in-memory counter.delivery_exhausted_atmay be set only by the transaction that abandons the final permitted attempt; while it is set, exactly two writes are valid, both human-directedkk repairplans. A retry plan clears it, incrementsdelivery_epoch(an integer starting at 0, monotonic per intent), recordsdelivery_rearmed_atfor display, and resetssoft_redelivery_countin one journaled transaction. Every replacement attempt is stamped with the intent'sdelivery_epochat creation, and attempt bounds count only rows whose stamp matches the current epoch — an exact id comparison, never a timestamp one, so clock skew or rollback cannot move an attempt between epochs. A re-armed intent therefore gets exactly one fresh cycle and may exhaust again. A one-shot-approved discard plan is the sole path toAcknowledgedwithout delivery proof and must record the human resolution inrecovery_details. Absent either plan, no new barrier tuple or replacement incarnation may reference the intent, restart recovery re-surfaces the blocked thread instead of retrying, and the thread's derived cascade status isconflicted.agent_sessions:delivered_intent_id, when non-null, must reference a same-threadMaterializedorDeliveredintent assigned to that incarnation's delivery mode. A partial unique index allows at most one non-retired incarnation to claim an intent. Starting a replacement process retires the old incarnation, recordsstartup_intent_id,startup_intent_epochcopied from that intent's currentdelivery_epoch, and the saved payload hash before exec, and supplies that payload as the first startup message. Delivery-attempt bounds count only rows whose stamped epoch matches the intent's current one. Its first managed boundary must re-prove exclusive hooks and the startup proof before atomically acknowledging and clearing both pointers. If the process exits earlier, the next replacement receives the same saved payload; no interrupted tool is replayed automatically.
Migrations
Every schema change ships as a migration. Migrations are forward-only. Once a release is cut, schema changes must preserve upgrade compatibility. The migration runner is part of kiki-core.