A db/db_test.go => db/db_test.go +154 -0
@@ 0,0 1,154 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "database/sql"
+ "encoding/hex"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+
+ _ "github.com/lib/pq"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// testEnv names the DSN env var that gates the Postgres-backed tests. When
+// unset, every integration test in this package skips with a clear message; the
+// pure-logic tests (transition guards, token hashing, batch duplicate
+// detection, schema/migration agreement) run regardless, so `go test ./db/...`
+// is meaningful on a machine with no database at all.
+const testEnv = "SPECSRHT_TEST_PG"
+
+// newTestStore connects to the Postgres pointed at by SPECSRHT_TEST_PG, creates
+// an isolated scratch schema (specsrht_test_<random>), applies schema.sql into
+// it, and returns a Store bound to a pool whose search_path is that schema. The
+// returned cleanup drops the schema and closes the pools. If the env var is
+// unset the test is skipped.
+//
+// Isolation is achieved with a per-test schema rather than a whole database so
+// no admin/CREATE DATABASE privilege is required and cleanup is a single
+// DROP SCHEMA ... CASCADE. The scratch pool routes every connection to that
+// schema via lib/pq's `options=-c search_path=...` startup parameter.
+func newTestStore(t *testing.T) (*Store, *sql.DB, func()) {
+ t.Helper()
+ base := os.Getenv(testEnv)
+ if base == "" {
+ t.Skipf("%s not set; skipping Postgres-backed test (set it to a DSN to run)", testEnv)
+ }
+
+ admin, err := sql.Open("postgres", base)
+ if err != nil {
+ t.Fatalf("open admin pool: %v", err)
+ }
+ if err := admin.Ping(); err != nil {
+ admin.Close()
+ t.Fatalf("ping %s: %v", testEnv, err)
+ }
+
+ schema := "specsrht_test_" + randToken()
+ if _, err := admin.Exec(`CREATE SCHEMA "` + schema + `"`); err != nil {
+ admin.Close()
+ t.Fatalf("create schema %s: %v", schema, err)
+ }
+
+ scopedDSN, err := withSearchPath(base, schema)
+ if err != nil {
+ admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
+ admin.Close()
+ t.Fatalf("build scoped dsn: %v", err)
+ }
+ pool, err := sql.Open("postgres", scopedDSN)
+ if err != nil {
+ admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
+ admin.Close()
+ t.Fatalf("open scoped pool: %v", err)
+ }
+
+ ddl, err := os.ReadFile("../schema.sql")
+ if err != nil {
+ pool.Close()
+ admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
+ admin.Close()
+ t.Fatalf("read schema.sql: %v", err)
+ }
+ if _, err := pool.Exec(string(ddl)); err != nil {
+ pool.Close()
+ admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`)
+ admin.Close()
+ t.Fatalf("apply schema.sql: %v", err)
+ }
+
+ cleanup := func() {
+ pool.Close()
+ if _, err := admin.Exec(`DROP SCHEMA "` + schema + `" CASCADE`); err != nil {
+ t.Errorf("drop schema %s: %v", schema, err)
+ }
+ admin.Close()
+ }
+ return NewStore(pool), pool, cleanup
+}
+
+// withSearchPath returns base with a connection option that pins search_path to
+// schema for every pooled connection, handling both URL and keyword DSN forms.
+func withSearchPath(base, schema string) (string, error) {
+ opt := "-c search_path=" + schema
+ if strings.Contains(base, "://") {
+ u, err := url.Parse(base)
+ if err != nil {
+ return "", err
+ }
+ q := u.Query()
+ q.Set("options", opt)
+ u.RawQuery = q.Encode()
+ return u.String(), nil
+ }
+ return base + " options='" + opt + "'", nil
+}
+
+func randToken() string {
+ b := make([]byte, 8)
+ if _, err := rand.Read(b); err != nil {
+ panic(err)
+ }
+ return hex.EncodeToString(b)
+}
+
+// mkSpace is a convenience for CreateSpace in tests.
+func mkSpace(t *testing.T, s *Store, ctx context.Context, owner, name string) *Space {
+ t.Helper()
+ sp, err := s.CreateSpace(ctx, core.SpaceRef{Owner: owner, Name: name})
+ if err != nil {
+ t.Fatalf("create space ~%s/%s: %v", owner, name, err)
+ }
+ return sp
+}
+
+// docID parses a document ID or fails the test.
+func docID(t *testing.T, s string) core.DocID {
+ t.Helper()
+ id, err := core.ParseDocID(s)
+ if err != nil {
+ t.Fatalf("parse doc id %q: %v", s, err)
+ }
+ return id
+}
+
+// mkProposal opens a proposal with plausible provenance.
+func mkProposal(t *testing.T, s *Store, ctx context.Context, spaceID int, title string) *Proposal {
+ t.Helper()
+ p, err := s.OpenProposal(ctx, &Proposal{
+ SpaceID: spaceID,
+ Title: title,
+ Rationale: "because",
+ BaseRev: "0000000000000000000000000000000000000000",
+ Agent: "claude-code/spec-writer",
+ AgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
+ })
+ if err != nil {
+ t.Fatalf("open proposal %q: %v", title, err)
+ }
+ return p
+}
A db/document.go => db/document.go +327 -0
@@ 0,0 1,327 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/lib/pq"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// Document is one row of the global document ID registry: where the document
+// carrying this ID currently lives on its space's approved branch.
+//
+// Paths move; IDs do not. Cross-space links resolve by ID, comment anchors
+// reference IDs, and merge staleness is keyed by ID — so this table is the map
+// from the stable name to the moving one, and nothing else.
+type Document struct {
+ ID core.DocID
+ SpaceID int
+ Path string
+ UpdatedRev string
+}
+
+// DocRef pairs a document ID with the path it occupies in a tree. It is the
+// unit the push validator and the merge path work in: a set of (id, path) taken
+// from the frontmatter of the documents a push or a proposal touches.
+type DocRef struct {
+ ID core.DocID
+ Path string
+}
+
+// Collision is one registered ID that a batch tried to claim for a different
+// space. Existing is the row that already holds it, so the rejection message
+// can name where the ID actually lives instead of just saying "taken".
+type Collision struct {
+ DocID core.DocID
+ Existing *Document
+}
+
+func (c Collision) String() string {
+ return fmt.Sprintf("%s is already registered in space %d at %s",
+ c.DocID, c.Existing.SpaceID, c.Existing.Path)
+}
+
+// CollisionError reports one or more global ID collisions. It wraps
+// ErrDocIDTaken so callers can match the class with errors.Is and still reach
+// the per-ID detail for the rejection message the `update` hook prints.
+type CollisionError struct {
+ Collisions []Collision
+}
+
+func (e *CollisionError) Error() string {
+ parts := make([]string, len(e.Collisions))
+ for i, c := range e.Collisions {
+ parts[i] = c.String()
+ }
+ return "db: document id collision: " + strings.Join(parts, "; ")
+}
+
+func (e *CollisionError) Unwrap() error { return ErrDocIDTaken }
+
+const documentSelect = `SELECT doc_id, space_id, path, updated_rev FROM document_id`
+
+func scanDocument(sc rowScanner) (*Document, error) {
+ var (
+ d Document
+ docID string
+ )
+ if err := sc.Scan(&docID, &d.SpaceID, &d.Path, &d.UpdatedRev); err != nil {
+ return nil, err
+ }
+ parsed, err := core.ParseDocID(docID)
+ if err != nil {
+ // The registry only ever accepts parsed IDs, so a row that no longer
+ // parses is corruption, not input. Surface it instead of guessing.
+ return nil, fmt.Errorf("registry row %q: %w", docID, err)
+ }
+ d.ID = parsed
+ return &d, nil
+}
+
+// RegisterDocID claims a document ID for a space at a path. Global uniqueness is
+// the doc_id PRIMARY KEY, so a second claim cannot be inserted at all — this
+// method maps that refusal to ErrDocIDTaken rather than being the thing that
+// prevents it.
+func (s *Store) RegisterDocID(ctx context.Context, spaceID int, ref DocRef, rev string) (*Document, error) {
+ if err := core.ValidateDocPath(ref.Path); err != nil {
+ return nil, err
+ }
+ const q = `
+INSERT INTO document_id (doc_id, space_id, path, updated_rev)
+VALUES ($1, $2, $3, $4)`
+ _, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
+ if err != nil {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) && pqErr.Code == "23505" {
+ return nil, fmt.Errorf("%w: %s", ErrDocIDTaken, ref.ID)
+ }
+ return nil, fmt.Errorf("register doc id %s: %w", ref.ID, err)
+ }
+ return &Document{ID: ref.ID, SpaceID: spaceID, Path: ref.Path, UpdatedRev: rev}, nil
+}
+
+// DocByID resolves a document ID to its current space and path. This is the
+// lookup wikilink resolution and merge staleness both go through. Returns
+// ErrNotFound if the ID is not registered.
+func (s *Store) DocByID(ctx context.Context, id core.DocID) (*Document, error) {
+ q := documentSelect + ` WHERE doc_id = $1`
+ d, err := scanDocument(s.q.QueryRowContext(ctx, q, id.String()))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get doc id %s: %w", id, err)
+ }
+ return d, nil
+}
+
+// SetDocPath re-points a registered ID at a new path after a rename, stamping
+// the revision that moved it. It is scoped by space: a rename never crosses
+// spaces (only a human push can move a file, and a push touches one repo), so a
+// spaceID that does not match the registered row yields ErrNotFound rather than
+// silently relocating the document into another space.
+func (s *Store) SetDocPath(ctx context.Context, spaceID int, ref DocRef, rev string) error {
+ if err := core.ValidateDocPath(ref.Path); err != nil {
+ return err
+ }
+ const q = `
+UPDATE document_id
+SET path = $3, updated_rev = $4
+WHERE doc_id = $1 AND space_id = $2`
+ res, err := s.q.ExecContext(ctx, q, ref.ID.String(), spaceID, ref.Path, rev)
+ if err != nil {
+ return fmt.Errorf("set path for doc id %s: %w", ref.ID, err)
+ }
+ return requireOne(res, "set doc path")
+}
+
+// UnregisterDocID drops an ID from the registry, for a human push that deleted
+// the document (deletion is human-push-only by design; an agent proposes
+// `status: superseded` instead). Scoped by space for the same reason SetDocPath
+// is. Returns ErrNotFound if no such row exists in that space.
+func (s *Store) UnregisterDocID(ctx context.Context, spaceID int, id core.DocID) error {
+ res, err := s.q.ExecContext(ctx,
+ `DELETE FROM document_id WHERE doc_id = $1 AND space_id = $2`, id.String(), spaceID)
+ if err != nil {
+ return fmt.Errorf("unregister doc id %s: %w", id, err)
+ }
+ return requireOne(res, "unregister doc id")
+}
+
+// ListDocsBySpace returns every registered document of a space, by path. The
+// reconciler uses it to diff the registry against what the approved tree
+// actually contains.
+func (s *Store) ListDocsBySpace(ctx context.Context, spaceID int) ([]*Document, error) {
+ q := documentSelect + ` WHERE space_id = $1 ORDER BY path`
+ rows, err := s.q.QueryContext(ctx, q, spaceID)
+ if err != nil {
+ return nil, fmt.Errorf("list docs space=%d: %w", spaceID, err)
+ }
+ defer rows.Close()
+ var docs []*Document
+ for rows.Next() {
+ d, err := scanDocument(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan document: %w", err)
+ }
+ docs = append(docs, d)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate documents: %w", err)
+ }
+ return docs, nil
+}
+
+// DuplicateDocIDs reports IDs that appear more than once in a single batch,
+// sorted. This is the half of the collision check that needs no database: a
+// push carrying the same `id:` on two different documents is malformed on its
+// own terms, whatever the registry says. It is also a precondition of
+// UpsertDocIDs, since a multi-row upsert cannot touch the same key twice.
+func DuplicateDocIDs(refs []DocRef) []core.DocID {
+ seen := make(map[string]int, len(refs))
+ for _, r := range refs {
+ seen[r.ID.String()]++
+ }
+ var dup []string
+ for id, n := range seen {
+ if n > 1 {
+ dup = append(dup, id)
+ }
+ }
+ sort.Strings(dup)
+ out := make([]core.DocID, 0, len(dup))
+ for _, id := range dup {
+ parsed, err := core.ParseDocID(id)
+ if err != nil {
+ // Impossible: the input carried parsed DocIDs.
+ panic(fmt.Sprintf("db: unparseable DocID in batch: %v", err))
+ }
+ out = append(out, parsed)
+ }
+ return out
+}
+
+// CheckDocIDCollisions reports which of refs are already registered to a
+// different space. This is what the push-validation path calls before a push is
+// allowed through: a duplicated `id:` corrupts the global registry and silently
+// breaks link resolution and search, and is far cheaper to reject at push time
+// than to find weeks later.
+//
+// An ID already registered to *this* space is not a collision — that is the
+// ordinary case of editing or renaming a document that already exists.
+// Duplicates within refs itself are reported separately by DuplicateDocIDs;
+// this method only asks the registry.
+func (s *Store) CheckDocIDCollisions(ctx context.Context, spaceID int, refs []DocRef) ([]Collision, error) {
+ if len(refs) == 0 {
+ return nil, nil
+ }
+ ids := make([]string, len(refs))
+ for i, r := range refs {
+ ids[i] = r.ID.String()
+ }
+ q := documentSelect + ` WHERE doc_id = ANY($1) AND space_id <> $2 ORDER BY doc_id`
+ rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID)
+ if err != nil {
+ return nil, fmt.Errorf("check doc id collisions: %w", err)
+ }
+ defer rows.Close()
+ var out []Collision
+ for rows.Next() {
+ d, err := scanDocument(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan collision: %w", err)
+ }
+ out = append(out, Collision{DocID: d.ID, Existing: d})
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate collisions: %w", err)
+ }
+ return out, nil
+}
+
+// UpsertDocIDs registers or re-points every ID in refs for one space, in a
+// single statement. It is the registry half of a merge: after the merge commit
+// lands, the documents it touched are at these paths, at this revision.
+//
+// The cross-space guard lives in the statement, not in Go: the ON CONFLICT
+// branch only updates when the existing row belongs to the same space, so a row
+// owned by another space is left untouched and simply not returned. Any ID that
+// does not come back is therefore a collision, reported as a *CollisionError
+// naming where it really lives. A check-then-write in Go would have a window
+// between the two; this does not.
+func (s *Store) UpsertDocIDs(ctx context.Context, spaceID int, refs []DocRef, rev string) error {
+ if len(refs) == 0 {
+ return nil
+ }
+ if dup := DuplicateDocIDs(refs); len(dup) > 0 {
+ names := make([]string, len(dup))
+ for i, d := range dup {
+ names[i] = d.String()
+ }
+ return fmt.Errorf("%w: %s", ErrDocIDDuplicate, strings.Join(names, ", "))
+ }
+ ids := make([]string, len(refs))
+ paths := make([]string, len(refs))
+ for i, r := range refs {
+ if err := core.ValidateDocPath(r.Path); err != nil {
+ return err
+ }
+ ids[i] = r.ID.String()
+ paths[i] = r.Path
+ }
+
+ const q = `
+INSERT INTO document_id (doc_id, space_id, path, updated_rev)
+SELECT d.doc_id, $2, d.path, $3
+FROM unnest($1::text[], $4::text[]) AS d(doc_id, path)
+ON CONFLICT (doc_id) DO UPDATE
+ SET path = EXCLUDED.path, updated_rev = EXCLUDED.updated_rev
+ WHERE document_id.space_id = EXCLUDED.space_id
+RETURNING doc_id`
+ rows, err := s.q.QueryContext(ctx, q, pq.Array(ids), spaceID, rev, pq.Array(paths))
+ if err != nil {
+ return fmt.Errorf("upsert doc ids: %w", err)
+ }
+ applied := make(map[string]bool, len(ids))
+ for rows.Next() {
+ var id string
+ if err := rows.Scan(&id); err != nil {
+ rows.Close()
+ return fmt.Errorf("scan upserted doc id: %w", err)
+ }
+ applied[id] = true
+ }
+ if err := rows.Err(); err != nil {
+ rows.Close()
+ return fmt.Errorf("iterate upserted doc ids: %w", err)
+ }
+ rows.Close()
+ if len(applied) == len(ids) {
+ return nil
+ }
+
+ var missing []DocRef
+ for _, r := range refs {
+ if !applied[r.ID.String()] {
+ missing = append(missing, r)
+ }
+ }
+ collisions, err := s.CheckDocIDCollisions(ctx, spaceID, missing)
+ if err != nil {
+ return err
+ }
+ if len(collisions) == 0 {
+ // The guard skipped rows but the registry says nothing owns them
+ // elsewhere. That is not a condition this schema can produce; refuse
+ // rather than report a merge as clean.
+ return fmt.Errorf("upsert doc ids: %d of %d rows not applied, but no collision found",
+ len(ids)-len(applied), len(ids))
+ }
+ return &CollisionError{Collisions: collisions}
+}
A db/document_test.go => db/document_test.go +176 -0
@@ 0,0 1,176 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestDocumentRegistryLifecycle(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ id := docID(t, "SPEC-0007")
+
+ doc, err := s.RegisterDocID(ctx, space.ID, DocRef{ID: id, Path: "specs/0007-storage.md"}, "rev1")
+ if err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if doc.SpaceID != space.ID || doc.Path != "specs/0007-storage.md" || doc.UpdatedRev != "rev1" {
+ t.Fatalf("unexpected registered doc: %+v", doc)
+ }
+
+ // The doc_id PRIMARY KEY makes a second claim unwritable, in this space or
+ // any other.
+ if _, err := s.RegisterDocID(ctx, space.ID, DocRef{ID: id, Path: "other.md"}, "rev2"); !errors.Is(err, ErrDocIDTaken) {
+ t.Fatalf("re-register in the same space = %v, want ErrDocIDTaken", err)
+ }
+ other := mkSpace(t, s, ctx, "bigbes", "notes")
+ if _, err := s.RegisterDocID(ctx, other.ID, DocRef{ID: id, Path: "n.md"}, "rev2"); !errors.Is(err, ErrDocIDTaken) {
+ t.Fatalf("re-register in another space = %v, want ErrDocIDTaken", err)
+ }
+
+ got, err := s.DocByID(ctx, id)
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if got.ID != id || got.Path != "specs/0007-storage.md" {
+ t.Fatalf("unexpected lookup: %+v", got)
+ }
+ if _, err := s.DocByID(ctx, docID(t, "SPEC-9999")); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing id = %v, want ErrNotFound", err)
+ }
+
+ // Rename: the ID is stable, the path moves.
+ if err := s.SetDocPath(ctx, space.ID, DocRef{ID: id, Path: "specs/0007-proposals.md"}, "rev3"); err != nil {
+ t.Fatalf("rename: %v", err)
+ }
+ got, err = s.DocByID(ctx, id)
+ if err != nil {
+ t.Fatalf("lookup after rename: %v", err)
+ }
+ if got.Path != "specs/0007-proposals.md" || got.UpdatedRev != "rev3" {
+ t.Fatalf("rename did not stick: %+v", got)
+ }
+
+ // A rename scoped to the wrong space must not relocate the document.
+ if err := s.SetDocPath(ctx, other.ID, DocRef{ID: id, Path: "stolen.md"}, "rev4"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("cross-space rename = %v, want ErrNotFound", err)
+ }
+ if got, _ := s.DocByID(ctx, id); got.Path != "specs/0007-proposals.md" {
+ t.Fatalf("cross-space rename moved the document: %+v", got)
+ }
+
+ docs, err := s.ListDocsBySpace(ctx, space.ID)
+ if err != nil {
+ t.Fatalf("list docs: %v", err)
+ }
+ if len(docs) != 1 || docs[0].ID != id {
+ t.Fatalf("unexpected doc list: %+v", docs)
+ }
+
+ // Deletion (human push only) frees the ID again.
+ if err := s.UnregisterDocID(ctx, other.ID, id); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("unregister from the wrong space = %v, want ErrNotFound", err)
+ }
+ if err := s.UnregisterDocID(ctx, space.ID, id); err != nil {
+ t.Fatalf("unregister: %v", err)
+ }
+ if _, err := s.DocByID(ctx, id); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("after unregister = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDocIDCollisionCheck(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ specs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+
+ mine := docID(t, "SPEC-1")
+ theirs := docID(t, "NOTE-1")
+ if _, err := s.RegisterDocID(ctx, specs.ID, DocRef{ID: mine, Path: "a.md"}, "rev1"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+ if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: theirs, Path: "b.md"}, "rev1"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ // Pushing into specs: SPEC-1 is ours (a rename at worst), NOTE-1 is not.
+ batch := []DocRef{
+ {ID: mine, Path: "a-renamed.md"},
+ {ID: theirs, Path: "c.md"},
+ {ID: docID(t, "SPEC-2"), Path: "d.md"},
+ }
+ collisions, err := s.CheckDocIDCollisions(ctx, specs.ID, batch)
+ if err != nil {
+ t.Fatalf("collision check: %v", err)
+ }
+ if len(collisions) != 1 {
+ t.Fatalf("expected 1 collision, got %+v", collisions)
+ }
+ if collisions[0].DocID != theirs || collisions[0].Existing.SpaceID != notes.ID {
+ t.Fatalf("unexpected collision: %+v", collisions[0])
+ }
+}
+
+func TestUpsertDocIDsRejectsCrossSpaceClaim(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ specs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+ stolen := docID(t, "NOTE-1")
+ if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: stolen, Path: "n.md"}, "rev1"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ // A fresh ID plus one owned by another space: the statement's ON CONFLICT
+ // guard leaves the foreign row alone and the missing row surfaces as a
+ // CollisionError.
+ fresh := docID(t, "SPEC-1")
+ err := s.UpsertDocIDs(ctx, specs.ID, []DocRef{
+ {ID: fresh, Path: "a.md"},
+ {ID: stolen, Path: "b.md"},
+ }, "rev2")
+ if !errors.Is(err, ErrDocIDTaken) {
+ t.Fatalf("cross-space upsert = %v, want ErrDocIDTaken", err)
+ }
+ var ce *CollisionError
+ if !errors.As(err, &ce) || len(ce.Collisions) != 1 || ce.Collisions[0].DocID != stolen {
+ t.Fatalf("expected a CollisionError naming %s, got %v", stolen, err)
+ }
+ // The foreign row is untouched.
+ got, err := s.DocByID(ctx, stolen)
+ if err != nil {
+ t.Fatalf("lookup foreign doc: %v", err)
+ }
+ if got.SpaceID != notes.ID || got.Path != "n.md" || got.UpdatedRev != "rev1" {
+ t.Fatalf("foreign row was modified: %+v", got)
+ }
+
+ // A clean batch inserts and then updates in place.
+ if err := s.UpsertDocIDs(ctx, specs.ID, []DocRef{
+ {ID: fresh, Path: "a.md"},
+ {ID: docID(t, "SPEC-2"), Path: "b.md"},
+ }, "rev3"); err != nil {
+ t.Fatalf("clean upsert: %v", err)
+ }
+ if err := s.UpsertDocIDs(ctx, specs.ID, []DocRef{
+ {ID: fresh, Path: "a-moved.md"},
+ }, "rev4"); err != nil {
+ t.Fatalf("upsert rename: %v", err)
+ }
+ got, err = s.DocByID(ctx, fresh)
+ if err != nil {
+ t.Fatalf("lookup: %v", err)
+ }
+ if got.Path != "a-moved.md" || got.UpdatedRev != "rev4" {
+ t.Fatalf("upsert rename did not stick: %+v", got)
+ }
+}
A db/proposal.go => db/proposal.go +282 -0
@@ 0,0 1,282 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "strconv"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// BranchPrefix is the ref namespace agents may write. The refs rule — an agent
+// token can only update refs under this prefix, and only the owner can move the
+// approved branch — is the boundary that actually bounds the damage a confused
+// agent can do.
+const BranchPrefix = "proposals/"
+
+// ProposalBranch is the branch name for a proposal id: "proposals/42". The row
+// stores it verbatim (proposal.branch) because the row, not this function, is
+// what the reconciler compares against the refs it finds.
+func ProposalBranch(id int) string { return BranchPrefix + strconv.Itoa(id) }
+
+// Proposal is a bundle of document edits awaiting review: a branch under
+// BranchPrefix plus this row.
+//
+// BaseRev is the If-Match value the agent sent when the proposal was opened —
+// the space's approved-head sha at the time it read the document — and it does
+// not move as the proposal accumulates edits. Agent and AgentSession are
+// mandatory provenance: one shared token still yields a full audit trail,
+// because the identity strings, not the credential, are what say who did what.
+//
+// Approval and MergedRev are empty until the proposal merges; Resolved is nil
+// until it leaves the open state.
+type Proposal struct {
+ ID int
+ SpaceID int
+ Title string
+ Rationale string
+ BaseRev string
+ Branch string
+ State core.ProposalState
+ Approval core.Approval
+ MergedRev string
+ Agent string
+ AgentSession string
+ Created time.Time
+ Resolved *time.Time
+}
+
+// Merge is everything one merge writes to Postgres: the proposal's transition
+// to merged, and the new location of every document the merge touched. The two
+// are one invariant — a merged proposal whose documents are still registered at
+// their old paths would break link resolution and the next staleness check —
+// so MergeProposal writes them in a single transaction.
+type Merge struct {
+ ProposalID int
+ SpaceID int
+ Approval core.Approval
+ MergedRev string
+ // Docs is the (id, path) set of the documents the merge landed, at their
+ // paths in the merge commit. Empty is legal but unusual: it means the
+ // proposal touched nothing the registry tracks.
+ Docs []DocRef
+}
+
+const proposalSelect = `
+SELECT id, space_id, title, COALESCE(rationale, ''), base_rev, branch, state,
+ COALESCE(approval, ''), COALESCE(merged_rev, ''), agent, agent_session,
+ created, resolved
+FROM proposal`
+
+func scanProposal(sc rowScanner) (*Proposal, error) {
+ var (
+ p Proposal
+ state string
+ approval string
+ resolved sql.NullTime
+ )
+ if err := sc.Scan(&p.ID, &p.SpaceID, &p.Title, &p.Rationale, &p.BaseRev,
+ &p.Branch, &state, &approval, &p.MergedRev, &p.Agent, &p.AgentSession,
+ &p.Created, &resolved); err != nil {
+ return nil, err
+ }
+ parsedState, err := core.ParseProposalState(state)
+ if err != nil {
+ return nil, fmt.Errorf("proposal %d: %w", p.ID, err)
+ }
+ p.State = parsedState
+ if approval != "" {
+ parsedApproval, err := core.ParseApproval(approval)
+ if err != nil {
+ return nil, fmt.Errorf("proposal %d: %w", p.ID, err)
+ }
+ p.Approval = parsedApproval
+ }
+ if resolved.Valid {
+ t := resolved.Time
+ p.Resolved = &t
+ }
+ return &p, nil
+}
+
+// OpenProposal inserts a new proposal in the open state and returns it with its
+// id, branch and creation time filled in. p.SpaceID, p.Title, p.BaseRev,
+// p.Agent and p.AgentSession must be set; State, Approval, MergedRev and
+// Resolved are ignored on input — a proposal is always born open.
+//
+// The branch name derives from the generated id ("proposals/42"), so id and
+// branch are allocated in one statement: taking the id in a first round trip
+// and writing the branch in a second would leave a window where a crash yields
+// a row whose branch names nothing.
+func (s *Store) OpenProposal(ctx context.Context, p *Proposal) (*Proposal, error) {
+ if p.Agent == "" || p.AgentSession == "" {
+ return nil, fmt.Errorf("open proposal: agent identity and session are required provenance")
+ }
+ if p.BaseRev == "" {
+ return nil, fmt.Errorf("open proposal: base rev is required")
+ }
+ const q = `
+WITH next AS (SELECT nextval(pg_get_serial_sequence('proposal', 'id')) AS id)
+INSERT INTO proposal (id, space_id, title, rationale, base_rev, branch, state,
+ agent, agent_session, created)
+SELECT next.id, $1, $2, $3, $4, $5::text || next.id::text, $6, $7, $8, $9
+FROM next
+RETURNING id, branch, created`
+ out := *p
+ out.State = core.StateOpen
+ out.Approval = ""
+ out.MergedRev = ""
+ out.Resolved = nil
+ err := s.q.QueryRowContext(ctx, q,
+ p.SpaceID, p.Title, nullable(p.Rationale), p.BaseRev, BranchPrefix,
+ string(core.StateOpen), p.Agent, p.AgentSession, time.Now().UTC(),
+ ).Scan(&out.ID, &out.Branch, &out.Created)
+ if err != nil {
+ return nil, fmt.Errorf("open proposal: %w", err)
+ }
+ return &out, nil
+}
+
+// GetProposal resolves a proposal by id. Returns ErrNotFound if it does not
+// exist. Proposal URLs are stable and shareable — a link still resolves after
+// merge or rejection, showing the outcome — so this is the same lookup whatever
+// the state.
+func (s *Store) GetProposal(ctx context.Context, id int) (*Proposal, error) {
+ q := proposalSelect + ` WHERE id = $1`
+ p, err := scanProposal(s.q.QueryRowContext(ctx, q, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get proposal %d: %w", id, err)
+ }
+ return p, nil
+}
+
+// ListProposalsByState lists proposals in one state, newest first — the inbox
+// query ("N proposals waiting on you"), served by ix_proposal_state_created.
+// limit <= 0 means no limit.
+func (s *Store) ListProposalsByState(ctx context.Context, state core.ProposalState, limit int) ([]*Proposal, error) {
+ if _, err := core.ParseProposalState(string(state)); err != nil {
+ return nil, err
+ }
+ q := proposalSelect + ` WHERE state = $1 ORDER BY created DESC, id DESC`
+ args := []any{string(state)}
+ if limit > 0 {
+ q += ` LIMIT $2`
+ args = append(args, limit)
+ }
+ rows, err := s.q.QueryContext(ctx, q, args...)
+ if err != nil {
+ return nil, fmt.Errorf("list proposals state=%s: %w", state, err)
+ }
+ defer rows.Close()
+ var out []*Proposal
+ for rows.Next() {
+ p, err := scanProposal(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan proposal: %w", err)
+ }
+ out = append(out, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate proposals: %w", err)
+ }
+ return out, nil
+}
+
+// MarkProposalMerged transitions a proposal to merged, recording how it was
+// authorized (human or policy) and the merge commit. It writes only the row;
+// use MergeProposal to update the document registry in the same transaction.
+//
+// The legality of the transition is enforced in SQL by `WHERE state = 'open'`,
+// not by reading the row first: a check-then-write would let two concurrent
+// resolutions both pass the check. When the guard bites, the current state is
+// read back only to name it in the error.
+func (s *Store) MarkProposalMerged(ctx context.Context, id int, approval core.Approval, mergedRev string) error {
+ if _, err := core.ParseApproval(string(approval)); err != nil {
+ return err
+ }
+ if mergedRev == "" {
+ return fmt.Errorf("merge proposal %d: merged rev is required", id)
+ }
+ return s.resolveProposal(ctx, id, core.StateMerged, string(approval), mergedRev)
+}
+
+// RejectProposal transitions a proposal to rejected. There is no
+// request-changes cycle: with one reviewer, a proposal you dislike is rejected
+// and the agent proposes again.
+func (s *Store) RejectProposal(ctx context.Context, id int) error {
+ return s.resolveProposal(ctx, id, core.StateRejected, "", "")
+}
+
+// resolveProposal is the shared open->terminal update. next must be a legal
+// destination from open; approval and mergedRev are stored as SQL NULL when
+// empty, which the ck_proposal_merged constraints require for a rejection.
+func (s *Store) resolveProposal(ctx context.Context, id int, next core.ProposalState, approval, mergedRev string) error {
+ if err := core.StateOpen.CanTransitionTo(next); err != nil {
+ return err
+ }
+ const q = `
+UPDATE proposal
+SET state = $2, approval = $3, merged_rev = $4, resolved = $5
+WHERE id = $1 AND state = $6`
+ res, err := s.q.ExecContext(ctx, q, id, string(next), nullable(approval),
+ nullable(mergedRev), time.Now().UTC(), string(core.StateOpen))
+ if err != nil {
+ return fmt.Errorf("resolve proposal %d as %s: %w", id, next, err)
+ }
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("resolve proposal %d: rows affected: %w", id, err)
+ }
+ if n == 1 {
+ return nil
+ }
+
+ // Nothing moved: either the proposal is gone, or it is no longer open.
+ var current string
+ err = s.q.QueryRowContext(ctx, `SELECT state FROM proposal WHERE id = $1`, id).Scan(¤t)
+ if errors.Is(err, sql.ErrNoRows) {
+ return ErrNotFound
+ }
+ if err != nil {
+ return fmt.Errorf("resolve proposal %d: read current state: %w", id, err)
+ }
+ from, err := core.ParseProposalState(current)
+ if err != nil {
+ return fmt.Errorf("proposal %d: %w", id, err)
+ }
+ if err := from.CanTransitionTo(next); err != nil {
+ return fmt.Errorf("proposal %d: %w", id, err)
+ }
+ // The row is open and the transition is legal, yet the guarded UPDATE
+ // matched nothing. That cannot happen; refuse rather than report success.
+ return fmt.Errorf("proposal %d: guarded update matched no row while state is %s", id, from)
+}
+
+// MergeProposal records a merge: the proposal's transition to merged and the
+// new registry location of every document it landed, atomically.
+//
+// Git refs remain the source of truth for whether the merge happened — this row
+// is what the reconciler repairs from the ref when a crash lands between the
+// two. What the transaction buys is that Postgres never holds the half-state
+// where the proposal reads merged but its documents are still registered at
+// their pre-merge paths.
+func (s *Store) MergeProposal(ctx context.Context, m Merge) error {
+ if _, err := core.ParseApproval(string(m.Approval)); err != nil {
+ return err
+ }
+ if m.MergedRev == "" {
+ return fmt.Errorf("merge proposal %d: merged rev is required", m.ProposalID)
+ }
+ return s.InTx(ctx, func(tx *Store) error {
+ if err := tx.MarkProposalMerged(ctx, m.ProposalID, m.Approval, m.MergedRev); err != nil {
+ return err
+ }
+ return tx.UpsertDocIDs(ctx, m.SpaceID, m.Docs, m.MergedRev)
+ })
+}
A db/proposal_test.go => db/proposal_test.go +295 -0
@@ 0,0 1,295 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+func TestProposalOpenAndRead(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ p := mkProposal(t, s, ctx, space.ID, "Add storage model section")
+
+ if p.ID == 0 {
+ t.Fatal("expected a non-zero proposal id")
+ }
+ if p.Branch != ProposalBranch(p.ID) {
+ t.Fatalf("branch = %q, want %q", p.Branch, ProposalBranch(p.ID))
+ }
+ if p.State != core.StateOpen {
+ t.Fatalf("state = %q, want open", p.State)
+ }
+ if p.Approval != "" || p.MergedRev != "" || p.Resolved != nil {
+ t.Fatalf("a fresh proposal must not be resolved: %+v", p)
+ }
+
+ got, err := s.GetProposal(ctx, p.ID)
+ if err != nil {
+ t.Fatalf("get proposal: %v", err)
+ }
+ if got.Title != p.Title || got.Rationale != "because" || got.BaseRev != p.BaseRev {
+ t.Fatalf("round-trip mismatch: %+v", got)
+ }
+ if got.Agent != "claude-code/spec-writer" || got.AgentSession == "" {
+ t.Fatalf("provenance lost: %+v", got)
+ }
+ if got.Branch != p.Branch || got.State != core.StateOpen {
+ t.Fatalf("unexpected proposal: %+v", got)
+ }
+
+ if _, err := s.GetProposal(ctx, 99999); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing proposal = %v, want ErrNotFound", err)
+ }
+
+ // An empty rationale round-trips as "" rather than surfacing a NULL.
+ bare, err := s.OpenProposal(ctx, &Proposal{
+ SpaceID: space.ID, Title: "no rationale", BaseRev: "abc",
+ Agent: "a", AgentSession: "s",
+ })
+ if err != nil {
+ t.Fatalf("open bare proposal: %v", err)
+ }
+ got, err = s.GetProposal(ctx, bare.ID)
+ if err != nil {
+ t.Fatalf("get bare proposal: %v", err)
+ }
+ if got.Rationale != "" {
+ t.Fatalf("rationale = %q, want empty", got.Rationale)
+ }
+}
+
+func TestProposalListByState(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ first := mkProposal(t, s, ctx, space.ID, "first")
+ second := mkProposal(t, s, ctx, space.ID, "second")
+ third := mkProposal(t, s, ctx, space.ID, "third")
+
+ open, err := s.ListProposalsByState(ctx, core.StateOpen, 0)
+ if err != nil {
+ t.Fatalf("list open: %v", err)
+ }
+ if len(open) != 3 {
+ t.Fatalf("expected 3 open proposals, got %d", len(open))
+ }
+ // Newest first.
+ if open[0].ID != third.ID || open[2].ID != first.ID {
+ t.Fatalf("expected newest first, got %d, %d, %d", open[0].ID, open[1].ID, open[2].ID)
+ }
+
+ limited, err := s.ListProposalsByState(ctx, core.StateOpen, 2)
+ if err != nil {
+ t.Fatalf("list limited: %v", err)
+ }
+ if len(limited) != 2 || limited[0].ID != third.ID {
+ t.Fatalf("unexpected limited list: %+v", limited)
+ }
+
+ if err := s.RejectProposal(ctx, second.ID); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+ rejected, err := s.ListProposalsByState(ctx, core.StateRejected, 0)
+ if err != nil {
+ t.Fatalf("list rejected: %v", err)
+ }
+ if len(rejected) != 1 || rejected[0].ID != second.ID {
+ t.Fatalf("unexpected rejected list: %+v", rejected)
+ }
+ if rejected[0].Resolved == nil {
+ t.Fatal("a rejected proposal must carry a resolved timestamp")
+ }
+ if rejected[0].Approval != "" || rejected[0].MergedRev != "" {
+ t.Fatalf("a rejection must not record approval or a merged rev: %+v", rejected[0])
+ }
+ merged, err := s.ListProposalsByState(ctx, core.StateMerged, 0)
+ if err != nil {
+ t.Fatalf("list merged: %v", err)
+ }
+ if len(merged) != 0 {
+ t.Fatalf("expected no merged proposals, got %d", len(merged))
+ }
+}
+
+func TestProposalTransitionsAreGuarded(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+
+ merged := mkProposal(t, s, ctx, space.ID, "to merge")
+ if err := s.MarkProposalMerged(ctx, merged.ID, core.ApprovalHuman, "deadbeef"); err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ got, err := s.GetProposal(ctx, merged.ID)
+ if err != nil {
+ t.Fatalf("get merged: %v", err)
+ }
+ if got.State != core.StateMerged || got.Approval != core.ApprovalHuman || got.MergedRev != "deadbeef" {
+ t.Fatalf("unexpected merged proposal: %+v", got)
+ }
+ if got.Resolved == nil {
+ t.Fatal("a merged proposal must carry a resolved timestamp")
+ }
+
+ // merged -> merged and merged -> rejected must both fail.
+ if err := s.MarkProposalMerged(ctx, merged.ID, core.ApprovalHuman, "cafe"); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("re-merge = %v, want ErrInvalidTransition", err)
+ }
+ if err := s.RejectProposal(ctx, merged.ID); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("merged->rejected = %v, want ErrInvalidTransition", err)
+ }
+ // The row did not move.
+ got, err = s.GetProposal(ctx, merged.ID)
+ if err != nil {
+ t.Fatalf("get merged: %v", err)
+ }
+ if got.State != core.StateMerged || got.MergedRev != "deadbeef" {
+ t.Fatalf("illegal transition modified the row: %+v", got)
+ }
+
+ rejected := mkProposal(t, s, ctx, space.ID, "to reject")
+ if err := s.RejectProposal(ctx, rejected.ID); err != nil {
+ t.Fatalf("reject: %v", err)
+ }
+ if err := s.RejectProposal(ctx, rejected.ID); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("re-reject = %v, want ErrInvalidTransition", err)
+ }
+ if err := s.MarkProposalMerged(ctx, rejected.ID, core.ApprovalPolicy, "cafe"); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("rejected->merged = %v, want ErrInvalidTransition", err)
+ }
+
+ if err := s.RejectProposal(ctx, 99999); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("resolving a missing proposal = %v, want ErrNotFound", err)
+ }
+ if err := s.MarkProposalMerged(ctx, 99999, core.ApprovalHuman, "cafe"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("merging a missing proposal = %v, want ErrNotFound", err)
+ }
+}
+
+// TestMergeProposalIsAtomic is the reason the transaction exists: a merge that
+// cannot re-point the registry must not leave the proposal reading merged.
+func TestMergeProposalIsAtomic(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ specs := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ notes := mkSpace(t, s, ctx, "bigbes", "notes")
+ foreign := docID(t, "NOTE-1")
+ if _, err := s.RegisterDocID(ctx, notes.ID, DocRef{ID: foreign, Path: "n.md"}, "rev1"); err != nil {
+ t.Fatalf("register: %v", err)
+ }
+
+ p := mkProposal(t, s, ctx, specs.ID, "claims a foreign id")
+ err := s.MergeProposal(ctx, Merge{
+ ProposalID: p.ID,
+ SpaceID: specs.ID,
+ Approval: core.ApprovalHuman,
+ MergedRev: "deadbeef",
+ Docs: []DocRef{
+ {ID: docID(t, "SPEC-1"), Path: "a.md"},
+ {ID: foreign, Path: "b.md"},
+ },
+ })
+ if !errors.Is(err, ErrDocIDTaken) {
+ t.Fatalf("merge with a colliding id = %v, want ErrDocIDTaken", err)
+ }
+ got, err := s.GetProposal(ctx, p.ID)
+ if err != nil {
+ t.Fatalf("get proposal: %v", err)
+ }
+ if got.State != core.StateOpen {
+ t.Fatalf("failed merge left the proposal %s, want open", got.State)
+ }
+ // The whole batch rolled back, including the ID that would have applied.
+ if _, err := s.DocByID(ctx, docID(t, "SPEC-1")); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("failed merge registered SPEC-1 anyway: %v", err)
+ }
+
+ // The same merge without the collision commits both halves.
+ if err := s.MergeProposal(ctx, Merge{
+ ProposalID: p.ID,
+ SpaceID: specs.ID,
+ Approval: core.ApprovalPolicy,
+ MergedRev: "deadbeef",
+ Docs: []DocRef{{ID: docID(t, "SPEC-1"), Path: "a.md"}},
+ }); err != nil {
+ t.Fatalf("merge: %v", err)
+ }
+ got, err = s.GetProposal(ctx, p.ID)
+ if err != nil {
+ t.Fatalf("get proposal: %v", err)
+ }
+ if got.State != core.StateMerged || got.Approval != core.ApprovalPolicy {
+ t.Fatalf("unexpected merged proposal: %+v", got)
+ }
+ doc, err := s.DocByID(ctx, docID(t, "SPEC-1"))
+ if err != nil {
+ t.Fatalf("lookup after merge: %v", err)
+ }
+ if doc.SpaceID != specs.ID || doc.Path != "a.md" || doc.UpdatedRev != "deadbeef" {
+ t.Fatalf("unexpected registry row after merge: %+v", doc)
+ }
+}
+
+// TestProposalCheckConstraints proves the invariants hold against SQL that does
+// not go through this package — a stray UPDATE cannot produce a merged row with
+// no approval, or an unknown state.
+func TestProposalCheckConstraints(t *testing.T) {
+ s, pool, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+ p := mkProposal(t, s, ctx, space.ID, "constrained")
+
+ for _, tc := range []struct {
+ name string
+ q string
+ args []any
+ }{
+ {"unknown state", `UPDATE proposal SET state = 'abandoned' WHERE id = $1`, []any{p.ID}},
+ {"unknown approval", `UPDATE proposal SET approval = 'vibes' WHERE id = $1`, []any{p.ID}},
+ {
+ "merged without approval",
+ `UPDATE proposal SET state = 'merged', merged_rev = 'abc', resolved = now() WHERE id = $1`,
+ []any{p.ID},
+ },
+ {
+ "merged without a merged rev",
+ `UPDATE proposal SET state = 'merged', approval = 'human', resolved = now() WHERE id = $1`,
+ []any{p.ID},
+ },
+ {
+ "open but resolved",
+ `UPDATE proposal SET resolved = now() WHERE id = $1`,
+ []any{p.ID},
+ },
+ {
+ "approval on an open proposal",
+ `UPDATE proposal SET approval = 'human' WHERE id = $1`,
+ []any{p.ID},
+ },
+ {
+ "empty provenance",
+ `UPDATE proposal SET agent = '' WHERE id = $1`,
+ []any{p.ID},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ if _, err := pool.ExecContext(ctx, tc.q, tc.args...); err == nil {
+ t.Fatalf("%s: expected a constraint violation, got none", tc.name)
+ }
+ })
+ }
+}
A db/space.go => db/space.go +117 -0
@@ 0,0 1,117 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/lib/pq"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// Space is one bare git repo: the unit of ownership, ACL and review policy.
+// The repo on disk is the real thing; this row exists so spaces can be listed
+// and so index bookkeeping has something to hang off. Ref.Owner never carries
+// the leading '~'.
+type Space struct {
+ ID int
+ Ref core.SpaceRef
+ Created time.Time
+}
+
+const spaceSelect = `SELECT id, owner, name, created FROM space`
+
+func scanSpace(sc rowScanner) (*Space, error) {
+ var sp Space
+ if err := sc.Scan(&sp.ID, &sp.Ref.Owner, &sp.Ref.Name, &sp.Created); err != nil {
+ return nil, err
+ }
+ return &sp, nil
+}
+
+// CreateSpace inserts a space row. The name is validated with core before it
+// reaches SQL: an owner or space name that is not a safe path segment would
+// become a directory under the repos root, so it is rejected at every door
+// rather than at whichever one happens to check.
+//
+// A uq_space_owner_name violation maps to ErrSpaceExists.
+func (s *Store) CreateSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) {
+ if err := core.ValidateOwner(ref.Owner); err != nil {
+ return nil, err
+ }
+ if err := core.ValidateSpaceName(ref.Name); err != nil {
+ return nil, err
+ }
+ const q = `
+INSERT INTO space (owner, name, created)
+VALUES ($1, $2, $3)
+RETURNING id, created`
+ var sp Space
+ sp.Ref = ref
+ err := s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name, time.Now().UTC()).
+ Scan(&sp.ID, &sp.Created)
+ if err != nil {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) && pqErr.Code == "23505" {
+ return nil, fmt.Errorf("%w: %s", ErrSpaceExists, ref)
+ }
+ return nil, fmt.Errorf("insert space %s: %w", ref, err)
+ }
+ return &sp, nil
+}
+
+// GetSpace resolves a space by owner and name. Returns ErrNotFound if no such
+// space exists.
+func (s *Store) GetSpace(ctx context.Context, ref core.SpaceRef) (*Space, error) {
+ q := spaceSelect + ` WHERE owner = $1 AND name = $2`
+ sp, err := scanSpace(s.q.QueryRowContext(ctx, q, ref.Owner, ref.Name))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get space %s: %w", ref, err)
+ }
+ return sp, nil
+}
+
+// GetSpaceByID resolves a space by its primary key — the form every other table
+// references it by. Returns ErrNotFound if no such space exists.
+func (s *Store) GetSpaceByID(ctx context.Context, id int) (*Space, error) {
+ q := spaceSelect + ` WHERE id = $1`
+ sp, err := scanSpace(s.q.QueryRowContext(ctx, q, id))
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get space %d: %w", id, err)
+ }
+ return sp, nil
+}
+
+// ListSpaces returns every space, ordered by owner then name. There is one human
+// on this instance and no visibility levels, so there is nothing to filter by:
+// the list is the whole corpus, which is also exactly what the meta-project (a
+// filter that excludes nothing) needs.
+func (s *Store) ListSpaces(ctx context.Context) ([]*Space, error) {
+ q := spaceSelect + ` ORDER BY owner, name`
+ rows, err := s.q.QueryContext(ctx, q)
+ if err != nil {
+ return nil, fmt.Errorf("list spaces: %w", err)
+ }
+ defer rows.Close()
+ var spaces []*Space
+ for rows.Next() {
+ sp, err := scanSpace(rows)
+ if err != nil {
+ return nil, fmt.Errorf("scan space: %w", err)
+ }
+ spaces = append(spaces, sp)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate spaces: %w", err)
+ }
+ return spaces, nil
+}
A db/space_test.go => db/space_test.go +77 -0
@@ 0,0 1,77 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+func TestSpaceLifecycle(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ ref := core.SpaceRef{Owner: "bigbes", Name: "rfcs"}
+ sp, err := s.CreateSpace(ctx, ref)
+ if err != nil {
+ t.Fatalf("create space: %v", err)
+ }
+ if sp.ID == 0 {
+ t.Fatal("expected a non-zero space id")
+ }
+ if sp.Created.IsZero() {
+ t.Fatal("expected created to be set")
+ }
+ if sp.Ref != ref {
+ t.Fatalf("ref round-trip: got %v, want %v", sp.Ref, ref)
+ }
+
+ // (owner, name) is unique.
+ if _, err := s.CreateSpace(ctx, ref); !errors.Is(err, ErrSpaceExists) {
+ t.Fatalf("duplicate space = %v, want ErrSpaceExists", err)
+ }
+ // Same name under a different owner is fine.
+ if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "someone", Name: "rfcs"}); err != nil {
+ t.Fatalf("same name, different owner: %v", err)
+ }
+
+ got, err := s.GetSpace(ctx, ref)
+ if err != nil {
+ t.Fatalf("get space: %v", err)
+ }
+ if got.ID != sp.ID || got.Ref != ref {
+ t.Fatalf("get space returned %+v, want id=%d ref=%v", got, sp.ID, ref)
+ }
+ byID, err := s.GetSpaceByID(ctx, sp.ID)
+ if err != nil {
+ t.Fatalf("get space by id: %v", err)
+ }
+ if byID.Ref != ref {
+ t.Fatalf("get by id returned %v, want %v", byID.Ref, ref)
+ }
+
+ if _, err := s.GetSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "absent"}); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing space = %v, want ErrNotFound", err)
+ }
+ if _, err := s.GetSpaceByID(ctx, 99999); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("missing space id = %v, want ErrNotFound", err)
+ }
+
+ mkSpace(t, s, ctx, "bigbes", "notes")
+ spaces, err := s.ListSpaces(ctx)
+ if err != nil {
+ t.Fatalf("list spaces: %v", err)
+ }
+ if len(spaces) != 3 {
+ t.Fatalf("expected 3 spaces, got %d", len(spaces))
+ }
+ // Ordered by owner then name.
+ want := []string{"~bigbes/notes", "~bigbes/rfcs", "~someone/rfcs"}
+ for i, sp := range spaces {
+ if sp.Ref.String() != want[i] {
+ t.Errorf("spaces[%d] = %s, want %s", i, sp.Ref, want[i])
+ }
+ }
+}
A db/stamps.go => db/stamps.go +101 -0
@@ 0,0 1,101 @@
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+// IndexStamp records which revision of a space's approved branch the global
+// bleve index currently reflects.
+//
+// It is the reconciler's staleness comparison, and it is why a crash between
+// merge and reindex is a defined repair rather than a bespoke one: stamp rev
+// != approved head means reindex. The index is a pure cache, so a missing or
+// wrong stamp costs a rebuild, never data.
+type IndexStamp struct {
+ SpaceID int
+ Rev string
+ IndexedAt time.Time
+}
+
+// GetIndexStamp returns the space's index stamp. Returns ErrNotFound when the
+// space has never been indexed — which the reconciler must treat as "stale",
+// not as "up to date"; that is exactly why this is an error rather than a zero
+// value.
+func (s *Store) GetIndexStamp(ctx context.Context, spaceID int) (*IndexStamp, error) {
+ const q = `SELECT space_id, rev, indexed_at FROM index_stamp WHERE space_id = $1`
+ var st IndexStamp
+ err := s.q.QueryRowContext(ctx, q, spaceID).Scan(&st.SpaceID, &st.Rev, &st.IndexedAt)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get index stamp space=%d: %w", spaceID, err)
+ }
+ return &st, nil
+}
+
+// SetIndexStamp records that the index now reflects rev for this space. Called
+// after a rebuild completes, never before: a stamp written ahead of the rebuild
+// would make a crash look like a fresh index.
+func (s *Store) SetIndexStamp(ctx context.Context, spaceID int, rev string) (*IndexStamp, error) {
+ if rev == "" {
+ return nil, fmt.Errorf("set index stamp space=%d: rev is required", spaceID)
+ }
+ const q = `
+INSERT INTO index_stamp (space_id, rev, indexed_at)
+VALUES ($1, $2, $3)
+ON CONFLICT (space_id) DO UPDATE
+ SET rev = EXCLUDED.rev, indexed_at = EXCLUDED.indexed_at
+RETURNING space_id, rev, indexed_at`
+ var st IndexStamp
+ err := s.q.QueryRowContext(ctx, q, spaceID, rev, time.Now().UTC()).
+ Scan(&st.SpaceID, &st.Rev, &st.IndexedAt)
+ if err != nil {
+ return nil, fmt.Errorf("set index stamp space=%d: %w", spaceID, err)
+ }
+ return &st, nil
+}
+
+// GetDigestMark returns when the owner last looked at the digest of
+// policy-merged content. Returns ErrNotFound if they never have.
+//
+// Auto-merged content that never appears in any view is write-only and rots
+// invisibly — the exact failure this service exists to prevent, just relocated.
+// This one timestamp is the whole of "what landed since you last looked".
+func (s *Store) GetDigestMark(ctx context.Context, owner string) (time.Time, error) {
+ const q = `SELECT seen_at FROM digest_mark WHERE owner = $1`
+ var seen time.Time
+ err := s.q.QueryRowContext(ctx, q, owner).Scan(&seen)
+ if errors.Is(err, sql.ErrNoRows) {
+ return time.Time{}, ErrNotFound
+ }
+ if err != nil {
+ return time.Time{}, fmt.Errorf("get digest mark %q: %w", owner, err)
+ }
+ return seen, nil
+}
+
+// SetDigestMark moves the owner's "last looked at" timestamp. seenAt is passed
+// in rather than defaulted to now() so the caller can mark the digest as of the
+// moment it rendered the page, not the moment the write happened — anything
+// that lands in between must still show up next time.
+func (s *Store) SetDigestMark(ctx context.Context, owner string, seenAt time.Time) error {
+ if owner == "" {
+ return fmt.Errorf("set digest mark: owner is required")
+ }
+ if seenAt.IsZero() {
+ return fmt.Errorf("set digest mark %q: seen_at is required", owner)
+ }
+ const q = `
+INSERT INTO digest_mark (owner, seen_at)
+VALUES ($1, $2)
+ON CONFLICT (owner) DO UPDATE SET seen_at = EXCLUDED.seen_at`
+ if _, err := s.q.ExecContext(ctx, q, owner, seenAt.UTC()); err != nil {
+ return fmt.Errorf("set digest mark %q: %w", owner, err)
+ }
+ return nil
+}
A db/stamps_test.go => db/stamps_test.go +97 -0
@@ 0,0 1,97 @@
+package db
+
+import (
+ "context"
+ "errors"
+ "testing"
+ "time"
+)
+
+func TestIndexStamp(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ space := mkSpace(t, s, ctx, "bigbes", "rfcs")
+
+ // Never indexed is ErrNotFound, not a zero rev: the reconciler must read it
+ // as stale.
+ if _, err := s.GetIndexStamp(ctx, space.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("unindexed space = %v, want ErrNotFound", err)
+ }
+
+ st, err := s.SetIndexStamp(ctx, space.ID, "rev1")
+ if err != nil {
+ t.Fatalf("set stamp: %v", err)
+ }
+ if st.SpaceID != space.ID || st.Rev != "rev1" || st.IndexedAt.IsZero() {
+ t.Fatalf("unexpected stamp: %+v", st)
+ }
+
+ got, err := s.GetIndexStamp(ctx, space.ID)
+ if err != nil {
+ t.Fatalf("get stamp: %v", err)
+ }
+ if got.Rev != "rev1" {
+ t.Fatalf("rev = %q, want rev1", got.Rev)
+ }
+
+ // Re-stamping the same space updates in place.
+ if _, err := s.SetIndexStamp(ctx, space.ID, "rev2"); err != nil {
+ t.Fatalf("re-stamp: %v", err)
+ }
+ got, err = s.GetIndexStamp(ctx, space.ID)
+ if err != nil {
+ t.Fatalf("get stamp: %v", err)
+ }
+ if got.Rev != "rev2" {
+ t.Fatalf("rev = %q, want rev2", got.Rev)
+ }
+ if !got.IndexedAt.After(st.IndexedAt) && !got.IndexedAt.Equal(st.IndexedAt) {
+ t.Fatalf("indexed_at went backwards: %v -> %v", st.IndexedAt, got.IndexedAt)
+ }
+
+ // The stamp is per space and cascades with it.
+ other := mkSpace(t, s, ctx, "bigbes", "notes")
+ if _, err := s.GetIndexStamp(ctx, other.ID); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("other space stamp = %v, want ErrNotFound", err)
+ }
+}
+
+func TestDigestMark(t *testing.T) {
+ s, _, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ if _, err := s.GetDigestMark(ctx, "bigbes"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("never looked = %v, want ErrNotFound", err)
+ }
+
+ first := time.Now().UTC().Add(-time.Hour).Truncate(time.Microsecond)
+ if err := s.SetDigestMark(ctx, "bigbes", first); err != nil {
+ t.Fatalf("set mark: %v", err)
+ }
+ got, err := s.GetDigestMark(ctx, "bigbes")
+ if err != nil {
+ t.Fatalf("get mark: %v", err)
+ }
+ if !got.Equal(first) {
+ t.Fatalf("mark = %v, want %v", got, first)
+ }
+
+ second := first.Add(30 * time.Minute)
+ if err := s.SetDigestMark(ctx, "bigbes", second); err != nil {
+ t.Fatalf("move mark: %v", err)
+ }
+ got, err = s.GetDigestMark(ctx, "bigbes")
+ if err != nil {
+ t.Fatalf("get mark: %v", err)
+ }
+ if !got.Equal(second) {
+ t.Fatalf("mark = %v, want %v", got, second)
+ }
+
+ if _, err := s.GetDigestMark(ctx, "someone"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("other owner = %v, want ErrNotFound", err)
+ }
+}
A db/store.go => db/store.go +178 -0
@@ 0,0 1,178 @@
+// Package db is the PostgreSQL persistence layer for spec.sr.ht. It maps the
+// six tables of schema.sql — space, document_id, proposal, agent_token,
+// index_stamp and digest_mark — to core value types with plain database/sql and
+// $n placeholders (no ORM).
+//
+// The layering rule from the design is what shapes this package: **git refs are
+// the source of truth for whether a proposal exists and whether it merged;
+// Postgres holds metadata that is reconstructable from git; the index and the
+// render cache are pure caches.** So nothing here stores a document body, and
+// every row is something the reconciler could rebuild from refs. The queries
+// are correspondingly small: registry lookups, one state machine, and two
+// key/value stamps.
+//
+// Design: a Store wraps a Querier — an interface satisfied by *sql.DB, *sql.Tx
+// and *sql.Conn alike. This gives us two things at once:
+//
+// - Context-first, middleware-compatible signatures. Production callers build
+// a Store from the connection that core-go's database middleware injects
+// into the request context (see FromContext, which reads the same *sql.DB
+// that database.Middleware installed). Every method takes ctx first and
+// threads it into the query for cancellation.
+//
+// - Trivial test injection. Tests call NewStore(db) with a plain *sql.DB, no
+// HTTP context required.
+//
+// Because the wrapped value is an interface, a Store can be re-bound to an open
+// transaction with WithTx(tx) or InTx(ctx, fn). That is what MergeProposal
+// needs: recording the merge and re-pointing the affected document_id rows are
+// one invariant, not two writes that may half-happen.
+package db
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+
+ "sourcecraft.dev/bigbes/sr-ht-core/database"
+)
+
+// Querier is the common subset of *sql.DB, *sql.Tx and *sql.Conn used by this
+// package. Binding a Store to any of them keeps every method identical whether
+// it runs standalone (autocommit) or inside a caller-managed transaction.
+type Querier interface {
+ ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
+ QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
+ QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
+
+// beginner is the subset of *sql.DB that can open a transaction. A Store bound
+// to a *sql.Tx does not satisfy it, which is how InTx refuses to nest instead of
+// silently running the body outside a transaction.
+type beginner interface {
+ BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
+}
+
+// Store is the entry point for all queries in this package.
+type Store struct {
+ q Querier
+}
+
+// NewStore builds a Store over a database handle (or any Querier). Tests pass a
+// plain *sql.DB; production wiring passes the shared pool.
+func NewStore(q Querier) *Store {
+ return &Store{q: q}
+}
+
+// FromContext builds a Store over the *sql.DB that core-go's database.Middleware
+// installed in ctx. It panics (via database.DBForContext) if no database is
+// present in the context — a programming error, never a runtime condition to
+// recover from. We wrap the pooled *sql.DB rather than checking out a *sql.Conn
+// (database.ForContext) so the Store has no connection to leak; the pool manages
+// connection lifetime and ctx still bounds each query.
+func FromContext(ctx context.Context) *Store {
+ return &Store{q: database.DBForContext(ctx)}
+}
+
+// WithTx returns a Store that runs every query on tx instead of the pool. Used
+// where the caller already owns a transaction and wants these queries inside it.
+func (s *Store) WithTx(tx *sql.Tx) *Store {
+ return &Store{q: tx}
+}
+
+// InTx runs fn inside a transaction, on a Store bound to it. fn returning an
+// error rolls back and the error is returned unwrapped, so callers can still
+// match sentinels with errors.Is. A panic in fn rolls back and re-panics rather
+// than leaving the transaction open.
+//
+// It requires the Store to wrap something that can begin a transaction (the
+// pool). A Store already bound to a *sql.Tx returns ErrNoTransaction: nesting is
+// a caller bug, and quietly running the body without transactional isolation
+// would defeat the only reason this method exists.
+func (s *Store) InTx(ctx context.Context, fn func(*Store) error) error {
+ b, ok := s.q.(beginner)
+ if !ok {
+ return ErrNoTransaction
+ }
+ tx, err := b.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("begin transaction: %w", err)
+ }
+ committed := false
+ defer func() {
+ if !committed {
+ tx.Rollback()
+ }
+ }()
+ if err := fn(&Store{q: tx}); err != nil {
+ return err
+ }
+ if err := tx.Commit(); err != nil {
+ return fmt.Errorf("commit transaction: %w", err)
+ }
+ committed = true
+ return nil
+}
+
+// Typed errors returned by this package. Callers match them with errors.Is.
+var (
+ // ErrNotFound is returned when a lookup, update or delete matched no row.
+ ErrNotFound = errors.New("db: not found")
+
+ // ErrSpaceExists is returned by CreateSpace when the owner already has a
+ // space with that name (uq_space_owner_name violation).
+ ErrSpaceExists = errors.New("db: space already exists")
+
+ // ErrDocIDTaken is returned when a document ID is already registered.
+ // Document IDs are globally unique, so this is the registry refusing a
+ // collision — the invariant that lets [[SPEC-0007]] resolve the same way
+ // everywhere and lets a later import not clash with what is already here.
+ ErrDocIDTaken = errors.New("db: document id already registered")
+
+ // ErrDocIDDuplicate is returned when one batch of documents carries the
+ // same ID twice. Distinct from ErrDocIDTaken: the collision is inside the
+ // push itself, not against the registry.
+ ErrDocIDDuplicate = errors.New("db: document id appears twice in one batch")
+
+ // ErrTokenExists is returned by CreateAgentToken when that exact token is
+ // already registered (agent_token.token_hash UNIQUE).
+ ErrTokenExists = errors.New("db: agent token already registered")
+
+ // ErrTokenRevoked is returned by AuthenticateAgentToken for a token that
+ // exists but has been revoked. Kept distinct from ErrNotFound so the audit
+ // log can say which happened; both map to 401 at the API boundary.
+ ErrTokenRevoked = errors.New("db: agent token revoked")
+
+ // ErrNoTransaction is returned by InTx when the Store is not bound to
+ // something that can begin one (i.e. it is already inside a transaction).
+ ErrNoTransaction = errors.New("db: store cannot begin a transaction")
+)
+
+// rowScanner is satisfied by both *sql.Row and *sql.Rows.
+type rowScanner interface {
+ Scan(dest ...any) error
+}
+
+// requireOne turns a zero-rows-affected result into ErrNotFound so that missing
+// targets surface loudly instead of passing silently.
+func requireOne(res sql.Result, what string) error {
+ n, err := res.RowsAffected()
+ if err != nil {
+ return fmt.Errorf("%s: rows affected: %w", what, err)
+ }
+ if n == 0 {
+ return ErrNotFound
+ }
+ return nil
+}
+
+// nullable maps the empty string to a SQL NULL, for the columns the schema
+// leaves nullable (proposal.rationale). Storing "" and NULL interchangeably
+// would make round-tripping lossy.
+func nullable(s string) any {
+ if s == "" {
+ return nil
+ }
+ return s
+}
A db/token.go => db/token.go +201 -0
@@ 0,0 1,201 @@
+package db
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "crypto/subtle"
+ "database/sql"
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/lib/pq"
+)
+
+// TokenBytes is the entropy of a minted agent token before encoding. 32 bytes
+// is well past any brute-force concern and keeps the encoded form short enough
+// to paste into an agent's environment.
+const TokenBytes = 32
+
+// AgentToken is a credential an agent presents on the write plane. The token
+// value itself is never stored — only Hash — so a database dump cannot be
+// replayed against the service.
+//
+// v1 ships one token plus mandatory provenance rather than per-agent scopes:
+// the refs rule (an agent credential can only move refs under BranchPrefix) is
+// the boundary that actually bounds the damage, and it holds with a single
+// shared token. Per-space scoping is a later column on this row plus a filter
+// clause, not an architectural change.
+type AgentToken struct {
+ ID int
+ Name string
+ Hash []byte
+ Created time.Time
+ Revoked *time.Time
+}
+
+// Active reports whether the token may still authenticate.
+func (t *AgentToken) Active() bool { return t.Revoked == nil }
+
+// GenerateToken mints a fresh token value: TokenBytes of crypto/rand, URL-safe
+// base64 without padding. This is the only moment the plaintext exists; the
+// caller shows it to the operator once and stores only HashToken(it).
+func GenerateToken() (string, error) {
+ b := make([]byte, TokenBytes)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("generate agent token: %w", err)
+ }
+ return base64.RawURLEncoding.EncodeToString(b), nil
+}
+
+// HashToken is the one-way function between a presented token and the stored
+// agent_token.token_hash. SHA-256 is the right tool here rather than a password
+// KDF: the input is 256 bits of uniform randomness we generated, not a
+// human-chosen secret, so there is no dictionary to stretch against.
+func HashToken(token string) []byte {
+ sum := sha256.Sum256([]byte(token))
+ return sum[:]
+}
+
+// TokenMatches compares a stored hash with the hash of a presented token in
+// constant time. Byte-wise early exit on a hash comparison leaks how many
+// leading bytes an attacker guessed right, which is enough to walk a forged
+// value into place one byte at a time; subtle.ConstantTimeCompare does not.
+func TokenMatches(stored []byte, token string) bool {
+ return subtle.ConstantTimeCompare(stored, HashToken(token)) == 1
+}
+
+// CreateAgentToken stores a token by hash and returns the row. The plaintext is
+// never passed to this function and never reaches SQL — callers hash with
+// HashToken and keep the value only long enough to show it once.
+//
+// A token_hash UNIQUE violation means the same token was registered twice —
+// for 32 random bytes, that is a caller re-registering a value it already had,
+// not a collision — and is mapped to ErrTokenExists.
+func (s *Store) CreateAgentToken(ctx context.Context, name string, hash []byte) (*AgentToken, error) {
+ if name == "" {
+ return nil, fmt.Errorf("create agent token: name is required")
+ }
+ if len(hash) != sha256.Size {
+ return nil, fmt.Errorf("create agent token: hash must be %d bytes, got %d",
+ sha256.Size, len(hash))
+ }
+ const q = `
+INSERT INTO agent_token (name, token_hash, created)
+VALUES ($1, $2, $3)
+RETURNING id, created`
+ t := AgentToken{Name: name, Hash: hash}
+ err := s.q.QueryRowContext(ctx, q, name, hash, time.Now().UTC()).Scan(&t.ID, &t.Created)
+ if err != nil {
+ var pqErr *pq.Error
+ if errors.As(err, &pqErr) && pqErr.Code == "23505" {
+ return nil, ErrTokenExists
+ }
+ return nil, fmt.Errorf("create agent token %q: %w", name, err)
+ }
+ return &t, nil
+}
+
+// AgentTokenByHash looks a token up by its stored hash. It does not consider
+// revocation — use AuthenticateAgentToken for the authorization decision.
+// Returns ErrNotFound if no such token is registered.
+func (s *Store) AgentTokenByHash(ctx context.Context, hash []byte) (*AgentToken, error) {
+ const q = `
+SELECT id, name, token_hash, created, revoked
+FROM agent_token
+WHERE token_hash = $1`
+ var (
+ t AgentToken
+ revoked sql.NullTime
+ )
+ err := s.q.QueryRowContext(ctx, q, hash).Scan(&t.ID, &t.Name, &t.Hash, &t.Created, &revoked)
+ if errors.Is(err, sql.ErrNoRows) {
+ return nil, ErrNotFound
+ }
+ if err != nil {
+ return nil, fmt.Errorf("agent token by hash: %w", err)
+ }
+ if revoked.Valid {
+ r := revoked.Time
+ t.Revoked = &r
+ }
+ return &t, nil
+}
+
+// AuthenticateAgentToken is the authorization boundary: it hashes the presented
+// token, looks the row up by that hash, re-verifies the stored hash against the
+// presentation in constant time, and rejects a revoked token.
+//
+// The re-verification is not redundant with the SQL equality. The index lookup
+// is what finds the row; TokenMatches is what decides, and it is the one
+// comparison an attacker can time. Returns ErrNotFound for an unknown token and
+// ErrTokenRevoked for a known but revoked one; both are 401 at the API edge.
+func (s *Store) AuthenticateAgentToken(ctx context.Context, token string) (*AgentToken, error) {
+ if token == "" {
+ return nil, ErrNotFound
+ }
+ t, err := s.AgentTokenByHash(ctx, HashToken(token))
+ if err != nil {
+ return nil, err
+ }
+ if !TokenMatches(t.Hash, token) {
+ // The row was found by hash equality, so a mismatch here means the
+ // stored hash is not what the index matched on — corruption, not a bad
+ // credential.
+ return nil, fmt.Errorf("agent token %d: stored hash does not verify", t.ID)
+ }
+ if !t.Active() {
+ return nil, fmt.Errorf("%w: token %q revoked at %s", ErrTokenRevoked, t.Name, t.Revoked)
+ }
+ return t, nil
+}
+
+// RevokeAgentToken stamps a token revoked. Revocation is a stamp rather than a
+// delete so the audit trail keeps naming the token that made past proposals.
+// Revoking an already-revoked token is a no-op that returns nil; re-revoking is
+// not an error worth failing an operator over. Returns ErrNotFound if id does
+// not exist.
+func (s *Store) RevokeAgentToken(ctx context.Context, id int) error {
+ const q = `UPDATE agent_token SET revoked = COALESCE(revoked, $2) WHERE id = $1`
+ res, err := s.q.ExecContext(ctx, q, id, time.Now().UTC())
+ if err != nil {
+ return fmt.Errorf("revoke agent token %d: %w", id, err)
+ }
+ return requireOne(res, "revoke agent token")
+}
+
+// ListAgentTokens returns every token, newest first, so the operator can see
+// what exists and pick one to revoke. Hashes are included; there is nothing
+// secret about them and the reconciler-style tooling compares by them.
+func (s *Store) ListAgentTokens(ctx context.Context) ([]*AgentToken, error) {
+ const q = `
+SELECT id, name, token_hash, created, revoked
+FROM agent_token
+ORDER BY created DESC, id DESC`
+ rows, err := s.q.QueryContext(ctx, q)
+ if err != nil {
+ return nil, fmt.Errorf("list agent tokens: %w", err)
+ }
+ defer rows.Close()
+ var out []*AgentToken
+ for rows.Next() {
+ var (
+ t AgentToken
+ revoked sql.NullTime
+ )
+ if err := rows.Scan(&t.ID, &t.Name, &t.Hash, &t.Created, &revoked); err != nil {
+ return nil, fmt.Errorf("scan agent token: %w", err)
+ }
+ if revoked.Valid {
+ r := revoked.Time
+ t.Revoked = &r
+ }
+ out = append(out, &t)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("iterate agent tokens: %w", err)
+ }
+ return out, nil
+}
A db/token_test.go => db/token_test.go +118 -0
@@ 0,0 1,118 @@
+package db
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestAgentTokenLifecycle(t *testing.T) {
+ s, pool, cleanup := newTestStore(t)
+ defer cleanup()
+ ctx := context.Background()
+
+ token, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("generate token: %v", err)
+ }
+ hash := HashToken(token)
+
+ created, err := s.CreateAgentToken(ctx, "spec-writer", hash)
+ if err != nil {
+ t.Fatalf("create token: %v", err)
+ }
+ if created.ID == 0 || !created.Active() {
+ t.Fatalf("unexpected created token: %+v", created)
+ }
+
+ // The plaintext must never have reached the database.
+ var found int
+ if err := pool.QueryRowContext(ctx,
+ `SELECT count(*) FROM agent_token WHERE encode(token_hash, 'escape') LIKE '%' || $1 || '%'`,
+ token).Scan(&found); err != nil {
+ t.Fatalf("scan for plaintext: %v", err)
+ }
+ if found != 0 {
+ t.Fatal("the token plaintext is recoverable from the row")
+ }
+
+ if _, err := s.CreateAgentToken(ctx, "duplicate", hash); !errors.Is(err, ErrTokenExists) {
+ t.Fatalf("duplicate token = %v, want ErrTokenExists", err)
+ }
+
+ byHash, err := s.AgentTokenByHash(ctx, hash)
+ if err != nil {
+ t.Fatalf("lookup by hash: %v", err)
+ }
+ if byHash.ID != created.ID || byHash.Name != "spec-writer" || !bytes.Equal(byHash.Hash, hash) {
+ t.Fatalf("unexpected lookup: %+v", byHash)
+ }
+ if _, err := s.AgentTokenByHash(ctx, HashToken("nope")); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("unknown hash = %v, want ErrNotFound", err)
+ }
+
+ auth, err := s.AuthenticateAgentToken(ctx, token)
+ if err != nil {
+ t.Fatalf("authenticate: %v", err)
+ }
+ if auth.ID != created.ID {
+ t.Fatalf("authenticated the wrong token: %+v", auth)
+ }
+ if _, err := s.AuthenticateAgentToken(ctx, token+"x"); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("wrong token = %v, want ErrNotFound", err)
+ }
+ if _, err := s.AuthenticateAgentToken(ctx, ""); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("empty token = %v, want ErrNotFound", err)
+ }
+
+ // Revocation is a stamp, so the row stays for the audit trail.
+ if err := s.RevokeAgentToken(ctx, created.ID); err != nil {
+ t.Fatalf("revoke: %v", err)
+ }
+ if _, err := s.AuthenticateAgentToken(ctx, token); !errors.Is(err, ErrTokenRevoked) {
+ t.Fatalf("revoked token = %v, want ErrTokenRevoked", err)
+ }
+ revoked, err := s.AgentTokenByHash(ctx, hash)
+ if err != nil {
+ t.Fatalf("lookup revoked: %v", err)
+ }
+ if revoked.Active() || revoked.Revoked == nil {
+ t.Fatalf("revocation did not stick: %+v", revoked)
+ }
+ first := *revoked.Revoked
+
+ // Re-revoking keeps the original timestamp rather than moving it.
+ if err := s.RevokeAgentToken(ctx, created.ID); err != nil {
+ t.Fatalf("re-revoke: %v", err)
+ }
+ again, err := s.AgentTokenByHash(ctx, hash)
+ if err != nil {
+ t.Fatalf("lookup revoked: %v", err)
+ }
+ if !again.Revoked.Equal(first) {
+ t.Fatalf("re-revoking moved the timestamp: %v -> %v", first, *again.Revoked)
+ }
+
+ if err := s.RevokeAgentToken(ctx, 99999); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("revoking a missing token = %v, want ErrNotFound", err)
+ }
+
+ second, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("generate token: %v", err)
+ }
+ if _, err := s.CreateAgentToken(ctx, "notes-writer", HashToken(second)); err != nil {
+ t.Fatalf("create second token: %v", err)
+ }
+ list, err := s.ListAgentTokens(ctx)
+ if err != nil {
+ t.Fatalf("list tokens: %v", err)
+ }
+ if len(list) != 2 || list[0].Name != "notes-writer" {
+ t.Fatalf("unexpected token list (newest first): %+v", list)
+ }
+ if _, err := s.AuthenticateAgentToken(ctx, second); err != nil {
+ t.Fatalf("authenticate second token: %v", err)
+ }
+}
A db/unit_test.go => db/unit_test.go +381 -0
@@ 0,0 1,381 @@
+package db
+
+import (
+ "context"
+ "crypto/sha256"
+ "database/sql"
+ "errors"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "sourcecraft.dev/bigbes/sr-ht-spec/core"
+)
+
+// These tests need no database. They cover the logic that decides things —
+// transition legality, token comparison, batch duplicate detection — plus the
+// agreement between schema.sql and the migration, which is otherwise only
+// discovered on a fresh install.
+
+func TestProposalBranch(t *testing.T) {
+ for _, tc := range []struct {
+ id int
+ want string
+ }{
+ {1, "proposals/1"},
+ {42, "proposals/42"},
+ {1000000, "proposals/1000000"},
+ } {
+ if got := ProposalBranch(tc.id); got != tc.want {
+ t.Errorf("ProposalBranch(%d) = %q, want %q", tc.id, got, tc.want)
+ }
+ }
+ if !strings.HasPrefix(ProposalBranch(7), BranchPrefix) {
+ t.Errorf("ProposalBranch must stay under the refs-rule prefix %q", BranchPrefix)
+ }
+}
+
+func TestHashTokenAndMatches(t *testing.T) {
+ tok, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("generate token: %v", err)
+ }
+ if len(tok) < 40 {
+ t.Fatalf("token %q is implausibly short for %d bytes of entropy", tok, TokenBytes)
+ }
+ hash := HashToken(tok)
+ if len(hash) != sha256.Size {
+ t.Fatalf("HashToken returned %d bytes, want %d", len(hash), sha256.Size)
+ }
+ if strings.Contains(string(hash), tok) {
+ t.Fatal("hash must not contain the token")
+ }
+ if !TokenMatches(hash, tok) {
+ t.Fatal("TokenMatches rejected the token it hashed")
+ }
+ if TokenMatches(hash, tok+"x") {
+ t.Fatal("TokenMatches accepted a different token")
+ }
+ if TokenMatches(hash, "") {
+ t.Fatal("TokenMatches accepted the empty token")
+ }
+ if TokenMatches(nil, tok) {
+ t.Fatal("TokenMatches accepted a nil stored hash")
+ }
+ if TokenMatches(hash[:16], tok) {
+ t.Fatal("TokenMatches accepted a truncated stored hash")
+ }
+
+ // Two mints must differ; a repeated value would mean the generator is not
+ // actually random and every token would be the same credential.
+ other, err := GenerateToken()
+ if err != nil {
+ t.Fatalf("generate token: %v", err)
+ }
+ if other == tok {
+ t.Fatal("GenerateToken returned the same value twice")
+ }
+}
+
+func TestDuplicateDocIDs(t *testing.T) {
+ ref := func(id, path string) DocRef {
+ return DocRef{ID: docID(t, id), Path: path}
+ }
+ for _, tc := range []struct {
+ name string
+ in []DocRef
+ want []string
+ }{
+ {"empty", nil, nil},
+ {"unique", []DocRef{ref("SPEC-1", "a.md"), ref("SPEC-2", "b.md")}, nil},
+ {
+ "same id twice at different paths",
+ []DocRef{ref("SPEC-1", "a.md"), ref("SPEC-1", "b.md")},
+ []string{"SPEC-1"},
+ },
+ {
+ "leading zeros are a different id",
+ []DocRef{ref("SPEC-7", "a.md"), ref("SPEC-0007", "b.md")},
+ nil,
+ },
+ {
+ "several duplicates, sorted",
+ []DocRef{
+ ref("SPEC-2", "a.md"), ref("SPEC-1", "b.md"),
+ ref("SPEC-2", "c.md"), ref("SPEC-1", "d.md"),
+ },
+ []string{"SPEC-1", "SPEC-2"},
+ },
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ got := DuplicateDocIDs(tc.in)
+ if len(got) != len(tc.want) {
+ t.Fatalf("DuplicateDocIDs = %v, want %v", got, tc.want)
+ }
+ for i, id := range got {
+ if id.String() != tc.want[i] {
+ t.Fatalf("DuplicateDocIDs[%d] = %s, want %s", i, id, tc.want[i])
+ }
+ }
+ })
+ }
+}
+
+func TestCollisionErrorWrapsErrDocIDTaken(t *testing.T) {
+ err := &CollisionError{Collisions: []Collision{{
+ DocID: docID(t, "SPEC-7"),
+ Existing: &Document{ID: docID(t, "SPEC-7"), SpaceID: 3, Path: "specs/0007.md"},
+ }}}
+ if !errors.Is(err, ErrDocIDTaken) {
+ t.Fatal("CollisionError must match ErrDocIDTaken")
+ }
+ msg := err.Error()
+ for _, want := range []string{"SPEC-7", "space 3", "specs/0007.md"} {
+ if !strings.Contains(msg, want) {
+ t.Errorf("collision message %q does not name %q", msg, want)
+ }
+ }
+}
+
+// fakeQuerier satisfies Querier but not beginner, standing in for a Store that
+// is already bound to a transaction.
+type fakeQuerier struct{}
+
+func (fakeQuerier) ExecContext(context.Context, string, ...any) (sql.Result, error) {
+ return nil, errors.New("unexpected Exec")
+}
+func (fakeQuerier) QueryContext(context.Context, string, ...any) (*sql.Rows, error) {
+ return nil, errors.New("unexpected Query")
+}
+func (fakeQuerier) QueryRowContext(context.Context, string, ...any) *sql.Row { return nil }
+
+func TestInTxRefusesToNest(t *testing.T) {
+ s := NewStore(fakeQuerier{})
+ called := false
+ err := s.InTx(context.Background(), func(*Store) error {
+ called = true
+ return nil
+ })
+ if !errors.Is(err, ErrNoTransaction) {
+ t.Fatalf("InTx on a non-beginner = %v, want ErrNoTransaction", err)
+ }
+ if called {
+ t.Fatal("InTx ran the body without a transaction")
+ }
+}
+
+// TestResolveArgumentsValidated covers the guards that reject before any SQL is
+// issued; the fake Querier would error if a query were attempted.
+func TestResolveArgumentsValidated(t *testing.T) {
+ s := NewStore(fakeQuerier{})
+ ctx := context.Background()
+
+ if err := s.MarkProposalMerged(ctx, 1, core.Approval("bogus"), "abc"); !errors.Is(err, core.ErrInvalidApproval) {
+ t.Fatalf("merge with a bogus approval = %v, want ErrInvalidApproval", err)
+ }
+ if err := s.MarkProposalMerged(ctx, 1, core.ApprovalHuman, ""); err == nil {
+ t.Fatal("merge without a merged rev must fail")
+ }
+ if err := s.MergeProposal(ctx, Merge{ProposalID: 1, Approval: core.ApprovalPolicy}); err == nil {
+ t.Fatal("MergeProposal without a merged rev must fail")
+ }
+ if _, err := s.ListProposalsByState(ctx, core.ProposalState("closed"), 0); !errors.Is(err, core.ErrInvalidState) {
+ t.Fatalf("list with a bogus state = %v, want ErrInvalidState", err)
+ }
+ if _, err := s.OpenProposal(ctx, &Proposal{SpaceID: 1, Title: "t", BaseRev: "abc"}); err == nil {
+ t.Fatal("OpenProposal without provenance must fail")
+ }
+ if _, err := s.OpenProposal(ctx, &Proposal{
+ SpaceID: 1, Title: "t", Agent: "a", AgentSession: "s",
+ }); err == nil {
+ t.Fatal("OpenProposal without a base rev must fail")
+ }
+ if _, err := s.CreateSpace(ctx, core.SpaceRef{Owner: "bigbes", Name: "../etc"}); !errors.Is(err, core.ErrInvalidName) {
+ t.Fatalf("CreateSpace with a traversing name = %v, want ErrInvalidName", err)
+ }
+ if _, err := s.CreateAgentToken(ctx, "", HashToken("x")); err == nil {
+ t.Fatal("CreateAgentToken without a name must fail")
+ }
+ if _, err := s.CreateAgentToken(ctx, "ci", []byte("short")); err == nil {
+ t.Fatal("CreateAgentToken with a non-sha256 hash must fail")
+ }
+ if _, err := s.AuthenticateAgentToken(ctx, ""); !errors.Is(err, ErrNotFound) {
+ t.Fatalf("authenticating an empty token = %v, want ErrNotFound", err)
+ }
+ if err := s.SetDigestMark(ctx, "", time.Now()); err == nil {
+ t.Fatal("SetDigestMark without an owner must fail")
+ }
+ if err := s.SetDigestMark(ctx, "bigbes", time.Time{}); err == nil {
+ t.Fatal("SetDigestMark with a zero timestamp must fail")
+ }
+ if _, err := s.SetIndexStamp(ctx, 1, ""); err == nil {
+ t.Fatal("SetIndexStamp without a rev must fail")
+ }
+ if err := s.UpsertDocIDs(ctx, 1, []DocRef{
+ {ID: docID(t, "SPEC-1"), Path: "a.md"},
+ {ID: docID(t, "SPEC-1"), Path: "b.md"},
+ }, "abc"); !errors.Is(err, ErrDocIDDuplicate) {
+ t.Fatalf("upsert with an in-batch duplicate = %v, want ErrDocIDDuplicate", err)
+ }
+ if err := s.UpsertDocIDs(ctx, 1, []DocRef{
+ {ID: docID(t, "SPEC-1"), Path: "../escape.md"},
+ }, "abc"); !errors.Is(err, core.ErrInvalidPath) {
+ t.Fatalf("upsert with a traversing path = %v, want ErrInvalidPath", err)
+ }
+ // An empty batch is a legal no-op and must not reach SQL.
+ if err := s.UpsertDocIDs(ctx, 1, nil, "abc"); err != nil {
+ t.Fatalf("empty upsert = %v, want nil", err)
+ }
+ if c, err := s.CheckDocIDCollisions(ctx, 1, nil); err != nil || c != nil {
+ t.Fatalf("empty collision check = %v, %v; want nil, nil", c, err)
+ }
+}
+
+// TestTransitionGuardMatchesCore pins the guard this package relies on: the
+// UPDATE ... WHERE state = 'open' clause is only correct because open is the
+// sole state anything may leave.
+func TestTransitionGuardMatchesCore(t *testing.T) {
+ legal := map[[2]core.ProposalState]bool{
+ {core.StateOpen, core.StateMerged}: true,
+ {core.StateOpen, core.StateRejected}: true,
+ }
+ for _, from := range core.ProposalStates() {
+ for _, to := range core.ProposalStates() {
+ want := legal[[2]core.ProposalState{from, to}]
+ if got := core.ValidTransition(from, to); got != want {
+ t.Errorf("ValidTransition(%s, %s) = %v, want %v", from, to, got, want)
+ }
+ }
+ }
+ if err := core.StateOpen.CanTransitionTo(core.StateOpen); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("open->open = %v, want ErrInvalidTransition", err)
+ }
+ if err := core.StateMerged.CanTransitionTo(core.StateRejected); !errors.Is(err, core.ErrInvalidTransition) {
+ t.Fatalf("merged->rejected = %v, want ErrInvalidTransition", err)
+ }
+}
+
+func TestNullable(t *testing.T) {
+ if nullable("") != nil {
+ t.Error("empty string must become SQL NULL")
+ }
+ if nullable("x") != any("x") {
+ t.Error("non-empty string must pass through")
+ }
+}
+
+// TestSchemaMatchesMigration is the check a fresh install would otherwise fail:
+// schema.sql is the authoritative DDL, migrations/0001_initial.sql builds the
+// same objects, and the two drifting apart means a migrated database and a
+// freshly created one are different databases.
+func TestSchemaMatchesMigration(t *testing.T) {
+ schema, err := os.ReadFile("../schema.sql")
+ if err != nil {
+ t.Fatalf("read schema.sql: %v", err)
+ }
+ migration, err := os.ReadFile("../migrations/0001_initial.sql")
+ if err != nil {
+ t.Fatalf("read migration: %v", err)
+ }
+
+ up, down, ok := splitBrant(string(migration))
+ if !ok {
+ t.Fatal("migration is missing a `-- +brant Up` / `-- +brant Down` pair")
+ }
+
+ fromSchema := normalizeStatements(string(schema))
+ fromMigration := normalizeStatements(up)
+ if len(fromSchema) != len(fromMigration) {
+ t.Fatalf("schema.sql has %d statements, migration Up has %d:\n%v\n%v",
+ len(fromSchema), len(fromMigration), fromSchema, fromMigration)
+ }
+ for i := range fromSchema {
+ if fromSchema[i] != fromMigration[i] {
+ t.Errorf("statement %d differs:\n schema.sql: %s\n migration : %s",
+ i, fromSchema[i], fromMigration[i])
+ }
+ }
+
+ // Every table created must be dropped, so a Down actually undoes the Up.
+ created := tableNames(fromSchema, "CREATE TABLE ")
+ dropped := tableNames(normalizeStatements(down), "DROP TABLE ")
+ if len(created) == 0 {
+ t.Fatal("no CREATE TABLE statements found in schema.sql")
+ }
+ for _, name := range created {
+ if !contains(dropped, name) {
+ t.Errorf("table %q is created by Up but not dropped by Down", name)
+ }
+ }
+
+ // The design deliberately omits these two; adding either needs a design
+ // change, not a quiet migration.
+ for _, absent := range []string{"project", "comment"} {
+ if contains(created, absent) {
+ t.Errorf("table %q is deliberately absent from v1", absent)
+ }
+ }
+ for _, want := range []string{"space", "document_id", "proposal", "agent_token",
+ "index_stamp", "digest_mark"} {
+ if !contains(created, want) {
+ t.Errorf("table %q is missing from schema.sql", want)
+ }
+ }
+}
+
+func splitBrant(src string) (up, down string, ok bool) {
+ i := strings.Index(src, "-- +brant Up")
+ j := strings.Index(src, "-- +brant Down")
+ if i < 0 || j < 0 || j < i {
+ return "", "", false
+ }
+ return src[i+len("-- +brant Up") : j], src[j+len("-- +brant Down"):], true
+}
+
+var (
+ lineComment = regexp.MustCompile(`(?m)--.*$`)
+ whitespace = regexp.MustCompile(`\s+`)
+)
+
+// normalizeStatements strips line comments, collapses whitespace and splits on
+// ';', so two DDL files that differ only in commentary and indentation compare
+// equal.
+func normalizeStatements(src string) []string {
+ src = lineComment.ReplaceAllString(src, " ")
+ var out []string
+ for _, stmt := range strings.Split(src, ";") {
+ stmt = strings.TrimSpace(whitespace.ReplaceAllString(stmt, " "))
+ stmt = strings.ReplaceAll(stmt, "( ", "(")
+ stmt = strings.ReplaceAll(stmt, " )", ")")
+ if stmt != "" {
+ out = append(out, stmt)
+ }
+ }
+ return out
+}
+
+func tableNames(stmts []string, prefix string) []string {
+ var out []string
+ for _, s := range stmts {
+ if !strings.HasPrefix(s, prefix) {
+ continue
+ }
+ rest := strings.TrimPrefix(s, prefix)
+ if i := strings.IndexAny(rest, " ("); i >= 0 {
+ rest = rest[:i]
+ }
+ out = append(out, strings.Trim(rest, `"`))
+ }
+ return out
+}
+
+func contains(hay []string, needle string) bool {
+ for _, h := range hay {
+ if h == needle {
+ return true
+ }
+ }
+ return false
+}
A migrations/0001_initial.sql => migrations/0001_initial.sql +66 -0
@@ 0,0 1,66 @@
+-- +brant Up
+CREATE TABLE space (
+ id SERIAL PRIMARY KEY,
+ owner TEXT NOT NULL,
+ name TEXT NOT NULL,
+ created TIMESTAMPTZ NOT NULL DEFAULT now(),
+ CONSTRAINT uq_space_owner_name UNIQUE (owner, name)
+);
+
+CREATE TABLE document_id (
+ doc_id TEXT PRIMARY KEY,
+ space_id INTEGER NOT NULL REFERENCES space(id) ON DELETE CASCADE,
+ path TEXT NOT NULL,
+ 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,
+ branch TEXT NOT NULL,
+ state TEXT NOT NULL,
+ approval TEXT,
+ merged_rev TEXT,
+ agent TEXT NOT NULL,
+ agent_session TEXT NOT NULL,
+ created TIMESTAMPTZ NOT NULL DEFAULT now(),
+ resolved TIMESTAMPTZ,
+
+ CONSTRAINT ck_proposal_state CHECK (state IN ('open', 'merged', 'rejected')),
+ CONSTRAINT ck_proposal_approval CHECK (approval IS NULL OR approval IN ('human', 'policy')),
+ 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)),
+ CONSTRAINT ck_proposal_provenance CHECK (length(agent) > 0 AND length(agent_session) > 0)
+);
+CREATE INDEX ix_proposal_state_created ON proposal (state, created DESC);
+
+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
+);
+
+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()
+);
+
+CREATE TABLE digest_mark (
+ owner TEXT PRIMARY KEY,
+ seen_at TIMESTAMPTZ NOT NULL
+);
+
+-- +brant Down
+DROP TABLE digest_mark;
+DROP TABLE index_stamp;
+DROP TABLE agent_token;
+DROP TABLE proposal;
+DROP TABLE document_id;
+DROP TABLE space;
A schema.sql => schema.sql +97 -0
@@ 0,0 1,97 @@
+-- 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: `project` (a project is a saved filter — a name plus a
+-- space-id list — and committing it before there are several spaces to filter
+-- would be speculative; it arrives with the meta-project in Phase 2) and
+-- `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
+);