package api
import (
"context"
"encoding/json"
"fmt"
"log"
"reflect"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// An agent never owns a repository (RFC 0021, amended 2026-10-21): it
// serves the person who answers for it, and may do everything they can
// except the deed's own acts. Those it may ASK for. An agent that asks
// to delete, un-serve or hand on a repository its owner holds gets a
// request back instead of a refusal; the owner approves or denies it,
// and approving runs exactly the request, as the owner's own act.
// signOffOps are the deed acts an agent may ask its owner for.
var signOffOps = map[string]bool{"repo.delete": true, "repo.remove": true, "repo.claim": true}
// SignOffRequest is one request as the agent and the owner see it.
type SignOffRequest struct {
ID string `json:"id"`
Agent string `json:"agent"`
Owner string `json:"owner"`
Op string `json:"op"`
Repo string `json:"repo"`
Code string `json:"-"`
Input string `json:"-"`
Summary string `json:"summary"`
State string `json:"state"`
Requested string `json:"requested"`
Decided string `json:"decided,omitempty"`
Err string `json:"err,omitempty"`
}
// SignOffStore keeps the requests. RequestSignOff returns the open one
// when the same request is already waiting; DecideSignOff closes a
// pending request exactly once, and errors on any other.
type SignOffStore interface {
RequestSignOff(SignOffRequest) (SignOffRequest, error)
SignOffsFor(owner string, limit int) ([]SignOffRequest, error)
SignOffByID(id string) (SignOffRequest, bool, error)
DecideSignOff(id, owner string, approve bool) (SignOffRequest, error)
FailSignOff(id, why string) error
}
// signOffOwner is the owner an agent would ask about repo: the deed
// holder, when they are the person answering for the caller (its
// operator; for a forge agent, its answerable admin). "" when the
// caller is not an agent, or the repository is not its owner's.
func (d Deps) signOffOwner(ctx context.Context, repo string) string {
if d.SignOffs == nil || d.Owners == nil {
return ""
}
who := ops.User(ctx)
acct, ok := d.accountNamed(who)
if !ok || acct.Operator == "" || acct.Disabled != "" {
return ""
}
answers := acct.Operator
if answers == forgeOperator {
answers = acct.Answerable
}
owner, err := d.Owners.OwnerOf(repo)
if err != nil || owner == "" || owner != answers {
return ""
}
return owner
}
// askOwner turns an agent's deed act on its owner's repository into a
// request for their sign-off. asked is false for every other caller,
// who goes on to the act's ordinary gate.
func (d Deps) askOwner(ctx context.Context, op, repo string, in any, summary string) (req *SignOffRequest, asked bool, err error) {
owner := d.signOffOwner(ctx, repo)
if owner == "" {
return nil, false, nil
}
r, err := d.repo(repo)
if err != nil {
return nil, true, err
}
raw, err := json.Marshal(in)
if err != nil {
return nil, true, err
}
agent := ops.User(ctx)
s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: owner, Op: op, Repo: repo,
Code: r.Code, Input: string(raw), Summary: summary})
if err != nil {
return nil, true, err
}
d.attend(owner, AttentionEvent{Kind: "signoff", Repo: repo, From: agent, Summary: summary, Link: "/me#signoffs"})
// And to the owner wherever they are (dmestas: "I probably need an
// email or sms for sign offs"): the request waits on them, and the
// attention stream only reaches an open tab.
d.fireNotify(NotifyEvent{
Workflow: "quarry-signoff", To: owner,
Payload: map[string]any{
"repo": repo, "from": agent, "summary": summary, "surface": "signoff",
"link": d.link("/me#signoffs"),
},
TxnID: "signoff:" + s.ID,
})
return &s, true, nil
}
type signOffListIn struct{}
type signOffListOut struct {
Requests []SignOffRequest `json:"requests"`
}
type signOffDecideIn struct {
ID string `json:"id" validate:"required,max=64"`
Approve bool `json:"approve"`
}
type signOffDecideOut struct {
Request SignOffRequest `json:"request"`
}
// registerSignOff is the owner's half: the requests waiting for them,
// and the decision.
func registerSignOff(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[signOffListIn, signOffListOut]{
Name: "signoff.list",
Doc: "The requests your agents have made for your sign-off on a deed act (deleting, un-serving or handing on a repository you own): waiting ones first, then the most recently decided.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/signoffs",
Run: func(ctx context.Context, in signOffListIn) (signOffListOut, error) {
if d.SignOffs == nil {
return signOffListOut{Requests: []SignOffRequest{}}, nil
}
reqs, err := d.SignOffs.SignOffsFor(ops.User(ctx), 50)
if err != nil {
return signOffListOut{}, err
}
if reqs == nil {
reqs = []SignOffRequest{}
}
return signOffListOut{Requests: reqs}, nil
},
}); err != nil {
return err
}
return ops.Register(r, ops.Op[signOffDecideIn, signOffDecideOut]{
Name: "signoff.decide",
Doc: "Approve or deny a request waiting for your sign-off. Approving runs exactly what the agent asked, as your own act, " +
"after checking that the repository, its owner and the agent are what they were when it asked. " +
"An agent cannot decide, and a token limited to other repositories cannot either.",
Kind: ops.Command,
Auth: ops.Token,
Route: "POST /api/v1/signoffs/decide",
Run: func(ctx context.Context, in signOffDecideIn) (signOffDecideOut, error) {
if d.SignOffs == nil {
return signOffDecideOut{}, fmt.Errorf("this forge keeps no sign-offs: %w", ops.ErrUnavailable)
}
who := ops.User(ctx)
if op := d.operatorOf(who); op != "" {
return signOffDecideOut{}, fmt.Errorf("%s is an agent; a sign-off is its owner's: %w", who, ops.ErrForbidden)
}
s, found, err := d.SignOffs.SignOffByID(in.ID)
if err != nil {
return signOffDecideOut{}, err
}
if !found || s.Owner != who {
return signOffDecideOut{}, fmt.Errorf("no sign-off request %q: %w", in.ID, ops.ErrNotFound)
}
if tok, byToken := ops.TokenOf(ctx); byToken && (tok.Limited || !tok.MayAt(s.Repo, ops.Admin)) {
return signOffDecideOut{}, fmt.Errorf("this token does not reach %s; decide from a session or a token that does: %w", s.Repo, ops.ErrForbidden)
}
if in.Approve {
// Checked before the request is closed, so a request that
// can no longer run says why and stays answerable.
if err := d.signOffStillHolds(s); err != nil {
return signOffDecideOut{}, err
}
}
s, err = d.SignOffs.DecideSignOff(in.ID, who, in.Approve)
if err != nil {
return signOffDecideOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
}
if !in.Approve {
return signOffDecideOut{Request: s}, nil
}
if err := d.runSignedOff(ctx, r, s); err != nil {
if ferr := d.SignOffs.FailSignOff(s.ID, err.Error()); ferr != nil {
log.Printf("signoff %s: recording failure: %v", s.ID, ferr)
}
s.State, s.Err = "failed", err.Error()
return signOffDecideOut{Request: s}, err
}
if d.Acts != nil {
if err := d.Acts.RecordForgeAct(s.Agent+" (signed off by "+s.Owner+")", s.Op, s.Repo, s.Owner); err != nil {
logForgeActFailure(s.Op, s.Repo, err)
}
}
return signOffDecideOut{Request: s}, nil
},
})
}
// signOffStillHolds checks, at approval, what the request was made
// about: the same repository (by project code, so a name deleted and
// made again is not it), still the approver's, and an agent that still
// exists, is open, and still answers to them.
func (d Deps) signOffStillHolds(s SignOffRequest) error {
stale := func(why string) error {
return fmt.Errorf("this request can no longer run: %s: %w", why, ops.ErrInvalid)
}
if !signOffOps[s.Op] {
return stale("it asks for something that needs no sign-off")
}
r, err := d.repo(s.Repo)
if err != nil || r.Code != s.Code {
return stale(s.Repo + " is not the repository it was asked about")
}
if owner, err := d.Owners.OwnerOf(s.Repo); err != nil || owner != s.Owner {
return stale(s.Repo + " is no longer yours")
}
acct, ok := d.accountNamed(s.Agent)
if !ok || acct.Disabled != "" {
return stale(s.Agent + " is gone or shut")
}
answers := acct.Operator
if answers == forgeOperator {
answers = acct.Answerable
}
if answers != s.Owner {
return stale(s.Agent + " no longer answers to you")
}
return nil
}
// runSignedOff runs the recorded request through the op itself, as the
// owner: the agent's token may be long revoked, and under the owner
// every deed gate the op has passes or refuses honestly.
func (d Deps) runSignedOff(ctx context.Context, r *ops.Registry, s SignOffRequest) error {
desc, ok := r.Lookup(s.Op)
if !ok {
return fmt.Errorf("no operation %q: %w", s.Op, ops.ErrInvalid)
}
in := desc.NewIn()
dec := json.NewDecoder(strings.NewReader(s.Input))
dec.DisallowUnknownFields()
if err := dec.Decode(in); err != nil {
return fmt.Errorf("the request's input no longer reads: %v: %w", err, ops.ErrInvalid)
}
if repoOf(in) != s.Repo {
return fmt.Errorf("the request names another repository: %w", ops.ErrInvalid)
}
_, err := r.Invoke(ctx, s.Op, reflect.ValueOf(in).Elem().Interface())
return err
}