1use sha2::{Digest, Sha256};
11use thiserror::Error;
12
13use crate::context::{ContextKey, ProposalId, ProposedFact, 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) -> 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#[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 #[must_use]
263 pub fn key(&self) -> ContextKey {
264 self.key
265 }
266
267 #[must_use]
269 pub fn proposal_id(&self) -> &ProposalId {
270 &self.proposal_id
271 }
272
273 #[must_use]
275 pub fn content_hash(&self) -> &ContentHash {
276 &self.content_hash
277 }
278
279 #[must_use]
281 pub fn target_truth_id(&self) -> Option<&TruthId> {
282 self.target_truth_id.as_ref()
283 }
284
285 #[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}