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.
// deedOps are the deed acts: a request for one goes to the repository's
// deed holder, and approval checks the deed is still theirs.
var deedOps = map[string]bool{"repo.delete": true, "repo.remove": true, "repo.claim": true}
// notAskable is what an agent may never ask its person for (RFC 0027,
// What may be asked): deciding requests and changing who answers for
// an agent. The person's own credentials, sessions and account are
// askableFor's other half.
var notAskable = map[string]bool{
"signoff.decide": true, "signoff.list": true, "signoff.get": true,
"account.agent": true, "account.answerable": true,
}
// askableFor reports whether an agent may ask person for op with this
// input: anything the person may do (checked when it runs, as them),
// except the exclusions. Every me.* op is the person's own account,
// credentials, sessions or devices; an account op aimed at the person
// themselves is too.
func askableFor(op, person string, in any) bool {
if notAskable[op] || strings.HasPrefix(op, "me.") {
return false
}
switch op {
case "account.delete", "account.disable", "account.reset", "account.revoke", "account.email.set":
return nameOf(in) != person
}
return true
}
// nameOf is an input's Name field, the account an account op names.
func nameOf(in any) string {
v := reflect.ValueOf(in)
if v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return ""
}
if f := v.FieldByName("Name"); f.IsValid() && f.Kind() == reflect.String {
return f.String()
}
return ""
}
// 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"`
// Reason is the agent's own line on why it asks (RFC 0027).
Reason string `json:"reason,omitempty"`
// Result is what the approved call answered, as JSON, without the
// fields the op marks secret; Secret is those fields, for the
// asking agent once. Neither is part of the request as listed:
// signoff.get hands them out.
Result string `json:"-"`
Secret string `json:"-"`
}
// 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
FinishSignOff(id, result, secret string) error
TakeSignOffSecret(id, agent string) (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/" + s.ID})
// 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/" + s.ID),
},
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"`
}
type signOffGetIn struct {
ID string `json:"id" validate:"required,max=64"`
}
type signOffGetOut struct {
Request SignOffRequest `json:"request"`
// Result is what the approved call answered, without its secret
// fields; Secret is those fields, given to the asking agent on its
// first read after approval and never again.
Result json.RawMessage `json:"result,omitempty"`
Secret json.RawMessage `json:"secret,omitempty"`
}
// registerSignOff is the owner's half: the requests waiting for them,
// and the decision; and the agent's: reading what became of one.
func registerSignOff(r *ops.Registry, d Deps) error {
if err := ops.Register(r, ops.Op[signOffGetIn, signOffGetOut]{
Name: "signoff.get",
Doc: "One request for sign-off, as the agent that asked or the person it asked sees it: its state and, once approved, " +
"what the call answered. Fields the call marks secret (a minted token) go to the asking agent alone, on its first read, and are then forgotten.",
Kind: ops.Query,
Auth: ops.Token,
Route: "GET /api/v1/signoffs/{id}",
Run: func(ctx context.Context, in signOffGetIn) (signOffGetOut, error) {
if d.SignOffs == nil {
return signOffGetOut{}, fmt.Errorf("this forge keeps no sign-offs: %w", ops.ErrUnavailable)
}
who := ops.User(ctx)
s, found, err := d.SignOffs.SignOffByID(in.ID)
if err != nil {
return signOffGetOut{}, err
}
if !found || (who != s.Agent && who != s.Owner) {
return signOffGetOut{}, fmt.Errorf("no sign-off request %q: %w", in.ID, ops.ErrNotFound)
}
out := signOffGetOut{Request: s}
if s.Result != "" {
out.Result = json.RawMessage(s.Result)
}
if who == s.Agent && s.Secret != "" {
sec, err := d.SignOffs.TakeSignOffSecret(s.ID, who)
if err != nil {
return signOffGetOut{}, err
}
if sec != "" {
out.Secret = json.RawMessage(sec)
}
}
return out, nil
},
}); err != nil {
return err
}
if err := ops.Register(r, ops.Op[signOffListIn, signOffListOut]{
Name: "signoff.list",
Doc: "The requests your agents have made for your sign-off: anything you may do that they may not (RFC 0027), deed acts on your repositories among them. Waiting ones first, then the most recently decided; a request waits seven days.",
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 s.Repo != "" {
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 deedOps[s.Op] {
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 s.Repo != "" && repoOf(in) != s.Repo {
return fmt.Errorf("the request names another repository: %w", ops.ErrInvalid)
}
arg := reflect.ValueOf(in).Elem().Interface()
if !askableFor(s.Op, s.Owner, arg) {
return fmt.Errorf("%s is not something an agent may ask for: %w", s.Op, ops.ErrInvalid)
}
out, err := r.Invoke(ctx, s.Op, arg)
if err != nil {
return err
}
result, secret, err := splitSecret(out)
if err != nil {
return err
}
return d.SignOffs.FinishSignOff(s.ID, result, secret)
}
// splitSecret renders an op's answer as two JSON objects: the answer
// without the fields tagged secret:"once", and those fields alone (""
// when there are none). The secret half goes to the asking agent once
// and never to a page, an email or the journal (RFC 0027, Results).
func splitSecret(out any) (result, secret string, err error) {
raw, err := json.Marshal(out)
if err != nil {
return "", "", err
}
v := reflect.ValueOf(out)
for v.Kind() == reflect.Ptr {
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return string(raw), "", nil
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(raw, &fields); err != nil {
return string(raw), "", nil
}
hidden := map[string]json.RawMessage{}
t := v.Type()
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
if f.Tag.Get("secret") != "once" {
continue
}
key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if key == "" {
key = f.Name
}
if val, ok := fields[key]; ok {
hidden[key] = val
delete(fields, key)
}
}
if len(hidden) == 0 {
return string(raw), "", nil
}
pub, err := json.Marshal(fields)
if err != nil {
return "", "", err
}
sec, err := json.Marshal(hidden)
if err != nil {
return "", "", err
}
return string(pub), string(sec), nil
}