diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..2b94b38 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,69 @@ +{ + "permissions": { + "allow": [ + "Bash(pql)", + "Bash(pql *)", + "Bash(git status:*)", + "Bash(git log:*)", + "Bash(git diff:*)", + "Bash(git branch:*)", + "Bash(pytest:*)", + "Bash(python -m pytest:*)", + "Bash(ruff check:*)", + "Bash(docker logs library-desk:*)", + "Bash(curl -s http://localhost:8089/*)" + ], + + "deny": [ + "Bash(sudo *)", + "Bash(su *)", + + "Bash(rm -rf /*)", + "Bash(rm -rf ~*)", + "Bash(rm -rf $HOME*)", + "Bash(mkfs*)", + "Bash(dd if=*)", + "Bash(chmod 777 *)", + "Bash(chmod -R 777 *)", + + "Bash(find * -exec*)", + "Bash(find * -delete*)", + + "Bash(git add -A*)", + "Bash(git * add -A*)", + "Bash(git add --all*)", + "Bash(git * add --all*)", + "Bash(git add .)", + "Bash(git * add .)", + "Bash(git push --force*)", + "Bash(git * push --force*)", + "Bash(git push -f*)", + "Bash(git * push -f*)", + "Bash(git reset --hard*)", + "Bash(git * reset --hard*)", + "Bash(git clean -fd*)", + "Bash(git * clean -fd*)", + "Bash(git clean -fdx*)", + "Bash(git * clean -fdx*)", + "Bash(git branch -D *)", + "Bash(git * branch -D *)", + "Bash(git checkout -- *)", + "Bash(git * checkout -- *)", + "Bash(git restore .*)", + "Bash(git * restore .*)", + "Bash(git commit --no-verify*)", + "Bash(git * commit --no-verify*)", + "Bash(git merge --no-ff*)", + "Bash(git * merge --no-ff*)", + + "Bash(redis-cli * FLUSHALL*)", + "Bash(redis-cli FLUSHALL*)", + "Bash(redis-cli * FLUSHDB*)", + "Bash(redis-cli FLUSHDB*)", + "Bash(dropdb *)", + "Bash(psql * -c DROP*)", + "Bash(psql * DROP DATABASE*)", + "Bash(psql * TRUNCATE*)" + ] + } +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..788efb1 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +.pql/changelog/*.sql merge=union diff --git a/.gitignore b/.gitignore index 52ec92b..5c033e5 100644 --- a/.gitignore +++ b/.gitignore @@ -136,3 +136,12 @@ Thumbs.db # profiling data .prof + +# Claude Code — local settings hold credentials. A global gitignore covers this +# on tower-of-joy, but that protection does not travel with the repo. +.claude/settings.local.json + +# pql — ignore everything except the changelog, which is the replication log of +# record and must be committed for tickets to travel with the repo. +.pql/* +!.pql/changelog/ 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_history/2026-08.sql b/.pql/changelog/ticket_history/2026-08.sql new file mode 100644 index 0000000..ca02e72 --- /dev/null +++ b/.pql/changelog/ticket_history/2026-08.sql @@ -0,0 +1,43 @@ +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'description', NULL, 'Dockerfile runs `uvicorn ... --workers 2`, and `startup_event` in src/main.py creates a +WikiChangeListener per worker. Each opens its own PostgreSQL LISTEN connection on +`wiki_page_changes`, and Postgres delivers NOTIFY to *every* listening session — so both +workers process the same event. + +Proven against production v1.9.1 on 2026-08-08: +- pg_stat_activity shows two library_desk_listener sessions, both LISTEN "wiki_page_changes" +- "Wiki.js change listener started successfully" is logged twice at startup +- a single pg_notify(''wiki_page_changes'', ''claude-probe-no-colons'') produced exactly two + "Invalid notification payload" lines, one per worker (that payload is rejected before any + ingestion runs, so it is a side-effect-free probe) + +The debounce in wiki_change_listener.py (self._recent_notifications) is an in-process dict +and cannot dedupe across workers. No Redis lock or other cross-process guard exists in the +process_wiki_page_change path in src/routers/webhooks.py. + +IMPACT: each edit runs ingestion twice — duplicate embedding generation against Ollama plus +duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama shares the GPU with +Speaches/Whisper, so the wasted work has a cost beyond CPU. + +NOT YET ESTABLISHED: whether this is merely wasteful or actually corrupting. Check whether +repeated ingestion of the same page creates duplicate Neo4j entities/relationships or +duplicate Qdrant points — that changes the severity. + +OPTIONS TO WEIGH: +- run the listener in one place only (single-worker sidecar, or elect an owner) so the + subscription is singular by construction +- keep per-worker listeners and add a short-TTL Redis claim lock keyed on page_id, so only + the first worker to claim a notification processes it. Redis is already a dependency — + the job manager uses it. + +Whatever approach is taken must preserve the reconnect supervision added in v1.9.1 and its +tests in tests/test_wiki_change_listener.py. + +Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:25', '2026-08-08 21:53:25.051', '2026-08-08 21:53:25.051', NULL, '14eeab6aa32e7b47edf3bdf1fdb572a8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.143', '2026-08-08 21:53:37.143', NULL, 'd793037ced050d17996991b71199c4e6', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.178', '2026-08-08 21:53:37.178', NULL, 'a9feaf6cf70f87d9f3e12e73311120d8', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.179', '2026-08-08 21:53:37.179', NULL, 'dfbe9ea63d608c433b8af3291c6ade71', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'parent_id', NULL, 'T-2', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.179', '2026-08-08 21:53:37.179', NULL, 'ecd603354edd2fc778a38e75782a10a1', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'description', NULL, 'Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. Needs: query existing documents by path, compare content hashes, return list of updates needed. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.284', '2026-08-08 21:53:37.284', NULL, 'f0dc060e8f3931fa30026d5476d6b906', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'description', NULL, 'Get processing status for a document. Needs a status tracking system (Redis or database) and per-document ingestion progress. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.421', '2026-08-08 21:53:37.421', NULL, '9ac8b8db277ef5f96fec157e6e1db2e7', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'description', NULL, 'Get indexing status for an entire repository. Needs repository-level statistics and tracking of which documents from a repo are indexed. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.573', '2026-08-08 21:53:37.573', NULL, 'cf4fca0273bf1de3fa9d2c63ff97323a', 2) ON CONFLICT(hash) DO NOTHING; +INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'description', NULL, 'Check for duplicate or highly similar documents using vector similarity and graph analysis. Needs: get document embedding from Qdrant, find similar vectors above threshold, check graph relationships, return candidates with similarity scores. Migrated from TODO.md 2026-08-08.', NULL, '2026-08-08 21:53:37', '2026-08-08 21:53:37.698', '2026-08-08 21:53:37.698', NULL, 'a96ed5423c39f04a515770ad6d60fb10', 2) ON CONFLICT(hash) DO NOTHING; 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_idmap/2026-08.sql b/.pql/changelog/ticket_idmap/2026-08.sql new file mode 100644 index 0000000..c889da5 --- /dev/null +++ b/.pql/changelog/ticket_idmap/2026-08.sql @@ -0,0 +1,6 @@ +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'T-1', '2026-08-08 21:53:14.322', '2026-08-08 21:53:14.322', NULL, 'f4caa0a362b236ef584eff529bfca422', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF8M3ZYNMK67A0VQYYQ7W', 'T-2', '2026-08-08 21:53:30.273', '2026-08-08 21:53:30.273', NULL, '413e76364f3d44f17cd86ef8badd107b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'T-3', '2026-08-08 21:53:30.408', '2026-08-08 21:53:30.408', NULL, '9bab8d22e7616f0376e7af232dc789a1', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'T-4', '2026-08-08 21:53:30.525', '2026-08-08 21:53:30.525', NULL, '188f0b5550707f6c55ccdabf01762e6b', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'T-5', '2026-08-08 21:53:30.651', '2026-08-08 21:53:30.651', NULL, '581e4c4fdba8fcfb414aa5212cb43995', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; +INSERT INTO ticket_idmap (record_id, ticket_id, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'T-6', '2026-08-08 21:53:30.767', '2026-08-08 21:53:30.767', NULL, 'fb194a09d4e8750537a61782e8d1eb12', 2) ON CONFLICT(record_id) DO UPDATE SET ticket_id=excluded.ticket_id, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= ticket_idmap.updated_at; 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/.pql/changelog/tickets/2026-08.sql b/.pql/changelog/tickets/2026-08.sql new file mode 100644 index 0000000..0f84b0a --- /dev/null +++ b/.pql/changelog/tickets/2026-08.sql @@ -0,0 +1,49 @@ +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'bug', NULL, 'Every wiki page change is ingested twice', NULL, 'backlog', 'high', NULL, NULL, NULL, '2026-08-08 21:53:14.322', '2026-08-08 21:53:14.322', NULL, 'e93392a341072352b147a3466ed3ab5b', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QDAA8EWH7D2KKQF3TBHXR', 'bug', NULL, 'Every wiki page change is ingested twice', 'Dockerfile runs `uvicorn ... --workers 2`, and `startup_event` in src/main.py creates a +WikiChangeListener per worker. Each opens its own PostgreSQL LISTEN connection on +`wiki_page_changes`, and Postgres delivers NOTIFY to *every* listening session — so both +workers process the same event. + +Proven against production v1.9.1 on 2026-08-08: +- pg_stat_activity shows two library_desk_listener sessions, both LISTEN "wiki_page_changes" +- "Wiki.js change listener started successfully" is logged twice at startup +- a single pg_notify(''wiki_page_changes'', ''claude-probe-no-colons'') produced exactly two + "Invalid notification payload" lines, one per worker (that payload is rejected before any + ingestion runs, so it is a side-effect-free probe) + +The debounce in wiki_change_listener.py (self._recent_notifications) is an in-process dict +and cannot dedupe across workers. No Redis lock or other cross-process guard exists in the +process_wiki_page_change path in src/routers/webhooks.py. + +IMPACT: each edit runs ingestion twice — duplicate embedding generation against Ollama plus +duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama shares the GPU with +Speaches/Whisper, so the wasted work has a cost beyond CPU. + +NOT YET ESTABLISHED: whether this is merely wasteful or actually corrupting. Check whether +repeated ingestion of the same page creates duplicate Neo4j entities/relationships or +duplicate Qdrant points — that changes the severity. + +OPTIONS TO WEIGH: +- run the listener in one place only (single-worker sidecar, or elect an owner) so the + subscription is singular by construction +- keep per-worker listeners and add a short-TTL Redis claim lock keyed on page_id, so only + the first worker to claim a notification processes it. Redis is already a dependency — + the job manager uses it. + +Whatever approach is taken must preserve the reconnect supervision added in v1.9.1 and its +tests in tests/test_wiki_change_listener.py. + +Migrated from TODO.md 2026-08-08.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-08 21:53:14.322', '2026-08-08 21:53:25.051', NULL, '34ff99ff78472b24fc35096faa05c8df', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF8M3ZYNMK67A0VQYYQ7W', 'epic', NULL, 'Implement the stub endpoints in src/main.py', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.273', '2026-08-08 21:53:30.273', NULL, '84c45755f930b52d22238199335e6641', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', NULL, 'Implement POST /ingest/check-updates', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:30.407', NULL, '2811d4e9372b5c838f8d524a57fc0b7c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', NULL, 'Implement GET /ingest/status/{document_id}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:30.525', NULL, 'b9f5120a8a63b817dc815bda5615a75a', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', NULL, 'Implement GET /ingest/repo-status/{repository}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:30.651', NULL, 'cb00e75f1f91e819f15f345eb18db460', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', NULL, 'Implement POST /deduplicate/check', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:30.767', NULL, '0300426ec940c0d43e36241d0b5968d1', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /ingest/check-updates', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:37.143', NULL, '2401d236cc1b289afa88a791a69caeec', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/status/{document_id}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:37.178', NULL, '36d4868e94a8f66a98fc370ac83b1e7c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/repo-status/{repository}', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:37.179', NULL, '8b44d54948e21faf5a7fa6bf20f25281', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /deduplicate/check', NULL, 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:37.179', NULL, 'd78fc0b019f24a626f6c9c800fc54023', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF94YZ1RRP8MYEZCZKJRM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /ingest/check-updates', 'Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. Needs: query existing documents by path, compare content hashes, return list of updates needed. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.407', '2026-08-08 21:53:37.284', NULL, 'a4005f13ed2272d82bcd4b3172e9aa57', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QF9KJ96C95T493C8WHQM0', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/status/{document_id}', 'Get processing status for a document. Needs a status tracking system (Redis or database) and per-document ingestion progress. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.525', '2026-08-08 21:53:37.421', NULL, '29670c509669343cababf536be083e6f', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFA3CB42112FNPTADNCCG', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement GET /ingest/repo-status/{repository}', 'Get indexing status for an entire repository. Needs repository-level statistics and tracking of which documents from a repo are indexed. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.651', '2026-08-08 21:53:37.572', NULL, '76be99d15816829ef4cd210107630d76', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; +INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FY6QFAHYRXZBK58NY73K3YSM', 'task', '06FY6QF8M3ZYNMK67A0VQYYQ7W', 'Implement POST /deduplicate/check', 'Check for duplicate or highly similar documents using vector similarity and graph analysis. Needs: get document embedding from Qdrant, find similar vectors above threshold, check graph relationships, return candidates with similarity scores. Migrated from TODO.md 2026-08-08.', 'backlog', 'medium', NULL, NULL, NULL, '2026-08-08 21:53:30.767', '2026-08-08 21:53:37.698', NULL, '2643ba102e78a305f8b70e329cbba84d', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at; diff --git a/AGENTS.md b/AGENTS.md index 345a4ea..8984434 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,8 +11,18 @@ * **Act:** Execute the changes in small, atomic steps. * **Reflect:** After coding, verify your work. Did you break existing tests? Did you add new tests? +### 📋 Work Tracking +* **Outstanding work lives in pql, not in a markdown file.** `TODO.md` was migrated + on 2026-08-08 and removed. +* `pql ticket list` — open work. `pql plan whatsnext` — the next unblocked item with + context. `pql ticket show T-N --with-children` — detail. +* File new work with `pql ticket new bug|task|story|epic "title"` rather than adding a + TODO section. Tickets travel with the repo: `.pql/changelog/` is committed and + replayed by the git hooks, while the databases are ignored and rebuildable + (`pql plan rebuild`). + ### 🛡️ Git Discipline -* **NEVER commit to `main` or `master` directly.** Always create a feature branch: `feature/your-feature-name` or `fix/issue-description`. +* **History is linear — no merge commits.** Work goes onto `main` directly, or onto a short-lived branch that is fast-forwarded and deleted. Never create a merge commit. (This replaced a feature-branch mandate on 2026-08-08, to match the workspace convention; `tatlock` remains the one repo that requires branches.) * **Commit Messages:** Use the [Conventional Commits](https://www.conventionalcommits.org/) format. * `feat: add user login endpoint` * `fix: resolve database connection timeout` diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 2de5202..0000000 --- a/TODO.md +++ /dev/null @@ -1,85 +0,0 @@ -# TODO - -Outstanding work items for Library Desk. - -## Bugs - -### Every wiki page change is ingested twice - -`Dockerfile` runs `uvicorn ... --workers 2`, and `startup_event` in `src/main.py` -creates a `WikiChangeListener` per worker. Each opens its own PostgreSQL `LISTEN` -connection on `wiki_page_changes`, and Postgres delivers `NOTIFY` to *every* -listening session — so both workers process the same event. - -Proven against production v1.9.1 on 2026-08-08: - -- `pg_stat_activity` shows two `library_desk_listener` sessions, both - `LISTEN "wiki_page_changes"` -- "Wiki.js change listener started successfully" is logged twice at startup -- a single `pg_notify('wiki_page_changes', 'claude-probe-no-colons')` produced - exactly two "Invalid notification payload" lines, one per worker (that payload - is rejected before any ingestion runs, so it is a side-effect-free probe) - -The debounce in `wiki_change_listener.py` (`self._recent_notifications`) is an -in-process dict and cannot dedupe across workers. No Redis lock or other -cross-process guard exists in the `process_wiki_page_change` path in -`src/routers/webhooks.py`. - -**Impact:** each edit runs ingestion twice — duplicate embedding generation -against Ollama plus duplicate Qdrant and Neo4j writes. On tower-of-joy, Ollama -shares the GPU with Speaches/Whisper, so the wasted work has a cost beyond CPU. - -**Not yet established:** whether this is merely wasteful or actually corrupting. -Check whether repeated ingestion of the same page creates duplicate Neo4j -entities/relationships or duplicate Qdrant points — that changes the severity. - -**Options to weigh:** - -- run the listener in one place only (single-worker sidecar, or elect an owner) - so the subscription is singular by construction -- keep per-worker listeners and add a short-TTL Redis claim lock keyed on - `page_id`, so only the first worker to claim a notification processes it. - Redis is already a dependency — the job manager uses it. - -Whatever approach is taken must preserve the reconnect supervision added in -v1.9.1 and its tests in `tests/test_wiki_change_listener.py`. - -## Stub Endpoints to Implement - -The following endpoints in `src/main.py` return stub responses and need real implementations: - -### Ingestion Status Endpoints - -#### `POST /ingest/check-updates` -Check which documents need updating based on content hashes. Used by Scheduler to determine what changed since last sync. - -**Implementation needed:** -1. Query existing documents by path -2. Compare content hashes -3. Return list of updates needed - -#### `GET /ingest/status/{document_id}` -Get processing status for a document. - -**Implementation needed:** -- Status tracking system (Redis or database) -- Track ingestion progress per document - -#### `GET /ingest/repo-status/{repository}` -Get indexing status for an entire repository. - -**Implementation needed:** -- Repository-level statistics -- Track which documents from a repo are indexed - -### Deduplication - -#### `POST /deduplicate/check` -Check for duplicate or highly similar documents using vector similarity and graph analysis. - -**Implementation needed:** -1. Get document embedding from Qdrant -2. Find similar vectors above threshold -3. Check graph relationships -4. Return candidates with similarity scores - diff --git a/governance/README.md b/governance/README.md new file mode 100644 index 0000000..0945720 --- /dev/null +++ b/governance/README.md @@ -0,0 +1,54 @@ +# Decisions, Questions, Rejected + +This directory holds structured planning records that pql parses +into pql.db. Each record is a `### [DQR]-N: Title` heading inside +a markdown file. Files live in three per-type subdirectories: + +- `decisions/.md` — confirmed design decisions +- `questions/.md` — open questions that may resolve into + decisions or rejected proposals +- `rejected/.md` — rejected proposals (kept for the audit + trail) + +The parser infers domain from the filename stem and record type +from the parent subdirectory. + +D-records that propose implementation work link to `initiative`-type +tickets via `decision_ref`. Run `pql decisions show +--with-tickets` to inspect implementation status. + +## Recommended domains + +Start with this canonical set; create files as records land in +each domain: + +- **architecture** — structural commitments (storage, layering, + languages, libraries) +- **process** — team workflow (commits, branches, releases, reviews) +- **design** — user-facing surface (UX, UI, public APIs) +- **coding-conventions** — team-internal code shape (style, lint, + file layout) +- **testing** — quality strategy (coverage, layers, gates) + +You might also want, project-permitting: + +- `accessibility` — if you ship user-facing software +- `security` — if you handle user data or network surfaces +- `licensing` — if you release open-source or commercial +- `documentation` — if user-docs are non-trivial +- `deployment` — if shipping is non-trivial +- `performance` — if you have perf budgets / SLOs + + + +## Decisions + +- _(none)_ + +## Open questions + +- _(none)_ + +## Rejected + +- _(none)_