AID-R-001.001: Versioned Model Rollback & Restoration
Pillars: model
Lifecycle: improvement
Scope boundary
This sub-technique owns restoration of an incident-affected model-serving release by selecting and deploying an exact authority-approved pre-incident model and serving-image pair, hosted deployment record, prompt or guardrail configuration, or adapter attachment set from immutable versioned state, then reading the live identity back through an identity outside the backup-writer and deployment trust domains before cutover. It applies only when a trustworthy prior version and trusted cutoff exist. Registry labels, endpoint names, hosted model IDs, and tool success do not authorize recovery or establish live identity. It does not quarantine a suspect model, prove adapter eviction, or build a newly remediated model.
AID-R-001.002: AID-R-001.001 restores an exact trusted pre-incident version; AID-R-001.002 creates a newly remediated model through clean retraining or recovery learning. Use rollback when immutable trusted prior state exists. Use AID-R-001.002 when rollback is unavailable, incomplete, or untrustworthy; AID-R-001.001 must not silently fall back to retraining.
AID-E-003.001: AID-E-003.001 quarantines the suspect model; AID-R-001.001 deploys and verifies a separate known-good version. AID-E-003.001 makes the suspect digest ineligible for serving, preserves it as incident evidence, and routes the incident to recovery. Successful rollback does not prove that the compromised model was quarantined everywhere.
AID-E-003.005: AID-E-003.005 evicts compromised adapters and derivatives; AID-R-001.001 restores and verifies known-good model or adapter state. AID-R-001.001 consumes quarantine and tombstone evidence but owns only the rollback and live-release verification. A restored deployment does not prove incident-time adapter eviction across serving, registries, caches, and lineage.
Implementation guidance
- AID-R-001.001-G001: Maintain Object-Locked model versions and authority-signed digest-bound recovery manifests.
- AID-R-001.001-G002: Restore an authority-selected immutable model version and read the live model and serving-image identity back through a separate deployment read path.
- AID-R-001.001-G003: Restore hosted fine-tuned models, prompt/guardrail configurations, and LoRA or adapter attachments from a signed provider recovery manifest.
AID-R-001.001-G001
Concept:
Versioning alone permits privileged deletion and is not immutable retention. Create the bucket with S3 Object Lock enabled, enable versioning, enforce a retention mode and KMS encryption, and keep runtime and deployment identities outside the backup-writer role. Retention duration is a reviewed system policy input, not a framework-wide constant.
Provision retention-enforced model backups
# File: infrastructure/model_backups.tf
variable "model_backup_bucket_name" {
type = string
}
variable "model_backup_retention_days" {
type = number
validation {
condition = var.model_backup_retention_days > 0
error_message = "Retention must be a positive approved policy value."
}
}
variable "model_backup_kms_key_arn" {
type = string
}
resource "aws_s3_bucket" "model_backups" {
bucket = var.model_backup_bucket_name
object_lock_enabled = true
}
resource "aws_s3_bucket_versioning" "model_backups" {
bucket = aws_s3_bucket.model_backups.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_object_lock_configuration" "model_backups" {
bucket = aws_s3_bucket.model_backups.id
rule {
default_retention {
mode = "COMPLIANCE"
days = var.model_backup_retention_days
}
}
depends_on = [aws_s3_bucket_versioning.model_backups]
}
resource "aws_s3_bucket_server_side_encryption_configuration" "model_backups" {
bucket = aws_s3_bucket.model_backups.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
kms_master_key_id = var.model_backup_kms_key_arn
}
bucket_key_enabled = true
}
}
resource "aws_s3_bucket_public_access_block" "model_backups" {
bucket = aws_s3_bucket.model_backups.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
Independent promotion evidence
For every promoted model, the backup writer records the bucket, key, immutable version ID, object digest, serving-image digest, build attestation digest, and trusted cutoff. A separate release-security identity verifies the uploaded version by reading it back, recomputing its digest, and signs the canonical recovery manifest with a key unavailable to backup writers, runtime services, and deployers. Retain that manifest and signature in a separately administered Object-Locked evidence bucket or transparency log.
Verify the result
Action: Test retention with denied mutations and independent periodic readback for the complete signed population; never treat upload, registry tag, or version ID alone as restore-capable evidence.
AID-R-001.001-G002
When provider recovery applies:
Use rollback only when the recovery authority has selected a pre-incident model and serving-image pair from a trusted cutoff. A model registry may locate candidates, but registry tags do not authorize recovery. The signed manifest is the trust root and must identify the exact immutable object version and digests.
Verify the authority-signed manifest and immutable version
{
"schema_version": "aidefend.r001001-recovery-manifest/v1",
"control": "AID-R-001.001",
"system_or_tenant": "example-system",
"incident_id": "INC-EXAMPLE",
"release_id": "recovery-example",
"trusted_cutoff_at": "2026-07-10T10:00:00Z",
"model": {
"bucket": "model-backups",
"key": "models/example/model.tar.zst",
"version_id": "IMMUTABLE_VERSION_ID",
"sha256": "2e1301125954d3ee79208b29e1a1eff74449619f20414dc7eef4bf744b6ddf52"
},
"serving_image_digest": "registry.example.invalid/inference@sha256:c3ef7782ff3a7ea742414d953744f4b7491fdf99fbea3a61ae836a915726ee45",
"runtime": {
"connect_timeout_seconds": 5,
"read_timeout_seconds": 60,
"maximum_retry_attempts": 3,
"maximum_manifest_bytes": 262144,
"maximum_model_bytes": 8589934592,
"signature_timeout_seconds": 30
}
}: "${AIDEFEND_BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES:?load from the versioned recovery runtime profile}"
: "${AIDEFEND_BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS:?load from the same versioned recovery runtime profile}"
python restore/fetch_verified_model.py \
--manifest recovery-manifest.json \
--manifest-bundle recovery-manifest.json.sigstore.json \
--authority-key keys/recovery-authority.pub \
--output recovered-model.tar.zst
# File: restore/fetch_verified_model.py
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import re
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import boto3
from botocore.config import Config
SHA256 = re.compile(r"^[0-9a-f]{64}$")
IMAGE = re.compile(r"^[^@\s]+@sha256:[0-9a-f]{64}$")
BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES = int(
os.environ["AIDEFEND_BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES"]
)
BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS = float(
os.environ["AIDEFEND_BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS"]
)
def reject_constant(value: str) -> None:
raise ValueError(f"non-finite JSON number is forbidden: {value}")
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for name, item in pairs:
if name in value:
raise ValueError(f"duplicate JSON key: {name}")
value[name] = item
return value
def strict_json(raw: bytes) -> dict[str, Any]:
value = json.loads(
raw.decode("utf-8", errors="strict"),
object_pairs_hook=unique_object,
parse_constant=reject_constant,
)
if not isinstance(value, dict):
raise ValueError("manifest must contain one JSON object")
return value
def positive(value: object, name: str, *, integer: bool = False) -> float | int:
if (
isinstance(value, bool)
or not isinstance(value, (int, float))
or not math.isfinite(value)
or value <= 0
or (integer and not isinstance(value, int))
):
raise ValueError(f"{name} must be finite and positive")
return value
def read_limited(path: Path, maximum: int) -> bytes:
with path.open("rb") as source:
raw = source.read(maximum + 1)
if len(raw) > maximum:
raise ValueError(f"{path} exceeds the configured bound")
return raw
def write_private_snapshot(path: Path, raw: bytes) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o400)
with os.fdopen(descriptor, "wb") as target:
target.write(raw)
target.flush()
os.fsync(target.fileno())
def verified_manifest(
path: Path,
bundle: Path,
public_key: str,
) -> tuple[dict[str, Any], bytes]:
if BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES <= 0:
raise RuntimeError("bootstrap trust-document bound is invalid")
signature_timeout = float(
positive(
BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS,
"bootstrap signature verification timeout",
)
)
raw = read_limited(path, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES)
bundle_raw = read_limited(bundle, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES)
if not raw or not bundle_raw:
raise ValueError("manifest and Sigstore bundle must both be non-empty")
with tempfile.TemporaryDirectory(prefix="r001001-provider-") as directory:
snapshot_root = Path(directory)
os.chmod(snapshot_root, 0o700)
manifest_snapshot = snapshot_root / "recovery-manifest.json"
bundle_snapshot = snapshot_root / "recovery-manifest.json.sigstore.json"
write_private_snapshot(manifest_snapshot, raw)
write_private_snapshot(bundle_snapshot, bundle_raw)
subprocess.run(
[
"cosign",
"verify-blob",
"--key",
public_key,
"--bundle",
str(bundle_snapshot),
str(manifest_snapshot),
],
check=True,
capture_output=True,
text=True,
timeout=signature_timeout,
)
if read_limited(manifest_snapshot, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES) != raw:
raise RuntimeError("verified manifest snapshot changed")
if read_limited(bundle_snapshot, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES) != bundle_raw:
raise RuntimeError("verified Sigstore bundle snapshot changed")
document = strict_json(raw)
runtime = document.get("runtime")
if not isinstance(runtime, dict):
raise ValueError("signed runtime is required")
positive(runtime.get("signature_timeout_seconds"), "signed signature timeout")
maximum_manifest = int(
positive(runtime.get("maximum_manifest_bytes"), "maximum manifest bytes", integer=True)
)
if len(raw) > maximum_manifest:
raise ValueError("manifest exceeds its signed bound")
return document, raw
def required_text(value: object, name: str) -> str:
if not isinstance(value, str) or not value or "\x00" in value:
raise ValueError(f"{name} must be a non-empty string")
return value
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--manifest-bundle", type=Path, required=True)
parser.add_argument("--authority-key", required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.manifest_bundle != Path(str(args.manifest) + ".sigstore.json"):
raise ValueError("manifest bundle must be the sibling <manifest>.sigstore.json")
manifest, raw_manifest = verified_manifest(
args.manifest, args.manifest_bundle, args.authority_key
)
root_fields = {
"schema_version",
"control",
"system_or_tenant",
"incident_id",
"release_id",
"trusted_cutoff_at",
"model",
"serving_image_digest",
"runtime",
}
if (
set(manifest) != root_fields
or manifest.get("schema_version")
!= "aidefend.r001001-recovery-manifest/v1"
or manifest.get("control") != "AID-R-001.001"
):
raise ValueError("unexpected recovery-manifest schema")
for name in ("system_or_tenant", "incident_id", "release_id"):
required_text(manifest.get(name), name)
cutoff_text = required_text(
manifest.get("trusted_cutoff_at"), "trusted_cutoff_at"
)
if not cutoff_text.endswith("Z"):
raise ValueError("trusted_cutoff_at must be an RFC 3339 UTC timestamp")
try:
cutoff = datetime.fromisoformat(cutoff_text[:-1] + "+00:00")
except ValueError as exc:
raise ValueError("trusted_cutoff_at is malformed") from exc
if cutoff.tzinfo is None or cutoff.utcoffset() is None:
raise ValueError("trusted_cutoff_at must include a timezone")
cutoff = cutoff.astimezone(timezone.utc)
if not isinstance(manifest.get("serving_image_digest"), str) or IMAGE.fullmatch(
manifest["serving_image_digest"]
) is None:
raise ValueError("serving image must be pinned by SHA-256 digest")
model = manifest.get("model")
model_fields = {"bucket", "key", "version_id", "sha256"}
if not isinstance(model, dict) or set(model) != model_fields:
raise ValueError("model locator schema differs")
for name in ("bucket", "key", "version_id"):
required_text(model.get(name), f"model.{name}")
if (
not isinstance(model.get("sha256"), str)
or SHA256.fullmatch(model["sha256"]) is None
or set(model["sha256"]) == {"0"}
):
raise ValueError("model.sha256 must be a nonzero lowercase SHA-256 digest")
runtime = manifest["runtime"]
runtime_fields = {
"connect_timeout_seconds",
"read_timeout_seconds",
"maximum_retry_attempts",
"maximum_manifest_bytes",
"maximum_model_bytes",
"signature_timeout_seconds",
}
if not isinstance(runtime, dict) or set(runtime) != runtime_fields:
raise ValueError("signed runtime schema differs")
connect_timeout = float(
positive(runtime["connect_timeout_seconds"], "connect timeout")
)
read_timeout = float(positive(runtime["read_timeout_seconds"], "read timeout"))
maximum_retry_attempts = int(
positive(
runtime["maximum_retry_attempts"],
"maximum retry attempts",
integer=True,
)
)
maximum_model = int(
positive(runtime["maximum_model_bytes"], "maximum model bytes", integer=True)
)
client = boto3.client(
"s3",
config=Config(
connect_timeout=connect_timeout,
read_timeout=read_timeout,
retries={"mode": "standard", "max_attempts": maximum_retry_attempts},
),
)
response = client.get_object(
Bucket=model["bucket"],
Key=model["key"],
VersionId=model["version_id"],
ChecksumMode="ENABLED",
)
if (
response.get("VersionId") != model["version_id"]
or response.get("ResponseMetadata", {}).get("HTTPStatusCode") != 200
):
response["Body"].close()
raise RuntimeError("S3 readback did not return the exact immutable version")
last_modified = response.get("LastModified")
if (
not isinstance(last_modified, datetime)
or last_modified.tzinfo is None
or last_modified.utcoffset() is None
):
response["Body"].close()
raise RuntimeError("S3 LastModified is absent or malformed")
last_modified = last_modified.astimezone(timezone.utc)
if last_modified > cutoff:
response["Body"].close()
raise RuntimeError("S3 immutable version was modified after the trusted cutoff")
content_length = response.get("ContentLength")
if (
isinstance(content_length, bool)
or not isinstance(content_length, int)
or content_length <= 0
or content_length > maximum_model
):
response["Body"].close()
raise RuntimeError("S3 object length is absent, empty, or outside the signed bound")
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(args.output, flags, 0o600)
digest = hashlib.sha256()
observed = 0
try:
with os.fdopen(descriptor, "wb") as handle:
try:
for chunk in iter(lambda: response["Body"].read(1024 * 1024), b""):
observed += len(chunk)
if observed > maximum_model:
raise RuntimeError("model stream exceeds the signed byte bound")
digest.update(chunk)
handle.write(chunk)
finally:
response["Body"].close()
handle.flush()
os.fsync(handle.fileno())
if observed != content_length or digest.hexdigest() != model["sha256"]:
raise RuntimeError("restored model length or digest differs")
except BaseException:
args.output.unlink(missing_ok=True)
raise
print(
json.dumps(
{
"schema_version": "aidefend.r001001-provider-fetch/v1",
"control": "AID-R-001.001",
"release_id": manifest["release_id"],
"manifest_sha256": hashlib.sha256(raw_manifest).hexdigest(),
"model_sha256": digest.hexdigest(),
"model_bytes": observed,
"s3_version_id": response["VersionId"],
"s3_last_modified": last_modified.isoformat().replace("+00:00", "Z"),
"trusted_cutoff_at": cutoff.isoformat().replace("+00:00", "Z"),
"serving_image_digest": manifest["serving_image_digest"],
"result": "PASS",
},
sort_keys=True,
separators=(",", ":"),
)
)
if __name__ == "__main__":
main()
Deploy and verify outside the deployment trust domain
Deploy the verified bytes and the exact image digest through the approved rollback workflow; never substitute a model name, mutable tag, default registry version, or serialization-specific filename. The deployment owner signs a receipt binding the authority manifest, fetched model digest, image digest, workflow run, and target release. A separate read-only verifier fetches workload identity and readiness data directly from the orchestrator and service endpoint, verifies that the running model digest and image digest exactly match the manifest, replays the approved smoke suite, and signs aidefend.r001001-verification/v1.
Fail semantics: Missing manifest bundle, inaccessible version, retention failure, digest mismatch, mutable image tag, post-cutoff artifact, deployment/verification key reuse, stale verification, or inability to observe the live model and image identity blocks cutover. Tool success or a healthy endpoint without exact identity is not proof.
AID-R-001.001-G003
Use a provider-bound, signed recovery snapshot
For Azure AI Services, bind the exact subscription, resource group, account, deployment, model, SKU, RAI policy, managed identity, API version, immutable prompt/adapter digests, and runtime limits in one recovery-authority-signed manifest. The example below uses the documented 2024-10-01 deployment API. Other providers need their own response schema; an Azure control-plane readback must never be used as proof of application-owned prompt or adapter bytes.
{
"schema_version": "aidefend.r001001-hosted-model-manifest/v1",
"control": "AID-R-001.001",
"release_id": "recovery-20260720-01",
"incident_id": "INC-20260720-001",
"management_origin": "https://management.azure.com",
"api_version": "2024-10-01",
"deployment": {
"subscription_id": "00000000-0000-4000-8000-000000000001",
"resource_group": "rg-ai-production",
"account_name": "aoai-production",
"deployment_name": "support-bot-production",
"managed_identity_client_id": "11111111-1111-4111-8111-111111111111",
"model_format": "OpenAI",
"model_name": "gpt-4.1-mini-2025-04-14.ft-production",
"model_version": "1",
"sku_name": "Standard",
"capacity": 50,
"rai_policy_name": "production-low-risk-v7"
},
"prompt_bundle": {"artifact_uri": "s3://ai-release-bom/support-bot/prompt-v41.json", "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},
"adapter_bundle": {"enabled": false, "artifact_uri": null, "sha256": null},
"application": {
"origin": "https://release-state.example.invalid",
"target_id": "support-bot-production",
"release_id": "recovery-20260720-01",
"consumer_ids": ["support-bot-replica-a", "support-bot-replica-b"]
},
"runtime": {"connect_timeout_seconds": 5, "read_timeout_seconds": 20, "operation_timeout_seconds": 600, "poll_interval_seconds": 5, "signature_timeout_seconds": 30, "maximum_manifest_bytes": 262144, "maximum_response_bytes": 1048576, "maximum_artifact_bytes": 16777216}
}Before reading this untrusted manifest, load AIDEFEND_BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES and AIDEFEND_BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS from the same local, versioned recovery runtime profile. Manifest runtime values become usable only after the manifest and its Sigstore bundle verify as an unchanged pair.
Verify the immutable snapshot, restore with an in-process identity, and bound every read
# File: restore/restore_hosted_azure.py
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import subprocess
import tempfile
import time
from pathlib import Path
from typing import Any
from urllib.parse import quote, urlsplit
import httpx
import requests as azure_requests
from azure.core.pipeline.transport import RequestsTransport
from azure.identity import ManagedIdentityCredential
BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES = int(os.environ["AIDEFEND_BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES"])
BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS = float(os.environ["AIDEFEND_BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS"])
HEX = set("0123456789abcdef")
ROOT_FIELDS = {"schema_version", "control", "release_id", "incident_id", "management_origin", "api_version", "deployment", "prompt_bundle", "adapter_bundle", "application", "runtime"}
APPLICATION_FIELDS = {"origin", "target_id", "release_id", "consumer_ids"}
DEPLOYMENT_FIELDS = {"subscription_id", "resource_group", "account_name", "deployment_name", "managed_identity_client_id", "model_format", "model_name", "model_version", "sku_name", "capacity", "rai_policy_name"}
RUNTIME_FIELDS = {"connect_timeout_seconds", "read_timeout_seconds", "operation_timeout_seconds", "poll_interval_seconds", "signature_timeout_seconds", "maximum_manifest_bytes", "maximum_response_bytes", "maximum_artifact_bytes"}
def reject_constant(value: str) -> None:
raise ValueError(f"non-finite JSON number is forbidden: {value}")
def unique_object(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
value: dict[str, Any] = {}
for name, item in pairs:
if name in value:
raise ValueError(f"duplicate JSON key: {name}")
value[name] = item
return value
def strict_json(raw: bytes) -> dict[str, Any]:
value = json.loads(raw.decode("utf-8", errors="strict"), object_pairs_hook=unique_object, parse_constant=reject_constant)
if not isinstance(value, dict):
raise ValueError("one JSON object is required")
return value
def canonical(value: Any) -> bytes:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False).encode("utf-8")
def digest(value: object, name: str) -> str:
if not isinstance(value, str) or len(value) != 64 or set(value) - HEX or set(value) == {"0"}:
raise ValueError(f"{name} must be a nonzero lowercase SHA-256 digest")
return value
def positive(value: object, name: str, *, integer: bool = False) -> float | int:
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value <= 0:
raise ValueError(f"{name} must be finite and positive")
if integer and not isinstance(value, int):
raise ValueError(f"{name} must be an integer")
return value
def read_limited(path: Path, maximum: int) -> bytes:
with path.open("rb") as source:
raw = source.read(maximum + 1)
if len(raw) > maximum:
raise ValueError(f"{path} exceeds the signed size bound")
return raw
def write_private_snapshot(path: Path, raw: bytes) -> None:
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(path, flags, 0o400)
with os.fdopen(descriptor, "wb") as target:
target.write(raw)
target.flush()
os.fsync(target.fileno())
def verified_snapshot(path: Path, bundle: Path, public_key: str) -> tuple[dict[str, Any], bytes]:
if BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES < 1:
raise RuntimeError("verified bootstrap profile has an invalid trust-document bound")
deadline = float(positive(BOOTSTRAP_SIGNATURE_VERIFY_TIMEOUT_SECONDS, "bootstrap signature verification timeout"))
raw = read_limited(path, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES)
bundle_raw = read_limited(bundle, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES)
if not raw or not bundle_raw:
raise ValueError("manifest and Sigstore bundle must both be non-empty")
with tempfile.TemporaryDirectory(prefix="r001001-") as directory:
snapshot_root = Path(directory)
os.chmod(snapshot_root, 0o700)
manifest_snapshot = snapshot_root / "hosted-model-manifest.json"
bundle_snapshot = snapshot_root / "hosted-model-manifest.json.sigstore.json"
write_private_snapshot(manifest_snapshot, raw)
write_private_snapshot(bundle_snapshot, bundle_raw)
subprocess.run(["cosign", "verify-blob", "--key", public_key, "--bundle", str(bundle_snapshot), str(manifest_snapshot)], check=True, capture_output=True, text=True, timeout=deadline)
if read_limited(manifest_snapshot, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES) != raw:
raise RuntimeError("verified manifest snapshot changed")
if read_limited(bundle_snapshot, BOOTSTRAP_MAXIMUM_TRUST_DOCUMENT_BYTES) != bundle_raw:
raise RuntimeError("verified Sigstore bundle snapshot changed")
document = strict_json(raw)
runtime = document.get("runtime")
if not isinstance(runtime, dict) or set(runtime) != RUNTIME_FIELDS:
raise ValueError("signed runtime schema differs")
positive(runtime["signature_timeout_seconds"], "signed signature timeout")
if len(raw) > positive(runtime["maximum_manifest_bytes"], "maximum manifest bytes", integer=True):
raise ValueError("manifest exceeds its signed size bound")
return document, raw
def read_response(response: httpx.Response, maximum: int) -> dict[str, Any]:
if 300 <= response.status_code < 400 or response.headers.get("location"):
raise RuntimeError("redirects are forbidden")
if response.headers.get("content-encoding", "identity").lower() != "identity":
raise RuntimeError("unexpected content encoding")
media_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if media_type != "application/json":
raise RuntimeError("Azure response is not application/json")
chunks: list[bytes] = []
observed = 0
for chunk in response.iter_bytes():
observed += len(chunk)
if observed > maximum:
raise RuntimeError("Azure response exceeds the signed decoded-body bound")
chunks.append(chunk)
response.raise_for_status()
return strict_json(b"".join(chunks))
def request_json(client: httpx.Client, method: str, url: str, maximum: int, *, body: dict[str, Any] | None = None, extra_headers: dict[str, str] | None = None) -> dict[str, Any]:
headers = {"Accept": "application/json", "Accept-Encoding": "identity"}
content = None
if body is not None:
headers["Content-Type"] = "application/json"
content = canonical(body)
if extra_headers is not None:
headers.update(extra_headers)
with client.stream(method, url, headers=headers, content=content) as response:
return read_response(response, maximum)
def verify_artifact(spec: dict[str, Any], path: Path | None, maximum: int, label: str) -> str | None:
if label == "adapter" and spec == {"enabled": False, "artifact_uri": None, "sha256": None}:
if path is not None:
raise ValueError("disabled adapter must not receive bytes")
return None
required = {"artifact_uri", "sha256"} if label == "prompt" else {"enabled", "artifact_uri", "sha256"}
if set(spec) != required or (label == "adapter" and spec.get("enabled") is not True) or not isinstance(spec.get("artifact_uri"), str) or path is None:
raise ValueError(f"{label} artifact schema differs")
expected = digest(spec.get("sha256"), f"{label} sha256")
raw = read_limited(path, maximum)
if not hashlib.sha256(raw).hexdigest() == expected:
raise RuntimeError(f"{label} artifact digest differs")
return expected
def exact_origin(value: object) -> str:
if not isinstance(value, str):
raise ValueError("application origin must be a string")
parsed = urlsplit(value)
if parsed.scheme != "https" or not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.path not in {"", "/"} or parsed.query or parsed.fragment:
raise ValueError("application origin must be one exact HTTPS origin")
return f"https://{parsed.netloc.lower()}"
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument("--manifest-bundle", type=Path, required=True)
parser.add_argument("--authority-key", required=True)
parser.add_argument("--prompt-bundle", type=Path, required=True)
parser.add_argument("--adapter-bundle", type=Path)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if args.manifest_bundle != Path(str(args.manifest) + ".sigstore.json"):
raise ValueError("manifest bundle must be the sibling <manifest>.sigstore.json")
manifest, raw_manifest = verified_snapshot(args.manifest, args.manifest_bundle, args.authority_key)
if set(manifest) != ROOT_FIELDS or manifest.get("schema_version") != "aidefend.r001001-hosted-model-manifest/v1" or manifest.get("control") != "AID-R-001.001":
raise ValueError("hosted recovery manifest schema differs")
if manifest["management_origin"] != "https://management.azure.com" or manifest["api_version"] != "2024-10-01":
raise ValueError("unexpected Azure management origin or API version")
deployment = manifest["deployment"]
runtime = manifest["runtime"]
if not isinstance(deployment, dict) or set(deployment) != DEPLOYMENT_FIELDS:
raise ValueError("deployment schema differs")
for name in DEPLOYMENT_FIELDS - {"capacity", "managed_identity_client_id"}:
if not isinstance(deployment[name], str) or not deployment[name]:
raise ValueError(f"deployment {name} is required")
if deployment["managed_identity_client_id"] is not None and (not isinstance(deployment["managed_identity_client_id"], str) or not deployment["managed_identity_client_id"]):
raise ValueError("managed identity client ID is invalid")
positive(deployment["capacity"], "capacity", integer=True)
maximum_response = int(positive(runtime["maximum_response_bytes"], "maximum response bytes", integer=True))
maximum_artifact = int(positive(runtime["maximum_artifact_bytes"], "maximum artifact bytes", integer=True))
prompt_digest = verify_artifact(manifest["prompt_bundle"], args.prompt_bundle, maximum_artifact, "prompt")
adapter_digest = verify_artifact(manifest["adapter_bundle"], args.adapter_bundle, maximum_artifact, "adapter")
application = manifest["application"]
if not isinstance(application, dict) or set(application) != APPLICATION_FIELDS:
raise ValueError("application restore schema differs")
application_origin = exact_origin(application["origin"])
if application["release_id"] != manifest["release_id"]:
raise RuntimeError("application release binding differs")
for name in ("target_id", "release_id"):
if not isinstance(application[name], str) or not application[name] or any(character in application[name] for character in "/\\?#\x00"):
raise ValueError(f"unsafe application {name}")
consumers = application["consumer_ids"]
if not isinstance(consumers, list) or not consumers or consumers != sorted(set(consumers)) or any(not isinstance(item, str) or not item or any(character in item for character in "/\\?#\x00") for item in consumers):
raise ValueError("application consumer population must be sorted, unique, and non-empty")
for name in ("subscription_id", "resource_group", "account_name", "deployment_name"):
if any(character in deployment[name] for character in "/\\?#\x00"):
raise ValueError(f"deployment {name} contains a path delimiter")
segments = ["subscriptions", deployment["subscription_id"], "resourceGroups", deployment["resource_group"], "providers", "Microsoft.CognitiveServices", "accounts", deployment["account_name"], "deployments", deployment["deployment_name"]]
resource_path = "/" + "/".join(str(segment) for segment in segments)
request_path = "/" + "/".join(quote(str(segment), safe="") for segment in segments)
url = manifest["management_origin"] + request_path + "?api-version=" + quote(manifest["api_version"], safe="")
expected_id = resource_path.lower()
identity_session = azure_requests.Session()
identity_session.trust_env = False
identity_transport = RequestsTransport(session=identity_session, session_owner=True, use_env_settings=False, connection_timeout=float(runtime["connect_timeout_seconds"]), read_timeout=float(runtime["read_timeout_seconds"]))
credential = ManagedIdentityCredential(client_id=deployment["managed_identity_client_id"], transport=identity_transport)
try:
token = credential.get_token("https://management.azure.com/.default").token
finally:
credential.close()
timeout = httpx.Timeout(connect=float(positive(runtime["connect_timeout_seconds"], "connect timeout")), read=float(positive(runtime["read_timeout_seconds"], "read timeout")), write=float(runtime["read_timeout_seconds"]), pool=float(runtime["connect_timeout_seconds"]))
body = {"sku": {"name": deployment["sku_name"], "capacity": deployment["capacity"]}, "properties": {"model": {"format": deployment["model_format"], "name": deployment["model_name"], "version": deployment["model_version"]}, "raiPolicyName": deployment["rai_policy_name"]}}
with httpx.Client(headers={"Authorization": "Bearer " + token}, timeout=timeout, follow_redirects=False, trust_env=False) as client:
request_json(client, "PUT", url, maximum_response, body=body)
deadline = time.monotonic() + float(positive(runtime["operation_timeout_seconds"], "operation timeout"))
observed: dict[str, Any] = {}
while time.monotonic() < deadline:
observed = request_json(client, "GET", url, maximum_response)
state = (observed.get("properties") or {}).get("provisioningState")
if state == "Succeeded":
break
if state in {"Failed", "Canceled"}:
raise RuntimeError(f"Azure deployment ended in {state}")
time.sleep(float(positive(runtime["poll_interval_seconds"], "poll interval")))
else:
raise TimeoutError("Azure deployment did not converge before the signed deadline")
properties = observed.get("properties")
model = properties.get("model") if isinstance(properties, dict) else None
sku = observed.get("sku")
if not isinstance(properties, dict) or not isinstance(model, dict) or not isinstance(sku, dict):
raise RuntimeError("Azure deployment readback schema differs")
expected = {"id": expected_id, "name": deployment["deployment_name"], "model_format": deployment["model_format"], "model_name": deployment["model_name"], "model_version": deployment["model_version"], "sku_name": deployment["sku_name"], "capacity": deployment["capacity"], "rai_policy_name": deployment["rai_policy_name"]}
actual = {"id": str(observed.get("id", "")).rstrip("/").lower(), "name": observed.get("name"), "model_format": model.get("format"), "model_name": model.get("name"), "model_version": model.get("version"), "sku_name": sku.get("name"), "capacity": sku.get("capacity"), "rai_policy_name": properties.get("raiPolicyName")}
if actual != expected:
raise RuntimeError(f"Azure deployment readback differs: {actual}")
application_body = {
"release_id": application["release_id"],
"target_id": application["target_id"],
"prompt_bundle": manifest["prompt_bundle"],
"adapter_bundle": manifest["adapter_bundle"],
"expected_consumer_ids": consumers,
}
application_path = "/v1/releases/" + quote(application["release_id"], safe="") + "/targets/" + quote(application["target_id"], safe="") + "/effective-state"
idempotency_key = manifest["release_id"] + ":" + hashlib.sha256(canonical(application_body)).hexdigest()
application_timeout = httpx.Timeout(connect=float(positive(runtime["connect_timeout_seconds"], "connect timeout")), read=float(positive(runtime["read_timeout_seconds"], "read timeout")), write=float(runtime["read_timeout_seconds"]), pool=float(runtime["connect_timeout_seconds"]))
expected_consumers = [
{
"consumer_id": consumer_id,
"release_id": application["release_id"],
"prompt_sha256": prompt_digest,
"adapter_sha256": adapter_digest,
"loaded": True,
}
for consumer_id in consumers
]
with httpx.Client(
verify=os.environ["APPLICATION_CA_FILE"],
cert=(os.environ["APPLICATION_CLIENT_CERT"], os.environ["APPLICATION_CLIENT_KEY"]),
timeout=application_timeout,
follow_redirects=False,
trust_env=False,
) as application_client:
request_json(
application_client,
"PUT",
application_origin + application_path,
maximum_response,
body=application_body,
extra_headers={"Idempotency-Key": idempotency_key},
)
deadline = time.monotonic() + float(positive(runtime["operation_timeout_seconds"], "operation timeout"))
application_observed: dict[str, Any] = {}
while time.monotonic() < deadline:
application_observed = request_json(application_client, "GET", application_origin + application_path, maximum_response)
exact_application_state = {
"release_id": application["release_id"],
"target_id": application["target_id"],
"prompt_sha256": prompt_digest,
"adapter_sha256": adapter_digest,
"consumers": expected_consumers,
}
if application_observed == exact_application_state:
break
time.sleep(float(positive(runtime["poll_interval_seconds"], "poll interval")))
else:
raise TimeoutError("application-owned prompt/adapter state did not converge on every declared consumer")
receipt = {"schema_version": "aidefend.r001001-hosted-readback/v1", "result": "PASS", "release_id": manifest["release_id"], "incident_id": manifest["incident_id"], "manifest_sha256": hashlib.sha256(raw_manifest).hexdigest(), "deployment_resource_id": expected_id, "provider_readback_sha256": hashlib.sha256(canonical(observed)).hexdigest(), "application_target_id": application["target_id"], "application_effective_state_sha256": hashlib.sha256(canonical(application_observed)).hexdigest(), "consumer_ids": consumers, "prompt_bundle_sha256": prompt_digest, "adapter_bundle_sha256": adapter_digest}
with args.output.open("xb") as target:
target.write(canonical(receipt) + b"\n")
target.flush()
os.fsync(target.fileno())
print(json.dumps(receipt, sort_keys=True))
if __name__ == "__main__":
main()
Run this under a workload identity limited to the named deployment. The token exists only inside the Python process and HTTP header; it is never placed in a shell variable, command line, subprocess argument, output, or log. The Azure PUT/GET proves only provider-owned model-deployment fields. The separately authenticated application adapter applies the prompt and adapter locators to the exact non-serving recovery release, then reads every declared consumer and requires each one to report the same loaded byte digests before the combined receipt can be PASS. A separate release verifier re-runs manifest/bundle and artifact-digest checks, performs both authoritative readbacks, compares the complete consumer population, and signs the result. Test a sibling target or release, a missing or stale replica, prompt/adapter fetch failure, partial provider or application apply, retry after timeout, redirect, wrong origin/API version, duplicate keys, non-finite values, oversized bodies, stale or changing manifest bytes, and every readback mismatch. Keep the recovery release non-serving on failure; Azure control-plane success alone never authorizes cutover.
Open-source tools
- MLflow Model Registry
- DVC
- AWS CLI v2 (Apache-2.0)
- Azure CLI (MIT)
- Google Cloud Storage client library for Python (Apache-2.0)
- GitLab Community Edition CI/CD
- Jenkins
- sha256sum (GNU Coreutils)
- GnuPG
Commercial / hosted tools
- Databricks Models in Unity Catalog
- Amazon SageMaker AI Model Registry
- Google Vertex AI Model Registry
- GitHub Actions
- Amazon Bedrock
- Azure OpenAI in Microsoft Foundry
- Google Vertex AI
- JFrog Artifactory
- Veeam Data Platform
- Rubrik Security Cloud
- Cohesity Data Cloud
Threat mappings
MITRE ATLAS
- AML.T0011 User Execution
- AML.T0115 Publish Poisoned AI Artifacts
- AML.T0011.000 User Execution: Unsafe AI Artifacts
- AML.T0018.003 Manipulate AI Model: Modify Prompt Construction Logic
- AML.T0018 Manipulate AI Model
- AML.T0115.001 Publish Poisoned AI Artifacts: Models
- AML.T0076 Corrupt AI Model
- AML.T0031 Erode AI Model Integrity (rollback restores model integrity)
- AML.T0018.000 Manipulate AI Model: Poison AI Model
- AML.T0018.001 Manipulate AI Model: Modify AI Model Architecture
- AML.T0018.002 Manipulate AI Model: Embed Malware
MAESTRO
- Backdoor Attacks (L1)
- Supply Chain Attacks (L3)
- Compromised Container Images (L4)
- Supply Chain Attacks (Cross-Layer)
OWASP LLM Top 10 2026
- LLM04:2026 Supply Chain
- LLM05:2026 Data and Model Poisoning
OWASP ML Top 10 2023
- ML06:2023 AI Supply Chain Attacks (rollback to pre-compromise version removes supply chain artifacts)
- ML07:2023 Transfer Learning Attack (rollback reverses effects of compromised transfer learning)
- ML10:2023 Model Poisoning
OWASP Top 10 for Agentic Applications 2026
- ASI04:2026 Agentic Supply Chain Vulnerabilities
NIST Adversarial Machine Learning 2025
- NISTAML.011 Model Poisoning (Availability)
- NISTAML.023 Backdoor Poisoning
- NISTAML.026 Model Poisoning (Integrity)
- NISTAML.051 Model Poisoning (Supply Chain)
Cisco Integrated AI Security and Safety Framework
- AISubtech-5.2.1 Agent Profile Tampering (Restores compromised prompt/guardrail configuration to an authority-selected prior version)
- AISubtech-9.2.2 Backdoors and Trojans (Restores a trusted pre-incident model or adapter; suspect-artifact eviction is separate)
- AITech-5.2 Configuration Persistence (Restores compromised prompt/guardrail configuration to an authority-selected prior version)
Google Secure AI Framework 2.0 - Risks
- MST: Model Source Tampering (rollback restores pre-tampering model artifact with integrity verification)
- MDT: Model Deployment Tampering (rollback redeploys trusted serving stack from verified backup)
Databricks AI Security Framework 3.0
- Model 7.1: Backdoor machine learning / Trojaned model (rollback restores pre-backdoor model version)
- Model 7.3: ML Supply chain vulnerabilities (rollback to pre-compromise version removes supply chain artifacts)