1use std::{collections::BTreeMap, sync::Arc};
4
5use exo_api::dagdb::{
6 ConsentPurpose, DagDbGraphContextPacket, DagDbGraphContextSelectionResponse, ReceiptEventType,
7 SubjectKind,
8};
9use exo_consent::ConsentError;
10use exo_core::{Hash256, PublicKey, Signature, Timestamp, crypto};
11use exo_dag_db_core::hash::ReceiptHashMaterial;
12use exo_dag_db_domain::{
13 context_packet_persistence::ContextPacketRecord,
14 continuation_persistence::{ContinuationPersistResult, ContinuationRecord},
15 default_route::DefaultRouteRecord,
16 lifecycle_action::{LifecycleAction, LifecycleApplyResult},
17 scoring::{DomainError, hash_event_body},
18};
19use exo_dag_db_exchange::kg_import::{hash_from_hex, stable_hash};
20use exo_dag_db_postgres::postgres::{
21 context_packet_persistence::persist_context_packet_record,
22 continuation_persistence::persist_continuation_record,
23 default_route::persist_default_route,
24 kg_context_selection_write::{
25 DbWriteSummary, UsageEventMemoryMetadata, persist_context_packet_receipt_to_db,
26 persist_usage_event_to_db, persist_usage_event_to_db_with_metadata,
27 },
28 lifecycle_action::persist_lifecycle_action,
29};
30use exo_identity::error::IdentityError;
31use sqlx::PgPool;
32use tracing::warn;
33
34use crate::{
35 error::GatekeeperError,
36 invariants::{
37 ConstitutionalInvariant, InvariantContext, InvariantEngine, InvariantSet, enforce_all,
38 },
39 types::{BailmentState, ConsentRecord, GovernedRoleName, Role},
40};
41
42const USAGE_EVENT_MEMORY_ID_DOMAIN: &str =
43 "exo.dagdb.graph_context_selection.usage_event.memory_id";
44const DAGDB_WRITE_SIGNATURE_DOMAIN: &str = "exo.gatekeeper.dagdb_write_signature.v1";
45const CREATED_AT: Timestamp = Timestamp::new(1, 0);
46const WRITER_DID: &str = "did:exo:dagdb-context-selection-writer";
47
48const LIFECYCLE_ACTION_SUBJECT_DOMAIN: &str = "exo.dagdb.lifecycle_action.subject_id.v1";
52const DEFAULT_ROUTE_SUBJECT_DOMAIN: &str = "exo.dagdb.default_route.subject_id.v1";
53const CONTINUATION_SUBJECT_DOMAIN: &str = "exo.dagdb.continuation_record.subject_id.v1";
54const CONTEXT_PACKET_RECORD_SUBJECT_DOMAIN: &str = "exo.dagdb.context_packet_record.subject_id.v1";
55
56#[must_use]
85fn dagdb_invariant_set() -> InvariantSet {
86 InvariantSet::with(vec![
87 ConstitutionalInvariant::ConsentRequired,
88 ConstitutionalInvariant::SeparationOfPowers,
89 ConstitutionalInvariant::NoSelfGrant,
90 ConstitutionalInvariant::HumanOverride,
91 ConstitutionalInvariant::KernelImmutability,
92 ConstitutionalInvariant::QuorumLegitimate,
93 ])
94}
95
96#[derive(Debug, Clone, Default)]
98pub struct ConsentEngine {
99 bailments: BTreeMap<String, BailmentState>,
100 records: BTreeMap<(String, String, ConsentPurpose), DagDbConsentRecord>,
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct DagDbConsentRecord {
106 pub tenant_id: String,
107 pub agent_did: String,
108 pub purpose: ConsentPurpose,
109 pub active: bool,
110}
111
112impl ConsentEngine {
113 #[must_use]
115 pub fn with_bailment(mut self, tenant_id: impl Into<String>, state: BailmentState) -> Self {
116 self.bailments.insert(tenant_id.into(), state);
117 self
118 }
119
120 #[must_use]
122 pub fn with_consent_record(mut self, record: DagDbConsentRecord) -> Self {
123 let key = (
124 record.tenant_id.clone(),
125 record.agent_did.clone(),
126 record.purpose,
127 );
128 self.records.insert(key, record);
129 self
130 }
131
132 fn bailment_for(&self, tenant_id: &str) -> Option<&BailmentState> {
133 self.bailments.get(tenant_id)
134 }
135
136 #[must_use]
139 pub fn bailment_state(&self, tenant_id: &str) -> BailmentState {
140 self.bailment_for(tenant_id)
141 .cloned()
142 .unwrap_or(BailmentState::None)
143 }
144
145 fn consent_for(
146 &self,
147 tenant_id: &str,
148 agent_did: &str,
149 purpose: ConsentPurpose,
150 ) -> Option<&DagDbConsentRecord> {
151 self.records
152 .get(&(tenant_id.to_owned(), agent_did.to_owned(), purpose))
153 }
154}
155
156#[derive(Debug, Clone, Default)]
158pub struct IdentityRegistry {
159 keys: BTreeMap<String, [u8; 32]>,
160 roles: BTreeMap<String, Vec<Role>>,
161}
162
163impl IdentityRegistry {
164 #[must_use]
166 pub fn with_public_key(mut self, agent_did: impl Into<String>, public_key: [u8; 32]) -> Self {
167 self.keys.insert(agent_did.into(), public_key);
168 self
169 }
170
171 #[must_use]
173 pub fn with_role(mut self, agent_did: impl Into<String>, role: Role) -> Self {
174 self.roles.entry(agent_did.into()).or_default().push(role);
175 self
176 }
177
178 #[must_use]
180 pub fn with_governed_role(
181 self,
182 agent_did: impl Into<String>,
183 role_name: GovernedRoleName,
184 ) -> Self {
185 self.with_role(agent_did, Role::governed(role_name))
186 }
187
188 fn public_key_for(&self, agent_did: &str) -> Option<&[u8; 32]> {
189 self.keys.get(agent_did)
190 }
191
192 #[must_use]
195 pub fn has_production_finality_authority(&self, authority_did: &str) -> bool {
196 self.roles
197 .get(authority_did)
198 .is_some_and(|roles| roles.iter().any(role_can_issue_production_finality))
199 }
200}
201
202fn role_can_issue_production_finality(role: &Role) -> bool {
203 matches!(
204 role.validate_governed(),
205 Ok(GovernedRoleName::Operator
206 | GovernedRoleName::Executive
207 | GovernedRoleName::ExecutiveAdmin
208 | GovernedRoleName::Judge
209 | GovernedRoleName::TransitionJudge)
210 )
211}
212
213pub fn verify_write_consent(
215 engine: &ConsentEngine,
216 tenant_id: &str,
217 agent_did: &str,
218 purpose: ConsentPurpose,
219) -> Result<bool, ConsentError> {
220 let Some(bailment) = engine.bailment_for(tenant_id) else {
221 return Err(ConsentError::NoConsent(format!(
222 "no bailment for tenant {tenant_id}"
223 )));
224 };
225 if !bailment.is_active() {
226 return Err(ConsentError::Denied(format!(
227 "bailment inactive for tenant {tenant_id}"
228 )));
229 }
230 if purpose == ConsentPurpose::Writeback && !bailment.authorizes_writeback(agent_did) {
234 return Err(ConsentError::Denied(format!(
235 "bailment does not authorize {agent_did} for writeback on tenant {tenant_id}"
236 )));
237 }
238 let Some(record) = engine.consent_for(tenant_id, agent_did, purpose) else {
239 return Err(ConsentError::NoConsent(format!(
240 "no consent record for {agent_did} purpose {purpose:?}"
241 )));
242 };
243 if !record.active {
244 return Err(ConsentError::Denied(format!(
245 "consent inactive for {agent_did} purpose {purpose:?}"
246 )));
247 }
248 Ok(true)
249}
250
251pub fn verify_write_signature(
253 registry: &IdentityRegistry,
254 payload_hash: &[u8; 32],
255 signature: &str,
256 agent_did: &str,
257) -> Result<bool, IdentityError> {
258 let did =
259 exo_core::Did::new(agent_did).map_err(|_| IdentityError::InvalidDidDocumentField {
260 did: agent_did.to_owned(),
261 field: "did".into(),
262 reason: "invalid agent DID".into(),
263 })?;
264 let public_key_bytes = registry
265 .public_key_for(agent_did)
266 .ok_or(IdentityError::KeyNotFound(did))?;
267 let signature_bytes = decode_ed25519_signature_hex(signature)?;
268 let message = dagdb_write_signature_message(payload_hash)?;
269 let public_key = PublicKey::from_bytes(*public_key_bytes);
270 let sig = Signature::from_bytes(signature_bytes);
271 Ok(crypto::verify(message.as_bytes(), &sig, &public_key))
272}
273
274pub fn verify_production_finality_authority(
277 registry: &IdentityRegistry,
278 requesting_agent_did: &str,
279 authority_did: &str,
280 payload_hash: &[u8; 32],
281 signature: &str,
282) -> Result<bool, IdentityError> {
283 if authority_did.trim().is_empty() || authority_did == requesting_agent_did {
284 return Ok(false);
285 }
286 if !registry.has_production_finality_authority(authority_did) {
287 return Ok(false);
288 }
289 verify_write_signature(registry, payload_hash, signature, authority_did)
290}
291
292pub struct DagDbGatekeeperService {
294 pub pool: PgPool,
295 pub consent_engine: Arc<ConsentEngine>,
296 pub identity_registry: Arc<IdentityRegistry>,
297}
298
299impl DagDbGatekeeperService {
300 #[must_use]
302 pub fn new(
303 pool: PgPool,
304 consent_engine: Arc<ConsentEngine>,
305 identity_registry: Arc<IdentityRegistry>,
306 ) -> Self {
307 Self {
308 pool,
309 consent_engine,
310 identity_registry,
311 }
312 }
313
314 #[must_use]
328 pub fn dagdb_invariant_context(
329 &self,
330 tenant_id: &str,
331 agent_did: &str,
332 ) -> Option<InvariantContext> {
333 let actor = exo_core::Did::new(agent_did).ok()?;
334 let bailment_state = self.consent_engine.bailment_state(tenant_id);
335 let consent_records = match &bailment_state {
339 BailmentState::Active {
340 bailor,
341 bailee,
342 scope,
343 } => vec![ConsentRecord {
344 subject: bailor.clone(),
345 granted_to: bailee.clone(),
346 scope: scope.clone(),
347 active: true,
348 }],
349 _ => Vec::new(),
350 };
351 Some(InvariantContext {
352 actor,
353 actor_roles: Vec::new(),
356 bailment_state,
357 consent_records,
358 authority_chain: Default::default(),
361 is_self_grant: false,
362 human_override_preserved: true,
365 kernel_modification_attempted: false,
367 quorum_evidence: None,
370 provenance: None,
373 actor_permissions: Default::default(),
376 requested_permissions: Default::default(),
377 trusted_authority_keys: Default::default(),
378 trusted_provenance_keys: Default::default(),
379 })
380 }
381
382 pub async fn persist_usage_event(
384 &self,
385 event: &DagDbGraphContextSelectionResponse,
386 agent_did: &str,
387 signature: &str,
388 invariant_context: Option<&InvariantContext>,
389 ) -> Result<DbWriteSummary, GatekeeperError> {
390 let payload_hash = usage_event_payload_hash(event)?;
391 self.validate_write(
392 &event.tenant_id,
393 agent_did,
394 ConsentPurpose::Writeback,
395 &payload_hash,
396 signature,
397 invariant_context,
398 )?;
399 persist_usage_event_to_db(&self.pool, event)
400 .await
401 .map_err(domain_to_gatekeeper)
402 }
403
404 pub async fn persist_usage_event_with_metadata(
406 &self,
407 event: &DagDbGraphContextSelectionResponse,
408 agent_did: &str,
409 signature: &str,
410 invariant_context: Option<&InvariantContext>,
411 metadata: Option<&UsageEventMemoryMetadata>,
412 ) -> Result<DbWriteSummary, GatekeeperError> {
413 let payload_hash = usage_event_payload_hash(event)?;
414 self.validate_write(
415 &event.tenant_id,
416 agent_did,
417 ConsentPurpose::Writeback,
418 &payload_hash,
419 signature,
420 invariant_context,
421 )?;
422 persist_usage_event_to_db_with_metadata(&self.pool, event, metadata)
423 .await
424 .map_err(domain_to_gatekeeper)
425 }
426
427 pub fn validate_usage_event_write(
430 &self,
431 event: &DagDbGraphContextSelectionResponse,
432 agent_did: &str,
433 signature: &str,
434 invariant_context: Option<&InvariantContext>,
435 ) -> Result<(), GatekeeperError> {
436 let payload_hash = usage_event_payload_hash(event)?;
437 self.validate_write(
438 &event.tenant_id,
439 agent_did,
440 ConsentPurpose::Writeback,
441 &payload_hash,
442 signature,
443 invariant_context,
444 )
445 }
446
447 pub async fn persist_context_packet_receipt(
449 &self,
450 packet: &DagDbGraphContextPacket,
451 agent_did: &str,
452 signature: &str,
453 invariant_context: Option<&InvariantContext>,
454 ) -> Result<DbWriteSummary, GatekeeperError> {
455 let payload_hash = context_packet_payload_hash(packet)?;
456 self.validate_write(
457 &packet.tenant_id,
458 agent_did,
459 ConsentPurpose::Writeback,
460 &payload_hash,
461 signature,
462 invariant_context,
463 )?;
464 persist_context_packet_receipt_to_db(&self.pool, packet)
465 .await
466 .map_err(domain_to_gatekeeper)
467 }
468
469 pub async fn persist_lifecycle_action(
483 &self,
484 action: &LifecycleAction,
485 agent_did: &str,
486 signature: &str,
487 invariant_context: Option<&InvariantContext>,
488 ) -> Result<LifecycleApplyResult, GatekeeperError> {
489 let payload_hash = lifecycle_action_payload_hash(action)?;
490 self.validate_write(
491 &action.tenant_id,
492 agent_did,
493 ConsentPurpose::Writeback,
494 &payload_hash,
495 signature,
496 invariant_context,
497 )?;
498 persist_lifecycle_action(&self.pool, action)
499 .await
500 .map_err(|error| domain_blocked("lifecycle_action_postgres", &error))
501 }
502
503 pub async fn persist_default_route(
508 &self,
509 route: &DefaultRouteRecord,
510 agent_did: &str,
511 signature: &str,
512 invariant_context: Option<&InvariantContext>,
513 ) -> Result<u64, GatekeeperError> {
514 let payload_hash = default_route_payload_hash(route)?;
515 self.validate_write(
516 &route.tenant_id,
517 agent_did,
518 ConsentPurpose::Writeback,
519 &payload_hash,
520 signature,
521 invariant_context,
522 )?;
523 persist_default_route(&self.pool, route)
524 .await
525 .map_err(|error| domain_blocked("default_route_postgres", &error))
526 }
527
528 pub async fn persist_continuation_record(
533 &self,
534 record: &ContinuationRecord,
535 now_epoch_seconds: u64,
536 agent_did: &str,
537 signature: &str,
538 invariant_context: Option<&InvariantContext>,
539 ) -> Result<ContinuationPersistResult, GatekeeperError> {
540 let payload_hash = continuation_record_payload_hash(record)?;
541 self.validate_write(
542 &record.tenant_id,
543 agent_did,
544 ConsentPurpose::Writeback,
545 &payload_hash,
546 signature,
547 invariant_context,
548 )?;
549 persist_continuation_record(&self.pool, record, now_epoch_seconds)
550 .await
551 .map_err(|error| domain_blocked("continuation_postgres", &error))
552 }
553
554 pub async fn persist_context_packet_record(
559 &self,
560 record: &ContextPacketRecord,
561 agent_did: &str,
562 signature: &str,
563 invariant_context: Option<&InvariantContext>,
564 ) -> Result<u64, GatekeeperError> {
565 let payload_hash = context_packet_record_payload_hash(record)?;
566 self.validate_write(
567 &record.tenant_id,
568 agent_did,
569 ConsentPurpose::Writeback,
570 &payload_hash,
571 signature,
572 invariant_context,
573 )?;
574 persist_context_packet_record(&self.pool, record)
575 .await
576 .map_err(|error| domain_blocked("context_packet_record_postgres", &error))
577 }
578
579 fn validate_write(
580 &self,
581 tenant_id: &str,
582 agent_did: &str,
583 purpose: ConsentPurpose,
584 payload_hash: &[u8; 32],
585 signature: &str,
586 invariant_context: Option<&InvariantContext>,
587 ) -> Result<(), GatekeeperError> {
588 match verify_write_consent(self.consent_engine.as_ref(), tenant_id, agent_did, purpose) {
589 Ok(true) => {}
590 Ok(false) => {
591 return Err(consent_gatekeeper_error(
592 tenant_id,
593 agent_did,
594 "consent verification returned false",
595 ));
596 }
597 Err(error) => {
598 log_invariant_violation(
599 ConstitutionalInvariant::ConsentRequired,
600 tenant_id,
601 agent_did,
602 &error.to_string(),
603 );
604 return Err(consent_gatekeeper_error(
605 tenant_id,
606 agent_did,
607 &error.to_string(),
608 ));
609 }
610 }
611
612 match verify_write_signature(
613 self.identity_registry.as_ref(),
614 payload_hash,
615 signature,
616 agent_did,
617 ) {
618 Ok(true) => {}
619 Ok(false) => {
620 log_invariant_violation(
621 ConstitutionalInvariant::ProvenanceVerifiable,
622 tenant_id,
623 agent_did,
624 "signature verification returned false",
625 );
626 return Err(provenance_gatekeeper_error(
627 tenant_id,
628 agent_did,
629 "invalid Ed25519 signature",
630 ));
631 }
632 Err(error) => {
633 log_invariant_violation(
634 ConstitutionalInvariant::ProvenanceVerifiable,
635 tenant_id,
636 agent_did,
637 &error.to_string(),
638 );
639 return Err(provenance_gatekeeper_error(
640 tenant_id,
641 agent_did,
642 &error.to_string(),
643 ));
644 }
645 }
646
647 if let Some(context) = invariant_context {
648 let engine = InvariantEngine::new(dagdb_invariant_set());
655 let violations = enforce_all(&engine, context);
656 if let Err(violations) = violations {
657 let detail = violations
658 .iter()
659 .map(|v| format!("{}: {}", v.invariant.id(), v.description))
660 .collect::<Vec<_>>()
661 .join("; ");
662 for violation in &violations {
663 log_invariant_violation(
664 violation.invariant,
665 tenant_id,
666 agent_did,
667 &violation.description,
668 );
669 }
670 return Err(GatekeeperError::InvariantViolation(detail));
671 }
672 }
673 Ok(())
674 }
675}
676
677pub fn sign_write_payload(
679 keypair: &exo_core::crypto::KeyPair,
680 payload_hash: &[u8; 32],
681) -> Result<String, IdentityError> {
682 let message = dagdb_write_signature_message(payload_hash)?;
683 Ok(format!("{}", keypair.sign(message.as_ref())))
684}
685
686pub fn usage_event_payload_hash(
688 event: &DagDbGraphContextSelectionResponse,
689) -> Result<[u8; 32], GatekeeperError> {
690 let memory_id = stable_hash(
691 USAGE_EVENT_MEMORY_ID_DOMAIN,
692 &[
693 &event.tenant_id,
694 &event.namespace,
695 &event.request_id,
696 &event.task_hash,
697 ],
698 )
699 .map_err(|error| GatekeeperError::Core(error.to_string()))?;
700 let event_body_hash = hash_event_body(event).map_err(domain_to_gatekeeper)?;
701 let receipt_hash = ReceiptHashMaterial {
702 tenant_id: event.tenant_id.clone(),
703 namespace: event.namespace.clone(),
704 subject_kind: SubjectKind::Memory,
705 subject_id: memory_id,
706 prev_receipt_hash: Hash256::ZERO,
707 seq: 1,
708 event_type: ReceiptEventType::IntakeCreated,
709 actor_did: WRITER_DID.to_owned(),
710 event_hlc: CREATED_AT,
711 event_body_hash,
712 }
713 .hash()
714 .map_err(|error| GatekeeperError::Core(error.to_string()))?;
715 Ok(*receipt_hash.as_bytes())
716}
717
718pub fn context_packet_payload_hash(
720 packet: &DagDbGraphContextPacket,
721) -> Result<[u8; 32], GatekeeperError> {
722 let subject_id = hash_from_hex("packet_hash", &packet.packet_hash)
723 .map_err(|_| GatekeeperError::InvariantViolation("invalid context packet hash".into()))?;
724 let event_body_hash = hash_event_body(packet).map_err(domain_to_gatekeeper)?;
725 let receipt_hash = ReceiptHashMaterial {
726 tenant_id: packet.tenant_id.clone(),
727 namespace: packet.namespace.clone(),
728 subject_kind: SubjectKind::ContextPacket,
729 subject_id,
730 prev_receipt_hash: Hash256::ZERO,
731 seq: 1,
732 event_type: ReceiptEventType::ContextPacketCreated,
733 actor_did: WRITER_DID.to_owned(),
734 event_hlc: CREATED_AT,
735 event_body_hash,
736 }
737 .hash()
738 .map_err(|error| GatekeeperError::Core(error.to_string()))?;
739 Ok(*receipt_hash.as_bytes())
740}
741
742pub fn lifecycle_action_payload_hash(
748 action: &LifecycleAction,
749) -> Result<[u8; 32], GatekeeperError> {
750 let idempotency_key = action
751 .idempotency_key()
752 .map_err(|error| GatekeeperError::InvariantViolation(error.to_string()))?;
753 surface_payload_hash(
754 LIFECYCLE_ACTION_SUBJECT_DOMAIN,
755 &action.tenant_id,
756 &action.memory_namespace,
757 SubjectKind::Memory,
758 ReceiptEventType::MemorySuperseded,
759 &[
760 &action.tenant_id,
761 &action.memory_namespace,
762 &action.action_id,
763 &idempotency_key,
764 ],
765 action,
766 )
767}
768
769pub fn default_route_payload_hash(route: &DefaultRouteRecord) -> Result<[u8; 32], GatekeeperError> {
772 surface_payload_hash(
773 DEFAULT_ROUTE_SUBJECT_DOMAIN,
774 &route.tenant_id,
775 &route.memory_namespace,
776 SubjectKind::Route,
777 ReceiptEventType::RouteCreated,
778 &[&route.tenant_id, &route.memory_namespace, &route.route_id],
779 route,
780 )
781}
782
783pub fn continuation_record_payload_hash(
786 record: &ContinuationRecord,
787) -> Result<[u8; 32], GatekeeperError> {
788 let idempotency_key = record
789 .idempotency_key()
790 .map_err(|error| GatekeeperError::InvariantViolation(error.to_string()))?;
791 surface_payload_hash(
792 CONTINUATION_SUBJECT_DOMAIN,
793 &record.tenant_id,
794 &record.memory_namespace,
795 SubjectKind::Memory,
796 ReceiptEventType::IntakeCreated,
797 &[
798 &record.tenant_id,
799 &record.memory_namespace,
800 &record.continuation_id,
801 &idempotency_key,
802 ],
803 record,
804 )
805}
806
807pub fn context_packet_record_payload_hash(
810 record: &ContextPacketRecord,
811) -> Result<[u8; 32], GatekeeperError> {
812 surface_payload_hash(
813 CONTEXT_PACKET_RECORD_SUBJECT_DOMAIN,
814 &record.tenant_id,
815 &record.memory_namespace,
816 SubjectKind::ContextPacket,
817 ReceiptEventType::ContextPacketCreated,
818 &[
819 &record.tenant_id,
820 &record.memory_namespace,
821 &record.packet_id,
822 &record.idempotency_key,
823 ],
824 record,
825 )
826}
827
828fn surface_payload_hash<T: serde::Serialize>(
833 subject_domain: &str,
834 tenant_id: &str,
835 namespace: &str,
836 subject_kind: SubjectKind,
837 event_type: ReceiptEventType,
838 subject_parts: &[&str],
839 body: &T,
840) -> Result<[u8; 32], GatekeeperError> {
841 let subject_id = stable_hash(subject_domain, subject_parts)
842 .map_err(|error| GatekeeperError::Core(error.to_string()))?;
843 let event_body_hash = hash_event_body(body).map_err(domain_to_gatekeeper)?;
844 let receipt_hash = ReceiptHashMaterial {
845 tenant_id: tenant_id.to_owned(),
846 namespace: namespace.to_owned(),
847 subject_kind,
848 subject_id,
849 prev_receipt_hash: Hash256::ZERO,
850 seq: 1,
851 event_type,
852 actor_did: WRITER_DID.to_owned(),
853 event_hlc: CREATED_AT,
854 event_body_hash,
855 }
856 .hash()
857 .map_err(|error| GatekeeperError::Core(error.to_string()))?;
858 Ok(*receipt_hash.as_bytes())
859}
860
861fn dagdb_write_signature_message(payload_hash: &[u8; 32]) -> Result<Hash256, IdentityError> {
862 exo_core::hash::hash_structured(&DagDbWriteSignaturePayload {
863 domain: DAGDB_WRITE_SIGNATURE_DOMAIN,
864 payload_hash,
865 })
866 .map_err(|error| IdentityError::InvalidDidDocumentField {
867 did: "dagdb-write".into(),
868 field: "signature_payload".into(),
869 reason: error.to_string(),
870 })
871}
872
873#[derive(serde::Serialize)]
874struct DagDbWriteSignaturePayload<'a> {
875 domain: &'static str,
876 payload_hash: &'a [u8; 32],
877}
878
879fn decode_ed25519_signature_hex(signature: &str) -> Result<[u8; 64], IdentityError> {
880 let bytes = hex::decode(signature).map_err(|error| IdentityError::InvalidDidDocumentField {
881 did: "dagdb-write".into(),
882 field: "signature".into(),
883 reason: format!("hex decode failed: {error}"),
884 })?;
885 bytes
886 .try_into()
887 .map_err(|bytes: Vec<u8>| IdentityError::InvalidDidDocumentField {
888 did: "dagdb-write".into(),
889 field: "signature".into(),
890 reason: format!("expected 64 bytes, got {}", bytes.len()),
891 })
892}
893
894fn consent_gatekeeper_error(tenant_id: &str, agent_did: &str, reason: &str) -> GatekeeperError {
895 GatekeeperError::InvariantViolation(format!(
896 "ConsentRequired: tenant={tenant_id} actor={agent_did} reason={reason}"
897 ))
898}
899
900fn provenance_gatekeeper_error(tenant_id: &str, agent_did: &str, reason: &str) -> GatekeeperError {
901 GatekeeperError::InvariantViolation(format!(
902 "ProvenanceVerifiable: tenant={tenant_id} actor={agent_did} reason={reason}"
903 ))
904}
905
906fn domain_to_gatekeeper(error: DomainError) -> GatekeeperError {
907 GatekeeperError::InvariantViolation(format!("dagdb write blocked: {error}"))
908}
909
910fn domain_blocked<E: std::error::Error>(surface: &str, error: &E) -> GatekeeperError {
918 let rendered = error.to_string();
919 let is_db_unavailable = rendered.contains("postgres_failed")
920 || rendered.contains("sql_failed")
921 || surface_error_source_is_sqlx(error);
922 if is_db_unavailable {
923 GatekeeperError::InvariantViolation(format!(
924 "dagdb write blocked: surface_database_unavailable surface={surface} detail={rendered}"
925 ))
926 } else {
927 GatekeeperError::InvariantViolation(format!(
928 "dagdb write blocked: metadata rejected surface={surface} detail={rendered}"
929 ))
930 }
931}
932
933fn surface_error_source_is_sqlx<E: std::error::Error>(error: &E) -> bool {
937 let mut current: Option<&(dyn std::error::Error + 'static)> = error.source();
938 while let Some(source) = current {
939 if source.is::<sqlx::Error>() {
940 return true;
941 }
942 current = source.source();
943 }
944 false
945}
946
947fn log_invariant_violation(
948 invariant: ConstitutionalInvariant,
949 tenant_id: &str,
950 agent_did: &str,
951 reason: &str,
952) {
953 warn!(
954 validation_status = "denied",
955 invariant = invariant.id(),
956 tenant_id,
957 actor_did = agent_did,
958 reason,
959 "InvariantViolated"
960 );
961}
962
963#[cfg(test)]
964mod tests {
965 use exo_api::dagdb::{
966 ConsentPurpose, DagDbGraphContextPacket, DagDbGraphContextSelectionResponse,
967 DagDbGraphContextSelectionStatus,
968 };
969 use exo_core::crypto::KeyPair;
970
971 use super::*;
972 use crate::{
973 invariants::ConstitutionalInvariant,
974 types::{BailmentState, GovernmentBranch, Role},
975 };
976
977 fn active_consent_engine(tenant_id: &str, agent_did: &str) -> ConsentEngine {
978 ConsentEngine::default()
979 .with_bailment(
980 tenant_id,
981 BailmentState::Active {
982 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
983 bailee: exo_core::Did::new(agent_did).expect("bailee"),
984 scope: "dag-db:writeback".into(),
985 },
986 )
987 .with_consent_record(DagDbConsentRecord {
988 tenant_id: tenant_id.to_owned(),
989 agent_did: agent_did.to_owned(),
990 purpose: ConsentPurpose::Writeback,
991 active: true,
992 })
993 }
994
995 #[test]
996 fn verify_write_consent_requires_active_bailment_and_record() {
997 let engine = active_consent_engine("tenant-a", "did:exo:agent");
998 assert!(
999 verify_write_consent(
1000 &engine,
1001 "tenant-a",
1002 "did:exo:agent",
1003 ConsentPurpose::Writeback
1004 )
1005 .expect("consent ok")
1006 );
1007
1008 let missing = ConsentEngine::default().with_consent_record(DagDbConsentRecord {
1009 tenant_id: "tenant-a".into(),
1010 agent_did: "did:exo:agent".into(),
1011 purpose: ConsentPurpose::Writeback,
1012 active: true,
1013 });
1014 assert!(
1015 verify_write_consent(
1016 &missing,
1017 "tenant-a",
1018 "did:exo:agent",
1019 ConsentPurpose::Writeback
1020 )
1021 .is_err()
1022 );
1023 }
1024
1025 #[test]
1026 fn verify_write_signature_accepts_valid_ed25519() {
1027 let keypair = KeyPair::generate();
1028 let payload_hash = [7u8; 32];
1029 let message = dagdb_write_signature_message(&payload_hash).expect("message");
1030 let signature = keypair.sign(message.as_bytes());
1031 let signature_hex = format!("{signature}");
1032 let registry = IdentityRegistry::default()
1033 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes());
1034 assert!(
1035 verify_write_signature(®istry, &payload_hash, &signature_hex, "did:exo:agent")
1036 .expect("verify ok")
1037 );
1038 }
1039
1040 #[test]
1041 fn verify_write_signature_rejects_forged_signature() {
1042 let keypair = KeyPair::generate();
1043 let other = KeyPair::generate();
1044 let payload_hash = [9u8; 32];
1045 let message = dagdb_write_signature_message(&payload_hash).expect("message");
1046 let forged = other.sign(message.as_bytes());
1047 let forged_hex = format!("{forged}");
1048 let registry = IdentityRegistry::default()
1049 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes());
1050 assert!(
1051 !verify_write_signature(®istry, &payload_hash, &forged_hex, "did:exo:agent")
1052 .expect("verify completes")
1053 );
1054 }
1055
1056 #[test]
1057 fn separation_of_powers_blocks_multi_branch_actor() {
1058 let engine = InvariantEngine::new(crate::invariants::InvariantSet::with(vec![
1059 ConstitutionalInvariant::SeparationOfPowers,
1060 ]));
1061 let actor = exo_core::Did::new("did:exo:agent").expect("actor");
1062 let context = InvariantContext {
1063 actor: actor.clone(),
1064 actor_roles: vec![
1065 Role {
1066 name: "senator".into(),
1067 branch: GovernmentBranch::Legislative,
1068 },
1069 Role {
1070 name: "executor".into(),
1071 branch: GovernmentBranch::Executive,
1072 },
1073 ],
1074 bailment_state: BailmentState::Active {
1075 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
1076 bailee: actor,
1077 scope: "dag-db".into(),
1078 },
1079 consent_records: Vec::new(),
1080 authority_chain: Default::default(),
1081 is_self_grant: false,
1082 human_override_preserved: true,
1083 kernel_modification_attempted: false,
1084 quorum_evidence: None,
1085 provenance: None,
1086 actor_permissions: Default::default(),
1087 requested_permissions: Default::default(),
1088 trusted_authority_keys: Default::default(),
1089 trusted_provenance_keys: Default::default(),
1090 };
1091 assert!(enforce_all(&engine, &context).is_err());
1092 }
1093
1094 fn sample_selection() -> DagDbGraphContextSelectionResponse {
1095 DagDbGraphContextSelectionResponse {
1096 tenant_id: "tenant-a".into(),
1097 namespace: "primary".into(),
1098 request_id: "req-gate-1".into(),
1099 task_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
1100 selection_status: DagDbGraphContextSelectionStatus::Selected,
1101 selected_memory_refs: Vec::new(),
1102 selected_graph_edges: Vec::new(),
1103 omitted_memory_refs: Vec::new(),
1104 selection_trace: Vec::new(),
1105 selected_token_estimate: 0,
1106 token_budget: 1_000,
1107 boundary_warnings: Vec::new(),
1108 }
1109 }
1110
1111 fn sample_packet() -> DagDbGraphContextPacket {
1112 DagDbGraphContextPacket {
1113 schema_version: "dagdb.graph_context_packet.v1".into(),
1114 tenant_id: "tenant-a".into(),
1115 namespace: "primary".into(),
1116 request_id: "req-gate-1".into(),
1117 task: "Build packet".into(),
1118 task_hash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(),
1119 packet_hash: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".into(),
1120 selected_memory_refs: Vec::new(),
1121 selected_graph_edges: Vec::new(),
1122 citation_refs: Vec::new(),
1123 packet_metrics: exo_api::dagdb::DagDbContextPacketMetrics {
1124 token_budget: 1_000,
1125 selected_token_estimate: 0,
1126 selected_memory_ref_count: 0,
1127 selected_graph_edge_count: 0,
1128 citation_ref_count: 0,
1129 end_to_end_savings_status: "blocked".into(),
1130 cost_savings_status: "blocked".into(),
1131 },
1132 boundaries: exo_api::dagdb::DagDbContextPacketBoundaries {
1133 repository_test_level_only: true,
1134 production_runtime: "blocked".into(),
1135 default_context_replacement: "blocked".into(),
1136 citation_locator_status: "omitted_citation_locator_blocked".into(),
1137 billing_savings: "blocked".into(),
1138 },
1139 agent_usage_instructions: Vec::new(),
1140 markdown: "# packet".into(),
1141 }
1142 }
1143
1144 #[test]
1145 fn verify_write_consent_requires_consent_record_for_purpose() {
1146 let bailment_only = ConsentEngine::default().with_bailment(
1147 "tenant-a",
1148 BailmentState::Active {
1149 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
1150 bailee: exo_core::Did::new("did:exo:agent").expect("bailee"),
1151 scope: "dag-db:writeback".into(),
1152 },
1153 );
1154 assert!(
1155 verify_write_consent(
1156 &bailment_only,
1157 "tenant-a",
1158 "did:exo:agent",
1159 ConsentPurpose::Writeback
1160 )
1161 .is_err()
1162 );
1163 }
1164
1165 #[test]
1166 fn verify_write_consent_rejects_inactive_bailment_and_consent() {
1167 let inactive_bailment = ConsentEngine::default()
1168 .with_bailment(
1169 "tenant-a",
1170 BailmentState::Suspended {
1171 reason: "test".into(),
1172 },
1173 )
1174 .with_consent_record(DagDbConsentRecord {
1175 tenant_id: "tenant-a".into(),
1176 agent_did: "did:exo:agent".into(),
1177 purpose: ConsentPurpose::Writeback,
1178 active: true,
1179 });
1180 assert!(
1181 verify_write_consent(
1182 &inactive_bailment,
1183 "tenant-a",
1184 "did:exo:agent",
1185 ConsentPurpose::Writeback
1186 )
1187 .is_err()
1188 );
1189
1190 let inactive_consent = active_consent_engine("tenant-a", "did:exo:agent")
1191 .with_consent_record(DagDbConsentRecord {
1192 tenant_id: "tenant-a".into(),
1193 agent_did: "did:exo:agent".into(),
1194 purpose: ConsentPurpose::Writeback,
1195 active: false,
1196 });
1197 assert!(
1198 verify_write_consent(
1199 &inactive_consent,
1200 "tenant-a",
1201 "did:exo:agent",
1202 ConsentPurpose::Writeback
1203 )
1204 .is_err()
1205 );
1206 }
1207
1208 #[test]
1209 fn verify_write_consent_rejects_active_bailment_for_other_bailee() {
1210 let engine = ConsentEngine::default()
1213 .with_bailment(
1214 "tenant-a",
1215 BailmentState::Active {
1216 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
1217 bailee: exo_core::Did::new("did:exo:other-agent").expect("bailee"),
1218 scope: "dag-db:writeback".into(),
1219 },
1220 )
1221 .with_consent_record(DagDbConsentRecord {
1222 tenant_id: "tenant-a".into(),
1223 agent_did: "did:exo:agent".into(),
1224 purpose: ConsentPurpose::Writeback,
1225 active: true,
1226 });
1227 assert!(
1228 verify_write_consent(
1229 &engine,
1230 "tenant-a",
1231 "did:exo:agent",
1232 ConsentPurpose::Writeback
1233 )
1234 .is_err()
1235 );
1236 }
1237
1238 #[test]
1239 fn verify_write_consent_rejects_active_bailment_with_wrong_scope() {
1240 let engine = ConsentEngine::default()
1242 .with_bailment(
1243 "tenant-a",
1244 BailmentState::Active {
1245 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
1246 bailee: exo_core::Did::new("did:exo:agent").expect("bailee"),
1247 scope: "dag-db:retrieval".into(),
1248 },
1249 )
1250 .with_consent_record(DagDbConsentRecord {
1251 tenant_id: "tenant-a".into(),
1252 agent_did: "did:exo:agent".into(),
1253 purpose: ConsentPurpose::Writeback,
1254 active: true,
1255 });
1256 assert!(
1257 verify_write_consent(
1258 &engine,
1259 "tenant-a",
1260 "did:exo:agent",
1261 ConsentPurpose::Writeback
1262 )
1263 .is_err()
1264 );
1265 }
1266
1267 #[test]
1268 fn verify_write_signature_rejects_invalid_hex_and_did() {
1269 let keypair = KeyPair::generate();
1270 let registry = IdentityRegistry::default()
1271 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes());
1272 let payload_hash = [1u8; 32];
1273 assert!(
1274 verify_write_signature(®istry, &payload_hash, "not-hex", "did:exo:agent").is_err()
1275 );
1276 assert!(verify_write_signature(®istry, &payload_hash, "abcd", "not-a-did").is_err());
1277 let short_hex = "aa".repeat(32);
1278 assert!(
1279 verify_write_signature(®istry, &payload_hash, &short_hex, "did:exo:agent").is_err()
1280 );
1281 }
1282
1283 #[test]
1284 fn sign_write_payload_and_payload_hash_helpers_are_deterministic() {
1285 let keypair = KeyPair::generate();
1286 let selection = sample_selection();
1287 let payload_hash = usage_event_payload_hash(&selection).expect("usage payload hash");
1288 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1289 let again = sign_write_payload(&keypair, &payload_hash).expect("signature again");
1290 assert_eq!(signature, again);
1291
1292 let packet = sample_packet();
1293 let packet_hash = context_packet_payload_hash(&packet).expect("packet payload hash");
1294 assert_ne!(payload_hash, packet_hash);
1295 }
1296
1297 #[test]
1298 fn context_packet_payload_hash_rejects_invalid_packet_hash() {
1299 let mut packet = sample_packet();
1300 packet.packet_hash = "invalid".into();
1301 assert!(context_packet_payload_hash(&packet).is_err());
1302 }
1303
1304 fn lazy_postgres_pool() -> sqlx::PgPool {
1305 use std::time::Duration;
1306
1307 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
1308
1309 if let Ok(database_url) = std::env::var("EXO_DAGDB_TEST_DATABASE_URL") {
1310 return PgPoolOptions::new()
1311 .connect_lazy(&database_url)
1312 .expect("lazy postgres pool");
1313 }
1314 let options = PgConnectOptions::new()
1315 .host("127.0.0.1")
1316 .port(1)
1317 .username("postgres")
1318 .database("postgres");
1319 PgPoolOptions::new()
1320 .acquire_timeout(Duration::from_millis(100))
1321 .connect_lazy_with(options)
1322 }
1323
1324 #[tokio::test]
1325 async fn persist_usage_event_fails_before_db_when_consent_missing() {
1326 use std::sync::Arc;
1327
1328 let pool = lazy_postgres_pool();
1329 let service = DagDbGatekeeperService::new(
1330 pool,
1331 Arc::new(ConsentEngine::default()),
1332 Arc::new(IdentityRegistry::default()),
1333 );
1334 let event = sample_selection();
1335 let err = service
1336 .persist_usage_event(&event, "did:exo:agent", &"aa".repeat(64), None)
1337 .await
1338 .expect_err("missing consent must fail closed");
1339 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1340 }
1341
1342 #[test]
1343 fn authority_resolver_unavailable_is_a_distinct_fail_closed_variant() {
1344 let error = GatekeeperError::AuthorityResolverUnavailable("pool absent".into());
1351 assert!(matches!(
1352 error,
1353 GatekeeperError::AuthorityResolverUnavailable(_)
1354 ));
1355 assert!(!matches!(error, GatekeeperError::InvariantViolation(_)));
1356 assert!(error.to_string().contains("authority resolver unavailable"));
1357 }
1358
1359 #[tokio::test]
1360 async fn persist_usage_event_fails_before_db_when_signature_invalid() {
1361 use std::sync::Arc;
1362
1363 let keypair = KeyPair::generate();
1364 let other = KeyPair::generate();
1365 let event = sample_selection();
1366 let payload_hash = usage_event_payload_hash(&event).expect("payload hash");
1367 let forged = other.sign(
1368 dagdb_write_signature_message(&payload_hash)
1369 .expect("message")
1370 .as_bytes(),
1371 );
1372 let registry = Arc::new(
1373 IdentityRegistry::default()
1374 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes()),
1375 );
1376 let pool = lazy_postgres_pool();
1377 let service = DagDbGatekeeperService::new(
1378 pool,
1379 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1380 registry,
1381 );
1382 let err = service
1383 .persist_usage_event(&event, "did:exo:agent", &format!("{forged}"), None)
1384 .await
1385 .expect_err("invalid signature must fail closed");
1386 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1387 }
1388
1389 #[tokio::test]
1390 async fn persist_usage_event_maps_db_errors_after_validation() {
1391 use std::sync::Arc;
1392
1393 let keypair = KeyPair::generate();
1394 let event = sample_selection();
1395 let payload_hash = usage_event_payload_hash(&event).expect("payload hash");
1396 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1397 let registry = Arc::new(
1398 IdentityRegistry::default()
1399 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes()),
1400 );
1401 let service = DagDbGatekeeperService::new(
1402 lazy_postgres_pool(),
1403 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1404 registry,
1405 );
1406 let err = service
1407 .persist_usage_event(&event, "did:exo:agent", &signature, None)
1408 .await
1409 .expect_err("unscoped lazy pool must fail closed at db layer");
1410 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1411 }
1412
1413 #[test]
1414 fn log_invariant_violation_emits_structured_warning() {
1415 log_invariant_violation(
1416 ConstitutionalInvariant::ConsentRequired,
1417 "tenant-a",
1418 "did:exo:agent",
1419 "coverage fixture",
1420 );
1421 }
1422
1423 #[tokio::test]
1424 async fn persist_usage_event_fails_before_db_when_signature_decode_errors() {
1425 use std::sync::Arc;
1426
1427 let keypair = KeyPair::generate();
1428 let event = sample_selection();
1429 let registry = Arc::new(
1430 IdentityRegistry::default()
1431 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes()),
1432 );
1433 let service = DagDbGatekeeperService::new(
1434 lazy_postgres_pool(),
1435 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1436 registry,
1437 );
1438 let err = service
1439 .persist_usage_event(&event, "did:exo:agent", "not-hex", None)
1440 .await
1441 .expect_err("malformed signature must fail closed");
1442 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1443 assert!(err.to_string().contains("ProvenanceVerifiable"));
1444 }
1445
1446 #[tokio::test]
1447 async fn persist_context_packet_receipt_maps_db_errors_after_validation() {
1448 use std::sync::Arc;
1449
1450 let keypair = KeyPair::generate();
1451 let packet = sample_packet();
1452 let payload_hash = context_packet_payload_hash(&packet).expect("payload hash");
1453 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1454 let registry = Arc::new(
1455 IdentityRegistry::default()
1456 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes()),
1457 );
1458 let service = DagDbGatekeeperService::new(
1459 lazy_postgres_pool(),
1460 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1461 registry,
1462 );
1463 let err = service
1464 .persist_context_packet_receipt(&packet, "did:exo:agent", &signature, None)
1465 .await
1466 .expect_err("unscoped lazy pool must fail closed at db layer");
1467 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1468 }
1469
1470 #[tokio::test]
1471 async fn persist_context_packet_receipt_fails_before_db_when_invariants_violated() {
1472 use std::sync::Arc;
1473
1474 let keypair = KeyPair::generate();
1475 let packet = sample_packet();
1476 let payload_hash = context_packet_payload_hash(&packet).expect("payload hash");
1477 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1478 let registry = Arc::new(
1479 IdentityRegistry::default()
1480 .with_public_key("did:exo:agent", *keypair.public_key().as_bytes()),
1481 );
1482 let pool = lazy_postgres_pool();
1483 let service = DagDbGatekeeperService::new(
1484 pool,
1485 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1486 registry,
1487 );
1488 let actor = exo_core::Did::new("did:exo:agent").expect("actor");
1489 let invariant_context = InvariantContext {
1490 actor: actor.clone(),
1491 actor_roles: vec![
1492 Role {
1493 name: "senator".into(),
1494 branch: GovernmentBranch::Legislative,
1495 },
1496 Role {
1497 name: "executor".into(),
1498 branch: GovernmentBranch::Executive,
1499 },
1500 ],
1501 bailment_state: BailmentState::Active {
1502 bailor: exo_core::Did::new("did:exo:bailor").expect("bailor"),
1503 bailee: actor,
1504 scope: "dag-db".into(),
1505 },
1506 consent_records: Vec::new(),
1507 authority_chain: Default::default(),
1508 is_self_grant: false,
1509 human_override_preserved: true,
1510 kernel_modification_attempted: false,
1511 quorum_evidence: None,
1512 provenance: None,
1513 actor_permissions: Default::default(),
1514 requested_permissions: Default::default(),
1515 trusted_authority_keys: Default::default(),
1516 trusted_provenance_keys: Default::default(),
1517 };
1518 let err = service
1519 .persist_context_packet_receipt(
1520 &packet,
1521 "did:exo:agent",
1522 &signature,
1523 Some(&invariant_context),
1524 )
1525 .await
1526 .expect_err("invariant violation must fail closed");
1527 assert!(matches!(err, GatekeeperError::InvariantViolation(_)));
1528 }
1529
1530 #[test]
1531 fn dagdb_invariant_set_excludes_authority_chain_and_provenance() {
1532 let set = dagdb_invariant_set();
1536 assert!(
1537 set.invariants
1538 .contains(&ConstitutionalInvariant::ConsentRequired)
1539 );
1540 assert!(
1541 !set.invariants
1542 .contains(&ConstitutionalInvariant::AuthorityChainValid),
1543 "AuthorityChainValid must be excluded from the dag-db engine subset"
1544 );
1545 assert!(
1546 !set.invariants
1547 .contains(&ConstitutionalInvariant::ProvenanceVerifiable),
1548 "ProvenanceVerifiable is enforced directly via Ed25519, not the engine"
1549 );
1550 }
1551
1552 #[tokio::test]
1553 async fn dagdb_invariant_context_from_active_consent_passes_subset_engine() {
1554 use crate::invariants::{InvariantEngine, enforce_all};
1555
1556 let service = DagDbGatekeeperService::new(
1557 lazy_postgres_pool(),
1558 Arc::new(active_consent_engine("tenant-a", "did:exo:agent")),
1559 Arc::new(IdentityRegistry::default()),
1560 );
1561 let context = service
1562 .dagdb_invariant_context("tenant-a", "did:exo:agent")
1563 .expect("context for valid DID");
1564 let engine = InvariantEngine::new(dagdb_invariant_set());
1568 assert!(
1569 enforce_all(&engine, &context).is_ok(),
1570 "active-consent dag-db context must satisfy the enforced invariant subset"
1571 );
1572 }
1573
1574 #[tokio::test]
1575 async fn dagdb_invariant_context_without_bailment_fails_consent_required() {
1576 use crate::invariants::{InvariantEngine, enforce_all};
1577
1578 let service = DagDbGatekeeperService::new(
1581 lazy_postgres_pool(),
1582 Arc::new(ConsentEngine::default()),
1583 Arc::new(IdentityRegistry::default()),
1584 );
1585 let context = service
1586 .dagdb_invariant_context("tenant-a", "did:exo:agent")
1587 .expect("context for valid DID");
1588 let engine = InvariantEngine::new(dagdb_invariant_set());
1589 let violations =
1590 enforce_all(&engine, &context).expect_err("missing bailment must fail closed");
1591 assert!(
1592 violations
1593 .iter()
1594 .any(|v| v.invariant == ConstitutionalInvariant::ConsentRequired),
1595 "missing bailment must surface a ConsentRequired violation"
1596 );
1597 }
1598
1599 #[tokio::test]
1600 async fn dagdb_invariant_context_rejects_malformed_did() {
1601 let service = DagDbGatekeeperService::new(
1602 lazy_postgres_pool(),
1603 Arc::new(ConsentEngine::default()),
1604 Arc::new(IdentityRegistry::default()),
1605 );
1606 assert!(
1607 service.dagdb_invariant_context("tenant-a", "").is_none(),
1608 "a structurally-invalid DID must not yield an invariant context"
1609 );
1610 }
1611
1612 use exo_dag_db_domain::{
1624 context_packet_persistence::{
1625 CONTEXT_PACKET_RECORD_SCHEMA_VERSION, DefaultContextQuality, PacketFreshnessStatus,
1626 PacketPersistenceStatus, PacketValidationStatus, canonical_idempotency_key,
1627 },
1628 continuation_persistence::{ContinuationRetrievalStatus, PRD17_CONTINUATION_RECORD_SCHEMA},
1629 default_route::{
1630 DEFAULT_ROUTE_SCHEMA_VERSION, DefaultRouteMemoryRef, DefaultRouteSource,
1631 DefaultRouteStatus, RouteFreshnessStatus,
1632 },
1633 lifecycle_action::{
1634 LifecycleActionType, LifecycleEvidenceRef, LifecycleMemoryRef, LifecycleRollbackRef,
1635 LifecycleTerminalState, PRD17_LIFECYCLE_ACTION_SCHEMA, ProductionLifecycleApproval,
1636 },
1637 };
1638 use exo_dag_db_postgres::postgres::{
1639 context_packet_persistence::ContextPacketPostgresError,
1640 lifecycle_action::LifecycleActionPostgresError,
1641 };
1642
1643 const SURFACE_TENANT: &str = "tenant-a";
1644 const SURFACE_AGENT: &str = "did:exo:agent";
1645 const SURFACE_NAMESPACE: &str = "project_memory_v3";
1646
1647 fn surface_memory_ref(memory_id: &str) -> LifecycleMemoryRef {
1648 LifecycleMemoryRef {
1649 tenant_id: SURFACE_TENANT.to_owned(),
1650 project_id: "dag_db".to_owned(),
1651 memory_namespace: SURFACE_NAMESPACE.to_owned(),
1652 memory_id: memory_id.to_owned(),
1653 }
1654 }
1655
1656 fn sample_lifecycle_action() -> LifecycleAction {
1657 let action_id = "lifecycle-writeback-d5";
1658 let validation_report_id = format!("validation-{action_id}");
1659 LifecycleAction {
1660 schema_version: PRD17_LIFECYCLE_ACTION_SCHEMA.to_owned(),
1661 action_id: action_id.to_owned(),
1662 action_type: LifecycleActionType::Writeback,
1663 tenant_id: SURFACE_TENANT.to_owned(),
1664 project_id: "dag_db".to_owned(),
1665 memory_namespace: SURFACE_NAMESPACE.to_owned(),
1666 actor_id: SURFACE_AGENT.to_owned(),
1667 source_packet_id: "packet-d5-001".to_owned(),
1668 source_receipt_id: "receipt-d5-001".to_owned(),
1669 parent_memory_ids: vec![surface_memory_ref("memory-parent-a")],
1670 target_memory_ids: vec![surface_memory_ref("memory-target-a")],
1671 validation_report_id: validation_report_id.clone(),
1672 policy_ref: "policy-d5-local-mutation".to_owned(),
1673 rollback_ref: LifecycleRollbackRef {
1674 rollback_id: format!("rollback-{action_id}"),
1675 action_id: action_id.to_owned(),
1676 inverse_action_type: LifecycleActionType::Writeback.inverse(),
1677 before_refs: vec![surface_memory_ref("memory-parent-a")],
1678 after_refs: vec![surface_memory_ref("memory-target-a")],
1679 validation_ref: validation_report_id,
1680 operator_required: true,
1681 },
1682 route_invalidation_event_ids: vec!["route-event-d5-001".to_owned()],
1683 evidence_refs: vec![LifecycleEvidenceRef {
1684 evidence_id: "evidence-d5-001".to_owned(),
1685 receipt_id: "receipt-evidence-d5-001".to_owned(),
1686 digest: "a".repeat(64),
1687 summary_ref: "summary-evidence-d5-001".to_owned(),
1688 preserved: true,
1689 }],
1690 terminal_state: LifecycleTerminalState::OperatorDeferred,
1691 production_lifecycle_approval: ProductionLifecycleApproval::OperatorDeferred,
1692 created_at: "2026-06-12T00:00:00Z".to_owned(),
1693 }
1694 }
1695
1696 fn sample_default_route() -> DefaultRouteRecord {
1697 DefaultRouteRecord {
1698 schema_version: DEFAULT_ROUTE_SCHEMA_VERSION.to_owned(),
1699 route_id: "route-d5-default".to_owned(),
1700 request_id: "request-route-d5-default".to_owned(),
1701 tenant_id: SURFACE_TENANT.to_owned(),
1702 project_id: "dag_db".to_owned(),
1703 memory_namespace: SURFACE_NAMESPACE.to_owned(),
1704 status: DefaultRouteStatus::Active,
1705 route_source: DefaultRouteSource::Persisted,
1706 policy_ref: "policy:d5-default-route".to_owned(),
1707 freshness_ref: "freshness:current".to_owned(),
1708 policy_allowed: true,
1709 freshness_status: RouteFreshnessStatus::Current,
1710 invalidated: false,
1711 production_default_route_approval_status: "operator_deferred".to_owned(),
1712 packet_quality_review_status: "operator_deferred".to_owned(),
1713 selected_memory_refs: vec![DefaultRouteMemoryRef {
1714 memory_id: "memory-a".to_owned(),
1715 latest_receipt_hash: "memory-a-receipt".to_owned(),
1716 validation_status: "passed".to_owned(),
1717 citation_ref: "citation:memory-a".to_owned(),
1718 }],
1719 created_at: "hlc:1".to_owned(),
1720 updated_at: "hlc:2".to_owned(),
1721 }
1722 }
1723
1724 fn sample_continuation_record() -> ContinuationRecord {
1725 ContinuationRecord {
1726 schema_version: PRD17_CONTINUATION_RECORD_SCHEMA.to_owned(),
1727 continuation_id: "continuation-d5-001".to_owned(),
1728 task_id: "task-d5-next-agent".to_owned(),
1729 tenant_id: SURFACE_TENANT.to_owned(),
1730 project_id: "dag_db".to_owned(),
1731 memory_namespace: SURFACE_NAMESPACE.to_owned(),
1732 actor_id: SURFACE_AGENT.to_owned(),
1733 route_id: "route-d5-default".to_owned(),
1734 summary_ref: "summary-continuation-d5-001".to_owned(),
1735 memory_refs: vec![surface_memory_ref("memory-target-a")],
1736 blocker_refs: vec!["blocker-production-lifecycle-approval-deferred".to_owned()],
1737 validation_refs: vec!["validation-continuation-d5-001".to_owned()],
1738 expiry_epoch_seconds: 2_000,
1739 later_retrieval_status: ContinuationRetrievalStatus::Pending,
1740 production_lifecycle_approval: ProductionLifecycleApproval::OperatorDeferred,
1741 created_at: "2026-06-12T00:00:00Z".to_owned(),
1742 }
1743 }
1744
1745 fn sample_context_packet_record() -> ContextPacketRecord {
1746 ContextPacketRecord {
1747 schema_version: CONTEXT_PACKET_RECORD_SCHEMA_VERSION.to_owned(),
1748 packet_id: "packet-d5-001".to_owned(),
1749 route_id: "route-d5-001".to_owned(),
1750 query_hash: "query-hash-d5-001".to_owned(),
1751 tenant_id: SURFACE_TENANT.to_owned(),
1752 project_id: "dag_db".to_owned(),
1753 memory_namespace: SURFACE_NAMESPACE.to_owned(),
1754 selected_memory_ids: vec!["memory-d5-001".to_owned()],
1755 selected_edge_ids: Vec::new(),
1756 token_budget: 1_000,
1757 token_estimate: 200,
1758 context_quality: DefaultContextQuality::UsableContext,
1759 citation_coverage_bp: 10_000,
1760 validation_coverage_bp: 10_000,
1761 freshness_status: PacketFreshnessStatus::Current,
1762 validation_status: PacketValidationStatus::Passed,
1763 source_proof_refs: vec!["receipt-d5-001".to_owned()],
1764 fallback_reason: None,
1765 idempotency_key: canonical_idempotency_key("route-d5-001", "query-hash-d5-001", 1_000),
1766 persistence_status: PacketPersistenceStatus::ProofBound,
1767 production_default_route_approval_status: "operator_deferred".to_owned(),
1768 packet_quality_review_status: "operator_deferred".to_owned(),
1769 created_at: "2026-06-12T00:00:00Z".to_owned(),
1770 }
1771 }
1772
1773 fn unreachable_postgres_pool() -> PgPool {
1778 use std::time::Duration;
1779
1780 use sqlx::postgres::{PgConnectOptions, PgPoolOptions};
1781
1782 let options = PgConnectOptions::new()
1783 .host("127.0.0.1")
1784 .port(1)
1785 .username("postgres")
1786 .database("postgres");
1787 PgPoolOptions::new()
1788 .acquire_timeout(Duration::from_millis(100))
1789 .connect_lazy_with(options)
1790 }
1791
1792 fn surface_service_consented(registry: Arc<IdentityRegistry>) -> DagDbGatekeeperService {
1793 DagDbGatekeeperService::new(
1794 unreachable_postgres_pool(),
1795 Arc::new(active_consent_engine(SURFACE_TENANT, SURFACE_AGENT)),
1796 registry,
1797 )
1798 }
1799
1800 fn registry_for(keypair: &KeyPair) -> Arc<IdentityRegistry> {
1801 Arc::new(
1802 IdentityRegistry::default()
1803 .with_public_key(SURFACE_AGENT, *keypair.public_key().as_bytes()),
1804 )
1805 }
1806
1807 fn assert_gate_passed<T>(result: Result<T, GatekeeperError>) {
1815 if let Err(error) = result {
1816 let detail = error.to_string();
1817 assert!(
1818 detail.contains("surface_database_unavailable"),
1819 "consented+signed call must not be gate-rejected; got: {detail}"
1820 );
1821 assert!(
1822 !detail.contains("ConsentRequired") && !detail.contains("ProvenanceVerifiable"),
1823 "consented+signed call must not be gate-rejected; got: {detail}"
1824 );
1825 }
1826 }
1827
1828 #[tokio::test]
1830 async fn lifecycle_action_consented_signed_reaches_db_layer() {
1831 let keypair = KeyPair::generate();
1832 let action = sample_lifecycle_action();
1833 let payload_hash = lifecycle_action_payload_hash(&action).expect("payload hash");
1834 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1835 let service = surface_service_consented(registry_for(&keypair));
1836 let result = service
1837 .persist_lifecycle_action(&action, SURFACE_AGENT, &signature, None)
1838 .await;
1839 assert_gate_passed(result);
1840 }
1841
1842 #[tokio::test]
1843 async fn lifecycle_action_unconsented_fails_closed_before_db() {
1844 let keypair = KeyPair::generate();
1845 let action = sample_lifecycle_action();
1846 let payload_hash = lifecycle_action_payload_hash(&action).expect("payload hash");
1847 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1848 let service = DagDbGatekeeperService::new(
1849 lazy_postgres_pool(),
1850 Arc::new(ConsentEngine::default()),
1851 registry_for(&keypair),
1852 );
1853 let err = service
1854 .persist_lifecycle_action(&action, SURFACE_AGENT, &signature, None)
1855 .await
1856 .expect_err("missing consent must fail closed");
1857 assert!(err.to_string().contains("ConsentRequired"), "{err}");
1858 }
1859
1860 #[tokio::test]
1861 async fn lifecycle_action_forged_signature_fails_closed_before_db() {
1862 let keypair = KeyPair::generate();
1863 let forger = KeyPair::generate();
1864 let action = sample_lifecycle_action();
1865 let payload_hash = lifecycle_action_payload_hash(&action).expect("payload hash");
1866 let forged = sign_write_payload(&forger, &payload_hash).expect("forged signature");
1867 let service = surface_service_consented(registry_for(&keypair));
1868 let err = service
1869 .persist_lifecycle_action(&action, SURFACE_AGENT, &forged, None)
1870 .await
1871 .expect_err("forged signature must fail closed");
1872 assert!(err.to_string().contains("ProvenanceVerifiable"), "{err}");
1873 }
1874
1875 #[tokio::test]
1877 async fn default_route_consented_signed_reaches_db_layer() {
1878 let keypair = KeyPair::generate();
1879 let route = sample_default_route();
1880 let payload_hash = default_route_payload_hash(&route).expect("payload hash");
1881 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1882 let service = surface_service_consented(registry_for(&keypair));
1883 let result = service
1884 .persist_default_route(&route, SURFACE_AGENT, &signature, None)
1885 .await;
1886 assert_gate_passed(result);
1887 }
1888
1889 #[tokio::test]
1890 async fn default_route_unconsented_fails_closed_before_db() {
1891 let keypair = KeyPair::generate();
1892 let route = sample_default_route();
1893 let payload_hash = default_route_payload_hash(&route).expect("payload hash");
1894 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1895 let service = DagDbGatekeeperService::new(
1896 lazy_postgres_pool(),
1897 Arc::new(ConsentEngine::default()),
1898 registry_for(&keypair),
1899 );
1900 let err = service
1901 .persist_default_route(&route, SURFACE_AGENT, &signature, None)
1902 .await
1903 .expect_err("missing consent must fail closed");
1904 assert!(err.to_string().contains("ConsentRequired"), "{err}");
1905 }
1906
1907 #[tokio::test]
1908 async fn default_route_forged_signature_fails_closed_before_db() {
1909 let keypair = KeyPair::generate();
1910 let forger = KeyPair::generate();
1911 let route = sample_default_route();
1912 let payload_hash = default_route_payload_hash(&route).expect("payload hash");
1913 let forged = sign_write_payload(&forger, &payload_hash).expect("forged signature");
1914 let service = surface_service_consented(registry_for(&keypair));
1915 let err = service
1916 .persist_default_route(&route, SURFACE_AGENT, &forged, None)
1917 .await
1918 .expect_err("forged signature must fail closed");
1919 assert!(err.to_string().contains("ProvenanceVerifiable"), "{err}");
1920 }
1921
1922 #[tokio::test]
1924 async fn continuation_consented_signed_reaches_db_layer() {
1925 let keypair = KeyPair::generate();
1926 let record = sample_continuation_record();
1927 let payload_hash = continuation_record_payload_hash(&record).expect("payload hash");
1928 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1929 let service = surface_service_consented(registry_for(&keypair));
1930 let result = service
1931 .persist_continuation_record(&record, 1_000, SURFACE_AGENT, &signature, None)
1932 .await;
1933 assert_gate_passed(result);
1934 }
1935
1936 #[tokio::test]
1937 async fn continuation_unconsented_fails_closed_before_db() {
1938 let keypair = KeyPair::generate();
1939 let record = sample_continuation_record();
1940 let payload_hash = continuation_record_payload_hash(&record).expect("payload hash");
1941 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1942 let service = DagDbGatekeeperService::new(
1943 lazy_postgres_pool(),
1944 Arc::new(ConsentEngine::default()),
1945 registry_for(&keypair),
1946 );
1947 let err = service
1948 .persist_continuation_record(&record, 1_000, SURFACE_AGENT, &signature, None)
1949 .await
1950 .expect_err("missing consent must fail closed");
1951 assert!(err.to_string().contains("ConsentRequired"), "{err}");
1952 }
1953
1954 #[tokio::test]
1955 async fn continuation_forged_signature_fails_closed_before_db() {
1956 let keypair = KeyPair::generate();
1957 let forger = KeyPair::generate();
1958 let record = sample_continuation_record();
1959 let payload_hash = continuation_record_payload_hash(&record).expect("payload hash");
1960 let forged = sign_write_payload(&forger, &payload_hash).expect("forged signature");
1961 let service = surface_service_consented(registry_for(&keypair));
1962 let err = service
1963 .persist_continuation_record(&record, 1_000, SURFACE_AGENT, &forged, None)
1964 .await
1965 .expect_err("forged signature must fail closed");
1966 assert!(err.to_string().contains("ProvenanceVerifiable"), "{err}");
1967 }
1968
1969 #[tokio::test]
1970 async fn context_packet_record_consented_signed_reaches_db_layer() {
1971 let keypair = KeyPair::generate();
1972 let record = sample_context_packet_record();
1973 let payload_hash = context_packet_record_payload_hash(&record).expect("payload hash");
1974 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1975 let service = surface_service_consented(registry_for(&keypair));
1976 let result = service
1977 .persist_context_packet_record(&record, SURFACE_AGENT, &signature, None)
1978 .await;
1979 assert_gate_passed(result);
1980 }
1981
1982 #[tokio::test]
1983 async fn context_packet_record_unconsented_fails_closed_before_db() {
1984 let keypair = KeyPair::generate();
1985 let record = sample_context_packet_record();
1986 let payload_hash = context_packet_record_payload_hash(&record).expect("payload hash");
1987 let signature = sign_write_payload(&keypair, &payload_hash).expect("signature");
1988 let service = DagDbGatekeeperService::new(
1989 lazy_postgres_pool(),
1990 Arc::new(ConsentEngine::default()),
1991 registry_for(&keypair),
1992 );
1993 let err = service
1994 .persist_context_packet_record(&record, SURFACE_AGENT, &signature, None)
1995 .await
1996 .expect_err("missing consent must fail closed");
1997 assert!(err.to_string().contains("ConsentRequired"), "{err}");
1998 }
1999
2000 #[tokio::test]
2001 async fn context_packet_record_forged_signature_fails_closed_before_db() {
2002 let keypair = KeyPair::generate();
2003 let forger = KeyPair::generate();
2004 let record = sample_context_packet_record();
2005 let payload_hash = context_packet_record_payload_hash(&record).expect("payload hash");
2006 let forged = sign_write_payload(&forger, &payload_hash).expect("forged signature");
2007 let service = surface_service_consented(registry_for(&keypair));
2008 let err = service
2009 .persist_context_packet_record(&record, SURFACE_AGENT, &forged, None)
2010 .await
2011 .expect_err("forged signature must fail closed");
2012 assert!(err.to_string().contains("ProvenanceVerifiable"), "{err}");
2013 }
2014
2015 #[test]
2021 fn surface_db_failure_classified_as_unavailable() {
2022 let db_error = LifecycleActionPostgresError::Postgres {
2023 source: sqlx::Error::PoolClosed,
2024 };
2025 let mapped = domain_blocked("lifecycle_action_postgres", &db_error);
2026 let detail = mapped.to_string();
2027 assert!(detail.contains("surface_database_unavailable"), "{detail}");
2028 assert!(!detail.contains("metadata rejected"), "{detail}");
2029 }
2030
2031 #[test]
2032 fn surface_contract_reject_classified_as_metadata_rejected() {
2033 let json_error = ContextPacketPostgresError::UnsafeReplay {
2034 packet_id: "packet-d5-001".to_owned(),
2035 };
2036 let mapped = domain_blocked("context_packet_record_postgres", &json_error);
2037 let detail = mapped.to_string();
2038 assert!(detail.contains("metadata rejected"), "{detail}");
2039 assert!(!detail.contains("surface_database_unavailable"), "{detail}");
2040 }
2041}