Skip to main content

exo_gatekeeper/
dagdb_gate.rs

1//! DAG DB write gate: active consent, Ed25519 provenance, then `M12` persistence.
2
3use 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
48// PRD-D5: subject-id domains for the four PRD17 lifecycle/persistence surfaces.
49// Each is domain-separated so the signed payload hash binds to one surface and
50// cannot be transplanted across surfaces.
51const 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/// Constitutional invariants enforced on the dag-db write path.
57///
58/// This is the subset of [`InvariantSet::all`] that the dag-db authorization
59/// state can construct honestly from the consent/identity DB rows the T2
60/// resolver loads. Two invariants from the full set are deliberately excluded
61/// here, NOT silently dropped:
62///
63/// * [`ConstitutionalInvariant::AuthorityChainValid`] requires a non-empty,
64///   per-link Ed25519-signed [`AuthorityChain`](crate::types::AuthorityChain)
65///   bound to independently-resolved grantor keys. The dag-db consent schema
66///   stores a bailment + consent grant, not a signed delegation chain, so a
67///   context built from it has an empty chain and this invariant would
68///   fail-closed-block EVERY legitimate dag-db write — a deadlock, not
69///   enforcement. Authorization on the dag-db path is instead established by the
70///   tenant-scoped consent grant (`ConsentRequired`) plus the route-layer
71///   session-authority binding.
72/// * [`ConstitutionalInvariant::ProvenanceVerifiable`] requires a signed
73///   [`Provenance`](crate::types::Provenance) object with trusted actor keys.
74///   The dag-db write path already enforces Ed25519 actor provenance directly
75///   in [`DagDbGatekeeperService::validate_write`] via [`verify_write_signature`]
76///   over the canonical payload hash (the same Ed25519 binding the invariant
77///   would re-check), so it is enforced — just not a second time through the
78///   engine.
79///
80/// `ConsentRequired` IS run through the engine here (in addition to the
81/// pre-engine `verify_write_consent`) because the engine check additionally
82/// asserts bailor/bailee/scope coherence and that the consent scope covers the
83/// requested permission.
84#[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/// Active bailment + consent lookup for DAG DB writeback.
97#[derive(Debug, Clone, Default)]
98pub struct ConsentEngine {
99    bailments: BTreeMap<String, BailmentState>,
100    records: BTreeMap<(String, String, ConsentPurpose), DagDbConsentRecord>,
101}
102
103/// Scoped consent row for a tenant/agent/purpose triple.
104#[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    /// Register bailment state for a tenant (BCTS).
114    #[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    /// Register a consent record.
121    #[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    /// Snapshot the bailment state registered for `tenant_id`, for constructing
137    /// the constitutional [`InvariantContext`] over the dag-db write path.
138    #[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/// Actor public-key registry for provenance verification.
157#[derive(Debug, Clone, Default)]
158pub struct IdentityRegistry {
159    keys: BTreeMap<String, [u8; 32]>,
160    roles: BTreeMap<String, Vec<Role>>,
161}
162
163impl IdentityRegistry {
164    /// Register an Ed25519 public key for a DID string.
165    #[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    /// Register an existing governed role for a DID.
172    #[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    /// Register a governed role by enum name for a DID.
179    #[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    /// Return true when the DID holds an existing governed role allowed to issue
193    /// production DAG DB finality receipts.
194    #[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
213/// Returns `true` when active bailment (BCTS) and consent exist for writeback.
214pub 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    // An active bailment is not sufficient: the bailor must have entrusted THIS
231    // agent (bailee) with the writeback scope. A bailment for a different bailee
232    // or scope must not authorize this agent's writeback.
233    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
251/// Returns `true` when the Ed25519 signature verifies over the payload hash bytes.
252pub 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
274/// Verify that an external production finality authority, not merely any
275/// registered DID, signed the canonical finality payload.
276pub 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
292/// Gatekeeper service that enforces consent and provenance before `M12` writes.
293pub struct DagDbGatekeeperService {
294    pub pool: PgPool,
295    pub consent_engine: Arc<ConsentEngine>,
296    pub identity_registry: Arc<IdentityRegistry>,
297}
298
299impl DagDbGatekeeperService {
300    /// Construct a gatekeeper service over a Postgres pool and policy engines.
301    #[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    /// Build the constitutional [`InvariantContext`] for a dag-db write from the
315    /// consent/identity authorization state this service already holds.
316    ///
317    /// The context is enforced through `dagdb_invariant_set` (NOT the full
318    /// `all()`): the dag-db consent schema yields a bailment + consent grant, so
319    /// `ConsentRequired`, `SeparationOfPowers`, `NoSelfGrant`, `HumanOverride`,
320    /// `KernelImmutability`, and `QuorumLegitimate` are all genuinely checkable.
321    /// `AuthorityChainValid`/`ProvenanceVerifiable` are excluded — see
322    /// `dagdb_invariant_set`.
323    ///
324    /// Returns `None` only when `agent_did` is not a structurally-valid DID
325    /// (caller already validated it upstream; the gate fails closed before any
326    /// write regardless).
327    #[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        // Mirror the bailment grant as a gatekeeper consent record so the engine's
336        // `ConsentRequired` check (bailor/bailee/scope coherence) resolves against
337        // the same DB-derived grant the pre-engine `verify_write_consent` used.
338        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            // The dag-db agent acts under a bailment, not a governed multi-branch
354            // role; no roles => SeparationOfPowers passes (single/zero branch).
355            actor_roles: Vec::new(),
356            bailment_state,
357            consent_records,
358            // A writeback is not a permission grant: the agent is not expanding
359            // its own permissions, so this is never a self-grant.
360            authority_chain: Default::default(),
361            is_self_grant: false,
362            // Human override is preserved: the dag-db write path never disables
363            // emergency human intervention.
364            human_override_preserved: true,
365            // A graph-memory write never mutates immutable kernel configuration.
366            kernel_modification_attempted: false,
367            // No quorum is gathered for a single-actor writeback; `None` makes
368            // QuorumLegitimate vacuously pass.
369            quorum_evidence: None,
370            // Ed25519 provenance is enforced directly in `validate_write`, not via
371            // the engine, so no `Provenance` object is constructed here.
372            provenance: None,
373            // No permission expansion is requested, so the consent-scope coverage
374            // check passes with an empty requested set.
375            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    /// Persist a usage event after consent, signature, and optional invariant checks.
383    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    /// Persist a usage event with searchable metadata after consent and provenance checks.
405    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    /// Validate usage-event write consent, provenance, and optional invariants
428    /// without opening a persistence transaction.
429    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    /// Persist a context-packet receipt after consent, signature, and optional invariant checks.
448    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    /// PRD-D5: persist a lifecycle action after consent, signature, and optional
470    /// invariant checks. Lifecycle actions mutate the graph (writeback / relink /
471    /// supersede / recycle / archive); this routes them through the same
472    /// gatekeeper chain as the other graph-mutating write paths instead of the
473    /// prior `validate()`-only persistence.
474    ///
475    /// Runtime caller status: served gateway routes now invoke these gated D5
476    /// persistence methods when `production-db` is enabled and a governed pool is
477    /// configured. Default-route persistence calls [`Self::persist_default_route`],
478    /// context-packet persistence calls [`Self::persist_context_packet_record`],
479    /// and writeback calls this method plus
480    /// [`Self::persist_continuation_record`]. The method-boundary regression tests
481    /// still prove the shared gate chain independently of route plumbing.
482    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    /// PRD-D5: persist a default-route record after consent, signature, and
504    /// optional invariant checks.
505    ///
506    /// Runtime caller status: see [`Self::persist_lifecycle_action`].
507    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    /// PRD-D5: persist a continuation record after consent, signature, and
529    /// optional invariant checks.
530    ///
531    /// Runtime caller status: see [`Self::persist_lifecycle_action`].
532    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    /// PRD-D5: persist a context-packet record after consent, signature, and
555    /// optional invariant checks.
556    ///
557    /// Runtime caller status: see [`Self::persist_lifecycle_action`].
558    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            // Enforce the constructible dag-db invariant subset, not the full
649            // `InvariantEngine::all()`: a context built from the dag-db consent
650            // schema carries no signed authority chain, so `all()` would
651            // fail-closed-block every legitimate write. See `dagdb_invariant_set`
652            // for which invariants are engine-enforced vs enforced directly
653            // above (Ed25519 provenance) on this path.
654            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
677/// Produce a lowercase hex Ed25519 signature over the canonical write payload hash.
678pub 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
686/// Compute the canonical receipt-hash bytes used as the signed payload for usage events.
687pub 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
718/// Compute the canonical receipt-hash bytes used as the signed payload for packet receipts.
719pub 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
742/// PRD-D5: canonical receipt-hash bytes used as the signed payload for a
743/// lifecycle action. The subject id is domain-separated over the action's
744/// deterministic identity (tenant/namespace/action id/idempotency key) and the
745/// full action body is folded into `event_body_hash`, so any change to the
746/// persisted action invalidates the signature.
747pub 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
769/// PRD-D5: canonical receipt-hash bytes used as the signed payload for a
770/// default-route record.
771pub 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
783/// PRD-D5: canonical receipt-hash bytes used as the signed payload for a
784/// continuation record.
785pub 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
807/// PRD-D5: canonical receipt-hash bytes used as the signed payload for a
808/// context-packet record.
809pub 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
828/// Shared PRD-D5 helper: build the signed payload hash for a persistence surface
829/// from a domain-separated subject id plus the full record body. Mirrors
830/// `usage_event_payload_hash`/`context_packet_payload_hash` so all gated surfaces
831/// share one signature-binding shape.
832fn 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
910/// PRD-D5: map a lifecycle/persistence-surface error into the classified
911/// gatekeeper error shape. A database/transaction failure carries the
912/// `surface_database_unavailable` marker the gateway maps to 503; every other
913/// failure (contract, idempotency replay, serialization) is a request-level
914/// rejection the gateway maps to 422. The decision is deterministic from the
915/// error's own Display string, so a DB outage is never reported as a policy
916/// rejection and a contract reject is never reported as DB unavailability.
917fn 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
933/// Returns `true` when the error (or any error in its `source()` chain) is a
934/// `sqlx::Error`, i.e. a live database/transaction failure rather than a
935/// request-level contract rejection.
936fn 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(&registry, &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(&registry, &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        // Active bailment, valid consent record, but the bailment was entrusted
1211        // to a different bailee than the acting agent. Must fail closed.
1212        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        // Active bailment for the correct bailee but a non-writeback scope.
1241        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(&registry, &payload_hash, "not-hex", "did:exo:agent").is_err()
1275        );
1276        assert!(verify_write_signature(&registry, &payload_hash, "abcd", "not-a-did").is_err());
1277        let short_hex = "aa".repeat(32);
1278        assert!(
1279            verify_write_signature(&registry, &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        // The gateway's request-time DB resolver returns this variant when it
1345        // cannot establish consent/identity state (pool absent or a resolver
1346        // query failed). It must be a distinct typed variant — NOT folded into
1347        // an InvariantViolation policy denial — so the gateway can map it to a
1348        // 5xx availability fault instead of a hard policy deny, and so the
1349        // resolver never silently falls back to empty registries.
1350        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        // The dag-db subset must NOT include the two invariants that need a
1533        // signed authority chain / provenance object the dag-db consent schema
1534        // does not carry, or every legitimate write would fail closed.
1535        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        // Built from a real active bailment + consent grant, the dag-db subset
1565        // engine passes (no fail-closed deadlock for a legitimately-authorized
1566        // agent).
1567        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        // No bailment registered for this tenant => no consent record mirrored
1579        // => ConsentRequired (in the subset) fails closed.
1580        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    // ---------------------------------------------------------------------
1613    // PRD-D5: route-contract coverage for the four lifecycle/persistence
1614    // surfaces now routed through the gatekeeper chain. Each surface proves:
1615    //   * consented + signed reaches the DB layer (and a DB outage is
1616    //     classified as `surface_database_unavailable`, i.e. 503 at the
1617    //     gateway), never a silent pass;
1618    //   * missing consent fails closed before any DB access;
1619    //   * a forged signature fails closed before any DB access.
1620    // Error-classification coverage (422 reject vs 503 unavailable) follows.
1621    // ---------------------------------------------------------------------
1622
1623    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    /// An unreachable (port-1) lazy pool, independent of
1774    /// `EXO_DAGDB_TEST_DATABASE_URL`. The consented-pass surface tests use this
1775    /// so they deterministically observe the gate-passed-then-DB-unavailable
1776    /// path and never write synthetic rows into a live shared store.
1777    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    /// Assert a consented + signed call passed the gate: the gatekeeper let it
1808    /// reach the persistence layer, so the outcome is either success or a
1809    /// classified database failure — never a consent/provenance rejection. This
1810    /// holds whether or not a live `EXO_DAGDB_TEST_DATABASE_URL` is configured
1811    /// (no DB => `surface_database_unavailable`; live DB => the write may
1812    /// succeed), so the test is robust under both the DB-independent suite and a
1813    /// live-DB run.
1814    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    // D5-S1: lifecycle action through the gate.
1829    #[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    // D5-S2: default route through the gate.
1876    #[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    // D5-S3: continuation + context-packet through the gate.
1923    #[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    // D5-S4: error classification — a contract/replay reject carries the
2016    // `metadata rejected` marker (422 at the gateway); a DB/transaction outage
2017    // carries the `surface_database_unavailable` marker (503). The two markers
2018    // are mutually exclusive so a reject is never reported as a DB outage and a
2019    // DB outage is never reported as a policy reject.
2020    #[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}