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.
117 lines
3.2 KiB
Go
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]
|
|
}
|