~bigbes/sr-ht-spec

ref: e756d504ebc789e9e88f9bdd175d57afd4c87439 sr-ht-spec/schema.sql -rw-r--r-- 13.8 KiB
e756d504 — Eugene Blikh web: draw the whole web tier from sr-ht-ecore 9 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
-- 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;