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