Add optional proofs for an exact human structure decision

Why: A model-visible flag cannot prove that a person confirmed one proposal revision and payload hash.
Evidence: go test ./pkg/extension ./internal/app ./internal/server -run 'HumanDecision|SurfaceSnapshot|ResolveHuman|NaturalLanguage' passed locally.
Impact: SurfaceVersion 2.22.0-experimental adds HumanDecisionVerifier. Existing Host methods stay unchanged, and the base does not consume the proof.
Refs: W4b human confirmation design r1
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-10-15 02:33:37 +00:00
co-authored by Cursor
parent da4b8c9956
commit b740f8b6f6
14 changed files with 835 additions and 5 deletions
+1 -1
View File
@@ -286,7 +286,7 @@ func cmdBackup(args []string) error {
var copyOrder = []string{
"projects", "users", "account_directory_revision", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "project_work_outbox", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"hitl_cards", "human_decisions", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "project_audit_links", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks", "work_task_bindings",
+2 -1
View File
@@ -65,7 +65,8 @@ func (r pluginRegistry) AddCommandHandler(spec json.RawMessage, h extension.Hand
}
return r.bus.RegisterPluginCommand(s, func(ctx context.Context, req cmdbus.Request) (any, error) {
host := requestBoundHost{inprocHost{caps: r.caps, pluginID: r.pluginID, owner: req.Owner, actor: req.Actor,
conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID)}}
conversationID: core.ID(req.ConversationID), hostProjectID: core.ID(req.HostProjectID),
entrance: req.Entrance, humanGrant: req.HumanGrant}}
return h(ctx, host, extension.Request{
ID: req.ID, Params: req.Params, Entrance: req.Entrance,
Actor: req.Actor, Owner: req.Owner, ConversationID: req.ConversationID, OperationID: req.OperationID,
+5
View File
@@ -7,7 +7,9 @@ import (
"sort"
"strings"
"sync"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/ingest"
"agent-system-backend/internal/rag"
"agent-system-backend/internal/runtime"
@@ -33,6 +35,7 @@ type hostcaps struct {
set *settings.Service
authz *projectAuthz // EXT-8 适配器槽(共享指针;首个注册者生效)
surfaces *surfacesvc.Service // EXT-17 Surface 能力槽(nil = 未装配,明确拒绝)
now func() time.Time
}
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
@@ -200,6 +203,8 @@ type inprocHost struct {
actor string // EXT-18:分发主体(空=非 dispatch 上下文)
conversationID core.ID // dispatch-bound, never taken from completion arguments
hostProjectID core.ID // dispatch project; rechecked against stored conversation
entrance string // dispatch entrance; model tools are "agent"
humanGrant *cmdbus.HumanGrant
}
func (h inprocHost) requireEffective() error {
+104
View File
@@ -0,0 +1,104 @@
package app
import (
"fmt"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func (h inprocHost) clock() time.Time {
if h.caps.now != nil {
return h.caps.now()
}
return time.Now()
}
func humanCommand(audience string) (string, error) {
if audience == extension.HumanDecisionAudienceStructure {
return "rdm.structure.decide", nil
}
return "", fmt.Errorf("extension: human decision audience is not issued for this host")
}
func humanEntranceAllowed(entrance string) bool {
switch entrance {
case "button", "card", "api":
return true
default:
return false
}
}
// IssueHumanDecision opens a pending card for the authenticated person.
// Model-tool, chat, and slash entrances cannot open one.
func (h inprocHost) IssueHumanDecision(binding extension.HumanDecisionBinding, question string) (extension.HumanDecisionCard, error) {
if h.caps.st == nil || h.actor == "" || h.actor != binding.Actor || !humanEntranceAllowed(h.entrance) {
return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision cannot be issued on this dispatch")
}
commandID, err := humanCommand(binding.Audience)
if err != nil {
return extension.HumanDecisionCard{}, err
}
if binding.ProjectID == "" || binding.ProposalID == "" || binding.ProposalRevision < 1 || binding.PayloadHash == "" {
return extension.HumanDecisionCard{}, fmt.Errorf("extension: human decision binding is incomplete")
}
rec, err := h.caps.st.IssueHumanDecision(store.HumanDecision{
PluginID: h.pluginID, Audience: binding.Audience, CommandID: commandID,
HumanActor: binding.Actor, Owner: h.owner, ProjectID: binding.ProjectID,
ProposalID: binding.ProposalID, ProposalRevision: binding.ProposalRevision,
PayloadHash: binding.PayloadHash, Question: question, ConversationID: string(h.conversationID),
}, h.clock())
if err != nil {
return extension.HumanDecisionCard{}, err
}
return extension.HumanDecisionCard{CardID: rec.ID, Question: rec.Question, ExpiresAt: rec.ExpiresAt}, nil
}
// VerifyHumanDecision reports the persisted human response for this dispatch.
// A model tool, a forged entrance, a mismatched binding, an expired card, or
// a revoked card fails closed. The record stays answered; it is not consumed.
func (h inprocHost) VerifyHumanDecision(expected extension.HumanDecisionBinding) (extension.HumanDecisionProof, error) {
if h.caps.st == nil || h.humanGrant == nil || h.entrance == "agent" || h.actor == "" || h.actor != h.humanGrant.Actor {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: this dispatch has no authenticated human decision")
}
rec, err := h.caps.st.HumanDecision(h.humanGrant.CardID)
if err != nil {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision card is not pending for this dispatch")
}
now := h.clock()
if rec.Status != "responded" || rec.PluginID != h.pluginID || rec.ProofID == "" || rec.ProofID != h.humanGrant.ProofID ||
rec.HumanActor != expected.Actor || rec.HumanActor != h.actor || rec.Decision != expected.Decision ||
rec.Audience != expected.Audience || rec.ProjectID != expected.ProjectID || rec.ProposalID != expected.ProposalID ||
rec.ProposalRevision != expected.ProposalRevision || rec.PayloadHash != expected.PayloadHash ||
!now.Before(rec.ExpiresAt) {
return extension.HumanDecisionProof{}, fmt.Errorf("extension: human decision does not match this dispatch")
}
return extension.HumanDecisionProof{
ProofID: rec.ProofID, Issuer: "agent-system-backend", HumanActor: rec.HumanActor,
Audience: rec.Audience, ProjectID: rec.ProjectID, ProposalID: rec.ProposalID,
Revision: rec.ProposalRevision, PayloadHash: rec.PayloadHash, Decision: rec.Decision,
IssuedAt: rec.IssuedAt, ExpiresAt: rec.ExpiresAt, ConversationID: rec.ConversationID,
HITLResponseRef: rec.ID,
}, nil
}
// RevokeHumanDecision invalidates a still-pending card. Answered proofs stay
// available until they expire; consumption is not a host concern.
func (h inprocHost) RevokeHumanDecision(cardID string) error {
if h.caps.st == nil || h.actor == "" || !humanEntranceAllowed(h.entrance) {
return fmt.Errorf("extension: human decision cannot be revoked on this dispatch")
}
return h.caps.st.RevokeHumanDecision(h.pluginID, h.actor, cardID)
}
// HumanGrantFromRecord copies the persisted response onto a dispatch.
// Callers must set it only after an authenticated response or session retry.
func HumanGrantFromRecord(rec store.HumanDecision) *cmdbus.HumanGrant {
if rec.ProofID == "" || rec.Status != "responded" {
return nil
}
return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision}
}
+170
View File
@@ -0,0 +1,170 @@
package app
import (
"testing"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
)
func openDecisionStore(t *testing.T) *store.Store {
t.Helper()
st, err := store.OpenSQLite(t.TempDir() + "/decision.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func decisionHost(st *store.Store, plugin, actor, entrance string, now time.Time, grant *cmdbus.HumanGrant) inprocHost {
return inprocHost{
caps: hostcaps{st: st, now: func() time.Time { return now }},
pluginID: plugin, owner: actor, actor: actor, entrance: entrance, humanGrant: grant,
}
}
func binding(decision, hash string, revision int) extension.HumanDecisionBinding {
return extension.HumanDecisionBinding{
Audience: extension.HumanDecisionAudienceStructure, Actor: "alice", ProjectID: "p1",
ProposalID: "prop-1", ProposalRevision: revision, PayloadHash: hash, Decision: decision,
}
}
func TestHumanDecisionAuthenticatedResponse(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "button", now, nil)
card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认结构变更")
if err != nil {
t.Fatal(err)
}
answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now)
if err != nil {
t.Fatal(err)
}
host.humanGrant = &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"}
host.entrance = "card"
proof, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1))
if err != nil {
t.Fatal(err)
}
if proof.ProofID == "" || proof.Issuer != "agent-system-backend" || proof.HumanActor != "alice" ||
proof.HITLResponseRef != card.CardID || proof.PayloadHash != "hash-1" {
t.Fatalf("proof = %+v", proof)
}
again, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1))
if err != nil || again.ProofID != proof.ProofID {
t.Fatalf("second verify consumed or changed the proof: %v %+v", err, again)
}
stored, err := st.HumanDecision(card.CardID)
if err != nil || stored.Status != "responded" {
t.Fatalf("host consumed the proof, status=%s err=%v", stored.Status, err)
}
}
func TestHumanDecisionRejectsUntrustedDispatch(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "button", now, nil)
card, err := host.IssueHumanDecision(binding("", "hash-1", 1), "确认")
if err != nil {
t.Fatal(err)
}
answered, err := st.RespondHumanDecision("alice", card.CardID, "accept", now)
if err != nil {
t.Fatal(err)
}
grant := &cmdbus.HumanGrant{CardID: answered.ID, ProofID: answered.ProofID, Actor: "alice", Decision: "accept"}
expect := binding("accept", "hash-1", 1)
agent := decisionHost(st, "rdm", "alice", "agent", now, grant)
if _, err := agent.VerifyHumanDecision(expect); err == nil {
t.Fatal("model tool dispatch verified a human decision")
}
forged := decisionHost(st, "rdm", "alice", "card", now, nil)
if _, err := forged.VerifyHumanDecision(expect); err == nil {
t.Fatal("forged entrance verified without a host grant")
}
if _, err := decisionHost(st, "rdm", "alice", "agent", now, nil).IssueHumanDecision(binding("", "hash-2", 1), "x"); err == nil {
t.Fatal("model tool issued a human decision card")
}
if _, err := decisionHost(st, "other", "alice", "card", now, grant).VerifyHumanDecision(expect); err == nil {
t.Fatal("another plugin reused the proof")
}
other := expect
other.ProposalID = "prop-2"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(other); err == nil {
t.Fatal("proof authorized a second proposal")
}
wrongActor := expect
wrongActor.Actor = "bob"
if _, err := decisionHost(st, "rdm", "bob", "card", now, grant).VerifyHumanDecision(wrongActor); err == nil {
t.Fatal("wrong actor verified")
}
wrongProject := expect
wrongProject.ProjectID = "p2"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongProject); err == nil {
t.Fatal("wrong project verified")
}
wrongRev := expect
wrongRev.ProposalRevision = 2
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongRev); err == nil {
t.Fatal("wrong revision verified")
}
wrongHash := expect
wrongHash.PayloadHash = "hash-changed"
if _, err := decisionHost(st, "rdm", "alice", "card", now, grant).VerifyHumanDecision(wrongHash); err == nil {
t.Fatal("wrong payload hash verified")
}
if _, err := decisionHost(st, "rdm", "alice", "button", now, nil).IssueHumanDecision(
extension.HumanDecisionBinding{Audience: "other.plugin", Actor: "alice", ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1, PayloadHash: "h"},
"x"); err == nil {
t.Fatal("foreign audience was issued")
}
}
func TestHumanDecisionCardChangeExpiryAndRevoke(t *testing.T) {
st := openDecisionStore(t)
now := time.Date(2026, 10, 15, 1, 0, 0, 0, time.UTC)
host := decisionHost(st, "rdm", "alice", "api", now, nil)
first, err := host.IssueHumanDecision(binding("", "hash-1", 1), "旧卡")
if err != nil {
t.Fatal(err)
}
if _, err := host.IssueHumanDecision(binding("", "hash-2", 2), "新卡"); err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", first.CardID, "accept", now); err == nil {
t.Fatal("changed card still accepted the old confirmation")
}
current, err := host.IssueHumanDecision(binding("", "hash-3", 3), "待撤销")
if err != nil {
t.Fatal(err)
}
if err := host.RevokeHumanDecision(current.CardID); err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", current.CardID, "accept", now); err == nil {
t.Fatal("revoked card was answered")
}
live, err := host.IssueHumanDecision(binding("", "hash-4", 4), "会过期")
if err != nil {
t.Fatal(err)
}
if _, err := st.RespondHumanDecision("alice", live.CardID, "accept", now.Add(16*time.Minute)); err == nil {
t.Fatal("expired card was answered")
}
}
func TestMissingHumanDecisionStoreFailsClosed(t *testing.T) {
host := decisionHost(nil, "rdm", "alice", "button", time.Now(), nil)
if _, err := host.IssueHumanDecision(binding("", "hash-1", 1), "x"); err == nil {
t.Fatal("missing store issued a card")
}
if _, err := host.VerifyHumanDecision(binding("accept", "hash-1", 1)); err == nil {
t.Fatal("missing store verified a decision")
}
}
+12
View File
@@ -101,6 +101,18 @@ type Request struct {
AnchorMessageID string // 确认卡锚定的发起消息(内联渲染)
Confirmed bool // 危险命令经确认卡放行后的执行
HostProjectID string // EXT-18:分发所属基座项目(空=无项目上下文)
// HumanGrant is set only by the authenticated session path or the human
// decision resolver. Client JSON and agent tool dispatch leave it nil.
HumanGrant *HumanGrant
}
// HumanGrant ties this dispatch to one persisted human response.
// It is not a secret token and is not read from command params.
type HumanGrant struct {
CardID string
ProofID string
Actor string
Decision string
}
// Response 分发结果:回执 + 处理负载 + 可能的确认卡。
+98
View File
@@ -0,0 +1,98 @@
package server
import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-core"
)
// resolveHumanDecision handles a card issued by HumanDecisionVerifier.
// Ordinary HITL cards return handled=false and keep the existing run path.
func (s *Server) resolveHumanDecision(w http.ResponseWriter, r *http.Request, resp core.HITLResponse) (bool, error) {
rec, err := s.st.HumanDecision(string(resp.CardID))
if errors.Is(err, store.ErrNotFound) {
return false, nil
}
if err != nil {
return true, err
}
decision := resp.ChoiceID
if decision == "" {
switch strings.TrimSpace(resp.FreeText) {
case "批准", "接受", "同意":
pending, perr := s.st.PendingHumanDecisions(s.owner(r), time.Now())
if perr != nil {
return true, perr
}
if len(pending) != 1 || pending[0].ID != rec.ID {
return true, errors.New("human decision text matches more than one pending card")
}
decision = "accept"
default:
return true, errors.New("human decision requires an explicit choice")
}
}
answered, err := s.st.RespondHumanDecision(s.owner(r), rec.ID, decision, time.Now())
if err != nil {
return true, err
}
out := map[string]any{
"resolved": true, "proofId": answered.ProofID, "decision": answered.Decision,
"proposalId": answered.ProposalID, "proposalRevision": answered.ProposalRevision,
}
if s.Bus != nil && answered.CommandID != "" {
follow := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: answered.CommandID,
Params: map[string]any{
"projectId": answered.ProjectID, "proposalId": answered.ProposalID,
"proposalRevision": answered.ProposalRevision, "payloadHash": answered.PayloadHash,
"decision": answered.Decision,
},
Entrance: "card", Actor: answered.HumanActor, Owner: s.owner(r),
ConversationID: answered.ConversationID,
HumanGrant: &cmdbus.HumanGrant{
CardID: answered.ID, ProofID: answered.ProofID,
Actor: answered.HumanActor, Decision: answered.Decision,
},
})
out["receipt"] = follow.Receipt
if follow.Payload != nil {
out["payload"] = follow.Payload
}
}
writeJSON(w, http.StatusOK, out)
return true, nil
}
func (s *Server) sessionHumanGrant(actor, commandID string, params map[string]any) (*cmdbus.HumanGrant, bool) {
if commandID != "rdm.structure.decide" || s.st == nil {
return nil, false
}
project, _ := params["projectId"].(string)
proposal, _ := params["proposalId"].(string)
hash, _ := params["payloadHash"].(string)
decision, _ := params["decision"].(string)
revision := 0
switch v := params["proposalRevision"].(type) {
case float64:
revision = int(v)
case int:
revision = v
case string:
revision, _ = strconv.Atoi(v)
}
if project == "" || proposal == "" || hash == "" || decision == "" || revision < 1 {
return nil, false
}
rec, err := s.st.SessionHumanGrant(actor, commandID, project, proposal, hash, decision, revision, time.Now())
if err != nil {
return nil, false
}
return &cmdbus.HumanGrant{CardID: rec.ID, ProofID: rec.ProofID, Actor: rec.HumanActor, Decision: rec.Decision}, true
}
+105
View File
@@ -0,0 +1,105 @@
package server
import (
"context"
"net/http/httptest"
"testing"
"time"
agentcommand "agent-command"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
"agent-system-core"
)
type nopHumanAudit struct{}
func (nopHumanAudit) AppendAudit(string, agentcommand.AuditEnvelope) error { return nil }
func TestResolveHumanDecisionBindsFollowUpAndRetry(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/decision.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
issued, err := st.IssueHumanDecision(store.HumanDecision{
PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure,
CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice",
ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1,
PayloadHash: "hash-1", Question: "确认结构变更",
}, time.Now().UTC())
if err != nil {
t.Fatal(err)
}
var got *cmdbus.HumanGrant
bus := cmdbus.New(nopHumanAudit{}, nil, nil)
if err := bus.RegisterPluginCommand(agentcommand.CommandSpec{ID: "rdm.structure.decide", Title: "确认结构"},
func(_ context.Context, req cmdbus.Request) (any, error) {
got = req.HumanGrant
if req.Params["confirmed"] != nil {
t.Fatal("follow-up copied a client confirmation flag")
}
return map[string]any{"applied": true}, nil
}); err != nil {
t.Fatal(err)
}
s := &Server{st: st, Bus: bus}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/hitl/"+issued.ID+"/resolve", nil)
req = req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "alice"))
handled, err := s.resolveHumanDecision(rec, req, core.HITLResponse{CardID: core.ID(issued.ID), ChoiceID: "accept"})
if err != nil || !handled {
t.Fatalf("resolve handled=%v err=%v body=%s", handled, err, rec.Body.String())
}
if got == nil || got.Actor != "alice" || got.ProofID == "" || got.CardID != issued.ID {
t.Fatalf("follow-up grant = %+v", got)
}
grant, ok := s.sessionHumanGrant("alice", "rdm.structure.decide", map[string]any{
"projectId": "p1", "proposalId": "prop-1", "proposalRevision": float64(1),
"payloadHash": "hash-1", "decision": "accept", "confirmed": true,
})
if !ok || grant.ProofID != got.ProofID {
t.Fatalf("retry grant = %+v ok=%v", grant, ok)
}
if _, ok := s.sessionHumanGrant("bob", "rdm.structure.decide", map[string]any{
"projectId": "p1", "proposalId": "prop-1", "proposalRevision": float64(1),
"payloadHash": "hash-1", "decision": "accept",
}); ok {
t.Fatal("another actor received the grant")
}
}
func TestNaturalLanguageApprovalRequiresOnePendingCard(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/decision.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
now := time.Now().UTC()
first, err := st.IssueHumanDecision(store.HumanDecision{
PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure,
CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice",
ProjectID: "p1", ProposalID: "prop-1", ProposalRevision: 1,
PayloadHash: "hash-1", Question: "第一张",
}, now)
if err != nil {
t.Fatal(err)
}
if _, err := st.IssueHumanDecision(store.HumanDecision{
PluginID: "rdm", Audience: extension.HumanDecisionAudienceStructure,
CommandID: "rdm.structure.decide", HumanActor: "alice", Owner: "alice",
ProjectID: "p1", ProposalID: "prop-2", ProposalRevision: 1,
PayloadHash: "hash-2", Question: "第二张",
}, now); err != nil {
t.Fatal(err)
}
s := &Server{st: st}
rec := httptest.NewRecorder()
req := httptest.NewRequest("POST", "/api/hitl/"+first.ID+"/resolve", nil)
req = req.WithContext(context.WithValue(req.Context(), ownerCtxKey{}, "alice"))
handled, err := s.resolveHumanDecision(rec, req, core.HITLResponse{CardID: core.ID(first.ID), FreeText: "批准"})
if !handled || err == nil {
t.Fatal("ambiguous natural-language approval was accepted")
}
}
+12 -2
View File
@@ -1297,11 +1297,15 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
}
}
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
dispatch := cmdbus.Request{
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
HostProjectID: hostProject,
})
}
if grant, ok := s.sessionHumanGrant(s.owner(r), dispatch.ID, body.Params); ok {
dispatch.HumanGrant = grant
}
resp := s.Bus.Dispatch(r.Context(), dispatch)
code := http.StatusOK
if resp.Receipt.State == agentcommand.StateRejected {
code = http.StatusBadRequest
@@ -1371,6 +1375,12 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
return
}
resp.CardID = r.PathValue("id")
if handled, err := s.resolveHumanDecision(w, r, resp); handled {
if err != nil {
writeStoreErr(w, err)
}
return
}
if s.projectLifecycle != nil {
card, err := s.st.GetHITLCard(s.owner(r), resp.CardID)
if err != nil {
+215
View File
@@ -0,0 +1,215 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
const humanDecisionTTL = 15 * time.Minute
// HumanDecision is the base's record of one pending or answered human card.
// Status stays "responded" after a proof is issued. Consumption belongs to
// the caller that receives the proof, not to this table.
type HumanDecision struct {
ID string
PluginID string
Audience string
CommandID string
HumanActor string
Owner string
ProjectID string
ProposalID string
ProposalRevision int
PayloadHash string
Question string
Status string
Decision string
ProofID string
ConversationID string
IssuedAt time.Time
ExpiresAt time.Time
RespondedAt time.Time
}
func humanProofID(cardID, decision, payloadHash string) string {
sum := sha256.Sum256([]byte(cardID + "\n" + decision + "\n" + payloadHash))
return "hdp-" + hex.EncodeToString(sum[:16])
}
// IssueHumanDecision persists a pending card and revokes older pending cards
// for the same proposal. A changed card cannot keep an earlier confirmation.
func (s *Store) IssueHumanDecision(rec HumanDecision, now time.Time) (HumanDecision, error) {
if rec.PluginID == "" || rec.Audience == "" || rec.CommandID == "" || rec.HumanActor == "" ||
rec.Owner == "" || rec.ProjectID == "" || rec.ProposalID == "" || rec.ProposalRevision < 1 ||
rec.PayloadHash == "" || rec.Question == "" {
return HumanDecision{}, fmt.Errorf("store: incomplete human decision")
}
rec.ID = newID("hd")
rec.Status = "pending"
rec.Decision = ""
rec.ProofID = ""
rec.IssuedAt = now.UTC()
rec.ExpiresAt = now.UTC().Add(humanDecisionTTL)
tx, err := s.db.Begin()
if err != nil {
return HumanDecision{}, err
}
defer tx.Rollback()
if _, err := tx.Exec(s.q(`UPDATE human_decisions SET status='revoked'
WHERE plugin_id=? AND audience=? AND project_id=? AND proposal_id=? AND status='pending'`),
rec.PluginID, rec.Audience, rec.ProjectID, rec.ProposalID); err != nil {
return HumanDecision{}, err
}
if _, err := tx.Exec(s.q(`INSERT INTO human_decisions(
id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,proposal_revision,
payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`),
rec.ID, rec.PluginID, rec.Audience, rec.CommandID, rec.HumanActor, rec.Owner, rec.ProjectID,
rec.ProposalID, rec.ProposalRevision, rec.PayloadHash, rec.Question, rec.Status, "", "",
rec.ConversationID, rec.IssuedAt.Format(time.RFC3339Nano), rec.ExpiresAt.Format(time.RFC3339Nano), ""); err != nil {
return HumanDecision{}, err
}
if err := tx.Commit(); err != nil {
return HumanDecision{}, err
}
return rec, nil
}
func (s *Store) HumanDecision(id string) (HumanDecision, error) {
row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,
proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at
FROM human_decisions WHERE id=?`), id)
return scanHumanDecision(row)
}
// RespondHumanDecision records the authenticated person's choice. It does not
// mark the proof consumed. Repeating a resolved card fails closed.
func (s *Store) RespondHumanDecision(actor, cardID, decision string, now time.Time) (HumanDecision, error) {
if decision != "accept" && decision != "reject" && decision != "cancel" {
return HumanDecision{}, fmt.Errorf("store: human decision must be accept, reject, or cancel")
}
rec, err := s.HumanDecision(cardID)
if err != nil {
return HumanDecision{}, err
}
if rec.HumanActor != actor {
return HumanDecision{}, fmt.Errorf("store: human decision actor mismatch")
}
if rec.Status == "revoked" {
return HumanDecision{}, fmt.Errorf("store: human decision revoked")
}
if rec.Status != "pending" {
return HumanDecision{}, fmt.Errorf("store: human decision already answered")
}
if !now.Before(rec.ExpiresAt) {
return HumanDecision{}, fmt.Errorf("store: human decision expired")
}
proof := humanProofID(rec.ID, decision, rec.PayloadHash)
responded := now.UTC()
res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='responded', decision=?, proof_id=?, responded_at=?
WHERE id=? AND status='pending'`), decision, proof, responded.Format(time.RFC3339Nano), cardID)
if err != nil {
return HumanDecision{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return HumanDecision{}, fmt.Errorf("store: human decision already answered")
}
rec.Status = "responded"
rec.Decision = decision
rec.ProofID = proof
rec.RespondedAt = responded
return rec, nil
}
// RevokeHumanDecision invalidates a card before it is answered. The same
// actor who was asked, and only that actor, may revoke it.
func (s *Store) RevokeHumanDecision(pluginID, actor, cardID string) error {
res, err := s.db.Exec(s.q(`UPDATE human_decisions SET status='revoked'
WHERE id=? AND plugin_id=? AND human_actor=? AND status='pending'`), cardID, pluginID, actor)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("store: human decision cannot be revoked")
}
return nil
}
// PendingHumanDecisions returns unanswered cards for one actor.
func (s *Store) PendingHumanDecisions(actor string, now time.Time) ([]HumanDecision, error) {
rows, err := s.db.Query(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,
proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at
FROM human_decisions WHERE human_actor=? AND status='pending' ORDER BY issued_at`), actor)
if err != nil {
return nil, err
}
defer rows.Close()
var out []HumanDecision
for rows.Next() {
rec, err := scanHumanDecision(rows)
if err != nil {
return nil, err
}
if now.Before(rec.ExpiresAt) {
out = append(out, rec)
}
}
return out, rows.Err()
}
// SessionHumanGrant returns the grant for an authenticated retry of the exact
// answered card. It does not consume the proof. Missing or mismatched input
// is ErrNotFound so callers can leave the dispatch ungranted.
func (s *Store) SessionHumanGrant(actor, commandID, projectID, proposalID, payloadHash, decision string, revision int, now time.Time) (HumanDecision, error) {
row := s.db.QueryRow(s.q(`SELECT id,plugin_id,audience,command_id,human_actor,owner,project_id,proposal_id,
proposal_revision,payload_hash,question,status,decision,proof_id,conversation_id,issued_at,expires_at,responded_at
FROM human_decisions
WHERE human_actor=? AND command_id=? AND project_id=? AND proposal_id=? AND proposal_revision=?
AND payload_hash=? AND decision=? AND status='responded'`),
actor, commandID, projectID, proposalID, revision, payloadHash, decision)
rec, err := scanHumanDecision(row)
if err != nil {
return HumanDecision{}, err
}
if !now.Before(rec.ExpiresAt) {
return HumanDecision{}, ErrNotFound
}
return rec, nil
}
type humanScanner interface {
Scan(dest ...any) error
}
func scanHumanDecision(row humanScanner) (HumanDecision, error) {
var rec HumanDecision
var issued, expires, responded string
err := row.Scan(&rec.ID, &rec.PluginID, &rec.Audience, &rec.CommandID, &rec.HumanActor, &rec.Owner,
&rec.ProjectID, &rec.ProposalID, &rec.ProposalRevision, &rec.PayloadHash, &rec.Question, &rec.Status,
&rec.Decision, &rec.ProofID, &rec.ConversationID, &issued, &expires, &responded)
if errors.Is(err, sql.ErrNoRows) {
return HumanDecision{}, ErrNotFound
}
if err != nil {
return HumanDecision{}, err
}
rec.IssuedAt, err = time.Parse(time.RFC3339Nano, issued)
if err != nil {
return HumanDecision{}, err
}
rec.ExpiresAt, err = time.Parse(time.RFC3339Nano, expires)
if err != nil {
return HumanDecision{}, err
}
if responded != "" {
rec.RespondedAt, err = time.Parse(time.RFC3339Nano, responded)
if err != nil {
return HumanDecision{}, err
}
}
return rec, nil
}
+47
View File
@@ -1094,6 +1094,53 @@ func init() {
"sqlite": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
"postgres": {`DROP INDEX IF EXISTS idx_project_work_outbox_pending`, `DROP TABLE IF EXISTS project_work_outbox`},
}},
migration{version: 45, name: "human_decision_proofs",
up: map[string][]string{
"sqlite": {`CREATE TABLE human_decisions (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
audience TEXT NOT NULL,
command_id TEXT NOT NULL,
human_actor TEXT NOT NULL,
owner TEXT NOT NULL,
project_id TEXT NOT NULL,
proposal_id TEXT NOT NULL,
proposal_revision INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
question TEXT NOT NULL,
status TEXT NOT NULL,
decision TEXT NOT NULL DEFAULT '',
proof_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
responded_at TEXT NOT NULL DEFAULT ''
)`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`},
"postgres": {`CREATE TABLE human_decisions (
id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
audience TEXT NOT NULL,
command_id TEXT NOT NULL,
human_actor TEXT NOT NULL,
owner TEXT NOT NULL,
project_id TEXT NOT NULL,
proposal_id TEXT NOT NULL,
proposal_revision INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
question TEXT NOT NULL,
status TEXT NOT NULL,
decision TEXT NOT NULL DEFAULT '',
proof_id TEXT NOT NULL DEFAULT '',
conversation_id TEXT NOT NULL DEFAULT '',
issued_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
responded_at TEXT NOT NULL DEFAULT ''
)`, `CREATE INDEX idx_human_decisions_pending ON human_decisions(human_actor, status, proposal_id)`},
},
down: map[string][]string{
"sqlite": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
"postgres": {`DROP INDEX IF EXISTS idx_human_decisions_pending`, `DROP TABLE IF EXISTS human_decisions`},
}},
)
}
+4 -1
View File
@@ -52,7 +52,10 @@ import (
// X2 project audit capabilities on one Backend surface.
// 2.21.0-experimental: optional ProjectWorkObservationRegistry; host-origin
// work-source observations and project action decisions (X3 contract).
const SurfaceVersion = "2.21.0-experimental"
// 2.22.0-experimental: optional HumanDecisionVerifier. The host proves that
// this dispatch is an authenticated human response to one exact pending card.
// It does not consume the proof; the caller does that in its own transaction.
const SurfaceVersion = "2.22.0-experimental"
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+52
View File
@@ -2,6 +2,7 @@ package extension
import (
"context"
"time"
core "agent-system-core"
)
@@ -46,6 +47,7 @@ type Handler func(ctx context.Context, h Host, req Request) (any, error)
// - SurfaceHost — EXT-17:页面保存/读取
// - WorkCompletionHost — X1:受信项目工作生成
// - WorkTaskHost — X1:分发期建立可恢复的受信项目任务绑定
// - HumanDecisionVerifier — 本次分发是否为认证人对确切待确认卡的响应
type Host interface {
// PluginID 返回注册处理器的插件标识(形如 rdm)。
PluginID() string
@@ -92,3 +94,53 @@ type Host interface {
}
// —— AdminProvider 定义移至本文件上方(Request 之后)——
// HumanDecisionAudienceStructure is the only audience this proof can authorize.
// Another plugin cannot reuse the same human response as its own confirmation.
const HumanDecisionAudienceStructure = "rdm.structure.decide"
// HumanDecisionBinding is the exact object a human must have confirmed.
// Callers pass it to the host; they do not carry a copyable secret.
type HumanDecisionBinding struct {
Audience string
Actor string
ProjectID string
ProposalID string
ProposalRevision int
PayloadHash string
Decision string
}
// HumanDecisionCard is the pending object a person confirms.
type HumanDecisionCard struct {
CardID string
Question string
ExpiresAt time.Time
}
// HumanDecisionProof is a read-only statement that a person already responded.
// ProofID is a consumption key for the caller. The host does not mark it used.
type HumanDecisionProof struct {
ProofID string
Issuer string
HumanActor string
Audience string
ProjectID string
ProposalID string
Revision int
PayloadHash string
Decision string
IssuedAt time.Time
ExpiresAt time.Time
ConversationID string
RunID string
HITLResponseRef string
}
// HumanDecisionVerifier is optional. A missing assertion means the capability
// is absent and the caller must refuse the write.
type HumanDecisionVerifier interface {
IssueHumanDecision(binding HumanDecisionBinding, question string) (HumanDecisionCard, error)
VerifyHumanDecision(expected HumanDecisionBinding) (HumanDecisionProof, error)
RevokeHumanDecision(cardID string) error
}
+8
View File
@@ -52,6 +52,14 @@ Host.TaskCreate
Host.TaskUpdate
HostAuditReceipt
HostClient
HumanDecisionAudienceStructure
HumanDecisionBinding
HumanDecisionCard
HumanDecisionProof
HumanDecisionVerifier
HumanDecisionVerifier.IssueHumanDecision
HumanDecisionVerifier.RevokeHumanDecision
HumanDecisionVerifier.VerifyHumanDecision
InProc
IntentSpec
Main