package service import ( "context" "errors" "fmt" "github.com/go-git/go-git/v5/plumbing" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "sourcecraft.dev/bigbes/sr-ht-spec/core" "sourcecraft.dev/bigbes/sr-ht-spec/db" "sourcecraft.dev/bigbes/sr-ht-spec/gitx" ) // DocumentWrite is one whole-document upload: a path and its complete bytes. // There is no patch form — the write plane takes whole documents because that // is how agents work, and it is what makes the merge model pure plumbing. type DocumentWrite struct { Path string Content []byte } // ProposeRequest is one call to the write plane, identical across REST and MCP: // an agent uploads whole documents against a base revision, either opening a new // proposal or adding to one it already owns. type ProposeRequest struct { // Space is the space being written to. Space core.SpaceRef // Principal is who is writing, as authn resolved the bearer token. It must // be an agent: proposing is an agent-only act, and the human write path is // native receive-pack. Principal authn.Principal // ProposalID selects an existing open proposal to add to (the REST // X-Proposal header). Zero opens a new one. ProposalID int // Title and Rationale describe a new proposal. Title is required when // opening and ignored when adding. Title string Rationale string // IfMatch is the approved-head sha the agent read the document at — the base // B. One value, one meaning across REST and MCP: opening cuts the branch // from it, adding is validated against the proposal's fixed B. It may be // abbreviated; it is resolved to a full object name before anything is // stored. IfMatch string // Message is the commit subject and body for this write. Empty defaults to // the title when opening; a write that is neither given a message nor a // title is refused, because a commit whose only content is provenance // records that something happened without saying what. Message string // Writes are the whole documents this call uploads. At least one is // required. Writes []DocumentWrite } // ProposeResult is what every write returns. The URL is the whole point of the // review plane's entry contract: the agent hands a human a link, so a write // that did not surface one would make the work invisible. type ProposeResult struct { // Proposal is the proposal as it now stands — merged already when // auto-merge policy landed it, otherwise open. Proposal Proposal // URL is the stable, shareable proposal link. URL string // Merged reports whether auto-merge policy landed this write immediately. // It lets a caller phrase its message ("merged" vs "proposed") without // re-reading the state. Merged bool } // Propose is the write plane: an agent uploads whole documents and gets back a // proposal and its URL. // // The shape is the design's, and the ordering is load-bearing: // // - The base is resolved and, when opening, checked to be an ancestor of the // approved head — the open-time 409. Adding is validated against the // proposal's fixed base instead, which does not move as edits accumulate. // - A new proposal is row-first (the branch name derives from the row's serial // id), then its branch is cut, then the documents are committed onto it with // the agent's provenance in the commit trailers. // - Auto-merge policy is evaluated against everything the proposal changes. If // every changed path may skip review, the proposal is merged immediately // with ApprovalPolicy; otherwise it stays open for a human. // // Auto-merge is best-effort: if the immediate merge cannot land (the approved // branch moved under the proposal between the commit and the merge), the // proposal is left open for review rather than failing the write — falling back // to human review is the safe direction, and the policy-merged digest exists to // surface it either way. func (s *Service) Propose(ctx context.Context, req ProposeRequest) (ProposeResult, error) { if !req.Principal.IsAgent() { return ProposeResult{}, fmt.Errorf("%w: %s may not propose; proposing is agent-only", ErrForbidden, req.Principal) } if len(req.Writes) == 0 { return ProposeResult{}, fmt.Errorf("%w: a proposal must write at least one document", ErrInvalid) } sp, err := s.OpenSpace(ctx, req.Space) if err != nil { return ProposeResult{}, err } // Resolve the agent's If-Match to a full object name up front: an // abbreviated base that is unique today could be ambiguous later, and a // canonical base is what the branch is cut from, what the provenance trailer // records, and what every later add and the merge measure staleness against. baseHash, err := sp.Repo.ResolveRev(ctx, req.IfMatch) if err != nil { return ProposeResult{}, readErr(err, "resolve If-Match %q in %s", req.IfMatch, req.Space) } base := baseHash.String() var row *db.Proposal if req.ProposalID == 0 { row, err = s.openNewProposal(ctx, sp, req, baseHash) } else { row, err = s.addToProposal(ctx, sp, req, base) } if err != nil { return ProposeResult{}, err } // A newly opened proposal fires PROPOSAL_OPENED; adding to an existing one // does not — the add is a revision of a proposal already announced. The event // goes out before the auto-merge attempt so an auto-merged proposal reports // Opened then Merged, in that order. if req.ProposalID == 0 { s.emit(EventProposalOpened, proposalView(row, sp.Ref)) } // Auto-merge: land immediately when every path the proposal changes may skip // human review under the policy at the approved head. A stale or otherwise // unlandable auto-merge leaves the proposal open — see the method doc. merged, mergedRow := s.tryAutoMerge(ctx, sp, row) if merged { row = mergedRow } return ProposeResult{ Proposal: proposalView(row, sp.Ref), URL: s.ProposalURL(sp.Ref, row.ID), Merged: merged, }, nil } // openNewProposal opens a proposal: the open-time ancestry 409, then row-first // insert, branch cut, and the provenance-stamped commit. func (s *Service) openNewProposal(ctx context.Context, sp *Space, req ProposeRequest, baseHash plumbing.Hash) (*db.Proposal, error) { if req.Title == "" { return nil, fmt.Errorf("%w: opening a proposal requires a title", ErrInvalid) } base := baseHash.String() // The open-time 409: a base that is not an ancestor of the current approved // head means the agent read a revision that the approved branch has moved // off, so the proposal could never merge. Reject it now rather than let it // sit open until a merge discovers it. head, err := sp.Repo.ApprovedHead(ctx) if err != nil { return nil, readErr(err, "read approved head of %s", sp.Ref) } onBranch, err := sp.Repo.IsAncestor(ctx, baseHash, head) if err != nil { return nil, readErr(err, "ancestry of base %s in %s", short(base), sp.Ref) } if !onBranch { return nil, fmt.Errorf("%w: base %s is not an ancestor of the approved head %s; refetch and re-propose", ErrStale, short(base), short(head.String())) } meta, err := s.agentCommit(sp, req, base) if err != nil { return nil, err } if err := s.validateWrites(ctx, sp, req.Writes, base); err != nil { return nil, err } // Row first: the branch name "proposals/" derives from the row's serial // id, so the id must be allocated before the branch can be named. A crash // between the insert and the branch write leaves an open row with no branch, // which the reconciler deletes after its grace window — the agent still // holds the document and re-proposes. row, err := s.store.OpenProposal(ctx, &db.Proposal{ SpaceID: sp.ID, Title: req.Title, Rationale: req.Rationale, BaseRev: base, // The raw agent identity, the way the read schema documents it // ("claude-code/spec-writer"). The git-author annotation ("… (for // bigbes)") is a commit-message concern and lives only in prov; storing // it here would make the row and the design's field disagree. Agent: req.Principal.Agent, AgentSession: req.Principal.Session, }) if err != nil { return nil, fmt.Errorf("service: open proposal in %s: %w", sp.Ref, err) } if _, err := sp.Repo.CreateProposalBranch(ctx, row.Branch, base); err != nil { return nil, fmt.Errorf("service: cut %s in %s: %w", row.Branch, sp.Ref, err) } if _, err := sp.Repo.CommitProposal(ctx, row.Branch, toGitxWrites(req.Writes), meta); err != nil { return nil, fmt.Errorf("service: commit onto %s in %s: %w", row.Branch, sp.Ref, err) } return row, nil } // addToProposal appends documents to an open proposal the agent already owns. // The base is the proposal's fixed B, not the request's If-Match: an agent // revising its own proposal keeps sending the same value, and a value that no // longer matches B is a base that drifted, which is a 409. func (s *Service) addToProposal(ctx context.Context, sp *Space, req ProposeRequest, base string) (*db.Proposal, error) { row, err := s.store.GetProposal(ctx, req.ProposalID) if err != nil { if errors.Is(err, db.ErrNotFound) { return nil, fmt.Errorf("%w: proposal %d", ErrNotFound, req.ProposalID) } return nil, fmt.Errorf("service: look up proposal %d: %w", req.ProposalID, err) } if row.SpaceID != sp.ID { return nil, fmt.Errorf("%w: proposal %d is not in %s", ErrNotFound, req.ProposalID, sp.Ref) } if row.State != core.StateOpen { return nil, fmt.Errorf("%w: proposal %d is %s", ErrProposalNotOpen, row.ID, row.State) } // The proposal's base does not move; the agent's If-Match must still name it. // A different value means the agent's understanding of the base drifted, and // silently writing against the old B anyway would let it merge a change it // thought it made against a newer revision. if base != row.BaseRev { return nil, fmt.Errorf("%w: proposal %d is based on %s, not the %s you sent; adds keep the original base", ErrStale, row.ID, short(row.BaseRev), short(base)) } meta, err := s.agentCommit(sp, req, row.BaseRev) if err != nil { return nil, err } if err := s.validateWrites(ctx, sp, req.Writes, row.BaseRev); err != nil { return nil, err } if _, err := sp.Repo.CommitProposal(ctx, row.Branch, toGitxWrites(req.Writes), meta); err != nil { return nil, fmt.Errorf("service: commit onto %s in %s: %w", row.Branch, sp.Ref, err) } return row, nil } // agentCommit builds the provenance and the gitx commit metadata for an agent // write at base: the agent authors, the instance owner commits, and the two // trailers carry the session and the base so the claim is auditable in a plain // git log rather than a Postgres-only table. func (s *Service) agentCommit(sp *Space, req ProposeRequest, base string) (gitx.CommitMeta, error) { write, err := req.Principal.AgentWriteFor(base) if err != nil { return gitx.CommitMeta{}, fmt.Errorf("%w: %v", ErrInvalid, err) } prov, err := s.cfg.Instance.Provenance(write) if err != nil { return gitx.CommitMeta{}, fmt.Errorf("%w: %v", ErrInvalid, err) } message := req.Message if message == "" { message = req.Title } if message == "" { return gitx.CommitMeta{}, fmt.Errorf("%w: a write needs a commit message (or a title to borrow one from)", ErrInvalid) } when := s.now().UTC() return gitx.CommitMeta{ Message: message, Trailers: []gitx.Trailer{ {Key: authn.TrailerAgentSession, Value: prov.Session}, {Key: authn.TrailerAgentBase, Value: prov.Base}, }, Author: gitx.Signature{Name: prov.Author.Name, Email: prov.Author.Email, When: when}, Committer: gitx.Signature{Name: prov.Committer.Name, Email: prov.Committer.Email, When: when}, }, nil } // validateWrites enforces at propose time what a native push has validated on // the receive path: every uploaded document parses, satisfies the space's // schema, and carries a well-formed id, with no two uploads claiming one id. // An agent write reaches gitx in-process, bypassing the update hook, so this is // the equivalent gate — without it a malformed document lands on a proposal // branch and only fails later, at merge, with a worse message. // // The schema is read at the base the agent proposed against, which is what it // read the document under. Cross-space id collisions are left to the merge's // registry write: an open proposal that would collide is a reviewable state, not // a reason to refuse the upload. func (s *Service) validateWrites(ctx context.Context, sp *Space, writes []DocumentWrite, base string) error { policy, err := s.Policy(ctx, sp, base) if err != nil { return err } seen := make(map[string]string, len(writes)) for _, w := range writes { if err := core.ValidateDocPath(w.Path); err != nil { return fmt.Errorf("%w: %v", ErrInvalid, err) } fm, _, err := core.ParseDocument(w.Content) if err != nil { return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err) } if err := policy.Schema.ValidateFrontmatter(fm); err != nil { return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err) } id, err := core.ParseDocID(fm.ID) if err != nil { return fmt.Errorf("%w: %s: %v", ErrInvalid, w.Path, err) } if prev, dup := seen[id.String()]; dup { return fmt.Errorf("%w: %s and %s both carry id %s", ErrInvalid, prev, w.Path, id) } seen[id.String()] = w.Path } return nil } // tryAutoMerge lands the proposal immediately when policy permits, reporting // whether it did and the resulting row. It never returns an error: auto-merge is // an optimization over human review, and any failure — a base that moved under // the proposal, a policy that does not cover every changed path — leaves the // proposal open, which is the safe fallback and where the digest picks it up. func (s *Service) tryAutoMerge(ctx context.Context, sp *Space, row *db.Proposal) (bool, *db.Proposal) { auto, err := s.autoMerges(ctx, sp, row) if err != nil || !auto { return false, nil } if _, err := s.mergeProposal(ctx, sp, row, core.ApprovalPolicy); err != nil { return false, nil } // mergeProposal returns the surface view; re-read the row so the caller // keeps working in the db shape it built the result from. mergedRow, err := s.store.GetProposal(ctx, row.ID) if err != nil { return false, nil } return true, mergedRow } // autoMerges reports whether every path the proposal changes may skip human // review under the policy at the approved head. // // It is fail-closed in three directions. An open review thread stops it (see // below), an empty changed set is not auto-merged (there is nothing to land), // and any path that does not match is enough to require a human: a proposal // that touches one reviewed document is reviewed as a whole, never split. The // policy is read at the approved head because that is where the merge lands and // whose auto_merge patterns therefore govern it. func (s *Service) autoMerges(ctx context.Context, sp *Space, row *db.Proposal) (bool, error) { // An unresolved review thread means the owner engaged with this proposal, // so it must not land unattended on the agent's next revision. This is // checked first because it is the cheapest decisive question and the one // most likely to be the answer: a commented proposal is, by definition, one // a human already stopped to look at. // // It gates policy auto-merge only. MergeHuman does not consult it, because // the owner clicking approve is the judgement the thread was asking for, and // a comment nobody got round to resolving must not be able to wedge a // proposal shut. open, err := s.store.HasUnresolvedComments(ctx, row.ID) if err != nil { return false, err } if open { return false, nil } head, err := sp.Repo.ApprovedHead(ctx) if err != nil { return false, err } policy, err := s.Policy(ctx, sp, head.String()) if err != nil { return false, err } if len(policy.Review.AutoMerge) == 0 { return false, nil } proposalHead, err := sp.Repo.BranchHead(ctx, row.Branch) if err != nil { return false, err } changed, err := s.changedPaths(ctx, sp, row.BaseRev, proposalHead.String()) if err != nil { return false, err } if len(changed) == 0 { return false, nil } for path := range changed { if !policy.AutoMerges(path) { return false, nil } } return true, nil } // changedPaths is the set of document paths whose blob differs between the base // and the proposal head. It is the auto-merge decision's input, and path-keyed // rather than id-keyed deliberately: auto_merge patterns are path patterns, and // an agent cannot rename or delete (both are human-push-only), so a proposal's // changes are only additions and modifications at stable paths. func (s *Service) changedPaths(ctx context.Context, sp *Space, baseRev, proposalRev string) (map[string]bool, error) { baseDocs, err := s.ListDocuments(ctx, sp, baseRev) if err != nil { return nil, err } headDocs, err := s.ListDocuments(ctx, sp, proposalRev) if err != nil { return nil, err } prior := make(map[string]string, len(baseDocs)) for _, d := range baseDocs { prior[d.Path] = d.Blob } changed := make(map[string]bool) for _, d := range headDocs { if prior[d.Path] != d.Blob { changed[d.Path] = true } } return changed, nil } // toGitxWrites converts the surface write shape into gitx's. It is a straight // field copy — the two types are kept separate only so the git layer's type // does not leak into every surface's request struct. func toGitxWrites(writes []DocumentWrite) []gitx.Write { out := make([]gitx.Write, 0, len(writes)) for _, w := range writes { out = append(out, gitx.Write{Path: w.Path, Content: w.Content}) } return out }