Skip to main content

mati_core/store/
policy_ops.rs

1//! Centralized mutations for developer-authored local policies.
2//!
3//! A policy record is the complete source of truth in Milestone 1. The record
4//! is committed before its best-effort audit event, and there is no derived
5//! index or repair path to maintain.
6
7use std::time::{SystemTime, UNIX_EPOCH};
8
9use anyhow::Result;
10use globset::Glob;
11
12use super::db::Store;
13use super::enforcement::{record_event, ControlChangeKind, EnforcementEventType, SubjectKind};
14use super::record::{
15    Category, ConfidenceScore, PolicyRecord, PolicyStage, QualityScore, Record, RecordLifecycle,
16    RecordSource, RecordVersion, StalenessScore, TombstoneReason,
17};
18
19/// Typed failure modes for policy mutations, so callers (e.g. the daemon
20/// dispatcher) can map them to protocol error codes without matching on the
21/// message text.
22#[derive(Debug, thiserror::Error)]
23pub enum PolicyOpError {
24    #[error("invalid policy key '{key}'; expected policy:<slug>")]
25    InvalidKey { key: String },
26    #[error("record not found: {key}")]
27    NotFound { key: String },
28    #[error("policy key '{key}' already exists; edit the existing record instead")]
29    AlreadyExists { key: String },
30    #[error("policy '{key}' is not active")]
31    NotActive { key: String },
32    #[error("'{key}' is not a policy record")]
33    NotAPolicy { key: String },
34    #[error("'{key}' has no valid PolicyRecord payload")]
35    InvalidPayload { key: String },
36    #[error("invalid {field} '{pattern}': {source}")]
37    InvalidGlob {
38        field: &'static str,
39        pattern: String,
40        source: globset::Error,
41    },
42    #[error(
43        "trigger has no predicate; an empty trigger matches every governed action. \
44         Name at least one of tool ({tools}), host_glob, target_path_glob, or command_glob"
45    )]
46    EmptyTrigger { tools: String },
47}
48
49/// Validate a trigger before a policy can become durable state, or before
50/// `mati policy test --trigger` reports on it.
51///
52/// An all-`None` trigger is rejected rather than compiled: absent fields are
53/// wildcards, so it would gate every `db_client`, `file_read` and `path` action
54/// in the repo. A rule that broad has to name the category it means.
55pub fn validate_trigger(trigger: &super::record::PolicyTrigger) -> Result<()> {
56    if trigger.tool.is_none()
57        && trigger.host_glob.is_none()
58        && trigger.target_path_glob.is_none()
59        && trigger.command_glob.is_none()
60    {
61        return Err(PolicyOpError::EmptyTrigger {
62            tools: crate::hooks::decide::KNOWN_ACTION_TOOLS.join(", "),
63        }
64        .into());
65    }
66    if let Some(pattern) = trigger.host_glob.as_deref() {
67        Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
68            field: "host_glob",
69            pattern: pattern.to_string(),
70            source,
71        })?;
72    }
73    if let Some(pattern) = trigger.target_path_glob.as_deref() {
74        Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
75            field: "target_path_glob",
76            pattern: pattern.to_string(),
77            source,
78        })?;
79    }
80    if let Some(pattern) = trigger.command_glob.as_deref() {
81        Glob::new(pattern).map_err(|source| PolicyOpError::InvalidGlob {
82            field: "command_glob",
83            pattern: pattern.to_string(),
84            source,
85        })?;
86    }
87    Ok(())
88}
89
90/// Return author-time warnings without changing policy state.
91/// Key prefixes `mem_set` will write. A `requires.key` outside this set can
92/// never be backed by a record, so telling the author to "store a record there"
93/// would be advice they cannot follow.
94pub const WRITABLE_KEY_PREFIXES: &[&str] = &["gotcha:", "decision:", "dev_note:", "policy:"];
95
96/// Trigger tools that at least one installed policy adapter evaluates.
97pub const ADAPTER_POLICY_TOOLS: &[&str] = &["db_client", "path"];
98
99/// Can a record ever exist at this key?
100pub fn key_is_writable(key: &str) -> bool {
101    WRITABLE_KEY_PREFIXES
102        .iter()
103        .any(|prefix| key.starts_with(prefix) && key.len() > prefix.len())
104}
105
106pub fn author_warnings(policy: &PolicyRecord, has_backing_record: bool) -> Vec<String> {
107    let mut warnings = Vec::new();
108    if policy.mode == super::record::PolicyMode::Block && policy.requires.via.is_empty() {
109        warnings.push(
110            "warning: block policy has an empty requires.via; no receipt source can satisfy it."
111                .into(),
112        );
113    } else if policy.mode == super::record::PolicyMode::Block
114        && !policy
115            .requires
116            .via
117            .iter()
118            .any(|source| super::record::CODEX_PRODUCIBLE_SOURCES.contains(source))
119    {
120        warnings.push(
121            "warning: Codex cannot produce any accepted receipt source for this block policy; "
122                .to_string()
123                + "Codex will steer it with a diagnostic instead of denying an unsatisfiable action."
124        );
125    }
126    if policy.trigger.tool.as_deref() == Some("file_read") {
127        warnings.push(
128            "warning: policy trigger tool 'file_read' has no enforcing adapter; Bash file reads are governed by gotchas, so this policy will never be evaluated. Use a gotcha for reads or a db_client/path policy for an adapter-backed action.".into(),
129        );
130    }
131    if let Some(tool) = policy
132        .trigger
133        .tool
134        .as_deref()
135        .filter(|tool| !crate::hooks::decide::is_known_action_tool(tool))
136    {
137        warnings.push(format!(
138            "warning: policy trigger tool '{tool}' is not a recognized governable category (known: {}); this policy will never match until such a category is supported.",
139            crate::hooks::decide::KNOWN_ACTION_TOOLS.join(", ")
140        ));
141    }
142    if policy
143        .requires
144        .via
145        .contains(&super::record::ReceiptSource::MemGet)
146        && !has_backing_record
147    {
148        let key = &policy.requires.key;
149        if key_is_writable(key) {
150            warnings.push(format!(
151                "warning: requires.key '{key}' has no backing record; a mem_get on it would mint a receipt without the agent learning anything. Store a record at that key (e.g. the schema doc) so consultation is substantive."
152            ));
153        } else {
154            // The old wording told the author to store a record at a key
155            // nothing can write to, which reads as a step they skipped rather
156            // than a key they must rename.
157            warnings.push(format!(
158                "warning: requires.key '{key}' can never hold a record: mem_set writes only {} prefixes. A mem_get on it mints a receipt that teaches the agent nothing. Use a writable key, such as 'decision:{}'.",
159                WRITABLE_KEY_PREFIXES.join(", "),
160                key.rsplit(':').next().unwrap_or("the-doc")
161            ));
162        }
163    }
164    if policy.requires.freshness.fingerprint
165        && !policy
166            .requires
167            .via
168            .contains(&super::record::ReceiptSource::MemGet)
169    {
170        warnings.push("warning: requires.freshness.fingerprint is unsatisfiable without MemGet; DbIntrospection receipts do not carry a content fingerprint.".into());
171    }
172    if policy.mode == super::record::PolicyMode::Block && policy.requires.key.is_empty() {
173        warnings.push(
174            "warning: block policy has an empty requires.key; nothing could ever unlock it.".into(),
175        );
176    }
177    warnings
178}
179
180fn now_secs() -> u64 {
181    SystemTime::now()
182        .duration_since(UNIX_EPOCH)
183        .unwrap_or_default()
184        .as_secs()
185}
186
187fn ensure_policy_key(key: &str) -> Result<()> {
188    if !key.starts_with("policy:") || key.len() == "policy:".len() {
189        return Err(PolicyOpError::InvalidKey {
190            key: key.to_string(),
191        }
192        .into());
193    }
194    Ok(())
195}
196
197fn audit_kind(kind: ControlChangeKind) -> &'static str {
198    match kind {
199        ControlChangeKind::Created => "control_created",
200        ControlChangeKind::Updated => "control_updated",
201        ControlChangeKind::Deleted => "control_deleted",
202        ControlChangeKind::Confirmed => "control_updated",
203    }
204}
205
206async fn audit(store: &Store, key: &str, kind: ControlChangeKind) {
207    if let Err(error) = record_event(
208        store,
209        EnforcementEventType::ControlChanged { change_kind: kind },
210        SubjectKind::Control,
211        key.to_string(),
212        "developer".to_string(),
213        None,
214        audit_kind(kind).to_string(),
215        None,
216    )
217    .await
218    {
219        tracing::warn!("policy_ops: enforcement event recording failed for {key}: {error}");
220    }
221}
222
223/// Build the neutral universal-record envelope for a policy payload.
224pub fn record_for(key: &str, policy: &PolicyRecord) -> Result<Record> {
225    ensure_policy_key(key)?;
226    let now = now_secs();
227    let source = RecordSource::DeveloperManual;
228    Ok(Record {
229        key: key.to_string(),
230        value: policy.rule.clone(),
231        category: Category::Policy,
232        priority: policy.severity.clone(),
233        tags: vec![],
234        created_at: now,
235        updated_at: now,
236        ref_url: None,
237        staleness: StalenessScore::fresh(),
238        lifecycle: RecordLifecycle::Active,
239        version: RecordVersion {
240            device_id: crate::store::stable_device_id(),
241            logical_clock: 1,
242            wall_clock: now,
243        },
244        quality: QualityScore::developer_entry_default(),
245        access_count: 0,
246        last_accessed: 0,
247        source: source.clone(),
248        confidence: ConfidenceScore::for_new_record(&source),
249        gap_analysis_score: 0.0,
250        payload: Some(serde_json::to_value(policy)?),
251    })
252}
253
254/// Create a policy. The canonical record write fails hard; the audit is best effort.
255///
256/// An existing *active* policy at this key is rejected. A *tombstoned* one is
257/// replaced by the fresh record, so a deleted slug can be recreated.
258pub async fn create(store: &Store, key: &str, policy: &PolicyRecord) -> Result<()> {
259    validate_trigger(&policy.trigger)?;
260    let record = record_for(key, policy)?;
261    if let Some(existing) = store.get(key).await? {
262        if matches!(existing.lifecycle, RecordLifecycle::Active) {
263            return Err(PolicyOpError::AlreadyExists {
264                key: key.to_string(),
265            }
266            .into());
267        }
268    }
269    store.put(key, &record).await?;
270    audit(store, key, ControlChangeKind::Created).await;
271    Ok(())
272}
273
274/// Replace an existing active policy payload.
275pub async fn edit(store: &Store, key: &str, policy: &PolicyRecord) -> Result<()> {
276    validate_trigger(&policy.trigger)?;
277    ensure_policy_key(key)?;
278    let mut record = store
279        .get(key)
280        .await?
281        .ok_or_else(|| PolicyOpError::NotFound {
282            key: key.to_string(),
283        })?;
284    if record.category != Category::Policy {
285        return Err(PolicyOpError::NotAPolicy {
286            key: key.to_string(),
287        }
288        .into());
289    }
290    if !matches!(record.lifecycle, RecordLifecycle::Active) {
291        return Err(PolicyOpError::NotActive {
292            key: key.to_string(),
293        }
294        .into());
295    }
296    record.value = policy.rule.clone();
297    record.priority = policy.severity.clone();
298    record.updated_at = now_secs();
299    record.version.logical_clock += 1;
300    record.version.wall_clock = record.updated_at;
301    record.payload = Some(serde_json::to_value(policy)?);
302    store.put(key, &record).await?;
303    audit(store, key, ControlChangeKind::Updated).await;
304    Ok(())
305}
306
307/// Enable or disable an active policy.
308pub async fn set_stage(store: &Store, key: &str, stage: PolicyStage) -> Result<()> {
309    ensure_policy_key(key)?;
310    let mut record = store
311        .get(key)
312        .await?
313        .ok_or_else(|| PolicyOpError::NotFound {
314            key: key.to_string(),
315        })?;
316    let mut policy =
317        record
318            .payload_as::<PolicyRecord>()
319            .ok_or_else(|| PolicyOpError::InvalidPayload {
320                key: key.to_string(),
321            })?;
322    if !matches!(record.lifecycle, RecordLifecycle::Active) {
323        return Err(PolicyOpError::NotActive {
324            key: key.to_string(),
325        }
326        .into());
327    }
328    policy.stage = stage;
329    record.payload = Some(serde_json::to_value(&policy)?);
330    record.updated_at = now_secs();
331    record.version.logical_clock += 1;
332    record.version.wall_clock = record.updated_at;
333    store.put(key, &record).await?;
334    audit(store, key, ControlChangeKind::Updated).await;
335    Ok(())
336}
337
338/// Tombstone a policy. The tombstone write fails hard; its audit event is best effort.
339pub async fn delete(store: &Store, key: &str) -> Result<()> {
340    ensure_policy_key(key)?;
341    let mut record = store
342        .get(key)
343        .await?
344        .ok_or_else(|| PolicyOpError::NotFound {
345            key: key.to_string(),
346        })?;
347    if !matches!(record.lifecycle, RecordLifecycle::Active) {
348        return Err(PolicyOpError::NotActive {
349            key: key.to_string(),
350        }
351        .into());
352    }
353    let now = now_secs();
354    record.lifecycle = RecordLifecycle::Tombstoned {
355        reason: TombstoneReason::ManualDeletion,
356        at: now,
357    };
358    record.updated_at = now;
359    record.version.logical_clock += 1;
360    record.version.wall_clock = now;
361    store.put(key, &record).await?;
362    audit(store, key, ControlChangeKind::Deleted).await;
363    Ok(())
364}
365
366pub async fn list(store: &Store) -> Result<Vec<Record>> {
367    Ok(store
368        .scan_prefix("policy:")
369        .await?
370        .into_iter()
371        .filter(|record| matches!(record.lifecycle, RecordLifecycle::Active))
372        .collect())
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use tempfile::TempDir;
379
380    fn sample() -> PolicyRecord {
381        PolicyRecord {
382            name: "Query safety".into(),
383            rule: "Consult the schema first.".into(),
384            reason: "Schemas drift because production changes independently.".into(),
385            scope: "repo".into(),
386            mode: super::super::record::PolicyMode::Block,
387            trigger: super::super::record::PolicyTrigger {
388                tool: Some("db_client".into()),
389                ..Default::default()
390            },
391            requires: super::super::record::PolicyRequires {
392                key: "schema:orders".into(),
393                via: vec![super::super::record::ReceiptSource::MemGet],
394                freshness: super::super::record::PolicyFreshness {
395                    ttl_secs: 900,
396                    fingerprint: false,
397                },
398            },
399            stage: PolicyStage::Enforce,
400            severity: super::super::record::Priority::High,
401            created_by: "developer".into(),
402        }
403    }
404
405    #[tokio::test]
406    async fn lifecycle_emits_control_events_and_tombstones() {
407        let dir = TempDir::new().unwrap();
408        let store = Store::open(dir.path()).await.unwrap();
409        let key = "policy:query-safety";
410        create(&store, key, &sample()).await.unwrap();
411        assert_eq!(list(&store).await.unwrap().len(), 1);
412        set_stage(&store, key, PolicyStage::Off).await.unwrap();
413        set_stage(&store, key, PolicyStage::Enforce).await.unwrap();
414        delete(&store, key).await.unwrap();
415        assert!(list(&store).await.unwrap().is_empty());
416        let events = super::super::enforcement::scan_enforcement_events(&store, 0, u64::MAX)
417            .await
418            .unwrap();
419        assert_eq!(events.len(), 4);
420        assert!(events
421            .iter()
422            .all(|event| event.subject_kind == SubjectKind::Control && event.subject_key == key));
423        let kinds: Vec<_> = events
424            .iter()
425            .filter_map(|event| match event.event_type {
426                EnforcementEventType::ControlChanged { change_kind } => Some(change_kind),
427                _ => None,
428            })
429            .collect();
430        assert_eq!(
431            kinds,
432            vec![
433                ControlChangeKind::Created,
434                ControlChangeKind::Updated,
435                ControlChangeKind::Updated,
436                ControlChangeKind::Deleted
437            ]
438        );
439    }
440
441    #[tokio::test]
442    async fn canonical_failure_happens_before_event() {
443        let dir = TempDir::new().unwrap();
444        let store = Store::open(dir.path()).await.unwrap();
445        let error = delete(&store, "policy:missing").await.unwrap_err();
446        assert!(error.to_string().contains("record not found"));
447        assert!(
448            super::super::enforcement::scan_enforcement_events(&store, 0, u64::MAX)
449                .await
450                .unwrap()
451                .is_empty()
452        );
453    }
454
455    #[tokio::test]
456    async fn edit_updates_payload_and_bumps_clock() {
457        let dir = TempDir::new().unwrap();
458        let store = Store::open(dir.path()).await.unwrap();
459        let key = "policy:query-safety";
460        create(&store, key, &sample()).await.unwrap();
461
462        let mut revised = sample();
463        revised.rule = "Always consult the schema before any write.".into();
464        revised.severity = super::super::record::Priority::Critical;
465        edit(&store, key, &revised).await.unwrap();
466
467        let record = store.get(key).await.unwrap().unwrap();
468        let policy = record.payload_as::<PolicyRecord>().unwrap();
469        assert_eq!(policy.rule, "Always consult the schema before any write.");
470        assert_eq!(record.priority, super::super::record::Priority::Critical);
471        assert_eq!(record.version.logical_clock, 2);
472
473        // editing a missing key fails hard
474        assert!(edit(&store, "policy:missing", &revised).await.is_err());
475    }
476
477    #[tokio::test]
478    async fn create_rejects_active_duplicate_but_resurrects_tombstone() {
479        let dir = TempDir::new().unwrap();
480        let store = Store::open(dir.path()).await.unwrap();
481        let key = "policy:dup";
482        create(&store, key, &sample()).await.unwrap();
483        // an active duplicate is rejected
484        assert!(create(&store, key, &sample()).await.is_err());
485        // after delete, the slug can be recreated
486        delete(&store, key).await.unwrap();
487        create(&store, key, &sample()).await.unwrap();
488        assert_eq!(list(&store).await.unwrap().len(), 1);
489    }
490
491    #[tokio::test]
492    async fn create_rejects_malformed_trigger_before_persisting() {
493        let dir = TempDir::new().unwrap();
494        let store = Store::open(dir.path()).await.unwrap();
495        let key = "policy:invalid-glob";
496        let mut policy = sample();
497        policy.trigger.host_glob = Some("[".into());
498
499        let error = create(&store, key, &policy).await.unwrap_err();
500
501        assert!(error.to_string().contains("invalid host_glob"));
502        assert!(store.get(key).await.unwrap().is_none());
503    }
504
505    /// A trigger JSON with a misspelled key used to deserialize into an
506    /// all-`None` trigger, which every predicate treats as a wildcard.
507    #[test]
508    fn trigger_json_rejects_unknown_fields() {
509        let error = serde_json::from_str::<super::super::record::PolicyTrigger>(
510            r#"{"tooool":"db_client"}"#,
511        )
512        .unwrap_err();
513        assert!(error.to_string().contains("unknown field `tooool`"));
514        assert!(serde_json::from_str::<super::super::record::PolicyTrigger>(
515            r#"{"tool":"db_client"}"#
516        )
517        .is_ok());
518    }
519
520    #[tokio::test]
521    async fn create_rejects_empty_trigger_before_persisting() {
522        let dir = TempDir::new().unwrap();
523        let store = Store::open(dir.path()).await.unwrap();
524        let key = "policy:matches-everything";
525        let mut policy = sample();
526        policy.trigger = Default::default();
527
528        let error = create(&store, key, &policy).await.unwrap_err();
529
530        assert!(error.to_string().contains("no predicate"));
531        assert!(store.get(key).await.unwrap().is_none());
532    }
533
534    #[test]
535    fn validate_trigger_accepts_any_single_predicate() {
536        for trigger in [
537            super::super::record::PolicyTrigger {
538                tool: Some("db_client".into()),
539                ..Default::default()
540            },
541            super::super::record::PolicyTrigger {
542                host_glob: Some("*prod*".into()),
543                ..Default::default()
544            },
545            super::super::record::PolicyTrigger {
546                target_path_glob: Some("**/*.sql".into()),
547                command_glob: None,
548                ..Default::default()
549            },
550            super::super::record::PolicyTrigger {
551                command_glob: Some("terraform destroy*".into()),
552                ..Default::default()
553            },
554        ] {
555            validate_trigger(&trigger).expect("one predicate is enough");
556        }
557        assert!(validate_trigger(&Default::default()).is_err());
558    }
559}