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}