Skip to main content

kernel/
goal.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4// Copyright (c) 2026 2youg1 and the sprawling contributors
5
6//! Same-resource mutual exclusion. Detection is
7//! kernel's; arbitration is not — reading statements and judging intent
8//! belongs to models (collab::arbiter) and humans.
9//! The part `crates/kernel/spec/Goal.lean` specifies this module.
10
11use serde::{Deserialize, Serialize};
12
13use crate::address::Address;
14
15/// Non-empty goal identity; uniqueness bookkeeping is the caller's.
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18pub struct GoalId(String);
19
20impl GoalId {
21    pub fn new(raw: impl Into<String>) -> Option<GoalId> {
22        let raw = raw.into();
23        if raw.is_empty() {
24            None
25        } else {
26            Some(GoalId(raw))
27        }
28    }
29
30    pub fn as_str(&self) -> &str {
31        &self.0
32    }
33}
34
35/// What a goal claims. Paths conflict on prefix overlap either way;
36/// external names conflict on equality; the two kinds never conflict
37/// with each other.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum GoalResource {
41    Path(Address),
42    External(String),
43}
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct GoalEntry {
47    pub id: GoalId,
48    pub owner: String,
49    pub resources: Vec<GoalResource>,
50    pub statement: String,
51    pub standing: bool,
52}
53
54/// Deliberately exhaustive verdict.
55#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum GoalVerdict {
57    Clear,
58    Conflict { with: GoalId },
59}
60
61fn resources_clash(a: &GoalResource, b: &GoalResource) -> bool {
62    match (a, b) {
63        (GoalResource::Path(pa), GoalResource::Path(pb)) => pa.is_within(pb) || pb.is_within(pa),
64        (GoalResource::External(ea), GoalResource::External(eb)) => ea == eb,
65        _ => false,
66    }
67}
68
69/// First conflict wins, in the registered slice's order (deterministic:
70/// the caller's table order is the clock). A candidate re-submitting its
71/// own id is idempotent, not a conflict.
72pub fn detect_conflict(registered: &[GoalEntry], candidate: &GoalEntry) -> GoalVerdict {
73    for entry in registered {
74        if entry.id == candidate.id {
75            continue;
76        }
77        for held in &entry.resources {
78            for wanted in &candidate.resources {
79                if resources_clash(held, wanted) {
80                    return GoalVerdict::Conflict {
81                        with: entry.id.clone(),
82                    };
83                }
84            }
85        }
86    }
87    GoalVerdict::Clear
88}
89
90#[cfg(test)]
91#[allow(
92    clippy::unwrap_used,
93    clippy::expect_used,
94    clippy::panic,
95    clippy::indexing_slicing,
96    reason = "test code"
97)]
98mod tests {
99    use super::*;
100
101    fn entry(id: &str, resources: Vec<GoalResource>) -> GoalEntry {
102        GoalEntry {
103            id: GoalId::new(id).unwrap(),
104            owner: "worker@sim.1".into(),
105            resources,
106            statement: "hold".into(),
107            standing: true,
108        }
109    }
110
111    fn path(raw: &str) -> GoalResource {
112        GoalResource::Path(Address::parse(raw).unwrap())
113    }
114
115    #[test]
116    fn prefix_overlap_conflicts_both_ways() {
117        let held = entry("g1", vec![path("b/docs")]);
118        let narrower = entry("g2", vec![path("b/docs/ch1")]);
119        let wider = entry("g3", vec![path("b")]);
120        assert_eq!(
121            detect_conflict(std::slice::from_ref(&held), &narrower),
122            GoalVerdict::Conflict {
123                with: GoalId::new("g1").unwrap()
124            }
125        );
126        assert_eq!(
127            detect_conflict(&[held], &wider),
128            GoalVerdict::Conflict {
129                with: GoalId::new("g1").unwrap()
130            }
131        );
132    }
133
134    #[test]
135    fn externals_conflict_on_equality_only() {
136        let held = entry("g1", vec![GoalResource::External("crates.io/foo".into())]);
137        let same = entry("g2", vec![GoalResource::External("crates.io/foo".into())]);
138        let other = entry("g3", vec![GoalResource::External("crates.io/bar".into())]);
139        assert!(matches!(
140            detect_conflict(std::slice::from_ref(&held), &same),
141            GoalVerdict::Conflict { .. }
142        ));
143        assert_eq!(detect_conflict(&[held], &other), GoalVerdict::Clear);
144    }
145
146    #[test]
147    fn path_and_external_never_clash_and_resubmission_is_idempotent() {
148        let held = entry("g1", vec![path("b/docs")]);
149        let external = entry("g2", vec![GoalResource::External("b/docs".into())]);
150        assert_eq!(
151            detect_conflict(std::slice::from_ref(&held), &external),
152            GoalVerdict::Clear
153        );
154        let resubmitted = entry("g1", vec![path("b/docs")]);
155        assert_eq!(detect_conflict(&[held], &resubmitted), GoalVerdict::Clear);
156    }
157}