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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 分发结果:回执 + 处理负载 + 可能的确认卡。
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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`},
|
||||
}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ Host.TaskCreate
|
||||
Host.TaskUpdate
|
||||
HostAuditReceipt
|
||||
HostClient
|
||||
HumanDecisionAudienceStructure
|
||||
HumanDecisionBinding
|
||||
HumanDecisionCard
|
||||
HumanDecisionProof
|
||||
HumanDecisionVerifier
|
||||
HumanDecisionVerifier.IssueHumanDecision
|
||||
HumanDecisionVerifier.RevokeHumanDecision
|
||||
HumanDecisionVerifier.VerifyHumanDecision
|
||||
InProc
|
||||
IntentSpec
|
||||
Main
|
||||
|
||||
Reference in New Issue
Block a user