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
}
// 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/<id>" 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 two directions. 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) {
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
}