-- spec.sr.ht full initial schema. -- -- This is the authoritative DDL for a fresh install. The brant migration in -- migrations/0001_initial.sql applies the same objects incrementally; keep the -- two in sync. -- -- Git is authoritative for document bodies; Postgres never stores a body. Every -- table here holds only what git cannot answer cheaply, and every table here is -- reconstructable from refs by the reconciler — which is what makes the -- non-transactional merge path (git refs, then Postgres, then the index) -- tolerable. -- -- Deliberately absent: `comment` (inline comments are post-v1, and the -- anchoring model should be settled by building the review UI before it is -- committed to a schema). -- Spaces exist as repos; this table is for listing and index bookkeeping. CREATE TABLE space ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, -- "bigbes", no ~ prefix name TEXT NOT NULL, created TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_space_owner_name UNIQUE (owner, name) ); -- Global, not per-project: a later import cannot collide. doc_id is the PRIMARY -- KEY rather than a (space_id, doc_id) pair on purpose — global uniqueness is -- the invariant the whole link/comment/staleness model rests on, so a colliding -- registration must be impossible to insert, not merely detected in Go. CREATE TABLE document_id ( doc_id TEXT PRIMARY KEY, -- "SPEC-0007" space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE, path TEXT NOT NULL, -- current path on the approved branch updated_rev TEXT NOT NULL ); CREATE TABLE proposal ( id SERIAL PRIMARY KEY, space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE, title TEXT NOT NULL, rationale TEXT, base_rev TEXT NOT NULL, -- the If-Match value; does not move branch TEXT NOT NULL, -- "proposals/42" state TEXT NOT NULL, -- open | merged | rejected approval TEXT, -- human | policy, set on merge merged_rev TEXT, agent TEXT NOT NULL, -- "claude-code/spec-writer" agent_session TEXT NOT NULL, created TIMESTAMPTZ NOT NULL DEFAULT now(), resolved TIMESTAMPTZ, -- The state machine is `open -> merged` and `open -> rejected`, and nothing -- else. These constraints make every row that would contradict it -- unwritable; the UPDATE ... WHERE state = 'open' guard in db/proposal.go -- is what makes an illegal *transition* unwritable. CONSTRAINT ck_proposal_state CHECK (state IN ('open', 'merged', 'rejected')), CONSTRAINT ck_proposal_approval CHECK (approval IS NULL OR approval IN ('human', 'policy')), -- Auto-merged is not human-approved and readers must be able to tell, so a -- merged row without an approval kind (or an unmerged row carrying one) -- would launder unreviewed agent output as blessed. CONSTRAINT ck_proposal_merged CHECK ((state = 'merged') = (approval IS NOT NULL)), CONSTRAINT ck_proposal_merged_rev CHECK ((state = 'merged') = (merged_rev IS NOT NULL)), CONSTRAINT ck_proposal_resolved CHECK ((state = 'open') = (resolved IS NULL)), -- Provenance is the one thing that is not optional: one shared token still -- yields a full audit trail because the identity strings, not the -- credential, are what identify who did what. NOT NULL alone would accept -- the empty string and lose that. CONSTRAINT ck_proposal_provenance CHECK (length(agent) > 0 AND length(agent_session) > 0) ); -- The inbox ("N proposals waiting on you") and the digest are both -- state-filtered, newest-first scans. CREATE INDEX ix_proposal_state_created ON proposal (state, created DESC); -- Only a hash is ever stored; the token itself exists exactly once, at mint -- time, in the response to the operator. CREATE TABLE agent_token ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, token_hash BYTEA NOT NULL UNIQUE, created TIMESTAMPTZ NOT NULL DEFAULT now(), revoked TIMESTAMPTZ ); -- Index staleness: compared against the space's approved head. CREATE TABLE index_stamp ( space_id INTEGER PRIMARY KEY REFERENCES space(id) ON DELETE CASCADE, rev TEXT NOT NULL, indexed_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- "What landed since you last looked", for the policy-merged digest. CREATE TABLE digest_mark ( owner TEXT PRIMARY KEY, seen_at TIMESTAMPTZ NOT NULL ); -- A project is pure metadata: a saved filter, not a container. It owns no index -- and no storage, so this table is a name and the next one is the filter. -- -- There is exactly one bleve index; querying a project means restricting that -- index to the project's spaces. Per-project indexes were specified in an -- earlier draft and retracted: every merge would fan out to N rebuilds and -- adding a space to a project would force one. Nothing here scopes document -- IDs either — those are global (see document_id above), because projects are -- edited *after* merges, so a per-project registry could juxtapose two -- already-merged documents sharing an ID with no merge left to reject. CREATE TABLE project ( id SERIAL PRIMARY KEY, owner TEXT NOT NULL, -- "bigbes", no ~ prefix name TEXT NOT NULL, -- "tarantool", no + prefix created TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT uq_project_owner_name UNIQUE (owner, name) ); -- The filter itself: which spaces a project selects. The composite primary key -- is what makes membership a set — a space cannot be added to a project twice, -- so no query has to deduplicate — and it is also the index for resolving a -- project to its spaces. Both sides cascade: deleting a project drops its -- membership rows and nothing else, and deleting a space removes it from every -- project that named it rather than leaving a dangling filter term. CREATE TABLE project_space ( project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE, PRIMARY KEY (project_id, space_id) ); -- The other direction: which projects contain this space. Not covered by the -- primary key, whose leading column is project_id. CREATE INDEX ix_project_space_space ON project_space (space_id);