Files
WeiCHEN d019388f37 feat(intent): wire production QueryOwner and confirm dispatcher
Replace FakeActionOwner with QueryOwner+QueryGuard at the composition
root; DispatchEligible reuses claim/execute/recover so confirm stays
zero-write before dispatch and default tier remains legacy.
2026-09-22 21:44:56 +08:00

117 lines
3.2 KiB
Go

package intentflow
import (
"context"
"sync"
)
// ActionOwner executes one step. Old fencing tokens must be rejected here,
// not only in the plan store. The owner also stores receipts by idempotency key.
type ActionOwner interface {
Execute(ctx context.Context, exec StepExec) (Receipt, error)
LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error)
}
// FenceObserver is optional; ClaimStep notifies it so fakes can reject stale tokens.
type FenceObserver interface {
ObserveFence(stepID string, token int64)
}
// FakeActionOwner is the U3 test double. It records executions and receipts
// without touching business tables. Production composition must use a real
// query owner (see app.QueryOwner); never wire this into Assemble.
type FakeActionOwner struct {
mu sync.Mutex
execCount map[string]int
receipts map[string]Receipt
fences map[string]int64
FailWrite bool
Denied bool
DeniedKeys map[string]bool
RequiredVersion map[string]string
}
// StepAuthorizer re-checks the current principal before confirm and execute.
// Confirmation is not authorization.
type StepAuthorizer interface {
Authorize(ctx context.Context, exec StepExec) error
}
func NewFakeActionOwner() *FakeActionOwner {
return &FakeActionOwner{
execCount: map[string]int{},
receipts: map[string]Receipt{},
fences: map[string]int64{},
}
}
func (f *FakeActionOwner) ObserveFence(stepID string, token int64) {
if f == nil {
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.fences[stepID] = token
}
func (f *FakeActionOwner) Authorize(ctx context.Context, exec StepExec) error {
if f == nil {
return ErrPlanActionForbidden
}
f.mu.Lock()
defer f.mu.Unlock()
if f.Denied || f.DeniedKeys[exec.ActionKey] {
return ErrPlanActionForbidden
}
if want, ok := f.RequiredVersion[exec.ActionKey]; ok && exec.ExpectedResourceVersion != "" && exec.ExpectedResourceVersion != want {
return ErrPlanActionVersionConflict
}
return nil
}
func (f *FakeActionOwner) Execute(ctx context.Context, exec StepExec) (Receipt, error) {
if f == nil {
return Receipt{}, ErrPlanActionForbidden
}
if err := f.Authorize(ctx, exec); err != nil {
return Receipt{}, err
}
f.mu.Lock()
defer f.mu.Unlock()
if cur, ok := f.fences[exec.StepID]; ok && exec.FencingToken != cur {
return Receipt{}, ErrPlanActionVersionConflict
}
if existing, ok := f.receipts[exec.IdempotencyKey]; ok {
if existing.ActionDigest != exec.ActionDigest {
return Receipt{}, ErrPlanDecisionConflict
}
return existing, nil
}
f.execCount[exec.IdempotencyKey]++
rec := Receipt{
IdempotencyKey: exec.IdempotencyKey,
ActionDigest: exec.ActionDigest,
ResultRef: "fake:" + exec.StepID,
ResultDigest: "digest:" + exec.ActionDigest,
SafeSummary: "validated_only",
}
f.receipts[exec.IdempotencyKey] = rec
return rec, nil
}
func (f *FakeActionOwner) LookupReceipt(ctx context.Context, idempotencyKey string) (Receipt, bool, error) {
if f == nil {
return Receipt{}, false, nil
}
f.mu.Lock()
defer f.mu.Unlock()
rec, ok := f.receipts[idempotencyKey]
return rec, ok, nil
}
func (f *FakeActionOwner) ExecCount(idempotencyKey string) int {
f.mu.Lock()
defer f.mu.Unlock()
return f.execCount[idempotencyKey]
}