Data Model
Database Schema
pam uses PostgreSQL as its single source of truth (ADR-012, overturning the original SQLite decision). Core tables follow. All IDs are UUID (gen_random_uuid() where defaulted). All timestamps are TIMESTAMPTZ (UTC). Enumeration-like columns use TEXT with CHECK constraints — the application layer (pam-core enums) remains the authority for legal values.
hosts
CREATE TABLE hosts (
id UUID PRIMARY KEY,
name TEXT UNIQUE NOT NULL, -- operator-chosen worker/host name
platform TEXT NOT NULL CHECK(platform IN ('macos', 'linux')),
token_fingerprint TEXT, -- fingerprint of the worker auth token (never the token itself)
status TEXT NOT NULL CHECK(status IN ('registered', 'online', 'offline', 'disabled')),
instance_id UUID, -- worker-generated, persisted in the worker's local data dir; NULL until the first instance-id-aware registration
instance_suspect BOOLEAN NOT NULL DEFAULT false, -- pinned instance_id disagrees with a recent/concurrent presenter (OQ-14)
instance_flagged_at TIMESTAMPTZ, -- when instance_suspect last transitioned false -> true; NULL while not suspect
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
Columns:
id: Unique host identifier (UUID)name: Operator-chosen unique worker name (used for dispatch targeting and display)platform:macosorlinux— worker hosts are Unix-only (ADR-011)token_fingerprint: Hash fingerprint identifying which worker token this host authenticates withstatus: Registry state —registered(known, not yet seen),online(heartbeat current),offline(heartbeat lost),disabled(excluded from dispatch)instance_id: Per-process worker instance UUID (OQ-14, 071-risks-and-open-questions.md) — generated once by the worker process and persisted in its local data dir, then sent at every registration and heartbeat. Guards against one worker token cloned onto two machines aliasing a singlehostsrow. Pinned, not overwritten: when an incominginstance_iddisagrees with the stored value, the column is updated only when the mismatch resolves as a legitimate reinstall (seeinstance_suspectbelow) — overwriting on every mismatch would let an alternating clone erase its own trailinstance_suspect: Settruewhen an incominginstance_iddisagrees with the pinned value AND the gap between now and the pinned instance’s lastlast_seen_atis within the offline threshold (90s) — i.e., the pinned instance still looked alive when the new one showed up, so the mismatch reads as a concurrent clone rather than a reinstall. A host in this state is excluded from new dispatch (alongsidedisabled) but its already-running runs are left untouched; the mismatch is journaled once on the false→true transition, not on every subsequent heartbeat (debounced) while it stays suspect. Cleared by the operator (pam host clear-suspect, 040-cli.md), which also accepts the newinstance_idas the pinned valueinstance_flagged_at: Timestamp of theinstance_suspectfalse→true transition;NULLwhile not suspect. Reset toNULLwhen the operator clears the flaglast_seen_at: Last heartbeat/registration timestamp; drives online/offline transitions (default cadence: heartbeat every 30s; a host is marked offline after 90s without one — 3 missed beats). This same 90s window is the recency thresholdinstance_suspectcompares against — when the gap since the pinned instance’s last heartbeat already exceeds it, the mismatch is read as a legitimate reinstall instead:instance_idupdates silently and an info-level event is journaled, noinstance_suspectflag setcreated_at/updated_at: Record lifecycle timestamps
projects
CREATE TABLE projects (
id UUID PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL, -- URL-safe identifier
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('active', 'archived')),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
);
Columns:
id: Unique project identifier (UUIDv4)name: Human-readable project nameslug: URL-safe identifier for CLI/API referencesdescription: Optional project descriptionstatus: Project lifecycle state (active/archived)created_at: Creation timestampupdated_at: Last modification timestamp
Archived semantics: an archived project is read-only for structural mutation — no new work items may be created in it, and no new runs may be created against its work items. Runs already in flight when the project was archived continue to completion, and their events still journal. Un-archiving (archived → active) is permitted and restores full mutability.
work_items
CREATE TABLE work_items (
id UUID PRIMARY KEY,
project_id UUID NOT NULL,
parent_id UUID, -- Self-referential FK for hierarchy
kind TEXT NOT NULL CHECK(kind IN ('milestone', 'epic', 'task', 'subtask')),
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL CHECK(status IN ('todo', 'doing', 'done', 'cancelled')),
assignee TEXT, -- Optional assignee identifier
path TEXT NOT NULL, -- Materialized path for subtree queries
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (parent_id) REFERENCES work_items(id) ON DELETE CASCADE,
CHECK (parent_id IS NULL OR parent_id <> id) -- hierarchy guard: no self-parenting
);
Columns:
id: Unique work item identifier (UUIDv4)project_id: Parent project referenceparent_id: Parent work item for hierarchical structure (NULL for root items)kind: Work item type (milestone/epic/task/subtask)title: Work item namedescription: Optional detailed descriptionstatus: Current workflow state (todo/doing/done/cancelled)assignee: Optional person/team assignedpath: Materialized path for efficient subtree queries (see Path Semantics)sort_order: Ordering within siblingscreated_at: Creation timestampupdated_at: Last modification timestamp
Hierarchy guards: CHECK (parent_id IS NULL OR parent_id <> id) rejects self-parenting at the database level. Additionally, a parent and its children must share the same project_id — a documented constraint enforced at the application layer, since a column CHECK cannot compare values across rows. Reparenting is the mutation that must re-check this invariant: the new parent must belong to the same project_id as the moved item, and cross-project moves are rejected. Preventing deeper cycles (A → B → A beyond the trivial self-cycle) likewise remains an application-layer concern, tracked as OQ-6 in 071-risks-and-open-questions.md.
runs
CREATE TABLE runs (
id UUID PRIMARY KEY,
task_id UUID NOT NULL,
host_id UUID, -- Worker host assigned to execute this run (NULL while pending)
provider TEXT NOT NULL, -- claude, codex, grok, gemini
session_name TEXT NOT NULL, -- tmux session name: pam-xxxxxxxx (unique per host)
status TEXT NOT NULL CHECK(status IN ('pending', 'spawning', 'running', 'exited', 'failed', 'stopped')),
attempt INT NOT NULL DEFAULT 1, -- incarnation epoch: incremented centrally on each restart requeue
last_progress_seq BIGINT, -- highest applied progress seq within the current attempt (NULL until first progress)
failure_reason TEXT, -- machine-readable terminal reason (host_lost, session_lost, spawn_failed, restart_exhausted, dispatch_timeout, no_eligible_host)
pending_since TIMESTAMPTZ NOT NULL, -- dispatch-timeout anchor; set to created_at at insert, reset to now() on every failed -> pending requeue
cwd TEXT NOT NULL, -- Working directory for CLI execution (on the worker host)
exit_code INTEGER, -- Process exit code (NULL if not exited)
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
FOREIGN KEY (task_id) REFERENCES work_items(id) ON DELETE CASCADE,
FOREIGN KEY (host_id) REFERENCES hosts(id),
UNIQUE (host_id, session_name)
);
Columns:
id: Unique run identifier (UUID)task_id: Associated task work item; must reference a work item of kindtaskorsubtaskonly. Application-enforced — a column CHECK cannot read the referenced row. The central reconciliation pass treats a run pointing at amilestoneorepicas a data-integrity anomaly to journal, not to auto-repairhost_id: Worker host executing the run; assigned at dispatch (NULL whilepending)session_name: tmux session name (format: pam-<short_run_id>); uniqueness is scoped per host viaUNIQUE (host_id, session_name)provider: LLM provider identifier (claude/codex/grok/gemini)status: Run lifecycle state (pending/spawning/running/exited/failed/stopped)attempt: Incarnation epoch — which execution incarnation of the run is speaking. Incremented centrally each time the restart policy requeues the run (failed → pending); workers never allocate attempts (they never write the database). The value is injected into the wrapper’s spawn environment so the wrapper can stamp it on every signal, where it is REQUIRED. A signal whoseattemptis less thanruns.attemptcomes from a dead incarnation and is discarded — no status write, no event emission. Anattemptgreater thanruns.attemptis impossible under central issuance and is rejected as a protocol errorlast_progress_seq: Highest appliedseqamong repeated, dedup-exempt signal kinds (progress) within the current attempt (NULL until the first progress signal is applied). Rule: apply aprogresssignal only ifseq > last_progress_seq. Reset on restart requeue — the new incarnation numbers itsprogresssignals from scratchfailure_reason: Machine-readable terminal reason (host_lost,session_lost,spawn_failed,restart_exhausted,dispatch_timeout,no_eligible_host); NULL while the run is non-terminal or ended without a distinct reasonpending_since: Dispatch-timeout anchor (OQ-15, 071-risks-and-open-questions.md). Set equal tocreated_atwhen the row is first inserted, and reset tonow()on everyfailed → pendingrequeue. Kept distinct fromcreated_at(which never changes after insert) precisely so a requeued run gets a fresh dispatch-timeout window instead of inheriting the original creation time — a run that failed after 55 minutes and requeued would otherwise be measured against acreated_atalready 55 minutes stale, and would breach the 5-minute dispatch timeout the instant it re-enteredpendingcwd: Working directory for CLI process execution (set atpam run startvia--cwdflag; default: current working directory of CLI invocation)exit_code: Process exit code when terminatedstarted_at: Process start timestampfinished_at: Process completion timestampcreated_at: Record creation timestampupdated_at: Last modification timestamp
UNIQUE-NULL semantics on UNIQUE (host_id, session_name): PostgreSQL treats NULLs as distinct in unique constraints, so rows with host_id IS NULL — runs still in pending, not yet dispatched to a host — never collide with each other or with host-assigned rows. The constraint binds only once a host is assigned at dispatch. This is intended, not a bug: it enforces per-host session-name uniqueness exactly when a session can actually exist (post-dispatch) without blocking concurrent pending runs.
processed_signals
Dedup marker table that gives one-shot lifecycle signals exactly-once semantics under at-least-once delivery (spool + replay):
CREATE TABLE processed_signals (
run_id UUID NOT NULL,
attempt INT NOT NULL,
kind TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL,
PRIMARY KEY (run_id, attempt, kind),
FOREIGN KEY (run_id) REFERENCES runs(id) ON DELETE CASCADE
);
Columns:
run_id/attempt/kind: The dedup key — one row per applied one-shot signal of a given kind within a given incarnationreceived_at: When the signal was first applied (forensics only; not part of matching)
Ingest semantics — the guard lives centrally, at the signal-ingest edge; the worker never queries the database, so the judgment cannot live worker-side:
- One-shot kinds (
started,finished,failed) are exactly-once via this table: ingest performsINSERT ... ON CONFLICT DO NOTHINGbefore applying; a conflict means the signal was already applied and it is dropped. These kinds carry noseq— it would be pure cost - Repeated kinds (
progress) are dedup-exempt: naturally repeatable, ordered instead viaruns.last_progress_seq(apply only ifseq > last_progress_seq).seqis optional on the signal payload and carried only by these repeated kinds - The stale-epoch guard and the dedup both run strictly upstream of BOTH the run-status write AND the event journal / SSE emission — skipping the status write while still journaling the event would leak phantom events to the dashboard
- Ordering is a lexicographic comparison on
(attempt, seq). The two axes are orthogonal:attemptanswers “which execution incarnation is speaking”;seqanswers “is this newer than what I already applied within that incarnation”. Neither subsumes the other
events
CREATE TABLE events (
seq BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
topic TEXT NOT NULL,
payload JSONB NOT NULL, -- structured event data
created_at TIMESTAMPTZ NOT NULL
);
Columns:
seq: Monotonically increasing sequence numbertopic: Event type identifier (see Event Topics)payload: Structured event data as JSONBcreated_at: Event timestamp
_sqlx_migrations
Standard sqlx migration tracking table (auto-created by sqlx).
Indexes
CREATE INDEX work_items_project_id ON work_items(project_id);
CREATE INDEX work_items_parent_id ON work_items(parent_id);
CREATE INDEX work_items_path ON work_items(path text_pattern_ops);
CREATE INDEX runs_task_id ON runs(task_id);
CREATE INDEX runs_host_id ON runs(host_id);
CREATE INDEX events_topic_created_at ON events(topic, created_at);
These indexes optimize common queries:
- Project-scoped work item lookups
- Parent-child relationship queries
- Path-prefix subtree scans (board rendering, subtree listing — see Path Semantics)
- Task-associated run queries
- Host-scoped run queries (worker reconciliation)
- Topic-filtered event queries (SSE
?topic=filter, retention purge)
Path Semantics
The path column in work_items implements materialized path storage for efficient subtree queries:
Format: <short_id>/<parent_short_id>/<grandparent_short_id>/...
- Root items: path = own short ID (first 8 characters of UUID)
- Child items: path =
<parent_path>/<own_short_id> - Short IDs: First 8 lowercase hex characters of UUID
Example:
Item A (id: a1b2c3d4-...) path: "a1b2c3d4"
└─ Item B (id: e5f6a7b8-...) path: "a1b2c3d4/e5f6a7b8"
└─ Item C (id: c9d0e1f2-...) path: "a1b2c3d4/e5f6a7b8/c9d0e1f2"
Subtree Queries: two forms exist; which one is used depends on what the traversal needs.
Path-prefix (primary):
SELECT * FROM work_items
WHERE path = ? OR path LIKE ? || '/%'
ORDER BY path, sort_order;
A single index scan on work_items_path (text_pattern_ops exists for exactly this LIKE 'prefix%' shape). This is the default for read paths — board rendering, subtree listing.
Recursive CTE:
WITH RECURSIVE subtree AS (
SELECT * FROM work_items WHERE id = ?
UNION ALL
SELECT w.* FROM work_items w
INNER JOIN subtree s ON w.parent_id = s.id
)
SELECT * FROM subtree ORDER BY path, sort_order;
Used when the traversal needs live parent links rather than the materialized path — for example during a reparent, when path is mid-rewrite.
Reparent rewrite: moving a work item rewrites path for the moved item and every descendant. This is a single transaction; the subtree is located by the old path prefix before the rewrite begins. work_item.reparented is journaled once, for the moved item — not once per descendant.
Short-id collisions: path segments and tmux session names (pam-<short_run_id>) both use the first 8 lowercase hex characters of a UUID, and an 8-hex prefix is NOT guaranteed unique. The scopes differ: runs.session_name is unique per host via UNIQUE (host_id, session_name), while path short-ids are unique only within a sibling set in practice. path is a lookup accelerator, never an identity — resolution always goes through id. Where a collision would break a display, the UI disambiguates by showing more characters.
Status Transitions
Work Item Status
stateDiagram-v2
[*] --> todo
todo --> doing
doing --> done
todo --> done: complete without starting
done --> doing: reopen
doing --> todo
todo --> cancelled
doing --> cancelled
done --> cancelled
cancelled --> todo: reopen
cancelled --> [*]
Legal transitions:
todo → doing: Start work on itemdoing → done: Complete worktodo → done: Complete without starting (work finished out-of-band); justified by thepam task doneCLI verb (040-cli.md) and Kanban board drag (050-ui-dashboard.md)done → doing: Reopen for additional workdoing → todo: Defer workany → cancelled: Cancel workcancelled → todo: Reopen cancelled item
Parent/child status coupling:
- Cancelling a parent cascades
cancelledto all non-donedescendants;donedescendants keep their status — history is not rewritten - A parent does NOT auto-transition to
donewhen its children complete. Roll-up is a display concern (the dashboard may show a completion ratio); the stored parent status only changes through an explicit transition, because auto-roll-up would make the parent’s status unattributable to any actor, which conflicts with the transition-enforcement model in### Enforcement
Run Status
stateDiagram-v2
[*] --> pending
pending --> spawning: spawn requested
spawning --> running: process started
running --> exited: clean exit
running --> failed: error exit
running --> stopped: user stopped
spawning --> failed: spawn failed
pending --> failed: dispatch timeout / no eligible host
failed --> pending: automatic restart (bounded retries)
exited --> [*]
failed --> [*]
stopped --> [*]
Legal transitions:
pending → spawning: Initiate tmux session creationspawning → running: CLI process confirmed runningspawning → failed: tmux or CLI process creation failedpending → failed: Dispatch-timeout failure — the only exit for a run no worker ever claims. Two causes: dispatch timeout — a run sits inpendingbeyond the dispatch timeout (default 5 minutes, configurable, measured fromruns.pending_since— NOTcreated_at; see therunsschema above) without being claimed by any worker, and the central orchestrator writesfailedwithfailure_reason = 'dispatch_timeout'; no eligible host —POST /runsaccepted the run but no online, non-disabled, non-suspect host matched the requested target, so the run is created inpendingand fails at the same timeout withfailure_reason = 'no_eligible_host'. Host unassignment — apendingrun whose target host is disabled, marked suspect (hosts.instance_suspect, OQ-14), or goes offline before the claim — does NOT immediately fail the run: it stayspendingand may be claimed by another eligible host, failing only at the dispatch timeout. The 5-minute default is confirmed (OQ-15, 071-risks-and-open-questions.md)running → exited: Process terminated with exit code 0running → failed: Process terminated with non-zero exit coderunning → stopped: User-initiated stopfailed → pending: The canonical restart-requeue edge. Performed centrally by the restart policy; on requeue,runs.attemptis incremented and the per-attempt columns (started_at,finished_at,exit_code,last_progress_seq,pending_since) are reset —pending_sinceresets tonow()so the requeued attempt gets a fresh dispatch-timeout window (OQ-15) rather than inheriting the originalcreated_at. A requeue MAY select a different host than the failed attempt used — it is the only automatic escape hatch when the original host is lost or unhealthy, and this includes runs tombstonedfailure_reason = 'host_lost'(OQ-12, confirmed: requeue-eligible) — see 010-tmux-executor.md § Restart Policy for the accepted trade-off that decision carries. 010-tmux-executor.md owns restart POLICY only (trigger conditions, attempt caps, backoff)exited/failed/stopped → [*]: Terminal states
Ownership: This document owns the run state machine as canon — the legal edges above are the single authority for run-status transitions. 010-tmux-executor.md owns restart POLICY only (trigger conditions, attempt caps, backoff) and conforms to this machine; other renderings of the graph (provider adapter transition tables, lifecycle diagrams) are derived views and must not introduce edges absent here.
Enforcement
Value legality — which values a status column may hold — is enforced by the CHECK constraints in the schema blocks above. Transition legality — which edges between those values are permitted — is enforced at the application layer by a single pam-core state-machine module, which is the sole gate: every mutating path must pass through it, including HTTP handlers, CLI commands, and signal normalization. DB-level transition enforcement is deliberately not used: a CHECK constraint cannot cheaply see the previous state (it evaluates the incoming row in isolation), so encoding a (previous, next) pair would require triggers or two-row gymnastics that buy nothing under the single-writer architecture (see Concurrency Model — all writes flow through central). The cheap DB guards that are taken are the structural ones — the work_items hierarchy CHECK and same-project constraint above — not transition rules.
Event Topics
The append-only event journal uses these topics:
Project Management:
project.created: New project createdproject.updated: Project metadata modifiedproject.archived: Project marked as archived
Work Items:
work_item.created: New work item createdwork_item.updated: Work item metadata modifiedwork_item.status_changed: Work item status transitionwork_item.reparented: Work item moved to new parentwork_item.deleted: Work item deleted
Runs:
run.created: Run record created (pending status)run.spawning: tmux session creation startedrun.started: CLI process confirmed runningrun.finished: Wrapper process exited — any exit code; journaled on every wrapper exit, without exceptionrun.failed: Reserved for the failure shapes where no wrapper exit occurred or the provider hook reported failure: spawn failure (the session never started) and a provider completion hook reportingFailedrun.stopped: Process stopped by userrun.signal_received: Signal sent to processrun.output_appended: New output captured (NOTE: payload contains only metadata, not full text)run.lease_acquired: Interactive attach lease grantedrun.lease_released: Interactive attach lease released or expiredrun.orphan_detected: Unmapped tmux session discovered during reconciliation
Run topic rules (normative): every run.* payload carries the run’s attempt value alongside run_id — a consumer (SSE client, activity feed) must be able to tell which incarnation of a restarted run an event describes; without it, events from a dead incarnation are indistinguishable from the live one. run.finished and run.failed do not overlap: a non-zero exit journals run.finished with exit_code ≠ 0 and sets runs.status = failed — it does NOT additionally journal run.failed. One rule, no overlap.
Workers / Hosts:
worker.registered: Worker authenticated to the orchestrator (host → online)worker.offline: Heartbeat lost past the offline threshold (host → offline)worker.instance_suspect: A registration or heartbeat presented aninstance_idthat disagrees with the pinned value while the pinned instance still looked recently alive (OQ-14) — journaled once on the false→true transition, debounced while the host stays suspectworker.instance_reinstalled: A registration presented a differentinstance_idafter the pinned instance had already gone quiet past the offline threshold — read as a legitimate reinstall, info-level only,hosts.instance_idupdates silently
Important: Run output text is NOT journaled to events table. Only structured metadata (line count, byte count, timestamp) is included in payloads.
Storage Policy
Hybrid Persistence Model
pam uses hybrid persistence combining traditional state tables with an append-only event journal:
Current State: Stored in normalized tables (projects, work_items, runs) for efficient queries and updates.
Event Journal: Append-only events table for:
- Activity feed and audit trail
- Server-Sent Events (SSE) streaming at
/api/v1/events/stream - In-memory fan-out via
tokio::sync::broadcast
Rationale: Full event sourcing was rejected to avoid complexity in replay and projection maintenance. The hybrid model provides current state query performance with event streaming capabilities.
Data Retention
Never in PostgreSQL:
- tmux pane text and scrollback (held in the central snapshot cache in-memory + per-run on-disk logs on the worker host under its data dir
runs/) - Large output payloads (events carry only metadata)
In PostgreSQL:
- Current project/work item/run state
- Worker/host registry
- Event journal with small structured payloads (JSONB)
- Schema migration history
Events retention: Events table rows are retained for 30 days by default. Retention period is configurable via [retention] events_days setting in the configuration file. Purge is best-effort and executed at orchestrator startup. No special events are emitted during purge; the HTTP event stream may report purged sequence ranges for client awareness.
Concurrency Model
Write Model:
- PostgreSQL connection pool (sqlx) owned by the central orchestrator
- Workers never connect to PostgreSQL directly — all writes flow through the central API/uplink, keeping database credentials on exactly one host
- Short transactions; run dispatch transitions use
SELECT ... FOR UPDATEon the run row to serialize dispatch claims
Read Model:
- Multiple read connections from the same pool
- Read-only transactions for queries
Test Support:
MemStoreimplementation for in-memory testing- Same Store trait interface as the PostgreSQL implementation
- Enables fast unit tests without database I/O; a containerized PostgreSQL (ephemeral database per run) covers migration and dialect tests in CI
Entity Relationships
erDiagram
hosts ||--o{ runs : "executes on"
projects ||--o{ work_items : "contains"
work_items ||--o{ work_items : "parent-child"
work_items ||--o{ runs : "executes as"
work_items ||--o{ events : "generates"
runs ||--o{ events : "generates"
projects ||--o{ events : "generates"
runs ||--o{ processed_signals : "dedup ledger"
hosts {
uuid id PK
text name UK "Human-readable host name"
text platform "macos|linux"
text token_fingerprint "Worker token hash — raw token never stored"
text status "registered|online|offline|disabled"
uuid instance_id "Worker-generated, per-process — NULL until first instance-id-aware registration (OQ-14)"
boolean instance_suspect "Pinned instance_id disagrees with a recent/concurrent presenter (OQ-14)"
timestamptz instance_flagged_at "instance_suspect false->true transition time; NULL while not suspect"
timestamptz last_seen_at "Worker heartbeat"
timestamptz created_at
timestamptz updated_at
}
projects {
uuid id PK
text name "Human-readable name"
text slug UK "URL-safe identifier"
text description "Optional description"
text status "active|archived"
timestamptz created_at
timestamptz updated_at
}
work_items {
uuid id PK
uuid project_id FK "References projects(id)"
uuid parent_id FK "Self-reference for hierarchy"
text kind "milestone|epic|task|subtask"
text title "Item name"
text description "Optional details"
text status "todo|doing|done|cancelled"
text assignee "Optional assignee"
text path "Materialized path"
integer sort_order "Sibling ordering"
timestamptz created_at
timestamptz updated_at
}
runs {
uuid id PK
uuid task_id FK "References work_items(id)"
uuid host_id FK "References hosts(id) — NULL while pending"
text provider "claude|codex|grok|gemini"
text session_name "tmux session name — unique per host"
text status "pending|spawning|running|exited|failed|stopped"
integer attempt "Incarnation epoch — incremented on restart requeue"
bigint last_progress_seq "Highest applied progress seq in this attempt"
text failure_reason "host_lost|session_lost|spawn_failed|restart_exhausted|dispatch_timeout|no_eligible_host"
text cwd "Working directory"
integer exit_code "Process exit code"
timestamptz pending_since "Dispatch-timeout anchor — created_at at insert, reset to now() on failed->pending requeue (OQ-15)"
timestamptz started_at "Process start time"
timestamptz finished_at "Process end time"
timestamptz created_at
timestamptz updated_at
}
processed_signals {
uuid run_id PK,FK
integer attempt PK
text kind PK
timestamptz received_at
}
events {
bigint seq PK "GENERATED ALWAYS AS IDENTITY"
text topic "Event type identifier"
jsonb payload "Event data"
timestamptz created_at
}
The entity relationship diagram shows the key relationships and cardinalities between the five core tables, including the processed_signals dedup ledger (one row per applied one-shot signal kind per attempt, removed with its run via ON DELETE CASCADE). For complete schema definitions, see the SQL blocks above.