1use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13use crate::context::{ContextKey, ProposalId, ProposedFact, Provenance, TextPayload};
14use crate::types::{ActorId, ContentHash, TruthId};
15
16#[derive(Debug, Clone, PartialEq, Eq, Error)]
18pub enum AdmissionError {
19 #[error("admission actor id must not be empty")]
21 EmptyActorId,
22 #[error("admission source must not be empty")]
24 EmptySource,
25 #[error("admission id must not be empty")]
27 EmptyAdmissionId,
28 #[error("admission content must not be empty")]
30 EmptyContent,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum AdmissionActorKind {
36 Human,
38 Agent,
40 System,
42 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#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct AdmissionActor {
60 id: ActorId,
61 kind: AdmissionActorKind,
62}
63
64impl AdmissionActor {
65 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 #[must_use]
76 pub fn id(&self) -> &ActorId {
77 &self.id
78 }
79
80 #[must_use]
82 pub fn kind(&self) -> AdmissionActorKind {
83 self.kind
84 }
85}
86
87#[derive(Debug, Clone, PartialEq, Eq)]
89pub struct AdmissionSource(String);
90
91impl AdmissionSource {
92 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 #[must_use]
103 pub fn as_str(&self) -> &str {
104 &self.0
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
110pub struct AdmissionContent(String);
111
112impl AdmissionContent {
113 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 #[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#[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 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 #[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 #[must_use]
177 pub fn key(&self) -> ContextKey {
178 self.key
179 }
180
181 #[must_use]
183 pub fn id(&self) -> &ProposalId {
184 &self.id
185 }
186
187 #[must_use]
189 pub fn content(&self) -> &AdmissionContent {
190 &self.content
191 }
192
193 #[must_use]
195 pub fn actor(&self) -> &AdmissionActor {
196 &self.actor
197 }
198
199 #[must_use]
201 pub fn source(&self) -> &AdmissionSource {
202 &self.source
203 }
204
205 #[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#[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 #[must_use]
264 pub fn key(&self) -> ContextKey {
265 self.key
266 }
267
268 #[must_use]
270 pub fn proposal_id(&self) -> &ProposalId {
271 &self.proposal_id
272 }
273
274 #[must_use]
276 pub fn content_hash(&self) -> &ContentHash {
277 &self.content_hash
278 }
279
280 #[must_use]
282 pub fn target_truth_id(&self) -> Option<&TruthId> {
283 self.target_truth_id.as_ref()
284 }
285
286 #[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}