Skip to main content

auths_sdk/domains/compliance/
query.rs

1//! Compliance-as-a-query: turn the org's append-only history into a
2//! deterministic, offline-verifiable evidence pack.
3//!
4//! Each row answers "who signed this artifact, and were they authorized **at
5//! release time**?" — using [`classify_authority_at_signing`], ordered by KEL
6//! position, never wall-clock. The pack embeds the honest witness-diversity
7//! verdict ([`auths_transparency::HonestyCeiling`] via
8//! [`ceiling_for_policy_load`]) rather than a bare non-equivocation flag, so
9//! it can never over-claim third-party non-equivocation while only self-run
10//! witnesses exist.
11//!
12//! The pack's wire types and the **verification** half
13//! ([`verify_evidence_pack_offline`]) live in
14//! [`auths_verifier::evidence_pack`] — the leaf crate every surface (native,
15//! FFI, browser WASM) shares — and are re-exported here. This module keeps
16//! the **build** half: classifying releases against the live registry and
17//! embedding the org's KEL material as an [`AirGappedOrgBundle`] so each row
18//! verifies with **zero network**. The org DSSE signature over the in-toto
19//! statement lives in [`crate::domains::compliance::dsse`].
20
21use std::path::Path;
22
23use auths_id::keri::types::Prefix;
24use auths_transparency::{WitnessPolicy, WitnessPolicyError, ceiling_for_policy_load};
25use auths_verifier::IdentityDID;
26use chrono::{DateTime, Utc};
27use serde::{Deserialize, Serialize};
28
29pub use auths_verifier::evidence_pack::{
30    ComplianceFramework, EVIDENCE_PACK_SCHEMA_VERSION, EvidencePack, EvidencePackError,
31    EvidenceRow, RowVerdict, TransparencyInclusion, verify_evidence_pack_offline,
32    verify_transparency_inclusion,
33};
34
35use crate::context::AuthsContext;
36use crate::domains::org::audit::classify_authority_at_signing;
37use crate::domains::org::bundle::build_org_bundle;
38use crate::domains::org::error::OrgError;
39
40/// A typed failure building or verifying a compliance evidence pack.
41#[derive(Debug, thiserror::Error)]
42#[non_exhaustive]
43pub enum ComplianceQueryError {
44    /// Authority classification against the org KEL failed.
45    #[error("authority classification failed: {0}")]
46    Authority(#[from] OrgError),
47    /// Canonical serialization (`json-canon`) failed.
48    #[error("canonicalization failed: {0}")]
49    Canonicalize(String),
50    /// Org DSSE signing failed.
51    #[error("org signing failed: {0}")]
52    Signing(String),
53    /// A base64/hex decode failed while reading a signed/wrapped pack.
54    #[error("decode failed: {0}")]
55    Decode(String),
56    /// A DSSE signature did not verify against the org key.
57    #[error("verification failed: {0}")]
58    Verification(String),
59    /// Offline pack verification failed (tampered KEL, unpinned root, or a
60    /// transparency proof that did not check out).
61    #[error("offline verification failed: {0}")]
62    OfflineVerification(String),
63    /// Anchoring a release attestation in the org KEL failed.
64    #[error("release anchoring failed: {0}")]
65    Anchor(#[from] auths_id::keri::AnchorError),
66    /// A registry read/write failed while attesting or discovering releases.
67    #[error("registry access failed: {0}")]
68    Registry(String),
69    /// A malformed artifact digest or release-attestation blob.
70    #[error("invalid release attestation: {0}")]
71    InvalidRelease(String),
72    /// An anchored release blob does not hash back to its KEL seal digest.
73    #[error("anchored release attestation {0} does not match its KEL seal digest")]
74    TamperedRelease(String),
75}
76
77impl From<EvidencePackError> for ComplianceQueryError {
78    fn from(e: EvidencePackError) -> Self {
79        match e {
80            EvidencePackError::Canonicalize(m) => Self::Canonicalize(m),
81            EvidencePackError::Decode(m) => Self::Decode(m),
82            EvidencePackError::OfflineVerification(m) => Self::OfflineVerification(m),
83            other => Self::OfflineVerification(other.to_string()),
84        }
85    }
86}
87
88/// A release to classify: an artifact digest, the member that signed it, and its
89/// in-band signing KEL position (`None` when the artifact carries no position —
90/// which the classifier conservatively rejects).
91#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
92pub struct ReleaseRecord {
93    /// The artifact content digest (e.g. `sha256:<hex>`).
94    pub artifact_digest: String,
95    /// The signing member's KEL prefix.
96    pub signer_prefix: Prefix,
97    /// The artifact's in-band signing position (`Auths-Anchor-Seq`), if any.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub signed_at: Option<u128>,
100    /// Optional transparency-log inclusion evidence so the row verifies offline.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub transparency: Option<TransparencyInclusion>,
103}
104
105/// Load the pinned witness-diversity policy, **failing closed**.
106///
107/// Mirrors the monitor/cosigner: with no pinned policy path (the reality until an
108/// independent commons is admitted) this returns `Err`, which
109/// [`ceiling_for_policy_load`] renders as the honest "single-operator — not yet
110/// independent" verdict. A surface NEVER falls back to an unconstrained policy that
111/// would let it imply independence.
112///
113/// Args:
114/// * `path`: The pinned `witness_policy.json` path, or `None` when unset.
115///
116/// Usage:
117/// ```ignore
118/// let policy = load_witness_policy(path.as_deref());
119/// let pack = build_evidence_pack(&ctx, org, &p, "2026-Q3", fw, &rel, &policy, now)?;
120/// ```
121pub fn load_witness_policy(path: Option<&Path>) -> Result<WitnessPolicy, WitnessPolicyError> {
122    match path {
123        Some(p) => WitnessPolicy::load(p),
124        None => Err(WitnessPolicyError::NotFound {
125            path: "<AUTHS_WITNESS_POLICY_PATH unset>".into(),
126        }),
127    }
128}
129
130/// Classify each release into an [`EvidenceRow`], by KEL order.
131fn classify_rows(
132    ctx: &AuthsContext,
133    org_prefix: &Prefix,
134    releases: &[ReleaseRecord],
135) -> Result<Vec<EvidenceRow>, ComplianceQueryError> {
136    let mut rows = Vec::with_capacity(releases.len());
137    for r in releases {
138        let authority_at_release =
139            classify_authority_at_signing(ctx, org_prefix, &r.signer_prefix, r.signed_at)?;
140        let signer = IdentityDID::try_from(&r.signer_prefix)
141            .map_err(|e| ComplianceQueryError::InvalidRelease(e.to_string()))?;
142        rows.push(EvidenceRow {
143            artifact_digest: r.artifact_digest.clone(),
144            signer,
145            authority_at_release,
146            signed_at: r.signed_at,
147            transparency: r.transparency.clone(),
148        });
149    }
150    Ok(rows)
151}
152
153/// Build a compliance evidence pack by classifying each release against the org
154/// KEL.
155///
156/// The releases are the query input — the caller (CLI / a higher layer) supplies
157/// the artifacts released in the period; this engine classifies each signer's
158/// authority at the release position and embeds the honest witness verdict. It
159/// performs no I/O beyond the registry reads `classify_authority_at_signing`
160/// already does, and embeds no offline bundle (use [`build_offline_evidence_pack`]
161/// for a URL-free pack).
162///
163/// Args:
164/// * `ctx`: Auths context (registry).
165/// * `org`: The org's self-certifying identity (for the pack header).
166/// * `org_prefix`: The org's KEL prefix (the delegator).
167/// * `period`: The reporting period label.
168/// * `framework`: The target compliance framework.
169/// * `releases`: The artifacts released in the period.
170/// * `witness_policy`: The witness-policy load result (drives the honest verdict).
171/// * `generated_at`: Injected generation timestamp.
172///
173/// Usage:
174/// ```ignore
175/// let pack = build_evidence_pack(&ctx, org, &org_prefix, "2026-Q3",
176///     ComplianceFramework::Slsa, &releases, &policy_result, now)?;
177/// let canonical = pack.canonicalize()?;
178/// ```
179#[allow(clippy::too_many_arguments)]
180pub fn build_evidence_pack(
181    ctx: &AuthsContext,
182    org: IdentityDID,
183    org_prefix: &Prefix,
184    period: impl Into<String>,
185    framework: ComplianceFramework,
186    releases: &[ReleaseRecord],
187    witness_policy: &Result<WitnessPolicy, WitnessPolicyError>,
188    generated_at: DateTime<Utc>,
189) -> Result<EvidencePack, ComplianceQueryError> {
190    let rows = classify_rows(ctx, org_prefix, releases)?;
191    Ok(EvidencePack {
192        schema_version: EVIDENCE_PACK_SCHEMA_VERSION,
193        org,
194        period: period.into(),
195        framework,
196        equivocation_visibility: ceiling_for_policy_load(witness_policy),
197        generated_at,
198        rows,
199        org_bundle: None,
200    })
201}
202
203/// Build an **offline-verifiable** compliance evidence pack.
204///
205/// Identical to [`build_evidence_pack`] but embeds the org's KEL material as an
206/// [`AirGappedOrgBundle`](crate::domains::org::bundle::AirGappedOrgBundle)
207/// (org KEL + every member KEL + off-boarding records + pinned root), so
208/// [`verify_evidence_pack_offline`] can re-derive every row's authority and
209/// check each row's transparency proof with **zero network**.
210///
211/// Args:
212/// * `ctx`: Auths context (registry, clock — used to build the embedded bundle).
213/// * `org`: The org's self-certifying identity (for the pack header).
214/// * `org_prefix`: The org's KEL prefix (the delegator).
215/// * `period`: The reporting period label.
216/// * `framework`: The target compliance framework.
217/// * `releases`: The artifacts released in the period.
218/// * `witness_policy`: The witness-policy load result (drives the honest verdict).
219/// * `generated_at`: Injected generation timestamp.
220///
221/// Usage:
222/// ```ignore
223/// let pack = build_offline_evidence_pack(&ctx, org, &org_prefix, "2026-Q3",
224///     ComplianceFramework::Slsa, &releases, &policy_result, now)?;
225/// std::fs::write("acme-2026Q3.evidence", pack.canonicalize()?)?;
226/// ```
227#[allow(clippy::too_many_arguments)]
228pub fn build_offline_evidence_pack(
229    ctx: &AuthsContext,
230    org: IdentityDID,
231    org_prefix: &Prefix,
232    period: impl Into<String>,
233    framework: ComplianceFramework,
234    releases: &[ReleaseRecord],
235    witness_policy: &Result<WitnessPolicy, WitnessPolicyError>,
236    generated_at: DateTime<Utc>,
237) -> Result<EvidencePack, ComplianceQueryError> {
238    let mut pack = build_evidence_pack(
239        ctx,
240        org,
241        org_prefix,
242        period,
243        framework,
244        releases,
245        witness_policy,
246        generated_at,
247    )?;
248    pack.org_bundle = Some(build_org_bundle(ctx, org_prefix)?);
249    Ok(pack)
250}
251
252#[cfg(test)]
253#[allow(clippy::unwrap_used)]
254mod tests {
255    use super::*;
256    use auths_transparency::HonestyCeiling;
257    use auths_verifier::org_bundle::AuthorityAtSigning;
258
259    fn fixed_now() -> DateTime<Utc> {
260        DateTime::parse_from_rfc3339("2026-06-08T00:00:00Z")
261            .unwrap()
262            .with_timezone(&Utc)
263    }
264
265    fn single_operator_ceiling() -> HonestyCeiling {
266        // The reality today: no independent commons → not policy_met.
267        ceiling_for_policy_load(&Err(WitnessPolicyError::NotFound {
268            path: "<unset>".into(),
269        }))
270    }
271
272    fn sample_pack() -> EvidencePack {
273        EvidencePack {
274            schema_version: EVIDENCE_PACK_SCHEMA_VERSION,
275            org: IdentityDID::parse("did:keri:EOrg").unwrap(),
276            period: "2026-Q3".into(),
277            framework: ComplianceFramework::Slsa,
278            equivocation_visibility: single_operator_ceiling(),
279            generated_at: fixed_now(),
280            rows: vec![EvidenceRow {
281                artifact_digest: "sha256:aa".into(),
282                signer: IdentityDID::parse("did:keri:EAlice").unwrap(),
283                authority_at_release: AuthorityAtSigning::AuthorizedBeforeRevocation,
284                signed_at: Some(7),
285                transparency: None,
286            }],
287            org_bundle: None,
288        }
289    }
290
291    #[test]
292    fn pack_embeds_single_operator_ceiling_not_a_bare_flag() {
293        let pack = sample_pack();
294        assert!(!pack.equivocation_visibility.policy_met);
295        let json = pack.canonicalize().unwrap();
296        // Honest: renders the ceiling, never a `non_equivocation: true`.
297        assert!(!json.contains("non_equivocation"));
298        assert!(json.contains("not yet independent"));
299    }
300}