package db
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
"sourcecraft.dev/bigbes/sr-ht-spec/core"
)
// BranchPrefix is the ref namespace agents may write, under this package's
// name. It is core's constant: the prefix the INSERT below builds a branch from
// and the prefix gitx enforces on a push are one value, because a row and a ref
// that disagree about a proposal's branch name is a break that surfaces as a
// proposal nobody can find.
const BranchPrefix = core.ProposalPrefix
// 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.
//
// The derivation is core.ProposalBranch, so this and gitx.ProposalBranch cannot
// disagree — and it inherits core's refusal of a non-positive id, which here
// means an unwritten row rather than a proposal.
func ProposalBranch(id int) (string, error) { return core.ProposalBranch(int64(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)
}
return s.queryProposals(ctx, q, args...)
}
// ListProposalsBySpace lists one space's proposals in one state, newest first —
// the per-space proposal listing the read surfaces serve ("the open proposals
// on ~bigbes/rfcs"). ListProposalsByState is the instance-wide inbox; this is
// its space-scoped counterpart, which is the shape every surface above service/
// asks for, because a proposal is only ever meaningful inside its space.
//
// The (state, created DESC) index still serves this: the extra space_id
// predicate is a filter over a set that is already tiny at this instance's
// volume. limit <= 0 means no limit.
func (s *Store) ListProposalsBySpace(ctx context.Context, spaceID int, state core.ProposalState, limit int) ([]*Proposal, error) {
if _, err := core.ParseProposalState(string(state)); err != nil {
return nil, err
}
q := proposalSelect + ` WHERE space_id = $1 AND state = $2 ORDER BY created DESC, id DESC`
args := []any{spaceID, string(state)}
if limit > 0 {
q += ` LIMIT $3`
args = append(args, limit)
}
return s.queryProposals(ctx, q, args...)
}
// queryProposals runs a proposalSelect query and scans every row. The two
// listings above differ only in their WHERE and args, so the row loop — the
// part that is easy to get subtly wrong (a missing rows.Err, a leaked cursor) —
// lives in one place.
func (s *Store) queryProposals(ctx context.Context, q string, args ...any) ([]*Proposal, error) {
rows, err := s.q.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("list proposals: %w", 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)
}
// DeleteOpenProposal removes an open proposal row outright.
//
// It is the only delete in this package and it is deliberately narrow: the
// reconciler's repair for a row whose branch never appeared, where the daemon
// died between the row insert and the branch write. Such a row holds no content
// — the agent still has the document it wanted to write and re-proposes — so
// deleting it loses nothing. A resolved proposal is history and is never
// deleted, which is why this is not a general-purpose delete.
//
// The `state = 'open'` guard is in the statement, not in Go: a check-then-write
// would let a merge land in between and delete the row of a proposal that had
// just succeeded. When the guard bites, the current state is read back only to
// name it in the error — ErrNotFound when the row is gone, ErrProposalNotOpen
// when it has been resolved — exactly as resolveProposal does.
func (s *Store) DeleteOpenProposal(ctx context.Context, id int) error {
const q = `DELETE FROM proposal WHERE id = $1 AND state = $2`
res, err := s.q.ExecContext(ctx, q, id, string(core.StateOpen))
if err != nil {
return fmt.Errorf("delete proposal %d: %w", id, err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("delete proposal %d: rows affected: %w", id, err)
}
if n == 1 {
return nil
}
// Nothing was deleted: 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("delete 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 from == core.StateOpen {
// The row is open, yet the guarded DELETE matched nothing. That cannot
// happen; refuse rather than report success.
return fmt.Errorf("proposal %d: guarded delete matched no row while state is %s", id, from)
}
return fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, 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)
})
}