Skip to main content

eliciter_core/
engine.rs

1//! The kernel engine + session manager (spec §5 / §8).
2//!
3//! [`Engine`] is the single deterministic entry point: it validates each
4//! command payload, appends one event to the [`StateStore`], and recomputes the
5//! [`InterviewState`] projection by replay. Every read also projects. There is
6//! no in-memory mutable standing — replay is the source of truth, so restart-
7//! survival is free (spec §8).
8
9use std::collections::HashMap;
10use std::sync::{Arc, Mutex};
11
12use crate::error::{EngineError, Result};
13use crate::event::{Event, EvidenceEvent};
14use crate::model::{Claim, RemediationEvent, RemediationMove, RequirementMeta};
15use crate::projection::{self, InterviewState};
16use crate::session::CoverageSchema;
17use crate::signal::{Signal, next_question};
18use crate::store::{StateStore, validate_session_id};
19
20/// Per-session lock registry: a `session_id -> lock` map behind a `Mutex`.
21///
22/// The outer `Mutex` guards only the (brief) map lookup/insert; the per-session
23/// `Arc<Mutex<()>>` it hands back is what actually serializes a session's
24/// read-modify-write critical section. Holding the per-session lock — not the
25/// map lock — across the replay/check/append keeps DIFFERENT sessions fully
26/// concurrent (spec §4/§8 isolation) while making SAME-session writes mutually
27/// exclusive, so the dup/existence checks are race-free.
28type LockMap = Mutex<HashMap<String, Arc<Mutex<()>>>>;
29
30/// The kernel engine. Cheap to clone (the store and lock map are `Arc`s; clones
31/// share the same per-session locks).
32#[derive(Clone)]
33pub struct Engine {
34    store: Arc<dyn StateStore>,
35    /// Shared across clones so concurrent operations on the same `session_id`
36    /// — even via different `Engine` clones — contend on one lock.
37    session_locks: Arc<LockMap>,
38}
39
40impl Engine {
41    /// Build an engine over the given store.
42    pub fn new(store: Arc<dyn StateStore>) -> Self {
43        Self {
44            store,
45            session_locks: Arc::new(Mutex::new(HashMap::new())),
46        }
47    }
48
49    /// Fetch (or create) the per-session lock. The outer map lock is held only
50    /// for the lookup/insert, never across the critical section.
51    fn session_lock(&self, session_id: &str) -> Arc<Mutex<()>> {
52        let mut map = self.session_locks.lock().unwrap_or_else(|p| p.into_inner());
53        Arc::clone(
54            map.entry(session_id.to_string())
55                .or_insert_with(|| Arc::new(Mutex::new(()))),
56        )
57    }
58
59    /// `session.open` (spec §5): start or resume a session. If new, write the
60    /// `SessionOpened` event with the supplied (or default) coverage schema. If
61    /// it already exists, the schema arg is ignored (the original governs) and
62    /// the current state is returned.
63    pub fn open_session(
64        &self,
65        session_id: &str,
66        coverage_schema: Option<CoverageSchema>,
67    ) -> Result<InterviewState> {
68        validate_session_id(session_id)?;
69        let lock = self.session_lock(session_id);
70        let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
71        if !self.store.exists(session_id)? {
72            let event = Event::SessionOpened {
73                session_id: session_id.to_string(),
74                coverage_schema: coverage_schema.unwrap_or_default(),
75            };
76            self.store.append(session_id, &event)?;
77        }
78        self.state(session_id)
79    }
80
81    /// `state.get` (spec §5): read-only projection of the session.
82    pub fn state(&self, session_id: &str) -> Result<InterviewState> {
83        validate_session_id(session_id)?;
84        let events = self.store.replay(session_id)?;
85        Ok(projection::project(&events))
86    }
87
88    /// `append` variant `add_claim` (spec §5). Validates the claim, rejects
89    /// duplicate ids, appends, and returns the recomputed state.
90    pub fn add_claim(
91        &self,
92        session_id: &str,
93        claim: Claim,
94        requirement: Option<RequirementMeta>,
95    ) -> Result<InterviewState> {
96        validate_session_id(session_id)?;
97        let lock = self.session_lock(session_id);
98        let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
99        let events = self.require_session(session_id)?;
100        validate_claim(&claim, session_id)?;
101        if events
102            .iter()
103            .any(|e| matches!(e, Event::ClaimAdded { claim: c, .. } if c.id == claim.id))
104        {
105            return Err(EngineError::DuplicateId(claim.id));
106        }
107        self.store.append(
108            session_id,
109            &Event::ClaimAdded {
110                claim: Box::new(claim),
111                requirement,
112            },
113        )?;
114        self.state(session_id)
115    }
116
117    /// `append` variant `add_evidence` (spec §5).
118    pub fn add_evidence(
119        &self,
120        session_id: &str,
121        evidence: EvidenceEvent,
122    ) -> Result<InterviewState> {
123        validate_session_id(session_id)?;
124        let lock = self.session_lock(session_id);
125        let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
126        let events = self.require_session(session_id)?;
127        if evidence.id.is_empty() {
128            return Err(EngineError::InvalidEvidence("empty evidence id".into()));
129        }
130        if evidence.session_id != session_id {
131            return Err(EngineError::InvalidEvidence(format!(
132                "session_id mismatch: payload '{}' vs '{session_id}'",
133                evidence.session_id
134            )));
135        }
136        if !claim_exists(&events, &evidence.claim_id) {
137            return Err(EngineError::ClaimNotFound(evidence.claim_id));
138        }
139        if duplicate_event_id(&events, &evidence.id) {
140            return Err(EngineError::DuplicateId(evidence.id));
141        }
142        self.store
143            .append(session_id, &Event::EvidenceAdded { evidence })?;
144        self.state(session_id)
145    }
146
147    /// `append` variant `remediate{...}` (spec §5). Validates that every target
148    /// referenced by the move exists, then appends.
149    pub fn remediate(
150        &self,
151        session_id: &str,
152        remediation: RemediationEvent,
153    ) -> Result<InterviewState> {
154        validate_session_id(session_id)?;
155        let lock = self.session_lock(session_id);
156        let _guard = lock.lock().unwrap_or_else(|p| p.into_inner());
157        let events = self.require_session(session_id)?;
158        if remediation.id.is_empty() {
159            return Err(EngineError::InvalidRemediation(
160                "empty remediation id".into(),
161            ));
162        }
163        if remediation.session_id != session_id {
164            return Err(EngineError::InvalidRemediation(format!(
165                "session_id mismatch: payload '{}' vs '{session_id}'",
166                remediation.session_id
167            )));
168        }
169        if duplicate_event_id(&events, &remediation.id) {
170            return Err(EngineError::DuplicateId(remediation.id));
171        }
172        validate_remediation(&events, &remediation)?;
173        self.store
174            .append(session_id, &Event::Remediated { remediation })?;
175        self.state(session_id)
176    }
177
178    /// `question.next` (spec §5): the highest-leverage next question, or `None`.
179    pub fn next_question(&self, session_id: &str) -> Result<Option<Signal>> {
180        let state = self.state(session_id)?;
181        Ok(next_question(&state.signals).cloned())
182    }
183
184    /// `readiness.assess` (spec §5 / §6): the readiness report.
185    pub fn readiness(&self, session_id: &str) -> Result<crate::readiness::ReadinessReport> {
186        Ok(self.state(session_id)?.readiness)
187    }
188
189    /// `spec.export` (spec §6): the aligned, plan-ready spec document.
190    pub fn export(&self, session_id: &str) -> Result<crate::export::SpecDocument> {
191        let state = self.state(session_id)?;
192        Ok(crate::export::build(&state))
193    }
194
195    fn require_session(&self, session_id: &str) -> Result<Vec<Event>> {
196        if !self.store.exists(session_id)? {
197            return Err(EngineError::SessionNotFound(session_id.to_string()));
198        }
199        self.store.replay(session_id)
200    }
201}
202
203// --- validation helpers ----------------------------------------------------
204
205fn validate_claim(claim: &Claim, session_id: &str) -> Result<()> {
206    if claim.id.is_empty() {
207        return Err(EngineError::InvalidClaim("empty claim id".into()));
208    }
209    if claim.session_id != session_id {
210        return Err(EngineError::InvalidClaim(format!(
211            "session_id mismatch: payload '{}' vs '{session_id}'",
212            claim.session_id
213        )));
214    }
215    if claim.subject.id.is_empty() {
216        return Err(EngineError::InvalidClaim("empty subject id".into()));
217    }
218    if claim.predicate.is_empty() {
219        return Err(EngineError::InvalidClaim("empty predicate token".into()));
220    }
221    Ok(())
222}
223
224fn claim_exists(events: &[Event], claim_id: &str) -> bool {
225    events
226        .iter()
227        .any(|e| matches!(e, Event::ClaimAdded { claim, .. } if claim.id == claim_id))
228}
229
230fn duplicate_event_id(events: &[Event], id: &str) -> bool {
231    events.iter().any(|e| match e {
232        Event::ClaimAdded { claim, .. } => claim.id == id,
233        Event::EvidenceAdded { evidence } => evidence.id == id,
234        Event::Remediated { remediation } => remediation.id == id,
235        Event::SessionOpened { .. } => false,
236    })
237}
238
239fn validate_remediation(events: &[Event], remediation: &RemediationEvent) -> Result<()> {
240    let claim_ok = |id: &str| claim_exists(events, id);
241    match &remediation.r#move {
242        RemediationMove::Supersede { old, new } => {
243            if old == new {
244                return Err(EngineError::InvalidRemediation(format!(
245                    "supersede old and new must differ (both '{old}')"
246                )));
247            }
248            if !claim_ok(old) {
249                return Err(EngineError::ClaimNotFound(old.clone()));
250            }
251            if !claim_ok(new) {
252                return Err(EngineError::ClaimNotFound(new.clone()));
253            }
254        }
255        RemediationMove::Retract { claim }
256        | RemediationMove::Qualify { claim, .. }
257        | RemediationMove::Accept { target: claim, .. } => {
258            if !claim_ok(claim) {
259                return Err(EngineError::ClaimNotFound(claim.clone()));
260            }
261        }
262        RemediationMove::ReconcileByEvidence { contradiction } => {
263            // Validate the contradiction currently exists in the projection.
264            let state = projection::project(events);
265            if !state.contradictions.iter().any(|c| &c.id == contradiction) {
266                return Err(EngineError::ContradictionNotFound(contradiction.clone()));
267            }
268        }
269    }
270    Ok(())
271}