diff --git a/.pql/changelog/0000-format.sql b/.pql/changelog/0000-format.sql new file mode 100644 index 0000000..e7d3357 --- /dev/null +++ b/.pql/changelog/0000-format.sql @@ -0,0 +1,11 @@ +-- Changelog format marker, written by pql. Comments only: this file +-- is never executed — Import descends into the per-table directories +-- and does not read the changelog root. +-- +-- A changelog carrying no marker is format 1, the shape that existed +-- before formats were versioned. An older format is migrated forward +-- by `pql plan upgrade` (and automatically from the post-merge hook); +-- a newer one is refused rather than replayed under rules this binary +-- does not know. See D-28 and docs/versions.md. +-- pql:changelog_format: 2.0.0 +-- pql:written_by: 2.2.0 diff --git a/.pql/changelog/ticket_deps/0000-schema.sql b/.pql/changelog/ticket_deps/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_deps/0000-schema.sql @@ -0,0 +1,139 @@ +-- Auto-generated by pql init. CREATE TABLE statements +-- for the planning schema; per-table dir keeps the changelog +-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is +-- idempotent so running schema files from each directory in +-- replay order is harmless. +-- +-- Importer parses the markers below to detect schema drift +-- between the producing pql version and the local one — a +-- bumped canonical_version means projection rules changed +-- and replay must refuse rather than silently corrupt state. +-- pql:created_by: 2.2.0 +-- pql:canonical_version: 2 + + +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')), + domain TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','superseded','resolved','open')), + date TEXT, + file_path TEXT NOT NULL, + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS decision_refs ( + source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + ref_type TEXT NOT NULL + CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')), + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (source_id, target_id, ref_type) +); + +-- Identity split (D-26): a ticket's stable, collision-proof identity is its +-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly +-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural +-- reference (parent, deps, history, labels) targets record_id, so a label +-- clash never corrupts the graph — only ticket_idmap needs a relabel. +CREATE TABLE IF NOT EXISTS tickets ( + record_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')), + parent_record_id TEXT REFERENCES tickets(record_id), + title TEXT NOT NULL, + description TEXT, + -- No CHECK enumeration: the ticket status vocabulary is per-vault + -- configurable (ticket_statuses in .pql/config.yaml). Validation lives + -- in Go (planning.StatusSet), so adding/renaming statuses needs no + -- schema change. The DEFAULT is a harmless fallback — CreateTicket + -- always inserts the configured default explicitly. + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT DEFAULT 'medium' + CHECK(priority IN ('critical','high','medium','low')), + assigned_to TEXT, + team TEXT, + decision_ref TEXT REFERENCES decisions(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +-- ticket_idmap maps a record_id to its current friendly label (T-NNN). +-- ticket_id is intentionally NOT globally unique: two uncoordinated clones +-- can mint the same label, which surfaces as a duplicate-label collision +-- (detected at replay) and is fixed with "pql ticket relabel". +CREATE TABLE IF NOT EXISTS ticket_idmap ( + record_id TEXT PRIMARY KEY REFERENCES tickets(record_id), + ticket_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_deps ( + blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id), + blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (blocker_record_id, blocked_record_id) +); + +CREATE TABLE IF NOT EXISTS ticket_history ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT UNIQUE, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_labels ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (ticket_record_id, label) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team); +CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id); +CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id); +CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain); +CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); +CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id); diff --git a/.pql/changelog/ticket_history/0000-schema.sql b/.pql/changelog/ticket_history/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_history/0000-schema.sql @@ -0,0 +1,139 @@ +-- Auto-generated by pql init. CREATE TABLE statements +-- for the planning schema; per-table dir keeps the changelog +-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is +-- idempotent so running schema files from each directory in +-- replay order is harmless. +-- +-- Importer parses the markers below to detect schema drift +-- between the producing pql version and the local one — a +-- bumped canonical_version means projection rules changed +-- and replay must refuse rather than silently corrupt state. +-- pql:created_by: 2.2.0 +-- pql:canonical_version: 2 + + +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')), + domain TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','superseded','resolved','open')), + date TEXT, + file_path TEXT NOT NULL, + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS decision_refs ( + source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + ref_type TEXT NOT NULL + CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')), + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (source_id, target_id, ref_type) +); + +-- Identity split (D-26): a ticket's stable, collision-proof identity is its +-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly +-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural +-- reference (parent, deps, history, labels) targets record_id, so a label +-- clash never corrupts the graph — only ticket_idmap needs a relabel. +CREATE TABLE IF NOT EXISTS tickets ( + record_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')), + parent_record_id TEXT REFERENCES tickets(record_id), + title TEXT NOT NULL, + description TEXT, + -- No CHECK enumeration: the ticket status vocabulary is per-vault + -- configurable (ticket_statuses in .pql/config.yaml). Validation lives + -- in Go (planning.StatusSet), so adding/renaming statuses needs no + -- schema change. The DEFAULT is a harmless fallback — CreateTicket + -- always inserts the configured default explicitly. + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT DEFAULT 'medium' + CHECK(priority IN ('critical','high','medium','low')), + assigned_to TEXT, + team TEXT, + decision_ref TEXT REFERENCES decisions(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +-- ticket_idmap maps a record_id to its current friendly label (T-NNN). +-- ticket_id is intentionally NOT globally unique: two uncoordinated clones +-- can mint the same label, which surfaces as a duplicate-label collision +-- (detected at replay) and is fixed with "pql ticket relabel". +CREATE TABLE IF NOT EXISTS ticket_idmap ( + record_id TEXT PRIMARY KEY REFERENCES tickets(record_id), + ticket_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_deps ( + blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id), + blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (blocker_record_id, blocked_record_id) +); + +CREATE TABLE IF NOT EXISTS ticket_history ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT UNIQUE, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_labels ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (ticket_record_id, label) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team); +CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id); +CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id); +CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain); +CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); +CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id); diff --git a/.pql/changelog/ticket_idmap/0000-schema.sql b/.pql/changelog/ticket_idmap/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_idmap/0000-schema.sql @@ -0,0 +1,139 @@ +-- Auto-generated by pql init. CREATE TABLE statements +-- for the planning schema; per-table dir keeps the changelog +-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is +-- idempotent so running schema files from each directory in +-- replay order is harmless. +-- +-- Importer parses the markers below to detect schema drift +-- between the producing pql version and the local one — a +-- bumped canonical_version means projection rules changed +-- and replay must refuse rather than silently corrupt state. +-- pql:created_by: 2.2.0 +-- pql:canonical_version: 2 + + +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')), + domain TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','superseded','resolved','open')), + date TEXT, + file_path TEXT NOT NULL, + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS decision_refs ( + source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + ref_type TEXT NOT NULL + CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')), + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (source_id, target_id, ref_type) +); + +-- Identity split (D-26): a ticket's stable, collision-proof identity is its +-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly +-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural +-- reference (parent, deps, history, labels) targets record_id, so a label +-- clash never corrupts the graph — only ticket_idmap needs a relabel. +CREATE TABLE IF NOT EXISTS tickets ( + record_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')), + parent_record_id TEXT REFERENCES tickets(record_id), + title TEXT NOT NULL, + description TEXT, + -- No CHECK enumeration: the ticket status vocabulary is per-vault + -- configurable (ticket_statuses in .pql/config.yaml). Validation lives + -- in Go (planning.StatusSet), so adding/renaming statuses needs no + -- schema change. The DEFAULT is a harmless fallback — CreateTicket + -- always inserts the configured default explicitly. + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT DEFAULT 'medium' + CHECK(priority IN ('critical','high','medium','low')), + assigned_to TEXT, + team TEXT, + decision_ref TEXT REFERENCES decisions(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +-- ticket_idmap maps a record_id to its current friendly label (T-NNN). +-- ticket_id is intentionally NOT globally unique: two uncoordinated clones +-- can mint the same label, which surfaces as a duplicate-label collision +-- (detected at replay) and is fixed with "pql ticket relabel". +CREATE TABLE IF NOT EXISTS ticket_idmap ( + record_id TEXT PRIMARY KEY REFERENCES tickets(record_id), + ticket_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_deps ( + blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id), + blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (blocker_record_id, blocked_record_id) +); + +CREATE TABLE IF NOT EXISTS ticket_history ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT UNIQUE, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_labels ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (ticket_record_id, label) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team); +CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id); +CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id); +CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain); +CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); +CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id); diff --git a/.pql/changelog/ticket_labels/0000-schema.sql b/.pql/changelog/ticket_labels/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/ticket_labels/0000-schema.sql @@ -0,0 +1,139 @@ +-- Auto-generated by pql init. CREATE TABLE statements +-- for the planning schema; per-table dir keeps the changelog +-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is +-- idempotent so running schema files from each directory in +-- replay order is harmless. +-- +-- Importer parses the markers below to detect schema drift +-- between the producing pql version and the local one — a +-- bumped canonical_version means projection rules changed +-- and replay must refuse rather than silently corrupt state. +-- pql:created_by: 2.2.0 +-- pql:canonical_version: 2 + + +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')), + domain TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','superseded','resolved','open')), + date TEXT, + file_path TEXT NOT NULL, + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS decision_refs ( + source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + ref_type TEXT NOT NULL + CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')), + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (source_id, target_id, ref_type) +); + +-- Identity split (D-26): a ticket's stable, collision-proof identity is its +-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly +-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural +-- reference (parent, deps, history, labels) targets record_id, so a label +-- clash never corrupts the graph — only ticket_idmap needs a relabel. +CREATE TABLE IF NOT EXISTS tickets ( + record_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')), + parent_record_id TEXT REFERENCES tickets(record_id), + title TEXT NOT NULL, + description TEXT, + -- No CHECK enumeration: the ticket status vocabulary is per-vault + -- configurable (ticket_statuses in .pql/config.yaml). Validation lives + -- in Go (planning.StatusSet), so adding/renaming statuses needs no + -- schema change. The DEFAULT is a harmless fallback — CreateTicket + -- always inserts the configured default explicitly. + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT DEFAULT 'medium' + CHECK(priority IN ('critical','high','medium','low')), + assigned_to TEXT, + team TEXT, + decision_ref TEXT REFERENCES decisions(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +-- ticket_idmap maps a record_id to its current friendly label (T-NNN). +-- ticket_id is intentionally NOT globally unique: two uncoordinated clones +-- can mint the same label, which surfaces as a duplicate-label collision +-- (detected at replay) and is fixed with "pql ticket relabel". +CREATE TABLE IF NOT EXISTS ticket_idmap ( + record_id TEXT PRIMARY KEY REFERENCES tickets(record_id), + ticket_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_deps ( + blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id), + blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (blocker_record_id, blocked_record_id) +); + +CREATE TABLE IF NOT EXISTS ticket_history ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT UNIQUE, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_labels ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (ticket_record_id, label) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team); +CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id); +CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id); +CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain); +CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); +CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id); diff --git a/.pql/changelog/tickets/0000-schema.sql b/.pql/changelog/tickets/0000-schema.sql new file mode 100644 index 0000000..e552cd8 --- /dev/null +++ b/.pql/changelog/tickets/0000-schema.sql @@ -0,0 +1,139 @@ +-- Auto-generated by pql init. CREATE TABLE statements +-- for the planning schema; per-table dir keeps the changelog +-- self-describing per D-15. CREATE TABLE IF NOT EXISTS is +-- idempotent so running schema files from each directory in +-- replay order is harmless. +-- +-- Importer parses the markers below to detect schema drift +-- between the producing pql version and the local one — a +-- bumped canonical_version means projection rules changed +-- and replay must refuse rather than silently corrupt state. +-- pql:created_by: 2.2.0 +-- pql:canonical_version: 2 + + +CREATE TABLE IF NOT EXISTS decisions ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('confirmed','question','rejected')), + domain TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' + CHECK(status IN ('active','superseded','resolved','open')), + date TEXT, + file_path TEXT NOT NULL, + synced_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS decision_refs ( + source_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + target_id TEXT NOT NULL REFERENCES decisions(id) ON DELETE CASCADE, + ref_type TEXT NOT NULL + CHECK(ref_type IN ('supersedes','references','resolves','depends_on','amends')), + note TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (source_id, target_id, ref_type) +); + +-- Identity split (D-26): a ticket's stable, collision-proof identity is its +-- record_id (a locally-generated ULID, planning.NewRecordID); the friendly +-- T-NNN label lives in ticket_idmap and may be reconciled. Every structural +-- reference (parent, deps, history, labels) targets record_id, so a label +-- clash never corrupts the graph — only ticket_idmap needs a relabel. +CREATE TABLE IF NOT EXISTS tickets ( + record_id TEXT PRIMARY KEY, + type TEXT NOT NULL CHECK(type IN ('initiative','epic','story','task','bug')), + parent_record_id TEXT REFERENCES tickets(record_id), + title TEXT NOT NULL, + description TEXT, + -- No CHECK enumeration: the ticket status vocabulary is per-vault + -- configurable (ticket_statuses in .pql/config.yaml). Validation lives + -- in Go (planning.StatusSet), so adding/renaming statuses needs no + -- schema change. The DEFAULT is a harmless fallback — CreateTicket + -- always inserts the configured default explicitly. + status TEXT NOT NULL DEFAULT 'backlog', + priority TEXT DEFAULT 'medium' + CHECK(priority IN ('critical','high','medium','low')), + assigned_to TEXT, + team TEXT, + decision_ref TEXT REFERENCES decisions(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +-- ticket_idmap maps a record_id to its current friendly label (T-NNN). +-- ticket_id is intentionally NOT globally unique: two uncoordinated clones +-- can mint the same label, which surfaces as a duplicate-label collision +-- (detected at replay) and is fixed with "pql ticket relabel". +CREATE TABLE IF NOT EXISTS ticket_idmap ( + record_id TEXT PRIMARY KEY REFERENCES tickets(record_id), + ticket_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_deps ( + blocker_record_id TEXT NOT NULL REFERENCES tickets(record_id), + blocked_record_id TEXT NOT NULL REFERENCES tickets(record_id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (blocker_record_id, blocked_record_id) +); + +CREATE TABLE IF NOT EXISTS ticket_history ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + field TEXT NOT NULL, + old_value TEXT, + new_value TEXT, + changed_by TEXT, + changed_at TEXT NOT NULL DEFAULT (datetime('now')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT UNIQUE, + canonical_version INTEGER +); + +CREATE TABLE IF NOT EXISTS ticket_labels ( + ticket_record_id TEXT NOT NULL REFERENCES tickets(record_id), + label TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + deleted_at TEXT, + hash TEXT, + canonical_version INTEGER, + PRIMARY KEY (ticket_record_id, label) +); + +CREATE TABLE IF NOT EXISTS meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_tickets_status ON tickets(status); +CREATE INDEX IF NOT EXISTS idx_tickets_team ON tickets(team); +CREATE INDEX IF NOT EXISTS idx_tickets_decision_ref ON tickets(decision_ref); +CREATE INDEX IF NOT EXISTS idx_tickets_assigned ON tickets(assigned_to); +CREATE INDEX IF NOT EXISTS idx_tickets_parent ON tickets(parent_record_id); +CREATE INDEX IF NOT EXISTS idx_ticket_idmap_label ON ticket_idmap(ticket_id); +CREATE INDEX IF NOT EXISTS idx_decisions_domain ON decisions(domain); +CREATE INDEX IF NOT EXISTS idx_decisions_type ON decisions(type); +CREATE INDEX IF NOT EXISTS idx_decision_refs_target ON decision_refs(target_id); diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 5b0f8c3..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,72 +0,0 @@ - -# AGENTS.md - -> **Start every session by reading this file.** -> This file outlines the operational protocols, coding standards, and architectural decisions for this FastAPI project. - -## 1. Agent Operational Protocols - -### 🧠 Work Patterns (Plan-Act-Reflect) -* **Plan:** Before writing code, briefly outline your plan. Identify which files you will touch and what the side effects might be. -* **Act:** Execute the changes in small, atomic steps. -* **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests? - -### 🛡️ Git Discipline -* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`. -* **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format. - * `feat: add user login endpoint` - * `fix: resolve database connection timeout` - * `refactor: split monolith dependency file` -* **Atomic Commits:** Keep commits small. One logical change = one commit. - -### 📝 Changelog Maintenance -* **Update `CHANGELOG.md`** with every user-facing change. -* Format: `## [Unreleased] - YYYY-MM-DD` followed by `### Added`, `### Changed`, or `### Fixed`. - -### 🚀 Release Flow -When changes are ready for deployment: - -1. **Ask user if deploy cycle is desired ** - -2. **Update version** in `pyproject.toml`: - - Bug fixes: bump patch version (1.8.3 → 1.8.4) - - New features: bump minor version (1.8.4 → 1.9.0) - -3. **Update CHANGELOG.md**: - - Move items from `[Unreleased]` to new version section - - Add release date: `## [1.8.4] - 2025-12-16` - -4. **Commit and tag**: - ```bash - git add -A - git commit -m "fix: description of changes" - git tag v1.8.4 - git push origin main --tags - ``` - -5. **CI/CD triggers automatically**: - - Gitea CI builds Docker image on new tag - - Watchtower pulls and deploys to production - - Verify deployment: `curl http://192.168.86.149:8083/health` - ---- - -## 2. FastAPI Architecture & Best Practices -*Reference: [FastAPI Best Practices](https://github.com/zhanymkanov/fastapi-best-practices)* - -### 📂 Project Structure (Directory-based, NOT File-type based) -Do **not** group files by type (e.g., one huge `routers` folder). Group by **domain/module** inside a `src/` directory. - -**Correct Structure:** -```text -src/ -├── auth/ -│ ├── router.py # Endpoints -│ ├── schemas.py # Pydantic models -│ ├── service.py # Business logic (CRUD, etc.) -│ ├── dependencies.py# Module-specific dependencies -│ └── config.py # Module-specific settings -├── posts/ -│ ├── router.py -│ └── ... -└── main.py # App entry point \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5520fef --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,177 @@ +# CLAUDE.md — core-api + +FastAPI service providing infrastructure management, home automation, and utility +endpoints for the homelab. Talks to Portainer, Nginx Proxy Manager, Home Assistant, +Postgres (via SQLAlchemy async + Alembic), Qdrant, and Authentik (OIDC). Deployed on +tower-of-joy at **:8083**. + +## Ports — these differ, deliberately + +| | Port | How | +|---|---|---| +| Local dev | **8788** | `./wakeup.sh`, uvicorn `--reload`, logs to `logs/server.log` | +| Production | **8083** | container; health at `http://192.168.86.149:8083/health` | + +Testing `localhost:8083` on the dev box hits the *container*, not your reload server. + +## Live contract + +The live contract is always `http://localhost:8083/openapi.json` (62 paths, verified +2026-08-09) and human docs at `http://localhost:8083/docs` / `/redoc` — generated from +running code, so query it rather than inferring routes from source or from the README's +endpoint list, which can drift. + +## Architecture + +Domain-first layout under `src/domains//{controller,models,schemas,service}.py` +(auth, dashboard, health, housekeeping, infrastructure, static, tools). `src/main.py` +wires only `src.domains.*` — verify by reading its imports. + +**Legacy top-level packages — "not in `main.py`" does not mean dead.** Routes are wired +only from `src.domains.*`, so grepping `main.py`'s imports looks like it settles which +packages are live. It does not. `main.py:55` calls `initialize_oidc()`, and that function +(`src/shared/security.py:21`) deliberately imports and configures **both** `src.auth.oidc` +and `src.domains.auth.oidc` — a function-body import, invisible to a grep of `main.py`. +That one call drags in `src/auth/`, `src/controllers/`, `src/db/`, `src/logging_config.py` +and `src/base_schema.py` at startup. + +Three tiers, established by importing the app inside the container and reading +`sys.modules` (verified 2026-08-09): + +| Tier | Packages | +|---|---| +| Serving routes | `src/domains/`, `src/shared/`, `src/service_groups/` | +| **Loaded and configured**, but serving no routes | `src/auth/`, `src/db/`, `src/controllers/`, `src/logging_config.py`, `src/base_schema.py` | +| Genuinely unreferenced | `src/agent/`, `src/api/`, `src/clients/`, `src/dns/`, `src/memory/`, `src/models/` | + +`src/auth/` is the trap. Its `oidc_config` singleton is configured at every startup with +the real Authentik issuers — the log line `src.auth.oidc:configure` proves it — so a test +importing `src.auth.oidc` is exercising live, configured code, not a fossil. No `src/domains/*` +module depends on it, so it is configured defensively rather than used; that makes it a +deletion candidate, but a considered one, not obvious cleanup. + +**Before deleting anything from `src/`, import the app and read `sys.modules`** rather than +grepping `main.py`. Function-body imports exist here specifically to dodge circular imports, +and they are exactly what a grep misses. + +**That check has its own blind spot, so do not read the third tier as a delete list.** The +table above is a snapshot taken after a cold `import src.main` — it shows what *startup* +loads. A module imported inside a request handler would be absent from it while being +entirely live, and absence would then be a timing artifact rather than evidence of death. +This bit on webber, where a tool package imported from inside an agent method looked +unloaded and was serving every request. Nothing in core-api is currently known to work that +way, but that is the weaker claim — it means nobody has exercised the routes and re-checked, +not that nobody does it. Before deleting a third-tier package, drive the endpoints that +would plausibly load it and take the snapshot again. + +Some tests (`test_auth_controller.py`, `test_oidc.py`, `test_npm_client.py`, +`test_portainer_client.py`, `test_static_controller.py`, `test_tools_controller.py`) import +from the top-level paths rather than `src.domains.*`. Which of those cover live code follows +the table above — `test_oidc.py` does; the client tests target the unreferenced tier. Not +cleaned up in this pass; flagged, not fixed. + +Shared infra (config, database, logging, security/OIDC, external API clients) lives in +`src/shared/`. + +Group new work by **domain, not by file type** — a single large `routers/` folder is +the thing to avoid. Reference: [FastAPI best practices](https://github.com/zhanymkanov/fastapi-best-practices). + +## Database + +SQLAlchemy 2.0 async + asyncpg, migrations via Alembic (`alembic/versions/`). Models +live under `src/domains//models.py` and must be imported in `alembic/env.py` to +register with `Base.metadata` — check that file when adding a new model or `alembic +revision --autogenerate` will silently miss it. + +## Working here + +**Plan, act, reflect.** Outline which files you will touch and the side effects before +writing. Change in small atomic steps. Afterwards, verify: did existing tests break, and +does the new behaviour have a test? + +**Test locally first — the build-deploy loop is slow.** `./wakeup.sh` auto-reloads on +code changes (not on `requirements.txt` changes; restart the container/script after +adding a dependency). Deploy only when a feature is complete and tested. + +Run tests through the venv explicitly, to avoid environment mismatch: + +```bash +.venv/bin/python -m pytest tests/ +# or, with coverage: +.venv/bin/python -m pytest --cov=src --cov-report=term-missing +``` + +Copy `.env.example` to `.env` and configure Portainer, NPM, Home Assistant, SearXNG, +Postgres, and Qdrant hosts/credentials. + +No linter is configured in this repo (no ruff/flake8 config, none in `requirements.txt` +or `dev-requirements.txt`) — unlike some sibling repos, don't assume `ruff check` exists +here. + +## CI + +`.gitea/workflows/build.yml` is the only workflow: triggered on `v*` tag push, it +creates a Gitea release, builds and pushes the image, then pings Watchtower. There is +**no CI test/lint gate** — pytest only runs locally or on request. Verify tests pass +before tagging a release. + +## Work tracking + +Work lives in **pql**, not a markdown TODO. **This repo's vault is standalone** — its tickets +and its internal decisions live here in `.pql/` and `governance/`, and travel with a clone, +because `.pql/changelog/` is committed and replayed by the git hooks (D-15). The databases are +gitignored and rebuildable with `pql plan rebuild`. + +`pql` is **not** on the non-interactive `PATH` — invoke it as +`/home/jpmschweitzer/.local/bin/pql`. From inside this repo no `--vault` is needed: pql anchors +at the nearest `.git/` ancestor, which is this repo. + +```bash +/home/jpmschweitzer/.local/bin/pql ticket list # this repo's open work +/home/jpmschweitzer/.local/bin/pql plan whatsnext # next unblocked item, with context +/home/jpmschweitzer/.local/bin/pql decisions list # this repo's own decisions +``` + +Stack-level decisions that constrain this service live in the **workspace** vault and need the +flag: + +```bash +/home/jpmschweitzer/.local/bin/pql --vault /mnt/media/Projects decisions list --domain core-api +``` + +Note `ticket new --decision D-N` resolves ids within **one** vault, so a ticket here cannot link +to a workspace decision. Cite the id in the ticket body instead. + +Do not add a TODO section to a markdown file. + +## Git + +- **History is linear — no merge commits.** Work on `main`, or a short-lived branch + that is fast-forwarded and deleted. (This repo's AGENTS.md previously mandated a + feature branch for every change; that rule was retired workspace-wide on 2026-08-08 + and does not apply here anymore.) +- **Conventional Commits**: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`. +- **Atomic commits** — one logical change each. +- **Stage explicitly. Never `git add -A`** — it is denied by policy, and it sweeps in + whatever else is dirty, including secrets. +- Update `CHANGELOG.md` with every user-facing change, under `[Unreleased]` in `Added` / + `Changed` / `Fixed`. + +## Releasing + +Ask whether a deploy is wanted first — it is not automatic. + +1. Bump the version in `pyproject.toml` (patch for fixes, minor for features). +2. Move `[Unreleased]` entries into a dated version section in `CHANGELOG.md`. +3. Stage the changed files by name, commit, tag `vX.Y.Z`, `git push origin main --tags`. +4. Gitea CI (`build.yml`) builds and pushes the image on the tag; Watchtower deploys it. +5. Verify: `curl http://192.168.86.149:8083/health`. + +## Security + +- OIDC authentication via Authentik, multi-issuer/multi-audience support. +- Admin endpoints require authentication when `OIDC_ENABLED=true`. +- README claims the container "runs as non-root user (uid 1000)" — **checked and + false**: the Dockerfile has no `USER` directive, so the container runs as root. + Not fixed here (out of scope for a docs normalization pass); flagging so it isn't + restated as fact.