package intentflow import ( "fmt" "chat-agent-system/internal/intent" ) const PolicyVersion = "batch-action-policy.v1" // ActionBatchPolicy is the intentflow-owned batch row. It must not copy // Risk, RequiredSlots, CapabilityKey, or AllowedStates from ActionRegistry. type ActionBatchPolicy struct { ActionKey string RequiresBatchConfirm bool OpenFromTier PlanTier AllowedModes []ExecutionMode RejectPlan bool } // BatchActionPolicy is the versioned fail-closed matrix keyed by ActionSpec.Key. type BatchActionPolicy struct { Version string byKey map[string]ActionBatchPolicy } func DefaultBatchActionPolicy() *BatchActionPolicy { p, err := NewBatchActionPolicy(defaultBatchRows(), intent.DefaultRegistry()) if err != nil { panic(err) } return p } func NewBatchActionPolicy(rows []ActionBatchPolicy, registry *intent.ActionRegistry) (*BatchActionPolicy, error) { if registry == nil { return nil, fmt.Errorf("intentflow: action registry is required") } byKey := make(map[string]ActionBatchPolicy, len(rows)) for _, row := range rows { if row.ActionKey == "" { return nil, fmt.Errorf("intentflow: empty policy action key") } if _, exists := byKey[row.ActionKey]; exists { return nil, fmt.Errorf("intentflow: duplicate policy key %q", row.ActionKey) } if _, ok := registry.Get(row.ActionKey); !ok { return nil, fmt.Errorf("intentflow: unknown policy action key %q", row.ActionKey) } byKey[row.ActionKey] = row } for _, spec := range registry.All() { if spec == nil { continue } if _, ok := byKey[spec.Key]; !ok { return nil, fmt.Errorf("intentflow: action %q missing batch policy", spec.Key) } } return &BatchActionPolicy{Version: PolicyVersion, byKey: byKey}, nil } func (p *BatchActionPolicy) Lookup(key string) (ActionBatchPolicy, error) { if p == nil { return ActionBatchPolicy{}, fmt.Errorf("intentflow: policy is nil") } row, ok := p.byKey[key] if !ok { return ActionBatchPolicy{}, fmt.Errorf("intentflow: unknown action key %q", key) } return row, nil } func (p *BatchActionPolicy) RequiresBatchConfirm(key string) bool { row, err := p.Lookup(key) if err != nil { return true } return row.RequiresBatchConfirm || row.RejectPlan } func defaultBatchRows() []ActionBatchPolicy { autoRead := []string{ "regulation.query", "case.query", "permission.query", "document.reject_reason.query", "workflow.status.query", "document.status.query", "run.status.query", "project.advisory", "conversation.help", } confirmGenerate := []string{"task.run", "task.run.adhoc"} rows := make([]ActionBatchPolicy, 0, len(intent.DefaultRegistry().All())) seen := map[string]struct{}{} add := func(row ActionBatchPolicy) { rows = append(rows, row) seen[row.ActionKey] = struct{}{} } for _, key := range autoRead { add(ActionBatchPolicy{ ActionKey: key, RequiresBatchConfirm: false, OpenFromTier: TierAutoRead, AllowedModes: []ExecutionMode{ModeLive, ModeValidatedOnly}, }) } for _, key := range confirmGenerate { add(ActionBatchPolicy{ ActionKey: key, RequiresBatchConfirm: true, OpenFromTier: TierConfirm, AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive}, }) } for _, key := range []string{ "project.fact.supplement", "document.claim", "document.review.submit", "workflow.run", "run.resume", // P8 逐键放量:confirm 档即可进入计划(真实执行仍受 per-key LIVE flag 限制; // flag 未开时 Owner 落 dry-run validated_only)。 "document.review.approve", "document.review.reject", "document.final.approve", "document.final.reject", "document.proposal.decide", "workflow.switch", "run.restart", } { add(ActionBatchPolicy{ ActionKey: key, RequiresBatchConfirm: true, OpenFromTier: TierConfirm, AllowedModes: []ExecutionMode{ModeValidatedOnly, ModeLive}, }) } add(ActionBatchPolicy{ ActionKey: "security.forbidden", RejectPlan: true, OpenFromTier: TierFull, }) for _, spec := range intent.DefaultRegistry().All() { if spec == nil { continue } if _, ok := seen[spec.Key]; ok { continue } add(ActionBatchPolicy{ ActionKey: spec.Key, RequiresBatchConfirm: true, OpenFromTier: TierFull, AllowedModes: []ExecutionMode{ModeValidatedOnly}, }) } return rows }