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, Provenance, TextPayload};
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(
214            self.key,
215            self.id,
216            TextPayload::new(self.content.into_string()),
217            provenance,
218        )
219    }
220
221    fn provenance(&self) -> Provenance {
222        let value = match &self.target_truth_id {
223            Some(truth_id) => format!(
224                "admission:{}:{}:{}:truth:{}",
225                self.actor.kind.as_str(),
226                self.actor.id,
227                self.source.as_str(),
228                truth_id
229            ),
230            None => format!(
231                "admission:{}:{}:{}",
232                self.actor.kind.as_str(),
233                self.actor.id,
234                self.source.as_str()
235            ),
236        };
237        Provenance::new(value)
238    }
239}
240
241/// Receipt returned after staging an admitted observation.
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub struct AdmissionReceipt {
244    key: ContextKey,
245    proposal_id: ProposalId,
246    content_hash: ContentHash,
247    target_truth_id: Option<TruthId>,
248    staged: bool,
249}
250
251impl AdmissionReceipt {
252    pub(crate) fn new(request: &AdmissionRequest, staged: bool) -> Self {
253        Self {
254            key: request.key,
255            proposal_id: request.id.clone(),
256            content_hash: content_hash(request.content.as_str()),
257            target_truth_id: request.target_truth_id.clone(),
258            staged,
259        }
260    }
261
262    /// Returns the target context key.
263    #[must_use]
264    pub fn key(&self) -> ContextKey {
265        self.key
266    }
267
268    /// Returns the staged proposal identifier.
269    #[must_use]
270    pub fn proposal_id(&self) -> &ProposalId {
271        &self.proposal_id
272    }
273
274    /// Returns the content hash for audit/idempotency checks.
275    #[must_use]
276    pub fn content_hash(&self) -> &ContentHash {
277        &self.content_hash
278    }
279
280    /// Returns the optional semantic target.
281    #[must_use]
282    pub fn target_truth_id(&self) -> Option<&TruthId> {
283        self.target_truth_id.as_ref()
284    }
285
286    /// Returns true if this call staged a new proposal.
287    #[must_use]
288    pub fn staged(&self) -> bool {
289        self.staged
290    }
291}
292
293fn content_hash(content: &str) -> ContentHash {
294    let mut hasher = Sha256::new();
295    hasher.update(content.as_bytes());
296    ContentHash::new(hasher.finalize().into())
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use crate::ContextState;
303    use converge_pack::Context as _;
304
305    fn actor() -> AdmissionActor {
306        AdmissionActor::new("organism-runtime", AdmissionActorKind::System).unwrap()
307    }
308
309    fn source() -> AdmissionSource {
310        AdmissionSource::new("truth-document").unwrap()
311    }
312
313    fn content(value: &str) -> AdmissionContent {
314        AdmissionContent::new(value).unwrap()
315    }
316
317    #[test]
318    fn admission_requires_actor_source_id_and_content() {
319        assert_eq!(
320            AdmissionActor::new("", AdmissionActorKind::System),
321            Err(AdmissionError::EmptyActorId)
322        );
323        assert_eq!(AdmissionSource::new(" "), Err(AdmissionError::EmptySource));
324        assert!(matches!(
325            AdmissionRequest::new(
326                actor(),
327                source(),
328                ContextKey::Seeds,
329                "",
330                content("claim payload")
331            ),
332            Err(AdmissionError::EmptyAdmissionId)
333        ));
334        assert_eq!(
335            AdmissionContent::new(" "),
336            Err(AdmissionError::EmptyContent)
337        );
338    }
339
340    #[test]
341    fn admission_stages_proposal_not_fact() {
342        let mut context = ContextState::new();
343        let request = AdmissionRequest::new(
344            actor(),
345            source(),
346            ContextKey::Seeds,
347            "truth-doc-1",
348            content(r#"{"claim":"approved source"}"#),
349        )
350        .unwrap()
351        .with_target_truth("truth-1");
352
353        let receipt = context.submit_observation(request).unwrap();
354
355        assert!(receipt.staged());
356        assert_eq!(receipt.key(), ContextKey::Seeds);
357        assert_eq!(receipt.proposal_id().as_str(), "truth-doc-1");
358        assert_eq!(
359            receipt.target_truth_id().map(TruthId::as_str),
360            Some("truth-1")
361        );
362        assert!(!context.has(ContextKey::Seeds));
363        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
364    }
365
366    #[test]
367    fn duplicate_admission_is_idempotent_when_payload_matches() {
368        let mut context = ContextState::new();
369        let request = AdmissionRequest::new(
370            actor(),
371            source(),
372            ContextKey::Seeds,
373            "truth-doc-1",
374            content("same payload"),
375        )
376        .unwrap();
377
378        let first = context.submit_observation(request.clone()).unwrap();
379        let second = context.submit_observation(request).unwrap();
380
381        assert!(first.staged());
382        assert!(!second.staged());
383        assert_eq!(first.content_hash(), second.content_hash());
384        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
385    }
386
387    #[test]
388    fn duplicate_admission_rejects_conflicting_payload() {
389        let mut context = ContextState::new();
390        let first = AdmissionRequest::new(
391            actor(),
392            source(),
393            ContextKey::Seeds,
394            "truth-doc-1",
395            content("first payload"),
396        )
397        .unwrap();
398        let second = AdmissionRequest::new(
399            actor(),
400            source(),
401            ContextKey::Seeds,
402            "truth-doc-1",
403            content("second payload"),
404        )
405        .unwrap();
406
407        context.submit_observation(first).unwrap();
408        let err = context.submit_observation(second).unwrap_err();
409
410        assert!(matches!(err, crate::ConvergeError::Conflict { .. }));
411        assert_eq!(context.get_proposals(ContextKey::Seeds).len(), 1);
412    }
413}