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)
})
}