~bigbes/sr-ht-spec

ref: c23aec5721cb37973196ce27765eb23fd57029e9 sr-ht-spec/db/proposal.go -rw-r--r-- 12.3 KiB
c23aec57 — bigbes feat: projects — a saved filter over one global index, not a container 27 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
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(&current)
	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(&current)
	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)
	})
}