coverage~bigbes/sr-ht-spec3cb1c03dservice/propose.go

Coverage
78.3% 119/152 statements
Δ
Blob
258d846
1 package service
2
3 import (
4 "context"
5 "errors"
6 "fmt"
7
8 "github.com/go-git/go-git/v5/plumbing"
9
10 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
11 "sourcecraft.dev/bigbes/sr-ht-spec/core"
12 "sourcecraft.dev/bigbes/sr-ht-spec/db"
13 "sourcecraft.dev/bigbes/sr-ht-spec/gitx"
14 )
15
16 // DocumentWrite is one whole-document upload: a path and its complete bytes.
17 // There is no patch form — the write plane takes whole documents because that
18 // is how agents work, and it is what makes the merge model pure plumbing.
19 type DocumentWrite struct {
20 Path string
21 Content []byte
22 }
23
24 // ProposeRequest is one call to the write plane, identical across REST and MCP:
25 // an agent uploads whole documents against a base revision, either opening a new
26 // proposal or adding to one it already owns.
27 type ProposeRequest struct {
28 // Space is the space being written to.
29 Space core.SpaceRef
30
31 // Principal is who is writing, as authn resolved the bearer token. It must
32 // be an agent: proposing is an agent-only act, and the human write path is
33 // native receive-pack.
34 Principal authn.Principal
35
36 // ProposalID selects an existing open proposal to add to (the REST
37 // X-Proposal header). Zero opens a new one.
38 ProposalID int
39
40 // Title and Rationale describe a new proposal. Title is required when
41 // opening and ignored when adding.
42 Title string
43 Rationale string
44
45 // IfMatch is the approved-head sha the agent read the document at — the base
46 // B. One value, one meaning across REST and MCP: opening cuts the branch
47 // from it, adding is validated against the proposal's fixed B. It may be
48 // abbreviated; it is resolved to a full object name before anything is
49 // stored.
50 IfMatch string
51
52 // Message is the commit subject and body for this write. Empty defaults to
53 // the title when opening; a write that is neither given a message nor a
54 // title is refused, because a commit whose only content is provenance
55 // records that something happened without saying what.
56 Message string
57
58 // Writes are the whole documents this call uploads. At least one is
59 // required.
60 Writes []DocumentWrite
61 }
62
63 // ProposeResult is what every write returns. The URL is the whole point of the
64 // review plane's entry contract: the agent hands a human a link, so a write
65 // that did not surface one would make the work invisible.
66 type ProposeResult struct {
67 // Proposal is the proposal as it now stands — merged already when
68 // auto-merge policy landed it, otherwise open.
69 Proposal Proposal
70
71 // URL is the stable, shareable proposal link.
72 URL string
73
74 // Merged reports whether auto-merge policy landed this write immediately.
75 // It lets a caller phrase its message ("merged" vs "proposed") without
76 // re-reading the state.
77 Merged bool
78 }
79
80 // Propose is the write plane: an agent uploads whole documents and gets back a
81 // proposal and its URL.
82 //
83 // The shape is the design's, and the ordering is load-bearing:
84 //
85 // - The base is resolved and, when opening, checked to be an ancestor of the
86 // approved head — the open-time 409. Adding is validated against the
87 // proposal's fixed base instead, which does not move as edits accumulate.
88 // - A new proposal is row-first (the branch name derives from the row's serial
89 // id), then its branch is cut, then the documents are committed onto it with
90 // the agent's provenance in the commit trailers.
91 // - Auto-merge policy is evaluated against everything the proposal changes. If
92 // every changed path may skip review, the proposal is merged immediately
93 // with ApprovalPolicy; otherwise it stays open for a human.
94 //
95 // Auto-merge is best-effort: if the immediate merge cannot land (the approved
96 // branch moved under the proposal between the commit and the merge), the
97 // proposal is left open for review rather than failing the write — falling back
98 // to human review is the safe direction, and the policy-merged digest exists to
99 // surface it either way.
100 45 func (s *Service) Propose(ctx context.Context, req ProposeRequest) (ProposeResult, error) {
101 45 if !req.Principal.IsAgent() {
102 1 return ProposeResult{}, fmt.Errorf("%w: %s may not propose; proposing is agent-only", ErrForbidden, req.Principal)
103 1 }
104 // The grant check, at the layer that finally knows the action. It is a no-op
105 // for the local agent token — which carries no grants and never will, its
106 // boundary being the refs rule, unchanged by any of this — and refuses a
107 // tokens.sr.ht token that was minted without spec:propose. Both write
108 // surfaces come through here, so this is the one place it is spelled.
109 44 if err := req.Principal.Authorize(authn.ActionPropose); err != nil {
110 4 return ProposeResult{}, fmt.Errorf("%w: %s may not propose: %w", ErrForbidden, req.Principal, err)
111 4 }
112 40 if len(req.Writes) == 0 {
113 1 return ProposeResult{}, fmt.Errorf("%w: a proposal must write at least one document", ErrInvalid)
114 1 }
115
116 39 sp, err := s.OpenSpace(ctx, req.Space)
117 39 if err != nil {
118 7 return ProposeResult{}, err
119 7 }
120
121 // Resolve the agent's If-Match to a full object name up front: an
122 // abbreviated base that is unique today could be ambiguous later, and a
123 // canonical base is what the branch is cut from, what the provenance trailer
124 // records, and what every later add and the merge measure staleness against.
125 32 baseHash, err := sp.Repo.ResolveRev(ctx, req.IfMatch)
126 32 if err != nil {
127 0 return ProposeResult{}, readErr(err, "resolve If-Match %q in %s", req.IfMatch, req.Space)
128 0 }
129 32 base := baseHash.String()
130 32
131 32 var row *db.Proposal
132 32 if req.ProposalID == 0 {
133 25 row, err = s.openNewProposal(ctx, sp, req, baseHash)
134 25 } else {
135 7 row, err = s.addToProposal(ctx, sp, req, base)
136 7 }
137 32 if err != nil {
138 2 return ProposeResult{}, err
139 2 }
140
141 // A newly opened proposal fires PROPOSAL_OPENED; adding to an existing one
142 // does not — the add is a revision of a proposal already announced. The event
143 // goes out before the auto-merge attempt so an auto-merged proposal reports
144 // Opened then Merged, in that order.
145 30 if req.ProposalID == 0 {
146 24 s.emit(EventProposalOpened, proposalView(row, sp.Ref))
147 24 }
148
149 // Auto-merge: land immediately when every path the proposal changes may skip
150 // human review under the policy at the approved head. A stale or otherwise
151 // unlandable auto-merge leaves the proposal open — see the method doc.
152 30 merged, mergedRow := s.tryAutoMerge(ctx, sp, row)
153 30 if merged {
154 5 row = mergedRow
155 5 }
156
157 30 return ProposeResult{
158 30 Proposal: proposalView(row, sp.Ref),
159 30 URL: s.ProposalURL(sp.Ref, row.ID),
160 30 Merged: merged,
161 30 }, nil
162 }
163
164 // openNewProposal opens a proposal: the open-time ancestry 409, then row-first
165 // insert, branch cut, and the provenance-stamped commit.
166 25 func (s *Service) openNewProposal(ctx context.Context, sp *Space, req ProposeRequest, baseHash plumbing.Hash) (*db.Proposal, error) {
167 25 if req.Title == "" {
168 0 return nil, fmt.Errorf("%w: opening a proposal requires a title", ErrInvalid)
169 0 }
170 25 base := baseHash.String()
171 25
172 25 // The open-time 409: a base that is not an ancestor of the current approved
173 25 // head means the agent read a revision that the approved branch has moved
174 25 // off, so the proposal could never merge. Reject it now rather than let it
175 25 // sit open until a merge discovers it.
176 25 head, err := sp.Repo.ApprovedHead(ctx)
177 25 if err != nil {
178 0 return nil, readErr(err, "read approved head of %s", sp.Ref)
179 0 }
180 25 onBranch, err := sp.Repo.IsAncestor(ctx, baseHash, head)
181 25 if err != nil {
182 0 return nil, readErr(err, "ancestry of base %s in %s", short(base), sp.Ref)
183 0 }
184 25 if !onBranch {
185 1 return nil, fmt.Errorf("%w: base %s is not an ancestor of the approved head %s; refetch and re-propose",
186 1 ErrStale, short(base), short(head.String()))
187 1 }
188
189 24 meta, err := s.agentCommit(sp, req, base)
190 24 if err != nil {
191 0 return nil, err
192 0 }
193 24 if err := s.validateWrites(ctx, sp, req.Writes, base); err != nil {
194 0 return nil, err
195 0 }
196
197 // Row first: the branch name "proposals/<id>" derives from the row's serial
198 // id, so the id must be allocated before the branch can be named. A crash
199 // between the insert and the branch write leaves an open row with no branch,
200 // which the reconciler deletes after its grace window — the agent still
201 // holds the document and re-proposes.
202 24 row, err := s.store.OpenProposal(ctx, &db.Proposal{
203 24 SpaceID: sp.ID,
204 24 Title: req.Title,
205 24 Rationale: req.Rationale,
206 24 BaseRev: base,
207 24 // The raw agent identity, the way the read schema documents it
208 24 // ("claude-code/spec-writer"). The git-author annotation ("… (for
209 24 // bigbes)") is a commit-message concern and lives only in prov; storing
210 24 // it here would make the row and the design's field disagree.
211 24 Agent: req.Principal.Agent,
212 24 AgentSession: req.Principal.Session,
213 24 })
214 24 if err != nil {
215 0 return nil, fmt.Errorf("service: open proposal in %s: %w", sp.Ref, err)
216 0 }
217
218 24 if _, err := sp.Repo.CreateProposalBranch(ctx, row.Branch, base); err != nil {
219 0 return nil, fmt.Errorf("service: cut %s in %s: %w", row.Branch, sp.Ref, err)
220 0 }
221 24 if _, err := sp.Repo.CommitProposal(ctx, row.Branch, toGitxWrites(req.Writes), meta); err != nil {
222 0 return nil, fmt.Errorf("service: commit onto %s in %s: %w", row.Branch, sp.Ref, err)
223 0 }
224 24 return row, nil
225 }
226
227 // addToProposal appends documents to an open proposal the agent already owns.
228 // The base is the proposal's fixed B, not the request's If-Match: an agent
229 // revising its own proposal keeps sending the same value, and a value that no
230 // longer matches B is a base that drifted, which is a 409.
231 7 func (s *Service) addToProposal(ctx context.Context, sp *Space, req ProposeRequest, base string) (*db.Proposal, error) {
232 7 row, err := s.store.GetProposal(ctx, req.ProposalID)
233 7 if err != nil {
234 0 if errors.Is(err, db.ErrNotFound) {
235 0 return nil, fmt.Errorf("%w: proposal %d", ErrNotFound, req.ProposalID)
236 0 }
237 0 return nil, fmt.Errorf("service: look up proposal %d: %w", req.ProposalID, err)
238 }
239 7 if row.SpaceID != sp.ID {
240 0 return nil, fmt.Errorf("%w: proposal %d is not in %s", ErrNotFound, req.ProposalID, sp.Ref)
241 0 }
242 7 if row.State != core.StateOpen {
243 0 return nil, fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, row.ID, row.State)
244 0 }
245 // The proposal's base does not move; the agent's If-Match must still name it.
246 // A different value means the agent's understanding of the base drifted, and
247 // silently writing against the old B anyway would let it merge a change it
248 // thought it made against a newer revision.
249 7 if base != row.BaseRev {
250 1 return nil, fmt.Errorf("%w: proposal %d is based on %s, not the %s you sent; adds keep the original base",
251 1 ErrStale, row.ID, short(row.BaseRev), short(base))
252 1 }
253
254 6 meta, err := s.agentCommit(sp, req, row.BaseRev)
255 6 if err != nil {
256 0 return nil, err
257 0 }
258 6 if err := s.validateWrites(ctx, sp, req.Writes, row.BaseRev); err != nil {
259 0 return nil, err
260 0 }
261 6 if _, err := sp.Repo.CommitProposal(ctx, row.Branch, toGitxWrites(req.Writes), meta); err != nil {
262 0 return nil, fmt.Errorf("service: commit onto %s in %s: %w", row.Branch, sp.Ref, err)
263 0 }
264 6 return row, nil
265 }
266
267 // agentCommit builds the provenance and the gitx commit metadata for an agent
268 // write at base: the agent authors, the instance owner commits, and the two
269 // trailers carry the session and the base so the claim is auditable in a plain
270 // git log rather than a Postgres-only table.
271 30 func (s *Service) agentCommit(sp *Space, req ProposeRequest, base string) (gitx.CommitMeta, error) {
272 30 write, err := req.Principal.AgentWriteFor(base)
273 30 if err != nil {
274 0 return gitx.CommitMeta{}, fmt.Errorf("%w: %v", ErrInvalid, err)
275 0 }
276 30 prov, err := s.cfg.Instance.Provenance(write)
277 30 if err != nil {
278 0 return gitx.CommitMeta{}, fmt.Errorf("%w: %v", ErrInvalid, err)
279 0 }
280
281 30 message := req.Message
282 30 if message == "" {
283 0 message = req.Title
284 0 }
285 30 if message == "" {
286 0 return gitx.CommitMeta{},
287 0 fmt.Errorf("%w: a write needs a commit message (or a title to borrow one from)", ErrInvalid)
288 0 }
289
290 30 when := s.now().UTC()
291 30 return gitx.CommitMeta{
292 30 Message: message,
293 30 Trailers: []gitx.Trailer{
294 30 {Key: authn.TrailerAgentSession, Value: prov.Session},
295 30 {Key: authn.TrailerAgentBase, Value: prov.Base},
296 30 },
297 30 Author: gitx.Signature{Name: prov.Author.Name, Email: prov.Author.Email, When: when},
298 30 Committer: gitx.Signature{Name: prov.Committer.Name, Email: prov.Committer.Email, When: when},
299 30 }, nil
300 }
301
302 // validateWrites enforces at propose time what a native push has validated on
303 // the receive path: every uploaded document parses, satisfies the space's
304 // schema, and carries a well-formed id, with no two uploads claiming one id.
305 // An agent write reaches gitx in-process, bypassing the update hook, so this is
306 // the equivalent gate — without it a malformed document lands on a proposal
307 // branch and only fails later, at merge, with a worse message.
308 //
309 // The schema is read at the base the agent proposed against, which is what it
310 // read the document under. Cross-space id collisions are left to the merge's
311 // registry write: an open proposal that would collide is a reviewable state, not
312 // a reason to refuse the upload.
313 35 func (s *Service) validateWrites(ctx context.Context, sp *Space, writes []DocumentWrite, base string) error {
314 35 policy, err := s.Policy(ctx, sp, base)
315 35 if err != nil {
316 0 return err
317 0 }
318 35 seen := make(map[string]string, len(writes))
319 38 for _, w := range writes {
320 38 if err := core.ValidateDocPath(w.Path); err != nil {
321 1 return fmt.Errorf("%w: %v", ErrInvalid, err)
322 1 }
323 37 fm, _, err := core.ParseDocument(w.Content)
324 37 if err != nil {
325 1 return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err)
326 1 }
327 36 if err := policy.Schema.ValidateFrontmatter(fm); err != nil {
328 1 return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err)
329 1 }
330 35 id, err := core.ParseDocID(fm.ID)
331 35 if err != nil {
332 0 return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err)
333 0 }
334 35 if prev, dup := seen[id.String()]; dup {
335 1 return fmt.Errorf("%w: %s and %s both carry id %s", ErrInvalid, prev, w.Path, id)
336 1 }
337 34 seen[id.String()] = w.Path
338 }
339 31 return nil
340 }
341
342 // tryAutoMerge lands the proposal immediately when policy permits, reporting
343 // whether it did and the resulting row. It never returns an error: auto-merge is
344 // an optimization over human review, and any failure — a base that moved under
345 // the proposal, a policy that does not cover every changed path — leaves the
346 // proposal open, which is the safe fallback and where the digest picks it up.
347 30 func (s *Service) tryAutoMerge(ctx context.Context, sp *Space, row *db.Proposal) (bool, *db.Proposal) {
348 30 auto, err := s.autoMerges(ctx, sp, row)
349 30 if err != nil || !auto {
350 25 return false, nil
351 25 }
352 5 if _, err := s.mergeProposal(ctx, sp, row, core.ApprovalPolicy); err != nil {
353 0 return false, nil
354 0 }
355 // mergeProposal returns the surface view; re-read the row so the caller
356 // keeps working in the db shape it built the result from.
357 5 mergedRow, err := s.store.GetProposal(ctx, row.ID)
358 5 if err != nil {
359 0 return false, nil
360 0 }
361 5 return true, mergedRow
362 }
363
364 // autoMerges reports whether every path the proposal changes may skip human
365 // review under the policy at the approved head.
366 //
367 // It is fail-closed in three directions. An open review thread stops it (see
368 // below), an empty changed set is not auto-merged (there is nothing to land),
369 // and any path that does not match is enough to require a human: a proposal
370 // that touches one reviewed document is reviewed as a whole, never split. The
371 // policy is read at the approved head because that is where the merge lands and
372 // whose auto_merge patterns therefore govern it.
373 30 func (s *Service) autoMerges(ctx context.Context, sp *Space, row *db.Proposal) (bool, error) {
374 30 // An unresolved review thread means the owner engaged with this proposal,
375 30 // so it must not land unattended on the agent's next revision. This is
376 30 // checked first because it is the cheapest decisive question and the one
377 30 // most likely to be the answer: a commented proposal is, by definition, one
378 30 // a human already stopped to look at.
379 30 //
380 30 // It gates policy auto-merge only. MergeHuman does not consult it, because
381 30 // the owner clicking approve is the judgement the thread was asking for, and
382 30 // a comment nobody got round to resolving must not be able to wedge a
383 30 // proposal shut.
384 30 open, err := s.store.HasUnresolvedComments(ctx, row.ID)
385 30 if err != nil {
386 0 return false, err
387 0 }
388 30 if open {
389 2 return false, nil
390 2 }
391
392 28 head, err := sp.Repo.ApprovedHead(ctx)
393 28 if err != nil {
394 0 return false, err
395 0 }
396 28 policy, err := s.Policy(ctx, sp, head.String())
397 28 if err != nil {
398 0 return false, err
399 0 }
400 28 if len(policy.Review.AutoMerge) == 0 {
401 21 return false, nil
402 21 }
403 7 proposalHead, err := sp.Repo.BranchHead(ctx, row.Branch)
404 7 if err != nil {
405 0 return false, err
406 0 }
407 7 changed, err := s.changedPaths(ctx, sp, row.BaseRev, proposalHead.String())
408 7 if err != nil {
409 0 return false, err
410 0 }
411 7 if len(changed) == 0 {
412 0 return false, nil
413 0 }
414 8 for path := range changed {
415 8 if !policy.AutoMerges(path) {
416 2 return false, nil
417 2 }
418 }
419 5 return true, nil
420 }
421
422 // changedPaths is the set of document paths whose blob differs between the base
423 // and the proposal head. It is the auto-merge decision's input, and path-keyed
424 // rather than id-keyed deliberately: auto_merge patterns are path patterns, and
425 // an agent cannot rename or delete (both are human-push-only), so a proposal's
426 // changes are only additions and modifications at stable paths.
427 8 func (s *Service) changedPaths(ctx context.Context, sp *Space, baseRev, proposalRev string) (map[string]bool, error) {
428 8 baseDocs, err := s.ListDocuments(ctx, sp, baseRev)
429 8 if err != nil {
430 0 return nil, err
431 0 }
432 8 headDocs, err := s.ListDocuments(ctx, sp, proposalRev)
433 8 if err != nil {
434 0 return nil, err
435 0 }
436 8 prior := make(map[string]string, len(baseDocs))
437 8 for _, d := range baseDocs {
438 2 prior[d.Path] = d.Blob
439 2 }
440 8 changed := make(map[string]bool)
441 11 for _, d := range headDocs {
442 11 if prior[d.Path] != d.Blob {
443 10 changed[d.Path] = true
444 10 }
445 }
446 8 return changed, nil
447 }
448
449 // toGitxWrites converts the surface write shape into gitx's. It is a straight
450 // field copy — the two types are kept separate only so the git layer's type
451 // does not leak into every surface's request struct.
452 30 func toGitxWrites(writes []DocumentWrite) []gitx.Write {
453 30 out := make([]gitx.Write, 0, len(writes))
454 32 for _, w := range writes {
455 32 out = append(out, gitx.Write{Path: w.Path, Content: w.Content})
456 32 }
457 30 return out
458 }