Skip to main content

converge_core/
admission.rs

1// Copyright 2024-2026 Reflective Labs
2// SPDX-License-Identifier: MIT
3
4//! Typed admission boundary for external observations.
5//!
6//! Admission is not fact construction. External systems submit observations
7//! with actor and provenance; Converge stages them as proposals so the normal
8//! promotion gate remains the only path to governed facts.
9
10use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13use crate::context::{ContextKey, ProposalId, ProposedFact};
14use crate::types::{ActorId, ContentHash, TruthId};
15
16/// Error raised while constructing an admission request.
17#[derive(Debug, Clone, PartialEq, Eq, Error)]
18pub enum AdmissionError {
19    /// Actor identity must be present.
20    #[error("admission actor id must not be empty")]
21    EmptyActorId,
22    /// Observation source must be present.
23    #[error("admission source must not be empty")]
24    EmptySource,
25    /// Proposal/idempotency key must be present.
26    #[error("admission id must not be empty")]
27    EmptyAdmissionId,
28    /// Observation content must contain substantive payload.
29    #[error("admission content must not be empty")]
30    EmptyContent,
31}
32
33/// Actor class for an externally admitted observation.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AdmissionActorKind {
36    /// Human user.
37    Human,
38    /// Automated agent.
39    Agent,
40    /// System component.
41    System,
42    /// External service or integration.
43    External,
44}
45
46impl AdmissionActorKind {
47    fn as_str(self) -> &'static str {
48        match self {
49            Self::Human => "human",
50            Self::Agent => "agent",
51            Self::System => "system",
52            Self::External => "external",
53        }
54    }
55}
56
57/// Required actor metadata for an admission request.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct AdmissionActor {
60    id: ActorId,
61    kind: AdmissionActorKind,
62}
63
64impl AdmissionActor {
65    /// Creates a validated admission actor.
66    pub fn new(id: impl Into<ActorId>, kind: AdmissionActorKind) -> Result<Self, AdmissionError> {
67        let id = id.into();
68        if id.as_str().trim().is_empty() {
69            return Err(AdmissionError::EmptyActorId);
70        }
71        Ok(Self { id, kind })
72    }
73
74    /// Returns the actor identifier.
75    #[must_use]
76    pub fn id(&self) -> &ActorId {
77        &self.id
78    }
79
80    /// Returns the actor kind.
81    #[must_use]
82    pub fn kind(&self) -> AdmissionActorKind {
83        self.kind
84    }
85}
86
87/// Validated source label for admission provenance.
88#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct AdmissionSource(String);
90
91impl AdmissionSource {
92    /// Creates a validated source label.
93    pub fn new(value: impl Into<String>) -> Result<Self, AdmissionError> {
94        let value = value.into();
95        if value.trim().is_empty() {
96            return Err(AdmissionError::EmptySource);
97        }
98        Ok(Self(value))
99    }
100
101    /// Borrows the source label.
102    #[must_use]
103    pub fn as_str(&self) -> &str {
104        &self.0
105    }
106}
107
108/// Validated observation payload for admission.
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct AdmissionContent(String);
111
112impl AdmissionContent {
113    /// Creates a validated observation payload.
114    pub fn new(value: impl Into<String>) -> Result<Self, AdmissionError> {
115        let value = value.into();
116        if value.trim().is_empty() {
117            return Err(AdmissionError::EmptyContent);
118        }
119        Ok(Self(value))
120    }
121
122    /// Borrows the content payload.
123    #[must_use]
124    pub fn as_str(&self) -> &str {
125        &self.0
126    }
127
128    fn into_string(self) -> String {
129        self.0
130    }
131}
132
133/// Request to admit an external observation into the Converge truth pipeline.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct AdmissionRequest {
136    actor: AdmissionActor,
137    source: AdmissionSource,
138    key: ContextKey,
139    id: ProposalId,
140    content: AdmissionContent,
141    target_truth_id: Option<TruthId>,
142}
143
144impl AdmissionRequest {
145    /// Creates a validated admission request.
146    pub fn new(
147        actor: AdmissionActor,
148        source: AdmissionSource,
149        key: ContextKey,
150        id: impl Into<ProposalId>,
151        content: AdmissionContent,
152    ) -> Result<Self, AdmissionError> {
153        let id = id.into();
154        if id.as_str().trim().is_empty() {
155            return Err(AdmissionError::EmptyAdmissionId);
156        }
157
158        Ok(Self {
159            actor,
160            source,
161            key,
162            id,
163            content,
164            target_truth_id: None,
165        })
166    }
167
168    /// Attaches the semantic truth target this observation is intended to inform.
169    #[must_use]
170    pub fn with_target_truth(mut self, truth_id: impl Into<TruthId>) -> Self {
171        self.target_truth_id = Some(truth_id.into());
172        self
173    }
174
175    /// Returns the target context key.
176    #[must_use]
177    pub fn key(&self) -> ContextKey {
178        self.key
179    }
180
181    /// Returns the admission/proposal identifier.
182    #[must_use]
183    pub fn id(&self) -> &ProposalId {
184        &self.id
185    }
186
187    /// Returns the content payload.
188    #[must_use]
189    pub fn content(&self) -> &AdmissionContent {
190        &self.content
191    }
192
193    /// Returns the actor.
194    #[must_use]
195    pub fn actor(&self) -> &AdmissionActor {
196        &self.actor
197    }
198
199    /// Returns the source label.
200    #[must_use]
201    pub fn source(&self) -> &AdmissionSource {
202        &self.source
203    }
204
205    /// Returns the optional semantic target.
206    #[must_use]
207    pub fn target_truth_id(&self) -> Option<&TruthId> {
208        self.target_truth_id.as_ref()
209    }
210
211    pub(crate) fn into_proposal(self) -> ProposedFact {
212        let provenance = self.provenance();
213        ProposedFact::new(self.key, self.id, self.content.into_string(), provenance)
214    }
215
216    fn provenance(&self) -> String {
217        match &self.target_truth_id {
218            Some(truth_id) => format!(
219                "admission:{}:{}:{}:truth:{}",
220                self.actor.kind.as_str(),
221                self.actor.id,
222                self.source.as_str(),
223                truth_id
224            ),
225            None => format!(
226                "admission:{}:{}:{}",
227                self.actor.kind.as_str(),
228                self.actor.id,
229                self.source.as_str()
230            ),
231        }
232    }
233}
234
235/// Receipt returned after staging an admitted observation.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct AdmissionReceipt {
238    key: ContextKey,
239    proposal_id: ProposalId,
240    content_hash: ContentHash,
241    target_truth_id: Option<TruthId>,
242    staged: bool,
243}
244
245impl AdmissionReceipt {
246    pub(crate) fn new(request: &AdmissionRequest, staged: bool) -> Self {
247        Self {
248            key: request.key,
249            proposal_id: request.id.clone(),
250            content_hash: content_hash(request.content.as_str()),
251            target_truth_id: request.target_truth_id.clone(),
252            staged,
253        }
254    }
255
256    /// Returns the target context key.
257    #[must_use]
258    pub fn key(&self) -> ContextKey {
259        self.key
260    }
261
262    /// Returns the staged proposal identifier.
263    #[must_use]
264    pub fn proposal_id(&self) -> &ProposalId {
265        &self.proposal_id
266    }
267
268    /// Returns the content hash for audit/idempotency checks.
269    #[must_use]
270    pub fn content_hash(&self) -> &ContentHash {
271        &self.content_hash
272    }
273
274    /// Returns the optional semantic target.
275    #[must_use]
276    pub fn target_truth_id(&self) -> Option<&TruthId> {
277        self.target_truth_id.as_ref()
278    }
279
280    /// Returns true if this call staged a new proposal.
281    #[must_use]
282    pub fn staged(&self) -> bool {
283        self.staged
284    }
285}
286
287fn content_hash(content: &str) -> ContentHash {
288    let mut hasher = Sha256::new();
289    hasher.update(content.as_bytes());
290    ContentHash::new(hasher.finalize().into())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use crate::ContextState;
297    use converge_pack::Context as _;
298
299    fn actor() -> AdmissionActor {
300        AdmissionActor::new("organism-runtime", AdmissionActorKind::System).unwrap()
301    }
302
303    fn source() -> AdmissionSource {
304        AdmissionSource::new("truth-document").unwrap()
305    }
306
307    fn content(value: &str) -> AdmissionContent {
308        AdmissionContent::new(value).unwrap()
309    }
310
311    #[test]
312    fn admission_requires_actor_source_id_and_content() {
313        assert_eq!(
314            AdmissionActor::new("", AdmissionActorKind::System),
315            Err(AdmissionError::EmptyActorId)
316        );
317        assert_eq!(AdmissionSource::new(" "), Err(AdmissionError::EmptySource));
318        assert!(matches!(
319            AdmissionRequest::new(
320                actor(),
321                source(),
322                ContextKey::Seeds,
323                "",
324                content("claim payload")
325            ),
326            Err(AdmissionError::EmptyAdmissionId)
327        ));
328        assert_eq!(
329            AdmissionContent::new(" "),
330            Err(AdmissionError::EmptyContent)
331        );
332    }
333
334    #[test]
335    fn admission_stages_proposal_not_fact() {
336        let mut context = ContextState::new();
337        let request = AdmissionRequest::new(
338            actor(),
339            source(),
340            ContextKey::Seeds,
341            "truth-doc-1",
342            content(r#"{"claim":"approved source"}"#),
343        )
344        .unwrap()
345        .with_target_truth("truth-1");
346
347        let receipt = context.submit_observation(request).unwrap();
348
349        assert!(receipt.staged());
350        assert_eq!(receipt.key(), ContextKey::Seeds);
351        assert_eq!(receipt.proposal_id().as_str(), "truth-doc-1");
352        assert_eq!(
353            receipt.target_truth_id().map(TruthId::as_str),
354            Some("truth-1")
355        );
356        assert!(!context.has(ContextKey::Seeds));
357        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
358    }
359
360    #[test]
361    fn duplicate_admission_is_idempotent_when_payload_matches() {
362        let mut context = ContextState::new();
363        let request = AdmissionRequest::new(
364            actor(),
365            source(),
366            ContextKey::Seeds,
367            "truth-doc-1",
368            content("same payload"),
369        )
370        .unwrap();
371
372        let first = context.submit_observation(request.clone()).unwrap();
373        let second = context.submit_observation(request).unwrap();
374
375        assert!(first.staged());
376        assert!(!second.staged());
377        assert_eq!(first.content_hash(), second.content_hash());
378        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
379    }
380
381    #[test]
382    fn duplicate_admission_rejects_conflicting_payload() {
383        let mut context = ContextState::new();
384        let first = AdmissionRequest::new(
385            actor(),
386            source(),
387            ContextKey::Seeds,
388            "truth-doc-1",
389            content("first payload"),
390        )
391        .unwrap();
392        let second = AdmissionRequest::new(
393            actor(),
394            source(),
395            ContextKey::Seeds,
396            "truth-doc-1",
397            content("second payload"),
398        )
399        .unwrap();
400
401        context.submit_observation(first).unwrap();
402        let err = context.submit_observation(second).unwrap_err();
403
404        assert!(matches!(err, crate::ConvergeError::Conflict { .. }));
405        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
406    }
407}