Skip to main content

tandem_server/automation_v2/
types.rs

1use std::collections::HashMap;
2
3use serde::de::DeserializeOwned;
4use serde::{Deserialize, Serialize};
5use serde_json::Value;
6use tandem_orchestrator::KnowledgeBinding;
7use tandem_plan_compiler::api::{
8    ContextObject, PlanScopeSnapshot, PlanValidationReport,
9    ProjectedAutomationContextMaterialization, ProjectedRoutineContextPartition,
10    ProjectedStepContextBindings,
11};
12use tandem_types::TenantContext;
13
14use crate::routines::types::RoutineMisfirePolicy;
15
16pub type AutomationV2Schedule =
17    tandem_workflows::plan_package::AutomationV2Schedule<RoutineMisfirePolicy>;
18pub use tandem_workflows::plan_package::AutomationV2ScheduleType;
19
20pub type WorkflowPlanStep = tandem_workflows::plan_package::WorkflowPlanStep<
21    AutomationFlowInputRef,
22    AutomationFlowOutputContract,
23>;
24pub type WorkflowPlan =
25    tandem_workflows::plan_package::WorkflowPlan<AutomationV2Schedule, WorkflowPlanStep>;
26pub use tandem_workflows::plan_package::{WorkflowPlanChatMessage, WorkflowPlanConversation};
27pub type WorkflowPlanDraftRecord =
28    tandem_workflows::plan_package::WorkflowPlanDraftRecord<WorkflowPlan>;
29pub type AutomationRuntimeContextMaterialization = ProjectedAutomationContextMaterialization;
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum AutomationV2Status {
34    Active,
35    Paused,
36    Draft,
37}
38
39// ---------------------------------------------------------------------------
40// Connected-agent coordination types
41// ---------------------------------------------------------------------------
42
43/// A file-based handoff envelope written by an upstream automation and consumed
44/// by a downstream automation. Deposited in the workspace `shared/handoffs/`
45/// directory and processed by the scheduler's watch-condition loop.
46///
47/// Lifecycle: `inbox/` → (auto-approve) → `approved/` → (consumed) → `archived/`.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HandoffArtifact {
50    /// Stable unique ID for this handoff, e.g. `hoff-20260406-<uuid>`.
51    pub handoff_id: String,
52    /// The automation that produced this handoff.
53    pub source_automation_id: String,
54    /// The run that produced this handoff.
55    pub source_run_id: String,
56    /// The node within that run that produced this handoff.
57    pub source_node_id: String,
58    /// The downstream automation that should consume this handoff.
59    /// The watch evaluator enforces this match.
60    pub target_automation_id: String,
61    /// Semantic type of the artifact, e.g. `"shortlist"`, `"brief"`, `"report"`.
62    /// Used to match against watch condition `artifact_type` filters.
63    pub artifact_type: String,
64    /// Unix epoch milliseconds when the handoff was created.
65    pub created_at_ms: u64,
66    /// Relative path (from workspace root) of the real content file.
67    /// For example `"job-search/shortlists/2026-04-06.md"`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub content_path: Option<String>,
70    /// SHA-256 hex digest of the content at `content_path`, if computed.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub content_digest: Option<String>,
73    /// Arbitrary operator-controlled metadata.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub metadata: Option<serde_json::Value>,
76    // --- Fields added when the handoff is consumed and the file is archived ---
77    /// The run ID of the automation that consumed this handoff.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub consumed_by_run_id: Option<String>,
80    /// The automation ID of the consumer (mirrors `target_automation_id`).
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub consumed_by_automation_id: Option<String>,
83    /// Unix epoch milliseconds when the handoff was consumed.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub consumed_at_ms: Option<u64>,
86}
87
88/// The kind of watch condition. Only `HandoffAvailable` is implemented in Phase 1.
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case", tag = "kind")]
91pub enum WatchCondition {
92    /// Fire when at least one handoff artifact is available in the `approved/`
93    /// directory that matches all specified filter fields.
94    HandoffAvailable {
95        /// Optional filter: only match handoffs from this source automation.
96        #[serde(default, skip_serializing_if = "Option::is_none")]
97        source_automation_id: Option<String>,
98        /// Optional filter: only match handoffs with this `artifact_type` value.
99        #[serde(default, skip_serializing_if = "Option::is_none")]
100        artifact_type: Option<String>,
101    },
102    // Phase 2: FileExists, FlagSet, UpstreamCompleted
103}
104
105/// Per-automation filesystem scope restriction.
106///
107/// When present, all paths accessed by agents in this automation are validated
108/// against this policy in addition to the existing workspace-root sandbox.
109/// Paths are relative to `workspace_root`.
110///
111/// If absent, the automation has full workspace-root access (backward-compatible).
112#[derive(Debug, Clone, Serialize, Deserialize, Default)]
113pub struct AutomationScopePolicy {
114    /// Paths readable by agents in this automation.
115    /// An empty list means "inherit workspace root" (no extra restriction).
116    #[serde(default)]
117    pub readable_paths: Vec<String>,
118    /// Paths writable by agents in this automation.
119    /// A write-allowed path is implicitly also readable.
120    #[serde(default)]
121    pub writable_paths: Vec<String>,
122    /// Paths explicitly denied even if they fall inside readable/writable.
123    /// Deny-wins: this list is checked first.
124    #[serde(default)]
125    pub denied_paths: Vec<String>,
126    /// Paths the scheduler watch evaluator may scan on behalf of this automation.
127    /// Defaults to readable_paths. Watching does not grant write access.
128    #[serde(default)]
129    pub watch_paths: Vec<String>,
130}
131
132impl AutomationScopePolicy {
133    /// Returns `true` if this policy is effectively unrestricted (all lists empty).
134    pub fn is_open(&self) -> bool {
135        self.readable_paths.is_empty()
136            && self.writable_paths.is_empty()
137            && self.denied_paths.is_empty()
138    }
139
140    /// Check whether `path` (relative to workspace root) is readable under this
141    /// policy. Returns `Err(reason)` if the access is denied.
142    ///
143    /// Rules (evaluated in order):
144    /// 1. If `path` is covered by `denied_paths` → deny.
145    /// 2. If `writable_paths` is non-empty and `path` is covered → allow.
146    /// 3. If `readable_paths` is non-empty and `path` is covered → allow.
147    /// 4. If both `readable_paths` and `writable_paths` are empty → allow (open policy).
148    /// 5. Otherwise → deny.
149    pub fn check_read(&self, path: &str) -> Result<(), String> {
150        let path = path.trim_start_matches('/');
151        if self.path_is_denied(path) {
152            return Err(format!(
153                "scope policy: read denied for `{path}` (path is in denied_paths)"
154            ));
155        }
156        if self.readable_paths.is_empty() && self.writable_paths.is_empty() {
157            return Ok(()); // open policy
158        }
159        if self.path_is_readable(path) || self.path_is_writable(path) {
160            return Ok(());
161        }
162        Err(format!(
163            "scope policy: read denied for `{path}` (not in readable_paths or writable_paths)"
164        ))
165    }
166
167    /// Check whether `path` is writable under this policy.
168    pub fn check_write(&self, path: &str) -> Result<(), String> {
169        let path = path.trim_start_matches('/');
170        if self.path_is_denied(path) {
171            return Err(format!(
172                "scope policy: write denied for `{path}` (path is in denied_paths)"
173            ));
174        }
175        if self.writable_paths.is_empty() {
176            return Ok(()); // no write restriction
177        }
178        if self.path_is_writable(path) {
179            return Ok(());
180        }
181        Err(format!(
182            "scope policy: write denied for `{path}` (not in writable_paths)"
183        ))
184    }
185
186    /// Check whether `path` is scannable by the watch evaluator.
187    pub fn check_watch(&self, path: &str) -> Result<(), String> {
188        let path = path.trim_start_matches('/');
189        if self.path_is_denied(path) {
190            return Err(format!(
191                "scope policy: watch denied for `{path}` (path is in denied_paths)"
192            ));
193        }
194        let watch_paths = if self.watch_paths.is_empty() {
195            &self.readable_paths
196        } else {
197            &self.watch_paths
198        };
199        if watch_paths.is_empty() {
200            return Ok(()); // open watch policy
201        }
202        if watch_paths
203            .iter()
204            .any(|prefix| scope_path_matches_prefix(path, prefix))
205        {
206            return Ok(());
207        }
208        Err(format!(
209            "scope policy: watch denied for `{path}` (not in watch_paths / readable_paths)"
210        ))
211    }
212
213    fn path_is_denied(&self, path: &str) -> bool {
214        self.denied_paths
215            .iter()
216            .any(|prefix| scope_path_matches_prefix(path, prefix))
217    }
218
219    fn path_is_readable(&self, path: &str) -> bool {
220        self.readable_paths
221            .iter()
222            .any(|prefix| scope_path_matches_prefix(path, prefix))
223    }
224
225    fn path_is_writable(&self, path: &str) -> bool {
226        self.writable_paths
227            .iter()
228            .any(|prefix| scope_path_matches_prefix(path, prefix))
229    }
230}
231
232/// Returns true if `path` is equal to `prefix` or starts with `prefix + "/"`.
233fn scope_path_matches_prefix(path: &str, prefix: &str) -> bool {
234    let prefix = prefix.trim_matches('/');
235    let path = path.trim_matches('/');
236    path == prefix || path.starts_with(&format!("{prefix}/"))
237}
238
239/// Per-automation handoff directory configuration.
240///
241/// Paths are relative to `workspace_root` (or the automation's scoped workspace).
242/// Defaults follow the standard layout: `shared/handoffs/{inbox,approved,archived}`.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct AutomationHandoffConfig {
245    /// Directory where newly created handoffs are deposited.
246    /// Default: `"shared/handoffs/inbox"`
247    #[serde(default = "default_handoff_inbox_dir")]
248    pub inbox_dir: String,
249    /// Directory where approved handoffs wait for consumption.
250    /// Default: `"shared/handoffs/approved"`
251    #[serde(default = "default_handoff_approved_dir")]
252    pub approved_dir: String,
253    /// Directory where consumed handoffs are archived.
254    /// Default: `"shared/handoffs/archived"`
255    #[serde(default = "default_handoff_archived_dir")]
256    pub archived_dir: String,
257    /// When `true`, newly created handoffs bypass the approval step and are
258    /// moved directly from `inbox/` to `approved/`. Default: `true` (Phase 1).
259    #[serde(default = "default_auto_approve")]
260    pub auto_approve: bool,
261}
262
263fn default_handoff_inbox_dir() -> String {
264    "shared/handoffs/inbox".to_string()
265}
266fn default_handoff_approved_dir() -> String {
267    "shared/handoffs/approved".to_string()
268}
269fn default_handoff_archived_dir() -> String {
270    "shared/handoffs/archived".to_string()
271}
272fn default_auto_approve() -> bool {
273    true
274}
275
276impl Default for AutomationHandoffConfig {
277    fn default() -> Self {
278        Self {
279            inbox_dir: default_handoff_inbox_dir(),
280            approved_dir: default_handoff_approved_dir(),
281            archived_dir: default_handoff_archived_dir(),
282            auto_approve: default_auto_approve(),
283        }
284    }
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct AutomationAgentToolPolicy {
289    #[serde(default)]
290    pub allowlist: Vec<String>,
291    #[serde(default)]
292    pub denylist: Vec<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct AutomationAgentMcpPolicy {
297    #[serde(default)]
298    pub allowed_servers: Vec<String>,
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub allowed_tools: Option<Vec<String>>,
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct AutomationAgentProfile {
305    pub agent_id: String,
306    #[serde(default, skip_serializing_if = "Option::is_none")]
307    pub template_id: Option<String>,
308    pub display_name: String,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub avatar_url: Option<String>,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub model_policy: Option<Value>,
313    #[serde(default)]
314    pub skills: Vec<String>,
315    pub tool_policy: AutomationAgentToolPolicy,
316    pub mcp_policy: AutomationAgentMcpPolicy,
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub approval_policy: Option<String>,
319}
320
321impl From<tandem_plan_compiler::api::ProjectedAutomationAgentProfile> for AutomationAgentProfile {
322    fn from(value: tandem_plan_compiler::api::ProjectedAutomationAgentProfile) -> Self {
323        Self {
324            agent_id: value.agent_id,
325            template_id: value.template_id,
326            display_name: value.display_name,
327            avatar_url: None,
328            model_policy: value.model_policy,
329            skills: Vec::new(),
330            tool_policy: AutomationAgentToolPolicy {
331                allowlist: value.tool_allowlist,
332                denylist: Vec::new(),
333            },
334            mcp_policy: AutomationAgentMcpPolicy {
335                allowed_servers: value.allowed_mcp_servers,
336                allowed_tools: None,
337            },
338            approval_policy: None,
339        }
340    }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
344#[serde(rename_all = "snake_case")]
345pub enum AutomationNodeStageKind {
346    Orchestrator,
347    Workstream,
348    Review,
349    Test,
350    Approval,
351}
352
353impl From<tandem_plan_compiler::api::ProjectedAutomationStageKind> for AutomationNodeStageKind {
354    fn from(value: tandem_plan_compiler::api::ProjectedAutomationStageKind) -> Self {
355        match value {
356            tandem_plan_compiler::api::ProjectedAutomationStageKind::Workstream => Self::Workstream,
357            tandem_plan_compiler::api::ProjectedAutomationStageKind::Review => Self::Review,
358            tandem_plan_compiler::api::ProjectedAutomationStageKind::Test => Self::Test,
359            tandem_plan_compiler::api::ProjectedAutomationStageKind::Approval => Self::Approval,
360        }
361    }
362}
363
364#[derive(Debug, Clone, Serialize, Deserialize)]
365pub struct AutomationApprovalGate {
366    #[serde(default)]
367    pub required: bool,
368    #[serde(default)]
369    pub decisions: Vec<String>,
370    #[serde(default)]
371    pub rework_targets: Vec<String>,
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub instructions: Option<String>,
374}
375
376impl From<tandem_plan_compiler::api::ProjectedAutomationApprovalGate> for AutomationApprovalGate {
377    fn from(value: tandem_plan_compiler::api::ProjectedAutomationApprovalGate) -> Self {
378        Self {
379            required: value.required,
380            decisions: value.decisions,
381            rework_targets: value.rework_targets,
382            instructions: value.instructions,
383        }
384    }
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct AutomationFlowNode {
389    pub node_id: String,
390    pub agent_id: String,
391    pub objective: String,
392    #[serde(default)]
393    pub knowledge: KnowledgeBinding,
394    #[serde(default)]
395    pub depends_on: Vec<String>,
396    #[serde(default)]
397    pub input_refs: Vec<AutomationFlowInputRef>,
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub output_contract: Option<AutomationFlowOutputContract>,
400    #[serde(default, skip_serializing_if = "Option::is_none")]
401    pub retry_policy: Option<Value>,
402    #[serde(default, skip_serializing_if = "Option::is_none")]
403    pub timeout_ms: Option<u64>,
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub max_tool_calls: Option<u32>,
406    #[serde(default, skip_serializing_if = "Option::is_none")]
407    pub stage_kind: Option<AutomationNodeStageKind>,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub gate: Option<AutomationApprovalGate>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub metadata: Option<Value>,
412}
413
414impl<I, O> From<tandem_plan_compiler::api::ProjectedAutomationNode<I, O>> for AutomationFlowNode
415where
416    I: Into<AutomationFlowInputRef>,
417    O: Into<AutomationFlowOutputContract>,
418{
419    fn from(value: tandem_plan_compiler::api::ProjectedAutomationNode<I, O>) -> Self {
420        fn knowledge_from_metadata(metadata: Option<&Value>, objective: &str) -> KnowledgeBinding {
421            let mut binding = KnowledgeBinding::default();
422            if let Some(parsed) = metadata
423                .and_then(|metadata| metadata.get("builder"))
424                .and_then(Value::as_object)
425                .and_then(|builder| builder.get("knowledge"))
426                .cloned()
427                .and_then(|value| serde_json::from_value::<KnowledgeBinding>(value).ok())
428            {
429                binding = parsed;
430            }
431            if binding
432                .subject
433                .as_deref()
434                .map(str::trim)
435                .unwrap_or("")
436                .is_empty()
437            {
438                let subject = objective.trim();
439                if !subject.is_empty() {
440                    binding.subject = Some(subject.to_string());
441                }
442            }
443            binding
444        }
445
446        let objective = value.objective;
447        let knowledge = knowledge_from_metadata(value.metadata.as_ref(), &objective);
448
449        Self {
450            node_id: value.node_id,
451            agent_id: value.agent_id,
452            objective,
453            knowledge,
454            depends_on: value.depends_on,
455            input_refs: value.input_refs.into_iter().map(Into::into).collect(),
456            output_contract: value.output_contract.map(Into::into),
457            retry_policy: value.retry_policy,
458            timeout_ms: value.timeout_ms,
459            max_tool_calls: None,
460            stage_kind: value.stage_kind.map(Into::into),
461            gate: value.gate.map(Into::into),
462            metadata: value.metadata,
463        }
464    }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize, Default)]
468pub struct AutomationFlowInputRef {
469    pub from_step_id: String,
470    pub alias: String,
471}
472
473#[derive(Debug, Clone, Serialize, Deserialize, Default)]
474pub struct AutomationFlowOutputContract {
475    pub kind: String,
476    #[serde(default, skip_serializing_if = "Option::is_none")]
477    pub validator: Option<AutomationOutputValidatorKind>,
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub enforcement: Option<AutomationOutputEnforcement>,
480    #[serde(default, skip_serializing_if = "Option::is_none")]
481    pub schema: Option<Value>,
482    #[serde(default, skip_serializing_if = "Option::is_none")]
483    pub summary_guidance: Option<String>,
484}
485
486impl From<tandem_plan_compiler::api::ProjectedMissionInputRef> for AutomationFlowInputRef {
487    fn from(value: tandem_plan_compiler::api::ProjectedMissionInputRef) -> Self {
488        Self {
489            from_step_id: value.from_step_id,
490            alias: value.alias,
491        }
492    }
493}
494
495impl tandem_plan_compiler::api::WorkflowInputRefLike for AutomationFlowInputRef {
496    fn from_step_id(&self) -> &str {
497        self.from_step_id.as_str()
498    }
499}
500
501impl From<tandem_plan_compiler::api::OutputContractSeed> for AutomationFlowOutputContract {
502    fn from(value: tandem_plan_compiler::api::OutputContractSeed) -> Self {
503        Self {
504            kind: value.kind,
505            validator: value.validator_kind.map(|kind| match kind {
506                tandem_plan_compiler::api::ProjectedOutputValidatorKind::ResearchBrief => {
507                    AutomationOutputValidatorKind::ResearchBrief
508                }
509                tandem_plan_compiler::api::ProjectedOutputValidatorKind::ReviewDecision => {
510                    AutomationOutputValidatorKind::ReviewDecision
511                }
512                tandem_plan_compiler::api::ProjectedOutputValidatorKind::StructuredJson => {
513                    AutomationOutputValidatorKind::StructuredJson
514                }
515                tandem_plan_compiler::api::ProjectedOutputValidatorKind::CodePatch => {
516                    AutomationOutputValidatorKind::CodePatch
517                }
518                tandem_plan_compiler::api::ProjectedOutputValidatorKind::GenericArtifact => {
519                    AutomationOutputValidatorKind::GenericArtifact
520                }
521            }),
522            enforcement: value
523                .enforcement
524                .and_then(|raw| serde_json::from_value(raw).ok()),
525            schema: value.schema,
526            summary_guidance: value.summary_guidance,
527        }
528    }
529}
530
531#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
532pub struct AutomationRequiredToolCall {
533    pub tool: String,
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub args: Option<Value>,
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub evidence_key: Option<String>,
538    #[serde(default = "default_required_tool_call_success")]
539    pub required_success: bool,
540}
541
542fn default_required_tool_call_success() -> bool {
543    true
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
547pub struct AutomationOutputEnforcement {
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub validation_profile: Option<String>,
550    #[serde(default)]
551    pub required_tools: Vec<String>,
552    #[serde(default)]
553    pub required_tool_calls: Vec<AutomationRequiredToolCall>,
554    #[serde(default)]
555    pub required_evidence: Vec<String>,
556    #[serde(default)]
557    pub required_sections: Vec<String>,
558    #[serde(default)]
559    pub prewrite_gates: Vec<String>,
560    #[serde(default)]
561    pub retry_on_missing: Vec<String>,
562    #[serde(default)]
563    pub terminal_on: Vec<String>,
564    #[serde(default, skip_serializing_if = "Option::is_none")]
565    pub repair_budget: Option<u32>,
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub session_text_recovery: Option<String>,
568}
569
570#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
571#[serde(rename_all = "snake_case")]
572pub enum AutomationOutputValidatorKind {
573    CodePatch,
574    ResearchBrief,
575    ReviewDecision,
576    StructuredJson,
577    GenericArtifact,
578    /// Standup participant nodes. Produces a JSON object with `yesterday`, `today`, and
579    /// `blockers` fields. Status detection short-circuits all review-approval and
580    /// research-brief logic for this kind — participants either complete or need repair.
581    StandupUpdate,
582}
583
584impl AutomationOutputValidatorKind {
585    pub fn stable_key(self) -> &'static str {
586        match self {
587            Self::CodePatch => "code_patch",
588            Self::ResearchBrief => "research_brief",
589            Self::ReviewDecision => "review_decision",
590            Self::StructuredJson => "structured_json",
591            Self::GenericArtifact => "generic_artifact",
592            Self::StandupUpdate => "standup_update",
593        }
594    }
595}
596
597#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct AutomationFlowSpec {
599    #[serde(default)]
600    pub nodes: Vec<AutomationFlowNode>,
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct AutomationExecutionPolicy {
605    #[serde(default, skip_serializing_if = "Option::is_none")]
606    pub max_parallel_agents: Option<u32>,
607    #[serde(default, skip_serializing_if = "Option::is_none")]
608    pub max_total_runtime_ms: Option<u64>,
609    #[serde(default, skip_serializing_if = "Option::is_none")]
610    pub max_total_tool_calls: Option<u32>,
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub max_total_tokens: Option<u64>,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub max_total_cost_usd: Option<f64>,
615}
616
617impl From<tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy>
618    for AutomationExecutionPolicy
619{
620    fn from(value: tandem_plan_compiler::api::ProjectedAutomationExecutionPolicy) -> Self {
621        Self {
622            max_parallel_agents: value.max_parallel_agents,
623            max_total_runtime_ms: value.max_total_runtime_ms,
624            max_total_tool_calls: value.max_total_tool_calls,
625            max_total_tokens: value.max_total_tokens,
626            max_total_cost_usd: value.max_total_cost_usd,
627        }
628    }
629}
630
631impl AutomationV2Spec {
632    fn metadata_value<T>(&self, key: &str) -> Option<T>
633    where
634        T: DeserializeOwned,
635    {
636        self.metadata
637            .as_ref()
638            .and_then(|metadata| metadata.get(key).cloned())
639            .and_then(|value| serde_json::from_value(value).ok())
640    }
641
642    pub fn runtime_context_materialization(
643        &self,
644    ) -> Option<AutomationRuntimeContextMaterialization> {
645        self.metadata_value("context_materialization")
646    }
647
648    pub fn approved_plan_runtime_context_materialization(
649        &self,
650    ) -> Option<AutomationRuntimeContextMaterialization> {
651        let approved_plan = self.approved_plan_materialization()?;
652        let scope_snapshot = self.plan_scope_snapshot_materialization()?;
653        let context_objects = scope_snapshot
654            .context_objects
655            .into_iter()
656            .map(|context_object: ContextObject| {
657                (context_object.context_object_id.clone(), context_object)
658            })
659            .collect::<HashMap<_, _>>();
660        let routines = approved_plan
661            .routines
662            .into_iter()
663            .map(|routine| ProjectedRoutineContextPartition {
664                routine_id: routine.routine_id,
665                visible_context_objects: routine
666                    .visible_context_object_ids
667                    .into_iter()
668                    .filter_map(|context_object_id| {
669                        context_objects.get(&context_object_id).cloned()
670                    })
671                    .collect(),
672                step_context_bindings: routine
673                    .step_context_bindings
674                    .into_iter()
675                    .map(|binding| ProjectedStepContextBindings {
676                        step_id: binding.step_id,
677                        context_reads: binding.context_reads,
678                        context_writes: binding.context_writes,
679                    })
680                    .collect(),
681            })
682            .collect();
683        Some(AutomationRuntimeContextMaterialization { routines })
684    }
685
686    pub fn requires_runtime_context(&self) -> bool {
687        self.runtime_context_materialization().is_some()
688            || self.approved_plan_materialization().is_some()
689            || !crate::http::context_packs::shared_context_pack_ids_from_metadata(
690                self.metadata.as_ref(),
691            )
692            .is_empty()
693    }
694
695    pub fn plan_scope_snapshot_materialization(&self) -> Option<PlanScopeSnapshot> {
696        self.metadata
697            .as_ref()
698            .and_then(|metadata| metadata.get("plan_package_bundle"))
699            .and_then(|bundle| bundle.get("scope_snapshot"))
700            .cloned()
701            .and_then(|value| serde_json::from_value(value).ok())
702    }
703
704    pub(crate) fn plan_package_validation_report(&self) -> Option<PlanValidationReport> {
705        self.metadata_value("plan_package_validation")
706    }
707
708    pub(crate) fn approved_plan_materialization(
709        &self,
710    ) -> Option<tandem_plan_compiler::api::ApprovedPlanMaterialization> {
711        self.metadata_value("approved_plan_materialization")
712    }
713}
714
715#[derive(Debug, Clone, Serialize, Deserialize)]
716pub struct AutomationV2Spec {
717    pub automation_id: String,
718    pub name: String,
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub description: Option<String>,
721    pub status: AutomationV2Status,
722    pub schedule: AutomationV2Schedule,
723    #[serde(default)]
724    pub knowledge: KnowledgeBinding,
725    #[serde(default)]
726    pub agents: Vec<AutomationAgentProfile>,
727    pub flow: AutomationFlowSpec,
728    pub execution: AutomationExecutionPolicy,
729    #[serde(default)]
730    pub output_targets: Vec<String>,
731    pub created_at_ms: u64,
732    pub updated_at_ms: u64,
733    pub creator_id: String,
734    #[serde(default, skip_serializing_if = "Option::is_none")]
735    pub workspace_root: Option<String>,
736    #[serde(default, skip_serializing_if = "Option::is_none")]
737    pub metadata: Option<Value>,
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub next_fire_at_ms: Option<u64>,
740    #[serde(default, skip_serializing_if = "Option::is_none")]
741    pub last_fired_at_ms: Option<u64>,
742    /// Optional per-automation filesystem scope restrictions.
743    /// When absent, the automation has full workspace-root access (backward-compatible).
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub scope_policy: Option<AutomationScopePolicy>,
746    /// Watch conditions evaluated by the scheduler on each tick.
747    /// When any condition matches, a new run is created with `trigger_type: "watch_condition"`.
748    #[serde(default, skip_serializing_if = "Vec::is_empty")]
749    pub watch_conditions: Vec<WatchCondition>,
750    /// Handoff directory configuration. Uses defaults if absent.
751    #[serde(default, skip_serializing_if = "Option::is_none")]
752    pub handoff_config: Option<AutomationHandoffConfig>,
753}
754
755impl AutomationV2Spec {
756    /// Returns the effective handoff config, using defaults if none is set.
757    pub fn effective_handoff_config(&self) -> AutomationHandoffConfig {
758        self.handoff_config.clone().unwrap_or_default()
759    }
760
761    /// Returns true if this automation has any watch conditions configured.
762    pub fn has_watch_conditions(&self) -> bool {
763        !self.watch_conditions.is_empty()
764    }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize)]
768pub struct AutomationNodeOutput {
769    pub contract_kind: String,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub validator_kind: Option<AutomationOutputValidatorKind>,
772    #[serde(default, skip_serializing_if = "Option::is_none")]
773    pub validator_summary: Option<AutomationValidatorSummary>,
774    pub summary: String,
775    pub content: Value,
776    pub created_at_ms: u64,
777    pub node_id: String,
778    #[serde(default, skip_serializing_if = "Option::is_none")]
779    pub status: Option<String>,
780    #[serde(default, skip_serializing_if = "Option::is_none")]
781    pub blocked_reason: Option<String>,
782    #[serde(default, skip_serializing_if = "Option::is_none")]
783    pub approved: Option<bool>,
784    #[serde(default, skip_serializing_if = "Option::is_none")]
785    pub workflow_class: Option<String>,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    pub phase: Option<String>,
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub failure_kind: Option<String>,
790    #[serde(default, skip_serializing_if = "Option::is_none")]
791    pub tool_telemetry: Option<Value>,
792    #[serde(default, skip_serializing_if = "Option::is_none")]
793    pub preflight: Option<Value>,
794    #[serde(default, skip_serializing_if = "Option::is_none")]
795    pub knowledge_preflight: Option<Value>,
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub capability_resolution: Option<Value>,
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub attempt_evidence: Option<Value>,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub blocker_category: Option<String>,
802    #[serde(default, skip_serializing_if = "Option::is_none")]
803    pub receipt_timeline: Option<Value>,
804    #[serde(default, skip_serializing_if = "Option::is_none")]
805    pub quality_mode: Option<String>,
806    #[serde(default, skip_serializing_if = "Option::is_none")]
807    pub requested_quality_mode: Option<String>,
808    #[serde(default, skip_serializing_if = "Option::is_none")]
809    pub emergency_rollback_enabled: Option<bool>,
810    #[serde(default, skip_serializing_if = "Option::is_none")]
811    pub fallback_used: Option<bool>,
812    #[serde(default, skip_serializing_if = "Option::is_none")]
813    pub artifact_validation: Option<Value>,
814    #[serde(default, skip_serializing_if = "Option::is_none")]
815    pub provenance: Option<AutomationNodeOutputProvenance>,
816}
817
818#[derive(Debug, Clone, Serialize, Deserialize)]
819pub struct AutomationValidatorSummary {
820    pub kind: AutomationOutputValidatorKind,
821    pub outcome: String,
822    #[serde(default, skip_serializing_if = "Option::is_none")]
823    pub reason: Option<String>,
824    #[serde(default)]
825    pub unmet_requirements: Vec<String>,
826    #[serde(default)]
827    pub warning_requirements: Vec<String>,
828    #[serde(default)]
829    pub warning_count: u32,
830    #[serde(default, skip_serializing_if = "Option::is_none")]
831    pub accepted_candidate_source: Option<String>,
832    #[serde(default, skip_serializing_if = "Option::is_none")]
833    pub verification_outcome: Option<String>,
834    #[serde(default, skip_serializing_if = "Option::is_none")]
835    pub validation_basis: Option<Value>,
836    #[serde(default)]
837    pub repair_attempted: bool,
838    #[serde(default)]
839    pub repair_attempt: u32,
840    #[serde(default)]
841    pub repair_attempts_remaining: u32,
842    #[serde(default)]
843    pub repair_succeeded: bool,
844    #[serde(default)]
845    pub repair_exhausted: bool,
846}
847
848#[derive(Debug, Clone, Serialize, Deserialize)]
849pub struct AutomationNodeOutputFreshness {
850    pub current_run: bool,
851    pub current_attempt: bool,
852}
853
854#[derive(Debug, Clone, Serialize, Deserialize)]
855pub struct AutomationNodeOutputProvenance {
856    pub session_id: String,
857    pub node_id: String,
858    #[serde(default, skip_serializing_if = "Option::is_none")]
859    pub run_id: Option<String>,
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub output_path: Option<String>,
862    #[serde(default, skip_serializing_if = "Option::is_none")]
863    pub content_digest: Option<String>,
864    #[serde(default, skip_serializing_if = "Option::is_none")]
865    pub accepted_candidate_source: Option<String>,
866    #[serde(default, skip_serializing_if = "Option::is_none")]
867    pub validation_outcome: Option<String>,
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub repair_attempt: Option<u64>,
870    #[serde(default, skip_serializing_if = "Option::is_none")]
871    pub repair_succeeded: Option<bool>,
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub reuse_allowed: Option<bool>,
874    pub freshness: AutomationNodeOutputFreshness,
875}
876
877#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
878#[serde(rename_all = "snake_case")]
879pub enum AutomationRunStatus {
880    Queued,
881    Running,
882    Pausing,
883    Paused,
884    AwaitingApproval,
885    Completed,
886    Blocked,
887    Failed,
888    Cancelled,
889}
890
891#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct AutomationPendingGate {
893    pub node_id: String,
894    pub title: String,
895    #[serde(default, skip_serializing_if = "Option::is_none")]
896    pub instructions: Option<String>,
897    #[serde(default)]
898    pub decisions: Vec<String>,
899    #[serde(default)]
900    pub rework_targets: Vec<String>,
901    pub requested_at_ms: u64,
902    #[serde(default)]
903    pub upstream_node_ids: Vec<String>,
904}
905
906#[derive(Debug, Clone, Serialize, Deserialize)]
907pub struct AutomationGateDecisionRecord {
908    pub node_id: String,
909    pub decision: String,
910    #[serde(default, skip_serializing_if = "Option::is_none")]
911    pub reason: Option<String>,
912    pub decided_at_ms: u64,
913}
914
915#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
916#[serde(rename_all = "snake_case")]
917pub enum AutomationStopKind {
918    Cancelled,
919    OperatorStopped,
920    GuardrailStopped,
921    Panic,
922    Shutdown,
923    ServerRestart,
924    StaleReaped,
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
928pub struct AutomationLifecycleRecord {
929    pub event: String,
930    pub recorded_at_ms: u64,
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub reason: Option<String>,
933    #[serde(default, skip_serializing_if = "Option::is_none")]
934    pub stop_kind: Option<AutomationStopKind>,
935    #[serde(default, skip_serializing_if = "Option::is_none")]
936    pub metadata: Option<Value>,
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct AutomationFailureRecord {
941    pub node_id: String,
942    pub reason: String,
943    pub failed_at_ms: u64,
944}
945
946#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
947#[serde(rename_all = "snake_case")]
948pub enum WorkflowLearningCandidateKind {
949    MemoryFact,
950    RepairHint,
951    PromptPatch,
952    GraphPatch,
953}
954
955#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
956#[serde(rename_all = "snake_case")]
957pub enum WorkflowLearningCandidateStatus {
958    Proposed,
959    Approved,
960    Rejected,
961    Applied,
962    Superseded,
963    Regressed,
964}
965
966#[derive(Debug, Clone, Serialize, Deserialize, Default)]
967pub struct WorkflowLearningMetricsSnapshot {
968    #[serde(default)]
969    pub sample_size: usize,
970    #[serde(default)]
971    pub completion_rate: f64,
972    #[serde(default)]
973    pub validation_pass_rate: f64,
974    #[serde(default)]
975    pub mean_attempts_per_node: f64,
976    #[serde(default)]
977    pub repairable_failure_rate: f64,
978    #[serde(default)]
979    pub median_wall_clock_ms: u64,
980    #[serde(default)]
981    pub human_intervention_count: u64,
982    #[serde(default)]
983    pub computed_at_ms: u64,
984}
985
986#[derive(Debug, Clone, Serialize, Deserialize)]
987pub struct WorkflowLearningCandidate {
988    pub candidate_id: String,
989    pub workflow_id: String,
990    pub project_id: String,
991    pub source_run_id: String,
992    pub kind: WorkflowLearningCandidateKind,
993    pub status: WorkflowLearningCandidateStatus,
994    #[serde(default)]
995    pub confidence: f64,
996    pub summary: String,
997    pub fingerprint: String,
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub node_id: Option<String>,
1000    #[serde(default, skip_serializing_if = "Option::is_none")]
1001    pub node_kind: Option<String>,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub validator_family: Option<String>,
1004    #[serde(default)]
1005    pub evidence_refs: Vec<Value>,
1006    #[serde(default)]
1007    pub artifact_refs: Vec<String>,
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub proposed_memory_payload: Option<Value>,
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub proposed_revision_prompt: Option<String>,
1012    #[serde(default, skip_serializing_if = "Option::is_none")]
1013    pub source_memory_id: Option<String>,
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub promoted_memory_id: Option<String>,
1016    #[serde(default)]
1017    pub needs_plan_bundle: bool,
1018    #[serde(default, skip_serializing_if = "Option::is_none")]
1019    pub baseline_before: Option<WorkflowLearningMetricsSnapshot>,
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub latest_observed_metrics: Option<WorkflowLearningMetricsSnapshot>,
1022    #[serde(default, skip_serializing_if = "Option::is_none")]
1023    pub last_revision_session_id: Option<String>,
1024    #[serde(default)]
1025    pub run_ids: Vec<String>,
1026    pub created_at_ms: u64,
1027    pub updated_at_ms: u64,
1028}
1029
1030#[derive(Debug, Clone, Serialize, Deserialize, Default)]
1031pub struct WorkflowLearningRunSummary {
1032    #[serde(default)]
1033    pub generated_candidate_ids: Vec<String>,
1034    #[serde(default)]
1035    pub injected_learning_ids: Vec<String>,
1036    #[serde(default)]
1037    pub approved_learning_ids_considered: Vec<String>,
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub post_run_metrics: Option<WorkflowLearningMetricsSnapshot>,
1040}
1041
1042#[derive(Debug, Clone, Serialize, Deserialize)]
1043pub struct AutomationRunCheckpoint {
1044    #[serde(default)]
1045    pub completed_nodes: Vec<String>,
1046    #[serde(default)]
1047    pub pending_nodes: Vec<String>,
1048    #[serde(default)]
1049    pub node_outputs: std::collections::HashMap<String, Value>,
1050    #[serde(default)]
1051    pub node_attempts: std::collections::HashMap<String, u32>,
1052    #[serde(default)]
1053    pub blocked_nodes: Vec<String>,
1054    #[serde(default, skip_serializing_if = "Option::is_none")]
1055    pub awaiting_gate: Option<AutomationPendingGate>,
1056    #[serde(default)]
1057    pub gate_history: Vec<AutomationGateDecisionRecord>,
1058    #[serde(default)]
1059    pub lifecycle_history: Vec<AutomationLifecycleRecord>,
1060    #[serde(default, skip_serializing_if = "Option::is_none")]
1061    pub last_failure: Option<AutomationFailureRecord>,
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize)]
1065pub struct AutomationV2RunRecord {
1066    pub run_id: String,
1067    pub automation_id: String,
1068    #[serde(default = "default_tenant_context")]
1069    pub tenant_context: TenantContext,
1070    pub trigger_type: String,
1071    pub status: AutomationRunStatus,
1072    pub created_at_ms: u64,
1073    pub updated_at_ms: u64,
1074    #[serde(default, skip_serializing_if = "Option::is_none")]
1075    pub started_at_ms: Option<u64>,
1076    #[serde(default, skip_serializing_if = "Option::is_none")]
1077    pub finished_at_ms: Option<u64>,
1078    #[serde(default)]
1079    pub active_session_ids: Vec<String>,
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub latest_session_id: Option<String>,
1082    #[serde(default)]
1083    pub active_instance_ids: Vec<String>,
1084    pub checkpoint: AutomationRunCheckpoint,
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    pub runtime_context: Option<AutomationRuntimeContextMaterialization>,
1087    #[serde(default, skip_serializing_if = "Option::is_none")]
1088    pub automation_snapshot: Option<AutomationV2Spec>,
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub pause_reason: Option<String>,
1091    #[serde(default, skip_serializing_if = "Option::is_none")]
1092    pub resume_reason: Option<String>,
1093    #[serde(default, skip_serializing_if = "Option::is_none")]
1094    pub detail: Option<String>,
1095    #[serde(default, skip_serializing_if = "Option::is_none")]
1096    pub stop_kind: Option<AutomationStopKind>,
1097    #[serde(default, skip_serializing_if = "Option::is_none")]
1098    pub stop_reason: Option<String>,
1099    #[serde(default)]
1100    pub prompt_tokens: u64,
1101    #[serde(default)]
1102    pub completion_tokens: u64,
1103    #[serde(default)]
1104    pub total_tokens: u64,
1105    #[serde(default)]
1106    pub estimated_cost_usd: f64,
1107    #[serde(default, skip_serializing_if = "Option::is_none")]
1108    pub scheduler: Option<crate::app::state::automation::scheduler::SchedulerMetadata>,
1109    /// Human-readable description of why this run was triggered, e.g.
1110    /// `"handoff shortlist from opportunity-scout approved"`.
1111    /// Populated for `trigger_type: "watch_condition"` runs.
1112    #[serde(default, skip_serializing_if = "Option::is_none")]
1113    pub trigger_reason: Option<String>,
1114    /// The `handoff_id` of the `HandoffArtifact` that triggered this run, if any.
1115    /// Used for idempotency: a retry of this run will not re-consume a second handoff.
1116    #[serde(default, skip_serializing_if = "Option::is_none")]
1117    pub consumed_handoff_id: Option<String>,
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub learning_summary: Option<WorkflowLearningRunSummary>,
1120}
1121
1122fn default_tenant_context() -> TenantContext {
1123    TenantContext::local_implicit()
1124}
1125
1126#[cfg(test)]
1127mod tests {
1128    use super::*;
1129    use serde_json::json;
1130    use tandem_orchestrator::{KnowledgeReuseMode, KnowledgeTrustLevel};
1131    use tandem_plan_compiler::api::{
1132        OutputContractSeed, ProjectedAutomationNode, ProjectedMissionInputRef,
1133    };
1134
1135    #[test]
1136    fn projected_node_metadata_lifts_knowledge_binding() {
1137        let projected = ProjectedAutomationNode::<ProjectedMissionInputRef, OutputContractSeed> {
1138            node_id: "node-a".to_string(),
1139            agent_id: "agent-a".to_string(),
1140            objective: "Map the topic".to_string(),
1141            depends_on: vec![],
1142            input_refs: vec![],
1143            output_contract: None,
1144            retry_policy: None,
1145            timeout_ms: None,
1146            stage_kind: None,
1147            gate: None,
1148            metadata: Some(json!({
1149                "builder": {
1150                    "knowledge": {
1151                        "enabled": true,
1152                        "reuse_mode": "preflight",
1153                        "trust_floor": "promoted",
1154                        "read_spaces": [{"scope": "project"}],
1155                        "promote_spaces": [{"scope": "project"}],
1156                        "subject": "Topic map"
1157                    }
1158                }
1159            })),
1160        };
1161
1162        let node = AutomationFlowNode::from(projected);
1163        assert!(node.knowledge.enabled);
1164        assert_eq!(node.knowledge.reuse_mode, KnowledgeReuseMode::Preflight);
1165        assert_eq!(node.knowledge.trust_floor, KnowledgeTrustLevel::Promoted);
1166        assert_eq!(node.knowledge.subject.as_deref(), Some("Topic map"));
1167        assert_eq!(node.knowledge.read_spaces.len(), 1);
1168        assert_eq!(node.knowledge.promote_spaces.len(), 1);
1169    }
1170
1171    // ── AutomationScopePolicy ────────────────────────────────────────────────
1172
1173    fn open_policy() -> AutomationScopePolicy {
1174        AutomationScopePolicy::default()
1175    }
1176
1177    fn restricted_policy() -> AutomationScopePolicy {
1178        AutomationScopePolicy {
1179            readable_paths: vec!["shared/".to_string(), "job-search/reports/".to_string()],
1180            writable_paths: vec!["job-search/reports/".to_string()],
1181            denied_paths: vec!["shared/secrets/".to_string()],
1182            watch_paths: vec![],
1183        }
1184    }
1185
1186    #[test]
1187    fn scope_policy_open_allows_any_read() {
1188        let policy = open_policy();
1189        assert!(policy.check_read("anything/here.md").is_ok());
1190        assert!(policy.check_read("shared/secrets/token.txt").is_ok());
1191    }
1192
1193    #[test]
1194    fn scope_policy_open_allows_any_write() {
1195        let policy = open_policy();
1196        assert!(policy.check_write("anywhere/file.txt").is_ok());
1197    }
1198
1199    #[test]
1200    fn scope_policy_deny_wins_over_readable() {
1201        let policy = restricted_policy();
1202        // shared/secrets/ is explicitly denied, even though "shared/" is readable
1203        assert!(policy.check_read("shared/secrets/token.txt").is_err());
1204        assert!(policy.check_write("shared/secrets/token.txt").is_err());
1205    }
1206
1207    #[test]
1208    fn scope_policy_readable_path_allows_read() {
1209        let policy = restricted_policy();
1210        assert!(policy
1211            .check_read("shared/handoffs/approved/handoff.json")
1212            .is_ok());
1213    }
1214
1215    #[test]
1216    fn scope_policy_unreadable_path_denied() {
1217        let policy = restricted_policy();
1218        // "private/" is not in readable_paths
1219        assert!(policy.check_read("private/notes.md").is_err());
1220    }
1221
1222    #[test]
1223    fn scope_policy_writable_path_allows_write() {
1224        let policy = restricted_policy();
1225        assert!(policy.check_write("job-search/reports/week1.md").is_ok());
1226    }
1227
1228    #[test]
1229    fn scope_policy_non_writable_path_denied_for_write() {
1230        let policy = restricted_policy();
1231        // "shared/" is readable but not writable
1232        assert!(policy
1233            .check_write("shared/handoffs/approved/handoff.json")
1234            .is_err());
1235    }
1236
1237    #[test]
1238    fn scope_policy_watch_falls_back_to_readable_when_watch_paths_empty() {
1239        let policy = restricted_policy(); // watch_paths is empty
1240                                          // watched paths should follow readable_paths
1241        assert!(policy.check_watch("shared/handoffs/inbox/").is_ok());
1242        assert!(policy.check_watch("private/something").is_err());
1243    }
1244
1245    #[test]
1246    fn scope_policy_explicit_watch_paths_override_readable() {
1247        let policy = AutomationScopePolicy {
1248            readable_paths: vec!["shared/".to_string()],
1249            writable_paths: vec![],
1250            denied_paths: vec![],
1251            watch_paths: vec!["shared/handoffs/inbox/".to_string()],
1252        };
1253        // Only the explicit watch path is watchable
1254        assert!(policy
1255            .check_watch("shared/handoffs/inbox/alert.json")
1256            .is_ok());
1257        // "shared/other/" is readable but not in watch_paths
1258        assert!(policy.check_watch("shared/other/file.md").is_err());
1259    }
1260
1261    #[test]
1262    fn scope_path_prefix_matches_exact_and_children() {
1263        assert!(scope_path_matches_prefix("shared", "shared"));
1264        assert!(scope_path_matches_prefix("shared/foo/bar.json", "shared"));
1265        assert!(!scope_path_matches_prefix("sharedfoo", "shared")); // no slash boundary
1266        assert!(!scope_path_matches_prefix("other/shared", "shared"));
1267    }
1268
1269    #[test]
1270    fn scope_policy_is_open_reflects_empty_lists() {
1271        assert!(open_policy().is_open());
1272        assert!(!restricted_policy().is_open());
1273    }
1274}