-- 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. -- -- `comment` was deliberately absent until the review UI existed: the anchoring -- model had to be settled against a built UI before being committed to a -- schema. It was, in Phase 5b, and the table is below. -- 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); -- There is no credential table. agent_token stood here — one instance-wide -- shared secret, stored as a sha256 — until agent issuance moved to -- tokens.sr.ht (migration 0005). A working token is signed by the instance and -- carries its own owner, expiry and grants, so authenticating one is a -- signature check in authn/ and there is nothing here to look up. -- 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); -- GraphQL-native webhooks (added in migrations/0003_webhooks.sql). -- -- These tables are written and read by core-go's webhook engine, which inserts -- `NOW() at time zone 'utc'` — a `timestamp` WITHOUT time zone. So, unlike -- spec's own tables, the three tables in THIS SECTION use bare `timestamp` to -- match pages.sr.ht and avoid timezone coercion. This is deliberate; do not -- "fix" it to TIMESTAMPTZ. The rule is scoped to core-go's own tables: `comment` -- below is spec's, added later, and correctly uses TIMESTAMPTZ. -- Users. spec.sr.ht is single-owner, but the webhook subscription is -- user-scoped in the core-go convention, so a user row is the owner's identity -- and the FK target. Columns mirror what core-go's auth.LookupUser selects, so -- the table is ready if the service ever adopts core-go auth wholesale; today -- only id/username are used (the owner row is seeded at startup). CREATE TYPE user_type AS ENUM ('PENDING','USER','ADMIN','SUSPENDED'); CREATE TABLE "user" ( id serial PRIMARY KEY, created timestamp NOT NULL, updated timestamp NOT NULL, username varchar(256) NOT NULL UNIQUE, email varchar(256) NOT NULL DEFAULT '', user_type user_type NOT NULL DEFAULT 'USER', url varchar, location varchar, bio varchar, suspension_notice varchar ); -- Proposal lifecycle events a webhook may subscribe to. CREATE TYPE webhook_event AS ENUM ('PROPOSAL_OPENED','PROPOSAL_MERGED','PROPOSAL_REJECTED'); -- The auth captured on a subscription at creation time (core-go webhooks/config.go -- AuthConfig). Only OAUTH2 and INTERNAL are storable; the check enforces it. CREATE TYPE auth_method AS ENUM ('OAUTH_LEGACY','OAUTH2','COOKIE','INTERNAL','WEBHOOK'); -- GraphQL-native user webhook subscription. The auth columns -- (auth_method/token_hash/grants/client_id/expires/node_id) mirror core-go's -- webhooks.WebhookSubscription exactly; the engine selects them by these names. CREATE TABLE gql_user_wh_sub ( id serial PRIMARY KEY, created timestamp NOT NULL, events webhook_event[] NOT NULL CHECK (cardinality(events) > 0), url varchar NOT NULL, query varchar NOT NULL, auth_method auth_method NOT NULL CHECK (auth_method IN ('OAUTH2','INTERNAL')), token_hash varchar(128) CHECK ((auth_method = 'OAUTH2') = (token_hash IS NOT NULL)), grants varchar, client_id uuid, expires timestamp CHECK ((auth_method = 'OAUTH2') = (expires IS NOT NULL)), node_id varchar CHECK ((auth_method = 'INTERNAL') = (node_id IS NOT NULL)), user_id integer NOT NULL REFERENCES "user"(id) ON DELETE CASCADE ); CREATE INDEX gql_user_wh_sub_token_hash_idx ON gql_user_wh_sub (token_hash); -- Delivery records. Columns match core-go's insert/update in webhooks/queue.go. CREATE TABLE gql_user_wh_delivery ( id serial PRIMARY KEY, uuid uuid NOT NULL, date timestamp NOT NULL, event webhook_event NOT NULL, subscription_id integer NOT NULL REFERENCES gql_user_wh_sub(id) ON DELETE CASCADE, request_body varchar NOT NULL, response_body varchar, response_headers varchar, response_status integer ); -- Inline comments on a proposal, anchored to a block of a document (added in -- migrations/0004_comment.sql). -- -- The anchor is (doc_id, heading_path, block_index, block_hash) and is -- content-first: a block is found by its hash wherever it moved to, and only -- when that fails is position consulted. Line numbers are deliberately absent — -- prose reflows, so a one-word edit moves every line below it. -- -- WHAT IS NOT STORED: whether a comment still fits. A comment is not outdated -- in general, it is outdated *at a revision*, and a proposal branch moves under -- it as the agent revises. Anchor state is derived at read time by -- core.ResolveAnchor. A column here would cache a function of a moving input -- and be wrong every time the agent pushed. CREATE TABLE comment ( id SERIAL PRIMARY KEY, proposal_id INTEGER NOT NULL REFERENCES proposal(id) ON DELETE CASCADE, -- A reply. One level only: a thread is a root plus its replies, which is -- what a review conversation with one human and one agent actually is. -- Enforced in db/comment.go, since a CHECK cannot look at another row. parent_id INTEGER REFERENCES comment(id) ON DELETE CASCADE, -- The anchor. NULL on a reply, which inherits its root's rather than -- carrying a copy that could drift from it. doc_id TEXT, -- archive addressing key: "SPEC-0007", or the path doc_path TEXT, -- path as at comment time; display, and which diff it belongs to heading_path TEXT[], -- enclosing headings, outermost first block_index INTEGER, -- position within heading_path, NOT within the document block_hash TEXT, -- prosediff block hash when the comment was written side TEXT, -- new | old ("old" only for a block the proposal deletes) body TEXT NOT NULL, author TEXT NOT NULL, -- owner username, or "claude-code/spec-writer" author_kind TEXT NOT NULL, -- human | agent agent_session TEXT, created TIMESTAMPTZ NOT NULL DEFAULT now(), -- Set when the owner resolves the thread. Agents may not resolve — an agent -- marking its own critique resolved would defeat the auto-merge gate — which -- is enforced in the service layer, where the caller's identity is known; -- this column only records that it happened. resolved TIMESTAMPTZ, CONSTRAINT ck_comment_body CHECK (length(btrim(body)) > 0), CONSTRAINT ck_comment_author CHECK (length(author) > 0), CONSTRAINT ck_comment_author_kind CHECK (author_kind IN ('human', 'agent')), -- Provenance, on the same rule as proposal: the identity strings, not the -- credential, are what identify who said what, so an agent comment without a -- session is not a comment with a missing field — it is an unattributable -- one. NOT NULL alone would accept the empty string and lose that. CONSTRAINT ck_comment_provenance CHECK ( (author_kind = 'agent') = (agent_session IS NOT NULL AND length(agent_session) > 0) ), -- A root carries the whole anchor and a reply carries none of it. Written as -- one predicate over every anchor column so a half-populated anchor — the -- shape a partial write would leave — cannot be stored at all. CONSTRAINT ck_comment_anchor CHECK ( (parent_id IS NULL) = (doc_id IS NOT NULL) AND (doc_id IS NULL) = (doc_path IS NULL) AND (doc_id IS NULL) = (heading_path IS NULL) AND (doc_id IS NULL) = (block_index IS NULL) AND (doc_id IS NULL) = (block_hash IS NULL) AND (doc_id IS NULL) = (side IS NULL) ), CONSTRAINT ck_comment_side CHECK (side IS NULL OR side IN ('new', 'old')), CONSTRAINT ck_comment_block_index CHECK (block_index IS NULL OR block_index >= 0), -- Resolution is a property of the thread, not of one message in it. CONSTRAINT ck_comment_resolved CHECK (resolved IS NULL OR parent_id IS NULL) ); -- The review page reads a proposal's whole thread set in one go. CREATE INDEX ix_comment_proposal ON comment (proposal_id, created); -- Replies, by thread. CREATE INDEX ix_comment_parent ON comment (parent_id) WHERE parent_id IS NOT NULL; -- The auto-merge gate asks one question — "does this proposal have an -- unresolved thread?" — on every policy merge, so it gets its own partial index -- rather than scanning a proposal's comments to answer it. CREATE INDEX ix_comment_unresolved ON comment (proposal_id) WHERE parent_id IS NULL AND resolved IS NULL;