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