Files
Agent-System-Backend/internal/store/run_account_write.go
T
weichen adcd8fefd2 Fence U3 account writes and stabilize directory cursors
Why: Account disable could be followed by late Backend business writes, API key creation could implicitly create users, account bootstrap locks could invert, and directory cursors were process-local or leaked out-of-scope changes.

Evidence: Local go test ./... and isolated PostgreSQL package suites passed on the staged candidate; exact committed-head SELF replay and stage 6 human review remain pending.

Impact: Adds final account transactions and external-effect checks across HTTP, Host, Bus and run manager; tightens API key issuance and persists versioned cursor signing. Delegated RDM rename still needs the separately proposed real-operator contract, so A1 and stage 6 are not complete.

Refs: Documents U3 r1, approved A1-A4 amendment fb3a821, pending delegated-projection amendment 5783940, Dev feature/iam-u3-account-directory-recovery.
2026-10-14 07:33:54 +00:00

102 lines
3.9 KiB
Go

package store
import (
"database/sql"
"errors"
core "agent-system-core"
)
// UpdateRunAccountChecked fences forward run state and completed output at the
// final run-row mutation. Terminal failure cleanup has a separate legacy path.
func (s *Store) UpdateRunAccountChecked(actor string, r *core.Run, allowImplicitDefault bool) error {
if r == nil || actor == "" || string(r.Owner) != actor {
return ErrAccountForbidden
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var owner string
if err := tx.QueryRow(s.q(`SELECT owner FROM runs WHERE id=?`), r.ID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != actor {
return ErrNotFound
}
var ended any
if r.EndedAt != nil {
ended = nowISO()
}
_, err := tx.Exec(s.q(`UPDATE runs SET status=?,phase=?,usage=?,error=?,ended_at=? WHERE id=? AND owner=?`),
string(r.Status), string(r.Phase), marshalJSON(r.Usage), marshalJSON(r.Error), ended, r.ID, actor)
return err
})
}
// CreateRunAccountChecked fences run admission against account disable. The
// conversation's stored owner is checked in the same final transaction.
func (s *Store) CreateRunAccountChecked(r *core.Run, allowImplicitDefault bool) error {
if r == nil || r.Owner == "" {
return ErrAccountForbidden
}
if r.ID == "" {
r.ID = newID("run")
}
r.CreatedAt = r.CreatedAt.UTC()
return s.withEffectiveAccountWrite(string(r.Owner), allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
var owner string
if err := tx.QueryRow(s.q(`SELECT owner FROM conversations WHERE id=?`), r.ConversationID).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if owner != string(r.Owner) {
return ErrNotFound
}
_, err := tx.Exec(s.q(`INSERT INTO runs(id,parent_run_id,conversation_id,project_id,owner,kind,profile_id,status,phase,agent_name,created_at,instance_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`),
r.ID, r.ParentRunID, r.ConversationID, r.ProjectID, r.Owner, string(r.Kind), r.ProfileID, string(r.Status), string(r.Phase), r.AgentName, nowISO(), s.instanceID)
return err
})
}
// SetMessageRunIDAccountChecked links an admitted run only while its actor is
// still effective. Both rows must belong to the actor's conversation.
func (s *Store) SetMessageRunIDAccountChecked(actor, messageID, runID string, allowImplicitDefault bool) error {
var routeConversation string
if err := s.db.QueryRow(s.q(`SELECT conversation_id FROM messages WHERE id=?`), messageID).Scan(&routeConversation); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
return s.withEffectiveAccountWrite(actor, allowImplicitDefault, func(tx *sql.Tx, _ Account) error {
if err := s.dialect.lockConversation(tx, routeConversation); err != nil {
return err
}
var messageConversation, messageOwner, runConversation, runOwner string
if err := tx.QueryRow(s.q(`SELECT m.conversation_id,c.owner FROM messages m JOIN conversations c ON c.id=m.conversation_id WHERE m.id=?`), messageID).
Scan(&messageConversation, &messageOwner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if err := tx.QueryRow(s.q(`SELECT conversation_id,owner FROM runs WHERE id=?`), runID).Scan(&runConversation, &runOwner); errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
} else if err != nil {
return err
}
if messageConversation != routeConversation || runConversation != routeConversation || messageOwner != actor || runOwner != actor {
return ErrNotFound
}
res, err := tx.Exec(s.q(`UPDATE messages SET run_id=? WHERE id=? AND conversation_id=?`), runID, messageID, routeConversation)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
return nil
})
}