Skip to main content

Schema

Fairway uses SQLite via modernc.org/sqlite (pure Go, no CGO). The database lives at the path configured by [fairway] db_path (default .fairway/state.db).

Eight tables, plus schema_migrations for migration tracking. Task hierarchy (epics, stories, subtasks) lives in task_definitions via a self-referential parent_id — see hierarchy.md. Checkpoints attach append-only operating notes to tasks; see checkpoints.md.

Project scope

Every table carries a project_id TEXT NOT NULL column. In v1 SQLite, each DB holds exactly one project_id value (set from [fairway] project_name at DB open). In a future Postgres adapter, one DB will hold many project_id values — no migration needed to add the column because it is already there.

PKs and FKs that include project_id are explicit per table below. The store layer threads project_id through every read and write; callers never pass it.

Multi-project visibility on a single user's machine is still provided at the dashboard layer via ATTACH DATABASE over a registry; see multi-project.md.

Tables

task_definitions

Slowly-changing task metadata. One row per task. Most fields are mutable via fairway update — see "Mutability" below.

ColumnTypeNotes
project_idTEXT NOT NULLProject this task belongs to. Immutable.
idTEXT NOT NULLStable task identifier, e.g. T-042. Unique within a project. Immutable.
parent_idTEXTSelf-referential FK for hierarchy (epics → stories → tasks). NULL = root. Mutable (reparenting).
kindTEXTOptional label (epic, story, task, bug, spike). Validated against [task_kinds] allowed when configured. Mutable.
titleTEXT NOT NULLShort human-readable title. Mutable.
roleTEXT NOT NULLRole that owns this task. Mutable (handoff updates task_state.owner, not this).
notesTEXTLong-form description, acceptance criteria, links. Mutable.
acceptance_checksTEXTJSON array of opaque strings. Mutable.
dependenciesTEXTJSON array of task IDs that must reach a terminal state before this task is ready. Mutable.
priorityINTEGERUrgency. Lower = more urgent. NULL = unprioritized. Validated against [task_priorities] when configured. Cross-cutting (overrides epic boundaries in sort).
sequenceINTEGERSuggested order among siblings (same parent_id). Lower = earlier. NULL = unsequenced. Soft signal, not a gate.
profileTEXTOptional workstream profile name. Validated against [[workstream_profiles]] when configured.
owning_domainTEXTOptional architecture/domain owner label, e.g. platform, billing, identity.
owning_layerTEXTOptional layer label, e.g. api, service, frontend, guard, release.
source_pathsTEXTJSON array of source paths relevant to the task.
target_pathsTEXTJSON array of intended target paths or artifacts.
review_domainsTEXTJSON array of review domains expected for this task.
risk_levelTEXTOptional risk label, e.g. low, medium, high.
migration_typeTEXTOptional migration/refactor type, e.g. facade, boundary-guard, ownership-map.
created_atDATETIME NOT NULLImmutable.
created_byTEXTOS user or agent identifier. Immutable.
updated_atDATETIME NOT NULLTouched on any mutable-field change.

Primary key: (project_id, id). FK: (project_id, parent_id) → task_definitions(project_id, id).

Indices:

  • (project_id, parent_id) — descendant traversal.
  • (project_id, status, priority, sequence, created_at) via join with task_state — backlog sort hot path (see hierarchy.md and dashboard.md for the sort order).

Mutability

Three orthogonal ordering signals: dependencies (hard gate — task not ready until deps terminal), priority (soft, cross-cutting urgency), sequence (soft, within-siblings order). All three are mutable.

An audit table task_definitions_changes may come in v0.2 if drift becomes a debugging pain. For v0.1, the audit trail is updated_at plus the git history of any YAML/JSON imports.

See hierarchy.md for the tree model, the spawn command, granularity rules, and epic rollup semantics.

task_state

Mutable per-task execution state. One row per task.

ColumnTypeNotes
project_idTEXT NOT NULL
task_idTEXT NOT NULL
statusTEXT NOT NULLMust be in the configured [states] allowed.
ownerTEXTRole currently responsible.
claimantTEXTOS user or session identifier holding the claim.
branchTEXTBranch where work is happening.
claimed_atDATETIME
completed_atDATETIME
commit_shaTEXTCommit that satisfied the task, when done.
review_requiredBOOLEAN NOT NULL DEFAULT 0Set by fairway route review.
review_statusTEXTnot_required / pending / approved / changes_requested.
reviewerTEXTLatest routed or recorded reviewer.
reviewed_atDATETIMELatest review timestamp, when any.
review_noteTEXTLatest review summary, when any.
updated_atDATETIME NOT NULL

Primary key: (project_id, task_id). FK: (project_id, task_id) → task_definitions(project_id, id).

Indices:

  • (project_id, owner, status) — hot path for "what is each role doing?" on the dashboard.
  • (project_id, status) — backlog views.
  • (project_id, claimant) — session reconciliation.

task_state_history

One row per state transition. Append-only.

ColumnTypeNotes
idINTEGER PK
project_idTEXT NOT NULL
task_idTEXT NOT NULL
from_statusTEXTNULL on initial insert.
to_statusTEXT NOT NULL
from_ownerTEXTPrevious owner / responsible role.
to_ownerTEXTNew owner / responsible role.
from_branchTEXTPrevious branch.
to_branchTEXTNew branch.
from_commit_shaTEXTPrevious commit SHA.
to_commit_shaTEXTNew commit SHA.
command_sourceTEXTCLI command or integration that created the row.
actorTEXT NOT NULLActive session ID when known, otherwise <os_user>@<host>.
reasonTEXTOptional human note.
atDATETIME NOT NULL

FK: (project_id, task_id) → task_state(project_id, task_id). Index: (project_id, task_id, at) for the task detail page.

task_handoffs

Directed handoff between roles.

ColumnTypeNotes
idINTEGER PK
project_idTEXT NOT NULL
task_idTEXT NOT NULL
from_roleTEXT NOT NULL
to_roleTEXT NOT NULL
payloadTEXTInline text or path to a file.
commit_shaTEXTCommit being handed off, if any.
changed_filesTEXTHuman summary or newline-separated file list.
commandsTEXTAcceptance commands run before handoff.
resultsTEXTSummary of command results.
risksTEXTResidual risks.
blockersTEXTKnown blockers.
next_stepTEXTRecommended next slice of work.
acknowledged_atDATETIMEWhen to_role acknowledged.
created_atDATETIME NOT NULL

FK: (project_id, task_id) → task_state(project_id, task_id). Index: (project_id, to_role, acknowledged_at).

task_evidence

Artifact paths and result classifications.

ColumnTypeNotes
idINTEGER PK
project_idTEXT NOT NULL
task_idTEXT NOT NULL
handoff_idINTEGEROptional FK to task_handoffs(id).
command_textTEXTCommand or check that produced this evidence.
resultTEXTpass / fail / partial / skipped / blocked / NULL.
artifact_pathTEXTScreenshot, log, transcript, report, or other artifact path.
artifact_typeTEXTOptional display hint, e.g. log, screenshot, playwright, coverage, report.
duration_secondsINTEGEROptional elapsed time for timing reports.
notesTEXT
created_atDATETIME NOT NULL

FK: (project_id, task_id) → task_state(project_id, task_id).

task_reviews

Review records.

ColumnTypeNotes
idINTEGER PK
project_idTEXT NOT NULL
task_idTEXT NOT NULL
reviewerTEXT NOT NULL
verdictTEXT NOT NULLapprove / changes / reject.
reviewed_commit_shaTEXTCommit reviewed, if applicable.
route_reasonTEXT
notesTEXT
created_atDATETIME NOT NULL

FK: (project_id, task_id) → task_state(project_id, task_id).

Constraint (enforced in code): reviewer != task_state.claimant.

task_reviews is the audit log. The review columns on task_state (review_required, review_status, reviewer, reviewed_at, review_note) are denormalized/materialized dashboard fields. Every review insert updates task_reviews and the corresponding task_state review columns in the same transaction. Readers may use the denormalized columns for current state, but historical review questions must query task_reviews. Verdicts map to current review status as approveapproved; changes or rejectchanges_requested.

agent_sessions

Lifecycle for a single agent process attached to a lane.

ColumnTypeNotes
project_idTEXT NOT NULL
idTEXT NOT NULLSession identifier (UUID or role-pid-startts).
roleTEXT NOT NULL
laneTEXTOptional lane identifier when multiple execution slots share a role; see concepts.md.
worktree_pathTEXTWorktree path for status and attach affordances.
branchTEXTBranch active when the session was recorded.
session_backendTEXTtmux, zellij, shell, or another adapter label.
providerTEXTInformational provider label, e.g. codex, claude, gemini, shell.
session_nameTEXTHuman-readable backend session name.
task_idTEXTTask associated with the session, when known.
pidINTEGEROS PID.
tmux_paneTEXTe.g. agents:0.2.
transcript_pathTEXTOptional path reference; transcript contents are not stored in DB.
statusTEXT NOT NULLstarting / running / ended / failed / stale.
started_atDATETIME NOT NULL
last_heartbeat_atDATETIME
ended_atDATETIME
exit_codeINTEGERProcess exit code, when known.
end_reasonTEXTnormal / reconciled / crashed / NULL.

Primary key: (project_id, id). Index: (project_id, role, ended_at) — find the live session for a role.

task_checkpoints

Append-only operating checkpoints for epics, stories, side tracks, and watcher work.

ColumnTypeNotes
idINTEGER PK
project_idTEXT NOT NULL
task_idTEXT NOT NULL
stateTEXT NOT NULLplanned / active / awaiting_input / review / done / parked / abandoned.
ownerTEXTRole or lane responsible for the checkpoint.
target_close_byDATEOptional date for stale-track checks.
summaryTEXT NOT NULLCurrent operating summary.
artifact_pathTEXTOptional evidence link.
created_atDATETIME NOT NULL

FK: (project_id, task_id) → task_definitions(project_id, id). Index: (project_id, task_id, created_at). Index: (project_id, state, target_close_by).

Migration strategy

  • One SQL file per migration in internal/store/migrations/, named 001_init.sql, 002_*.sql, ...
  • Embedded via //go:embed.
  • A schema_migrations(version INTEGER PK, applied_at DATETIME) table tracks applied versions. (No project_id — migrations are per-DB, not per-project.)
  • Migrations are forward-only in v1. fairway db backup runs automatically before any migration beyond 001_init.sql.

Design notes

Why project_id everywhere even in single-project SQLite? So the schema is portable to a shared backend (Postgres) without a row-rewrite migration. The marginal cost in v1 is ~8 short string columns and a WHERE project_id = ? clause on every read — both hidden behind the store layer.

Why split definitions from state? Same reason a user table is split from a session table: definitions are referenced by foreign keys and rarely change; state churns.

Why an explicit task_state_history? SQLite has no built-in temporal tables. The audit trail is a first-class queryable surface for the dashboard's activity feed.

Why does agent_sessions carry tmux pane? So the dashboard can render a "click to attach" affordance. NULL when tmux is not in use.

Why evidence has both command text and artifact path. GPUaaS showed that completed work needs command-level proof even when there is no durable file artifact. Artifact paths remain optional references; large logs, screenshots, and transcripts stay out of the DB.

Why keep checkpoints after dropping track_checkpoints. Fairway does not need a separate track identity table because epics/stories already represent bounded work. It still needs append-only operating decisions for active, parked, awaiting-input, and watcher-style work; task_checkpoints provides that without creating a second task hierarchy.

Write Semantics

Claim Concurrency

SQLite claim must be atomic and deterministic:

  1. Open BEGIN IMMEDIATE so the writer lock is acquired before reading claimable state.

  2. Validate the task is claimable in the same transaction.

  3. Run a guarded update, for example:

    UPDATE task_state
    SET status = 'in_progress',
    owner = ?,
    claimant = ?,
    branch = ?,
    claimed_at = ?,
    updated_at = ?
    WHERE project_id = ?
    AND task_id = ?
    AND status IN ('todo', 'blocked')
    AND claimant IS NULL;
  4. If zero rows were updated, rollback and return ErrAlreadyClaimed or the more specific validation error.

  5. Insert the task_state_history row in the same transaction as the successful update.

  6. Commit.

Tests must prove two concurrent claim attempts produce exactly one winner and one loser.