1use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13use crate::context::{ContextKey, ProposalId, ProposedFact};
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(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#[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 #[must_use]
258 pub fn key(&self) -> ContextKey {
259 self.key
260 }
261
262 #[must_use]
264 pub fn proposal_id(&self) -> &ProposalId {
265 &self.proposal_id
266 }
267
268 #[must_use]
270 pub fn content_hash(&self) -> &ContentHash {
271 &self.content_hash
272 }
273
274 #[must_use]
276 pub fn target_truth_id(&self) -> Option<&TruthId> {
277 self.target_truth_id.as_ref()
278 }
279
280 #[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}