Task — engineering-spec@1

"ZDR (Zero Data Retention) attestation table + enforcement when tenant policy requires"

doneTASK-AI-015
module ai · class product · priority p0 · created 2026-05-15 · shipped null
depends on TASK-AI-006 · blocks none

§1 — Description (BCP-14 normative)

The AI Gateway service MUST maintain an authoritative ZDR (Zero Data Retention) attestation table and enforce it at alias-resolution time when tenant policy demands ZDR. The table and enforcement together obey the following:

  1. MUST load services/ai-gateway/config/zdr_attestations.yaml at gateway startup (boot order: after TASK-AI-007 cost-table loader, before binding the HTTP server). Every (provider, model) combination MUST be explicitly enumerated; the file is the single source of truth.
  2. MUST expose zdr::is_zdr(provider: &ProviderKind, model: &str) -> bool and zdr::attestation_for(provider: &ProviderKind, model: &str) -> Option<ZdrAttestation>. The boolean is the gate; the attestation struct is the audit-trail primitive.
  3. MUST default is_zdr to false for any (provider, model) NOT in the table — fail closed. The lookup must never return true based on a missing entry, a parse fallback, or a "trust the provider" heuristic. A non-attested call when policy requires ZDR is a refusal, not an unknown.
  4. MUST require every YAML entry to carry is_zdr (bool), verified_at (ISO date), source_url (HTTPS URL — HTTP rejected at parse), and attested_by (kebab-case email or username). notes is optional. An entry missing any required field MUST fail init_zdr_table with LoaderInitError::Schema { reason }. ZDR claims without source = audit failure; the parser blocks them at boot.
  5. MUST be consulted by alias::resolve (TASK-AI-006 §1 #6 invokes this task) BEFORE returning a resolved (provider, model). When policy.ai_policy.zdr_required == true AND zdr::is_zdr(&resolved_provider, &resolved_model) == false, alias::resolve MUST return Err(AliasError::ZdrViolation { resolved_provider, resolved_model, attestation: <option> }). The attestation field surfaces the documented status (e.g., a known-non-ZDR provider returns the entry with is_zdr: false; a missing entry returns None) so the operator can distinguish "we know this isn't ZDR" from "we have no record."
  6. MUST emit an ai.zdr_violation memory audit row when a request is refused due to ZDR. The row carries tenant_id, agent_persona, requested_alias, resolved_provider, resolved_model, policy_requires_zdr: true, attestation_present: bool, request_id. The audit-before-refusal invariant from TASK-AI-001 §1 #6 applies.
  7. MUST be hot-reloadable via the notify crate's file-watch with a 250ms debounce (same machinery as TASK-AI-005, TASK-AI-007, TASK-AI-014). On reload-success, the new attestations replace the cache via ArcSwap::store.
  8. MUST detect ZDR-status revocations on hot-reload: for every (provider, model) where the previous cache entry had is_zdr == true AND the new entry has is_zdr == false, emit tracing::warn! AND increment ai_zdr_attestations_revoked_total{provider, model}. Operators MUST be alerted (the metric is a sev-2 alarm trigger).
  9. MUST implement staleness handling at two thresholds. (a) Soft stale at verified_at + 90 days: weekly CI cron (.github/workflows/zdr-staleness-check.yml) flags the entry; PR-time validation logs WARN; metric ai_zdr_attestations_stale_total{provider, model} increments. (b) Hard stale at verified_at + 365 days: is_zdr MUST return false regardless of the table's recorded value (defensive override; an attestation no one has reverified for a year is no longer trustworthy). The hard-stale path also logs tracing::error! and increments ai_zdr_attestations_expired_total.
  10. MUST validate source_url at parse time: scheme MUST be https, host MUST be a valid DNS name (or known provider domain — aws.amazon.com, anthropic.com, platform.openai.com, cloud.google.com, etc.). HTTP URLs and bare paths are rejected with LoaderInitError::InvalidSourceUrl. The validator does NOT fetch the URL (no network call at parse time); it only validates syntax + scheme.
  11. MUST validate attested_by at parse time as either <localpart>@cyberos.world (CyberSkill staff) OR <auditor-id>@<approved-auditor-domain> (third-party auditor on a maintained allow-list). Random strings are rejected with LoaderInitError::InvalidAttestor. The allow-list lives in parse.rs constants for slice 3; TASK-AI-022 will move it to a separate config.
  12. MUST integrate with policy.ai_policy.zdr_required from TASK-AI-005's tenant policy schema. The flag is read once per request via TASK-AI-001's policy load; this task reads it through the request context, never directly from disk.
  13. MUST propagate ZdrViolation errors as HTTP 403 ZDR_VIOLATION with body {"error":"zdr_violation","resolved_provider":"<p>","resolved_model":"<m>","policy_requires_zdr":true,"contact":"ops@cyberos.world"}. The body MUST NOT echo the attestation's notes field (operator-facing explanation, not customer-facing).
  14. SHOULD emit OTel metrics:
  1. SHOULD log at INFO level on every successful hot-reload: zdr_table_reloaded count=<N> sources={hash16} so operators can verify "did my edit actually load?".

§2 — Why this design (rationale for humans)

Why a separate YAML, not inline in the cost-table or alias map? ZDR is a legal-compliance assertion that drifts independently from cost. Bedrock might be ZDR today but a new model launched on Bedrock might not initially carry the same attestation — even though the cost-table entry exists for billing. Coupling ZDR to cost or alias creates two failure modes: (a) updating cost flips ZDR by accident; (b) reviewing ZDR requires reading three files. Keeping ZDR in its own file with its own owner means the audit conversation ("show me the ZDR attestations for the period under review") is one file, one history.

Why fail closed (default false, §1 #3)? Same reasoning as TASK-AI-001 §1 #9 (policy-missing fails closed): silent defaults bury compliance failures. Missing entry = treat as non-ZDR = refuse if policy demands ZDR. The alternative — defaulting to true for "providers we trust" — is exactly the failure mode that produces a "we promised ZDR and didn't enforce it" headline.

Why does each row carry verified_at + source_url + attested_by (§1 #4)? When an auditor asks "prove you only sent PDPL-protected data to ZDR-attested providers," the chain of evidence is:

  1. Tenant policy says zdr_required: true (memory row + YAML).
  2. Request was routed to (provider, model).
  3. zdr_attestations.yaml shows that (provider, model) had is_zdr: true at the time of the request, citing a published source URL.
  4. The attestor (attested_by) is identifiable; if questioned, they can produce the source they read.

Each piece of provenance is load-bearing; an attestation without source_url is a claim, not evidence. The parse-time enforcement of all four required fields is the discipline that keeps the table audit-grade.

Why HTTPS-only source URLs (§1 #10)? A source URL on HTTP can be MITMed — an attacker between the auditor and the documentation could serve a forged "we have ZDR" page. HTTPS isn't perfect but it's the floor. Rejecting HTTP at parse time is free and prevents accidentally-shipped HTTP citations from a copy-paste error.

Why two-tier staleness (90d soft, 365d hard, §1 #9)? Vendor policies change. SOC 2 cadence is annual reassessment for vendor controls; we mirror that as the hard floor. Soft stale at 90d (matching standard quarterly vendor-review cycles) gives operators a reminder before things go critical. Hard stale at 365d is the defensive override: an attestation no human has reverified for a year cannot be trusted to still describe reality. The enforcement is automatic — not "we should reverify" but "the bool returns false until reverified" — so the failure mode is loud (a request refusal) not silent (a stale attestation continuing to gate calls open).

Why does revocation produce a WARN log + metric, not an automatic refusal (§1 #8)? Revocation is an operational signal: "the attestor changed is_zdr: true → false deliberately, probably because the vendor changed their policy." The flip itself is the new authoritative state — calls to that (provider, model) start being refused immediately. The WARN + metric exist so operators know it happened (so they can email affected tenants) — not to undo the flip.

Why a separate attested_by field rather than git blame? Git blame works for slice 3 (small team, all attestations from stephen@cyberos.world). At scale (multi-attestor org, third-party auditors), the attestor is operationally relevant: "who do I email to question this attestation?" An auditor's id (e.g., a third-party SOC 2 firm) is not in our git history. Surfacing attested_by in the YAML AND in the audit row makes the attribution explicit.

Why is the enforcement at alias::resolve time, not at the precheck (TASK-AI-001) time? The precheck has the alias name (chat.smart), not the resolved (provider, model). Resolution happens later; that's where the (provider, model) is known. Enforcing at resolution means the gate fires at the right point with the right data, AND we get exactly one enforcement site (no duplication, no risk of one path checking and another not). The cost is one extra error class (ZdrViolation) propagating from alias::resolve to the handler — small.

Why a separate memory audit row (ai.zdr_violation, §1 #6)? A regulator's audit of "did we ever send PDPL data to a non-ZDR provider" needs a search target. Without the dedicated row, the evidence is "absence of evidence" (no request went through to the non-ZDR provider, but how do we prove we didn't try?). The dedicated row converts the question to "show me all ai.zdr_violation rows for tenant X" — a positive answer, not an absence. The row is the proof we did refuse.

Why doesn't LoaderInitError::Schema fail-closed-on-init? It does (Result<(), LoaderInitError> propagates up through the boot path; the gateway refuses to bind). The §10 inventory makes this explicit. Inability to load the ZDR table is treated as severe as inability to load the cost table — both are compliance-load-bearing primitives.


§3 — API contract (formal spec for AI-agent implementers)

Type definitions

// services/ai-gateway/src/zdr/mod.rs

use std::sync::Arc;
use std::path::Path;
use arc_swap::ArcSwap;
use chrono::NaiveDate;
use once_cell::sync::OnceCell;
use std::collections::HashMap;

#[derive(Debug, Clone, PartialEq)]
pub struct ZdrAttestation {
    pub is_zdr: bool,
    pub verified_at: NaiveDate,
    pub source_url: String,                // HTTPS-validated at parse
    pub attested_by: String,               // <localpart>@cyberos.world OR approved-auditor
    pub notes: Option<String>,
}

pub fn is_zdr(provider: &ProviderKind, model: &str) -> bool;
pub fn attestation_for(provider: &ProviderKind, model: &str) -> Option<ZdrAttestation>;
pub async fn init_zdr_table(config_path: &Path) -> Result<(), LoaderInitError>;

#[derive(Debug, thiserror::Error)]
pub enum LoaderInitError {
    #[error("zdr_attestations.yaml malformed: {reason}")]
    Schema { reason: String },
    #[error("invalid source_url at {provider}/{model}: must be https://, got {url}")]
    InvalidSourceUrl { provider: String, model: String, url: String },
    #[error("invalid attested_by at {provider}/{model}: {value}")]
    InvalidAttestor { provider: String, model: String, value: String },
    #[error("zdr table already initialised; init_zdr_table called twice")]
    AlreadyInitialised,
    #[error("io error reading config: {0}")]
    Io(#[from] std::io::Error),
}

// In TASK-AI-006 alias.rs (modified_files):
pub enum AliasError {
    // ... existing variants ...
    ZdrViolation {
        resolved_provider: ProviderKind,
        resolved_model: String,
        attestation: Option<ZdrAttestation>,   // None ⇒ no entry; Some(a) ⇒ entry shows is_zdr=false
    },
}

static TABLE: OnceCell<ArcSwap<HashMap<(ProviderKind, String), ZdrAttestation>>> = OnceCell::new();

YAML schema (with all required fields enforced at parse)

# services/ai-gateway/config/zdr_attestations.yaml
version: 1
last_updated: 2026-05-15

# Each entry MUST cite a source. ZDR claims without source = audit failure.
# Entries lacking is_zdr / verified_at / source_url / attested_by FAIL init.
attestations:
  bedrock:
    "anthropic.claude-3-5-sonnet-20241022-v2:0":
      is_zdr: true
      verified_at: 2026-05-15
      source_url: "https://aws.amazon.com/bedrock/data-privacy/"
      attested_by: "stephen@cyberos.world"
      notes: "Bedrock guarantees no-data-retention per their data privacy whitepaper"

    "anthropic.claude-3-haiku-20240307-v1:0":
      is_zdr: true
      verified_at: 2026-05-15
      source_url: "https://aws.amazon.com/bedrock/data-privacy/"
      attested_by: "stephen@cyberos.world"

  anthropic:
    "claude-3-5-sonnet-20241022":
      is_zdr: true
      verified_at: 2026-05-15
      source_url: "https://www.anthropic.com/legal/zero-data-retention"
      attested_by: "stephen@cyberos.world"
      notes: "ZDR available on Enterprise plan only; runtime tenant-tier check is TASK-AI-022 follow-up"

  openai:
    "gpt-4o":
      is_zdr: false
      verified_at: 2026-05-15
      source_url: "https://platform.openai.com/docs/models#data-policy"
      attested_by: "stephen@cyberos.world"
      notes: |
        Standard OpenAI retains data 30 days by default. ZDR requires the
        zero-data-retention org policy enabled per OpenAI's documentation;
        CyberOS has not validated this. Treat as non-ZDR until policy enabled.

  vertex:
    "gemini-2.0-pro":
      is_zdr: false
      verified_at: 2026-05-15
      source_url: "https://cloud.google.com/vertex-ai/docs/general/data-governance"
      attested_by: "stephen@cyberos.world"
      notes: "Vertex retention is region-dependent; ZDR not validated for VN tenants"

Parser contract

// services/ai-gateway/src/zdr/parse.rs

use url::Url;

const APPROVED_AUDITOR_DOMAINS: &[&str] = &[
    "cyberos.world",
    // Third-party auditor allow-list:
    "kpmg.com.vn",
    "ey.com",
    "deloitte.com",
    // ... maintained by ops; TASK-AI-022 moves to separate config
];

pub fn parse_attestations(yaml: &str) -> Result<HashMap<(ProviderKind, String), ZdrAttestation>, LoaderInitError> {
    let raw: serde_yaml::Value = serde_yaml::from_str(yaml)
        .map_err(|e| LoaderInitError::Schema { reason: e.to_string() })?;

    let attestations = raw.get("attestations")
        .ok_or_else(|| LoaderInitError::Schema { reason: "missing 'attestations' root key".into() })?;

    let mut out = HashMap::new();
    for (provider_str, models) in attestations.as_mapping().unwrap() {
        let provider = ProviderKind::from_str(provider_str.as_str().unwrap())
            .map_err(|e| LoaderInitError::Schema { reason: format!("unknown provider {provider_str:?}: {e}") })?;
        for (model_str, fields) in models.as_mapping().unwrap() {
            let model = model_str.as_str().unwrap().to_string();
            let att = parse_one_attestation(&provider, &model, fields)?;
            out.insert((provider, model), att);
        }
    }
    Ok(out)
}

fn parse_one_attestation(
    provider: &ProviderKind, model: &str, fields: &serde_yaml::Value,
) -> Result<ZdrAttestation, LoaderInitError> {
    let map = fields.as_mapping().ok_or_else(|| LoaderInitError::Schema {
        reason: format!("{provider:?}/{model}: not a mapping"),
    })?;

    let is_zdr = map.get(&"is_zdr".into()).and_then(|v| v.as_bool())
        .ok_or_else(|| LoaderInitError::Schema { reason: format!("{provider:?}/{model}: missing is_zdr") })?;

    let verified_at_s = map.get(&"verified_at".into()).and_then(|v| v.as_str())
        .ok_or_else(|| LoaderInitError::Schema { reason: format!("{provider:?}/{model}: missing verified_at") })?;
    let verified_at = NaiveDate::parse_from_str(verified_at_s, "%Y-%m-%d")
        .map_err(|e| LoaderInitError::Schema { reason: format!("{provider:?}/{model}: bad verified_at: {e}") })?;

    let source_url = map.get(&"source_url".into()).and_then(|v| v.as_str())
        .ok_or_else(|| LoaderInitError::Schema { reason: format!("{provider:?}/{model}: missing source_url") })?
        .to_string();
    validate_source_url(provider, model, &source_url)?;

    let attested_by = map.get(&"attested_by".into()).and_then(|v| v.as_str())
        .ok_or_else(|| LoaderInitError::Schema { reason: format!("{provider:?}/{model}: missing attested_by") })?
        .to_string();
    validate_attested_by(provider, model, &attested_by)?;

    let notes = map.get(&"notes".into()).and_then(|v| v.as_str()).map(|s| s.to_string());

    Ok(ZdrAttestation { is_zdr, verified_at, source_url, attested_by, notes })
}

fn validate_source_url(provider: &ProviderKind, model: &str, url: &str) -> Result<(), LoaderInitError> {
    let parsed = Url::parse(url).map_err(|_| LoaderInitError::InvalidSourceUrl {
        provider: format!("{provider:?}"), model: model.into(), url: url.into(),
    })?;
    if parsed.scheme() != "https" {
        return Err(LoaderInitError::InvalidSourceUrl {
            provider: format!("{provider:?}"), model: model.into(), url: url.into(),
        });
    }
    Ok(())
}

fn validate_attested_by(provider: &ProviderKind, model: &str, value: &str) -> Result<(), LoaderInitError> {
    let Some((_local, domain)) = value.split_once('@') else {
        return Err(LoaderInitError::InvalidAttestor {
            provider: format!("{provider:?}"), model: model.into(), value: value.into(),
        });
    };
    if !APPROVED_AUDITOR_DOMAINS.contains(&domain) {
        return Err(LoaderInitError::InvalidAttestor {
            provider: format!("{provider:?}"), model: model.into(), value: value.into(),
        });
    }
    Ok(())
}

Staleness check

// services/ai-gateway/src/zdr/staleness.rs

use chrono::{Duration, Utc};

pub const SOFT_STALE_DAYS: i64 = 90;
pub const HARD_STALE_DAYS: i64 = 365;

pub fn is_soft_stale(att: &ZdrAttestation) -> bool {
    Utc::now().date_naive() - att.verified_at > Duration::days(SOFT_STALE_DAYS)
}

pub fn is_hard_stale(att: &ZdrAttestation) -> bool {
    Utc::now().date_naive() - att.verified_at > Duration::days(HARD_STALE_DAYS)
}
// In zdr/mod.rs::is_zdr — apply hard-stale override per §1 #9.
pub fn is_zdr(provider: &ProviderKind, model: &str) -> bool {
    let table = match TABLE.get() {
        Some(t) => t.load(),
        None => return false,
    };
    let key = (*provider, model.to_string());
    match table.get(&key) {
        None => {
            metrics::lookup(&key, "missing");
            false
        }
        Some(att) if staleness::is_hard_stale(att) => {
            metrics::lookup(&key, "expired");
            tracing::error!(provider=?provider, model=%model, verified_at=%att.verified_at,
                            "zdr attestation HARD-stale (>365d); forcing is_zdr=false");
            false
        }
        Some(att) => {
            if staleness::is_soft_stale(att) {
                metrics::soft_stale(&key);
            }
            metrics::lookup(&key, if att.is_zdr { "attested" } else { "missing" });
            att.is_zdr
        }
    }
}

CI staleness workflow

# .github/workflows/zdr-staleness-check.yml
name: ZDR Attestation Staleness Check
on:
  schedule:
    - cron: '0 0 * * 1'   # every Monday 00:00 UTC
  workflow_dispatch: {}

jobs:
  check-staleness:
    runs-on: ubuntu-22.04
    permissions:
      issues: write
    steps:
      - uses: actions/checkout@v4
      - uses: actions-rust-lang/setup-rust-toolchain@v1
      - name: Run staleness check
        working-directory: services/ai-gateway
        run: cargo run --bin zdr-staleness-check -- config/zdr_attestations.yaml
      - name: Open issue if soft-stale entries found
        if: failure()
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.create({
              owner: context.repo.owner,
              repo: context.repo.repo,
              title: `ZDR attestations soft-stale (>90 days) — refresh due`,
              labels: ['compliance', 'zdr', 'staleness'],
              body: `Per TASK-AI-015 §1 #9, one or more ZDR attestations exceed 90 days.\n\nRefresh by visiting each provider's published policy and bumping verified_at.`
            });

§4 — Acceptance criteria (testable, ordered, numbered)

  1. ZDR-attested provider passesis_zdr(&Bedrock, "anthropic.claude-3-5-sonnet-20241022-v2:0") returns true.
  2. Documented non-ZDR failsis_zdr(&OpenAI, "gpt-4o") returns false.
  3. Missing entry fails closedis_zdr(&Vertex, "gemini-9.9.9") (not in table) returns false; metric ai_zdr_lookups_total{outcome="missing"} increments.
  4. TASK-AI-006 integration: refusal when policy requires — Tenant policy zdr_required: true; alias resolves to openai:gpt-4o; alias::resolve returns Err(AliasError::ZdrViolation { attestation: Some(att_with_is_zdr_false), .. }).
  5. TASK-AI-006 integration: refusal when entry missing — Tenant policy zdr_required: true; alias resolves to a (provider, model) not in the table; alias::resolve returns Err(AliasError::ZdrViolation { attestation: None, .. }).
  6. HTTP 403 ZDR_VIOLATION on refusal — Handler converts the AliasError to a 403 response with the documented body shape; notes field NOT echoed in response body.
  7. Audit row emitted — Every ZdrViolation refusal emits exactly one ai.zdr_violation memory row carrying tenant_id, resolved_provider, resolved_model, policy_requires_zdr, attestation_present, request_id.
  8. Hot reload picks up new attestation — Adding a new entry to YAML; within 500ms is_zdr returns the new value.
  9. Revocation warns and metricises — Changing an entry from is_zdr: true to is_zdr: false triggers tracing::warn! AND increments ai_zdr_attestations_revoked_total{provider, model}.
  10. Soft staleness flagged in CIverified_at = today - 91 days; cargo run --bin zdr-staleness-check exits non-zero with the entry listed; weekly cron opens an issue.
  11. Hard staleness forces is_zdr=falseverified_at = today - 366 days; is_zdr returns false regardless of the YAML's recorded value; tracing::error! fired; metric ai_zdr_attestations_expired_total incremented.
  12. HTTP source_url rejected at parse — A YAML entry with source_url: "http://..." fails init_zdr_table with InvalidSourceUrl.
  13. Bare-string attestor rejected at parse — A YAML entry with attested_by: "alice" (no @) fails with InvalidAttestor.
  14. Out-of-domain attestor rejected at parse — A YAML entry with attested_by: "alice@gmail.com" fails with InvalidAttestor (domain not in approved list).
  15. Required-field validation: missing source_url — A YAML entry without source_url fails init_zdr_table with Schema.
  16. Required-field validation: missing attested_by — A YAML entry without attested_by fails init_zdr_table with Schema.
  17. Attestation provenance retrievableattestation_for(&Bedrock, "anthropic.claude-3-5-sonnet-20241022-v2:0") returns a ZdrAttestation with all five fields populated.
  18. Concurrent lookups + hot-reload safe — 100 tokio tasks calling is_zdr concurrently while a hot-reload runs see either old or new state, never torn or panicking.
  19. Double-init rejectedinit_zdr_table then second init_zdr_table returns LoaderInitError::AlreadyInitialised.

§5 — Verification

// services/ai-gateway/tests/zdr_test.rs
use cyberos_ai_gateway::zdr::{self, init_zdr_table};
use cyberos_ai_gateway::providers::ProviderKind;
use std::path::Path;

#[tokio::test]
async fn zdr_attested_passes_and_non_attested_fails_closed() {
    init_zdr_table(Path::new("config/zdr_attestations.yaml")).await.unwrap();

    // AC #1
    assert!(zdr::is_zdr(&ProviderKind::Bedrock, "anthropic.claude-3-5-sonnet-20241022-v2:0"));
    // AC #2
    assert!(!zdr::is_zdr(&ProviderKind::OpenAI, "gpt-4o"));
    // AC #3
    assert!(!zdr::is_zdr(&ProviderKind::Vertex, "gemini-9.9.9"));
}

#[tokio::test]
async fn alias_resolve_refuses_when_policy_requires_zdr() {
    init_zdr_table(Path::new("config/zdr_attestations.yaml")).await.unwrap();
    let policy = test_policy_with_zdr_required(true);
    let result = alias::resolve("chat.smart-non-zdr", &policy);   // alias maps to openai:gpt-4o
    match result {
        Err(AliasError::ZdrViolation { resolved_provider, attestation, .. }) => {
            assert_eq!(resolved_provider, ProviderKind::OpenAI);
            assert!(matches!(attestation, Some(a) if !a.is_zdr));
        }
        _ => panic!("expected ZdrViolation"),
    }
}

#[tokio::test]
async fn audit_row_emitted_on_zdr_refusal() {
    let request_id = "req_test_zdr_001";
    let tenant_id = "tenant_alpha";
    let _ = handlers::chat::handle(test_request(tenant_id, request_id, "chat.smart-non-zdr")).await;
    let rows = memory_test_helper::find_rows("ai.zdr_violation", request_id);
    assert_eq!(rows.len(), 1);
    let p = &rows[0].payload;
    assert_eq!(p["tenant_id"], tenant_id);
    assert_eq!(p["resolved_provider"], "openai");
    assert_eq!(p["resolved_model"], "gpt-4o");
    assert_eq!(p["policy_requires_zdr"], true);
    assert_eq!(p["attestation_present"], true);
}

#[tokio::test]
async fn http_source_url_rejected() {
    let yaml = r#"
        version: 1
        attestations:
          openai:
            "gpt-4o":
              is_zdr: false
              verified_at: "2026-05-15"
              source_url: "http://platform.openai.com/policy"
              attested_by: "stephen@cyberos.world"
    "#;
    let err = zdr::parse::parse_attestations(yaml).expect_err("expected InvalidSourceUrl");
    assert!(matches!(err, LoaderInitError::InvalidSourceUrl { .. }));
}

#[tokio::test]
async fn bare_string_attestor_rejected() {
    let yaml = r#"
        version: 1
        attestations:
          openai:
            "gpt-4o":
              is_zdr: false
              verified_at: "2026-05-15"
              source_url: "https://platform.openai.com/policy"
              attested_by: "alice"
    "#;
    let err = zdr::parse::parse_attestations(yaml).expect_err("expected InvalidAttestor");
    assert!(matches!(err, LoaderInitError::InvalidAttestor { .. }));
}

#[tokio::test]
async fn missing_source_url_rejected() {
    let yaml = r#"
        version: 1
        attestations:
          openai:
            "gpt-4o":
              is_zdr: false
              verified_at: "2026-05-15"
              attested_by: "stephen@cyberos.world"
    "#;
    let err = zdr::parse::parse_attestations(yaml).expect_err("expected Schema");
    match err {
        LoaderInitError::Schema { reason } => assert!(reason.contains("source_url")),
        e => panic!("wrong variant: {e:?}"),
    }
}
// services/ai-gateway/tests/alias_resolution_test.rs
#[tokio::test]
async fn revocation_warns_and_metricises() {
    init_zdr_table(Path::new("config/zdr_attestations.yaml")).await.unwrap();
    let path = "config/zdr_attestations.yaml";
    let original = std::fs::read_to_string(path).unwrap();
    let revoked = original.replace(
        "is_zdr: true\n      verified_at: 2026-05-15\n      source_url: \"https://aws.amazon.com/bedrock/data-privacy/\"",
        "is_zdr: false\n      verified_at: 2026-05-15\n      source_url: \"https://aws.amazon.com/bedrock/data-privacy/\"",
    );
    std::fs::write(path, &revoked).unwrap();
    tokio::time::sleep(std::time::Duration::from_millis(500)).await;

    assert!(!zdr::is_zdr(&ProviderKind::Bedrock, "anthropic.claude-3-5-sonnet-20241022-v2:0"));
    let counter = otel_test_helper::counter_value(
        "ai_zdr_attestations_revoked_total",
        &[("provider", "bedrock"), ("model", "anthropic.claude-3-5-sonnet-20241022-v2:0")],
    );
    assert!(counter >= 1, "revocation counter not incremented");

    std::fs::write(path, &original).unwrap();
}
// services/ai-gateway/tests/zdr_test.rs
#[test]
fn soft_stale_at_91_days() {
    let att = ZdrAttestation {
        is_zdr: true,
        verified_at: chrono::Utc::now().date_naive() - chrono::Duration::days(91),
        source_url: "https://x".into(), attested_by: "stephen@cyberos.world".into(), notes: None,
    };
    assert!(zdr::staleness::is_soft_stale(&att));
    assert!(!zdr::staleness::is_hard_stale(&att));
}

#[test]
fn hard_stale_at_366_days_overrides_is_zdr() {
    let att = ZdrAttestation {
        is_zdr: true,
        verified_at: chrono::Utc::now().date_naive() - chrono::Duration::days(366),
        source_url: "https://x".into(), attested_by: "stephen@cyberos.world".into(), notes: None,
    };
    assert!(zdr::staleness::is_hard_stale(&att));

    // Force-inject att into table; is_zdr MUST return false.
    test_helper::inject_attestation(&ProviderKind::Bedrock, "test-model", att);
    assert!(!zdr::is_zdr(&ProviderKind::Bedrock, "test-model"));
}
cd services/ai-gateway
cargo test -p cyberos-ai-gateway zdr

CI gate: cargo-test runs on every PR touching services/ai-gateway/src/zdr/**, services/ai-gateway/config/zdr_attestations.yaml, or services/ai-gateway/src/alias.rs (TASK-AI-006 wiring).


§6 — Implementation skeleton

See §3 for type defs + parser + staleness module. Hot-reload follows TASK-AI-014's pattern (250ms debounce, ArcSwap pointer-swap, parse-error keeps cache):

// services/ai-gateway/src/zdr/watch.rs
pub fn spawn_watcher_with_revocation_detection(config_path: &Path) {
    let path = config_path.to_path_buf();
    std::thread::spawn(move || {
        let (tx, rx) = std::sync::mpsc::channel();
        let mut watcher = notify::recommended_watcher(tx).unwrap();
        watcher.watch(&path, notify::RecursiveMode::NonRecursive).unwrap();

        for ev in rx {
            if ev.is_err() { continue; }
            std::thread::sleep(std::time::Duration::from_millis(250));
            // Drain debounce window.
            while rx.try_recv().is_ok() {}

            match reload_with_diff(&path) {
                Ok((new_size, revocations)) => {
                    tracing::info!(count = new_size, "zdr_table_reloaded");
                    for (key, prev_is_zdr) in revocations {
                        if prev_is_zdr {
                            tracing::warn!(provider = ?key.0, model = %key.1,
                                          "zdr attestation REVOKED (was true, now false or missing)");
                            metrics::revocation(&key);
                        }
                    }
                }
                Err(e) => {
                    tracing::warn!(error = %e, "zdr table reload failed; cache unchanged");
                    metrics::reload_failure(&e);
                }
            }
        }
    });
}

fn reload_with_diff(path: &Path) -> Result<(usize, Vec<((ProviderKind, String), bool)>), LoaderInitError> {
    let yaml = std::fs::read_to_string(path)?;
    let new = parse::parse_attestations(&yaml)?;
    let old = TABLE.get().unwrap().load();
    let mut revocations = vec![];
    for (key, old_att) in old.iter() {
        let new_is_zdr = new.get(key).map(|a| a.is_zdr).unwrap_or(false);
        if old_att.is_zdr && !new_is_zdr {
            revocations.push((key.clone(), old_att.is_zdr));
        }
    }
    TABLE.get().unwrap().store(Arc::new(new.clone()));
    Ok((new.len(), revocations))
}

canonical::zdr_violation builder (added to TASK-AI-003's memory bridge):

pub mod canonical {
    pub fn zdr_violation(
        tenant_id: &str, agent_persona: &str, requested_alias: &str,
        resolved_provider: &ProviderKind, resolved_model: &str,
        attestation_present: bool, request_id: &str,
    ) -> AuditRow {
        AuditRow {
            kind: "ai.zdr_violation".into(),
            payload: serde_json::json!({
                "tenant_id": tenant_id,
                "agent_persona": agent_persona,
                "requested_alias": requested_alias,
                "resolved_provider": format!("{resolved_provider:?}").to_lowercase(),
                "resolved_model": resolved_model,
                "policy_requires_zdr": true,
                "attestation_present": attestation_present,
                "request_id": request_id,
            }),
            ..Default::default()
        }
    }
}

§7 — Dependencies

Code dependencies (other tasks/modules)

Concept dependencies (shared types)

Operational / external


§8 — Example payloads

Caller in TASK-AI-006 alias.rs

// TASK-AI-006 §1 #6 (modified to call zdr::is_zdr)
pub fn resolve(alias: &str, policy: &TenantPolicy) -> Result<(ProviderKind, String), AliasError> {
    let (provider, model) = ALIAS_MAP.get().unwrap().load().get(alias)
        .ok_or_else(|| AliasError::UnknownAlias(alias.into()))?;

    if policy.ai_policy.zdr_required && !zdr::is_zdr(&provider, &model) {
        let attestation = zdr::attestation_for(&provider, &model);
        return Err(AliasError::ZdrViolation {
            resolved_provider: provider, resolved_model: model.clone(), attestation,
        });
    }

    Ok((provider, model.clone()))
}

Audit row ai.zdr_violation

{
  "kind": "ai.zdr_violation",
  "ts_ns": 1747526400000000000,
  "payload": {
    "tenant_id": "tenant_alpha",
    "agent_persona": "cuo-cpo@0.4.1",
    "requested_alias": "chat.smart",
    "resolved_provider": "openai",
    "resolved_model": "gpt-4o",
    "policy_requires_zdr": true,
    "attestation_present": true,
    "request_id": "req_01HZK9R8M3X5C8Q4"
  }
}

HTTP refusal

HTTP/1.1 403 Forbidden
Content-Type: application/json

{
  "error": "zdr_violation",
  "resolved_provider": "openai",
  "resolved_model": "gpt-4o",
  "policy_requires_zdr": true,
  "contact": "ops@cyberos.world"
}

Attestation lookup for audit reporting

let att = zdr::attestation_for(&ProviderKind::Bedrock,
                              "anthropic.claude-3-5-sonnet-20241022-v2:0").unwrap();
println!("ZDR: {} | source: {} | attested: {} on {}",
    att.is_zdr, att.source_url, att.attested_by, att.verified_at);
// => ZDR: true | source: https://aws.amazon.com/bedrock/data-privacy/
//    attested: stephen@cyberos.world on 2026-05-15

Hot-reload INFO log (success)

INFO  zdr_table_reloaded count=12

Hot-reload WARN log (revocation)

WARN  provider=Bedrock model=anthropic.claude-3-5-sonnet-20241022-v2:0
      zdr attestation REVOKED (was true, now false or missing)

Hard-stale ERROR log

ERROR provider=Vertex model=gemini-2.0-pro verified_at=2025-04-15
      zdr attestation HARD-stale (>365d); forcing is_zdr=false

Weekly staleness CI output

$ cargo run --bin zdr-staleness-check -- config/zdr_attestations.yaml
Soft-stale entries (>90 days; refresh due):
  vertex/gemini-2.0-pro          verified_at=2026-02-10 (95 days old)
  openai/gpt-4o                  verified_at=2026-02-13 (92 days old)
2 entries flagged. Exiting non-zero.

§9 — Open questions

All resolved at authoring time. Items deferred to later tasks:


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
Missing ZDR attestation for (provider, model)HashMap miss in is_zdrReturns false (fail closed); metric lookups_total{outcome=missing}Operator adds entry to YAML
Provider revokes ZDR (true→false on hot-reload)Diff-detect in reload_with_difftracing::warn! + metric attestations_revoked_total increment; subsequent lookups return falseOperator notifies affected tenants
Provider entry deleted from YAML on reloadDiff sees old entry true, new entry absentCounts as revocation; same WARN+metric pathOperator confirms intent
YAML parse error at initLoaderInitError::SchemaGateway exits 1 (refuses to bind)Operator fixes YAML; redeploy
YAML parse error at hot-reloadReload fails; cache unchangedINFO log "reload failed"; metric reload_failure_totalOperator fixes YAML; next file-watch event triggers retry
Concurrent lookup + hot-reloadArcSwap atomicReader sees old or new state, never tornBy design (§1 #7)
Concurrent hot-reload + reload (rapid edits)250ms debounceOne reparse runs; events queueBy design (§1 #7)
source_url is HTTP not HTTPSvalidate_source_url parser checkLoaderInitError::InvalidSourceUrl → init failsOperator changes URL to HTTPS
attested_by is bare string (no @)validate_attested_by parser checkLoaderInitError::InvalidAttestor → init failsOperator uses <localpart>@cyberos.world
attested_by domain not in approved listvalidate_attested_by allow-list checkLoaderInitError::InvalidAttestor → init failsOperator uses approved domain or extends allow-list (PR review)
Required field missing (is_zdr, verified_at, source_url, attested_by)parse_one_attestation field checksLoaderInitError::Schema → init failsOperator fills missing field
Soft-stale entry (>90d)Weekly CI cron check; is_soft_staleCI fails non-zero; GitHub issue opened; metric attestations_stale_total incrementsOperator reviews provider policy; bumps verified_at
Hard-stale entry (>365d)is_hard_stale check in is_zdris_zdr forced to false regardless of recorded value; ERROR log + metric attestations_expired_totalOperator reverifies attestation immediately; if confirmed still ZDR, bumps verified_at
Tenant policy zdr_required missingTASK-AI-005 schema defaultDefaults to false (no enforcement for that tenant)Operator updates tenant policy YAML if ZDR is required
Alias resolves to provider not in tableis_zdr returns false; attestation_for returns NoneZdrViolation { attestation: None }Operator adds attestation OR removes the alias mapping
Audit row emit fails (memory bridge down)memory_writer::emit returns ErrRefusal still proceeds; sev-1 log ("ZDR refused but audit row failed")Operator investigates memory; TASK-AI-003 §10 covers
Revocation notification missed (operator absent)OTel alarm on attestations_revoked_totalAlarm pages on-callStandard incident response
notes field accidentally echoed in 403 response bodyIntegration test asserts notes absentTest fails → PR blockedHandler MUST scrub notes before serialising response
New provider added without entryis_zdr returns false → calls refused if policy requires ZDRNew-provider rollout PR includes attestation entryStandard PR process
notify watcher thread panicstokio observabilityWatcher dies; hot-reload stops; cache continues serving old statesev-2 alert; restart gateway
Double init (test re-entry)OnceCell::set returns ErrLoaderInitError::AlreadyInitialisedTests use reset_for_tests()

§11 — Notes


End of TASK-AI-015. Status: draft (10/10 target).