AID-M-003.001: Model Card & Datasheet Generation
Pillars: model
Lifecycle: building, validation
Scope boundary
Owns generation, completeness validation, signing, release binding, retention, and publication of model cards and dataset datasheets. It documents evaluation claims and links their evidence but does not execute the underlying evaluation.
AID-M-007.002: AID-M-003.001 documents safety-boundary conformance results and binds them to the release record; AID-M-007.002 executes the deterministic safety-boundary release gate. A complete model card is not proof that every governed conformance case passed.
AID-M-007.003: AID-M-003.001 publishes fairness and disparity claims with references to evidence; AID-M-007.003 computes and independently replays the applicable fairness release evaluation. Documentation may report the result, but it must not become the evaluator or substitute for population-complete fairness evidence.
Implementation guidance
- AID-M-003.001-G001: Use a standardized toolkit to programmatically generate model cards.
- AID-M-003.001-G002: Create and maintain 'Datasheets for Datasets' to document data provenance, composition, and collection processes.
- AID-M-003.001-G003: Integrate documentation generation and validation into a CI/CD pipeline.
- AID-M-003.001-G004: Store and version control documentation in a centralized, accessible repository or model registry.
- AID-M-003.001-G005: Publish a signed CycloneDX model card bound to the release and to independently owned safety-boundary, fairness, and conformance evidence.
AID-M-003.001-G001
Concept:
Instead of manually writing docs in Confluence, generate a model card as part of the pipeline from release metadata. Use a maintained, schema-validated path that emits CycloneDX 1.7 model-card / AI/ML-BOM JSON plus Markdown for reviewers. Google's model-card-toolkit is archived; keep it only as a legacy adapter if your organization already depends on it, not as the primary implementation path.
Step 1: Install validation dependencies
# requirements.lock is reviewed, hash-pinned, and bound to the signed documentation profile.
python -m pip install --require-hashes --no-deps -r requirements.lockStep 2: Generate the Model Card in Your Pipeline
After model evaluation, call the following script. It: (1) creates an output directory, (2) fills in core metadata, (3) attaches quantitative metrics, and (4) writes a CycloneDX JSON model card and Markdown summary that you can archive in MLflow or attach to a pull request.
from __future__ import annotations
# File: modeling/generate_model_card.py
import json
import uuid
from datetime import datetime, timezone
from pathlib import Path
import yaml
from jsonschema import Draft7Validator, FormatChecker
OUTPUT_DIR = Path("model_card_output")
METADATA_PATH = Path("model_release_metadata.yaml")
CYCLONEDX_SCHEMA_PATH = Path("schemas/bom-1.7.schema.json")
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
release = yaml.safe_load(METADATA_PATH.read_text(encoding="utf-8"))
required_top = {"model", "dataset", "metrics", "use_cases", "limitations"}
if set(release) != required_top:
raise ValueError(f"release metadata must contain exactly {sorted(required_top)}")
model = release["model"]
dataset = release["dataset"]
metrics = release["metrics"]
for key in ("name", "version", "task", "architecture_family", "architecture", "sha256"):
if not isinstance(model.get(key), str) or not model[key]:
raise ValueError(f"model.{key} is required")
for key in ("name", "version", "sha256", "classification"):
if not isinstance(dataset.get(key), str) or not dataset[key]:
raise ValueError(f"dataset.{key} is required")
if len(model["sha256"]) != 64:
raise ValueError("model.sha256 must be a SHA-256 hex digest")
if len(dataset["sha256"]) != 64:
raise ValueError("dataset.sha256 must be a SHA-256 hex digest")
if not isinstance(metrics, dict) or not metrics:
raise ValueError("at least one metric is required")
model_ref = f"model:{model['name']}@{model['version']}"
dataset_ref = f"dataset:{dataset['name']}@{dataset['version']}"
performance_metrics = [
{"type": name, "value": str(value)}
for name, value in sorted(metrics.items())
]
bom = {
"bomFormat": "CycloneDX",
"specVersion": "1.7",
"serialNumber": f"urn:uuid:{uuid.uuid4()}",
"version": 1,
"metadata": {
"timestamp": datetime.now(timezone.utc).isoformat(),
"lifecycles": [{"phase": "post-build"}],
},
"components": [
{
"bom-ref": model_ref,
"type": "machine-learning-model",
"name": model["name"],
"version": model["version"],
"hashes": [{"alg": "SHA-256", "content": model["sha256"]}],
"modelCard": {
"bom-ref": f"{model_ref}:model-card",
"modelParameters": {
"approach": {"type": model.get("learning_type", "supervised")},
"task": model["task"],
"architectureFamily": model["architecture_family"],
"modelArchitecture": model["architecture"],
"datasets": [{"ref": dataset_ref}],
"inputs": model.get("inputs", [{"format": "tabular"}]),
"outputs": model.get("outputs", [{"format": "probability"}]),
},
"quantitativeAnalysis": {
"performanceMetrics": performance_metrics
},
"considerations": {
"useCases": release["use_cases"],
"technicalLimitations": release["limitations"],
"ethicalConsiderations": [
{
"name": "High-impact decision support",
"mitigationStrategy": "Use human review before adverse action.",
}
],
},
},
},
{
"bom-ref": dataset_ref,
"type": "data",
"name": dataset["name"],
"version": dataset["version"],
"hashes": [{"alg": "SHA-256", "content": dataset["sha256"]}],
"properties": [
{"name": "aidefend:data-classification", "value": dataset["classification"]},
{"name": "aidefend:data-role", "value": dataset.get("role", "training")},
],
},
],
"dependencies": [{"ref": model_ref, "dependsOn": [dataset_ref]}],
}
schema = json.loads(CYCLONEDX_SCHEMA_PATH.read_text(encoding="utf-8"))
Draft7Validator(schema, format_checker=FormatChecker()).validate(bom)
json_path = OUTPUT_DIR / f"{model['name']}_model_card.cdx.json"
json_path.write_text(json.dumps(bom, indent=2, sort_keys=True), encoding="utf-8")
md_path = OUTPUT_DIR / f"{model['name']}_model_card.md"
md_path.write_text(
"\n".join([
f"# {model['name']} {model['version']}",
"",
f"- Task: {model['task']}",
f"- Architecture: {model['architecture_family']} / {model['architecture']}",
f"- Dataset: {dataset['name']} {dataset['version']} ({dataset['sha256']})",
"",
"## Metrics",
*[f"- {name}: {value}" for name, value in sorted(metrics.items())],
"",
"## Intended Use",
*[f"- {item}" for item in release["use_cases"]],
"",
"## Limitations",
*[f"- {item}" for item in release["limitations"]],
]),
encoding="utf-8",
)
print(f"Model card JSON written to {json_path}")
print(f"Model card Markdown written to {md_path}")
Action: Bake generate_model_card.py into CI/CD after model evaluation. The generated CycloneDX JSON and Markdown files become auditable artifacts tied to that specific model version.
AID-M-003.001-G002
Concept:
A datasheet formalizes provenance: where the data came from, how it was collected, and what it should/shouldn't be used for. This is critical for compliance, legal review, and security forensics. Store each datasheet in Git next to the dataset pointer (DVC file), so every dataset version has auditable metadata.
Define a Datasheet Template
# File: data/credit_card_transactions_v2.yaml
datasheet_version: 1.0
dataset_name: "Credit Card Transactions V2"
dataset_hash_manifest: "data/credit_card_transactions_v2.sha256" # detached-signed manifest verified by the release gate
motivation:
purpose: "To train a model to detect fraudulent transactions."
who_created: "Internal Data Analytics Team"
composition:
instance_type: "Individual credit card transactions."
num_instances: 284807
features: ["Time", "V1-V28 (Anonymized PCA)", "Amount", "Class"]
collection_process:
source: "Internal transaction logs from production database."
collection_period: "2024-01-01 to 2024-12-31"
preprocessing: "Sensitive features removed; numerical features transformed via PCA."
known_limitations:
- "The dataset is highly imbalanced."
- "Anonymized features are not human-interpretable."
licensing: "Internal Use Only - Confidential"
Action: Every production dataset must ship with a YAML datasheet stored in version control. Updates to the dataset (new time ranges, schema changes) require updating and re-reviewing this file.
AID-M-003.001-G003
Concept:
Documentation cannot live only in people's heads or rely on an upload step that merely warns when files are absent. Generate the model card in CI, validate its schema again in a separate step, bind its model hash to the exact downloaded build artifact, and make a missing file a hard workflow error. Release signing and the broader evidence package remain in AID-M-003.001-G005.
Add a documentation gate to the pipeline
# File: .github/workflows/ci_cd_pipeline.yml
jobs:
train_and_evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.10'
- name: Install dependencies
run: python -m pip install --require-hashes --no-deps -r requirements.lock
- name: Train and evaluate model
run: python modeling/train_and_eval.py
- name: Upload exact model artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: model
path: model.pkl
if-no-files-found: error
generate_documentation:
needs: train_and_evaluate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065
with:
python-version: '3.10'
- name: Download exact model artifact
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
with:
name: model
path: downloaded-model
- name: Install locked documentation toolkit
run: |
python -m pip install --require-hashes --no-deps -r requirements.lock
python -m pip check
- name: Generate schema-validated model card
run: python modeling/generate_model_card.py
- name: Independently verify model-card binding
shell: bash
run: |
set -euo pipefail
mapfile -t CARDS < <(find model_card_output -maxdepth 1 -type f -name '*_model_card.cdx.json' -print)
[[ ${#CARDS[@]} -eq 1 ]] || { echo "expected exactly one generated CycloneDX model card" >&2; exit 1; }
python - "${CARDS[0]}" downloaded-model/model.pkl <<'PY'
import hashlib
import json
import sys
from pathlib import Path
from jsonschema import Draft7Validator, FormatChecker
card_path = Path(sys.argv[1])
model_path = Path(sys.argv[2])
if not card_path.is_file() or not model_path.is_file():
raise SystemExit("model card or model artifact is missing")
card_bytes = card_path.read_bytes()
card = json.loads(card_bytes)
schema = json.loads(Path("schemas/bom-1.7.schema.json").read_text(encoding="utf-8"))
Draft7Validator(schema, format_checker=FormatChecker()).validate(card)
components = [
item for item in card.get("components", [])
if item.get("type") == "machine-learning-model"
]
if len(components) != 1 or not isinstance(components[0].get("modelCard"), dict):
raise SystemExit("card must contain exactly one model component with modelCard")
hash_entries = [
item.get("content") for item in components[0].get("hashes", [])
if item.get("alg") == "SHA-256"
]
digest = hashlib.sha256(model_path.read_bytes()).hexdigest()
if hash_entries != [digest]:
raise SystemExit("model-card hash is not bound to the downloaded model bytes")
receipt = {
"schema_version": "aidefend.model-card-ci-binding.v1",
"model_sha256": digest,
"model_card_sha256": hashlib.sha256(card_bytes).hexdigest(),
"model_component_ref": components[0].get("bom-ref"),
}
output = Path("model_card_output/model-card-ci-binding.json")
temporary = output.with_suffix(".json.tmp")
temporary.write_text(
json.dumps(receipt, sort_keys=True, separators=(",", ":")) + "\n",
encoding="utf-8",
)
temporary.replace(output)
if json.loads(output.read_text(encoding="utf-8")) != receipt:
raise SystemExit("model-card binding readback differs")
PY
- name: Upload verified documentation artifact
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
with:
name: documentation
path: model_card_output/
if-no-files-found: error
Action: Block the workflow when generation, schema validation, model-byte binding, receipt readback, or artifact upload fails. The release path must consume the verified documentation artifact from this exact workflow run.
AID-M-003.001-G004
Concept and evidence boundary
Attach documentation to the source run of the exact registered model version without re-logging the model. Treat the signed release manifest as authority and MLflow tags only as mutable discovery pointers. Capture the manifest, signature bundle, model card, and dataset datasheet through no-follow descriptors into a private immutable snapshot before any registry upload; all verification and upload paths must refer only to that snapshot.
Capture, verify, upload, and read back the complete population
External dependencies: the CI profile supplies byte limits, verification timeout, trusted Cosign key, and authenticated MLflow URI policy. The artifact store must enforce approved write-once or version-retention controls, and the CI identity may write only the candidate model source run.
# File: modeling/publish_model_documentation.py
from __future__ import annotations
import hashlib
import json
import os
import re
import stat
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from mlflow import MlflowClient
SHA256 = re.compile(r"^[0-9a-f]{64}$")
KINDS = {"model_card", "dataset_datasheet"}
class DocumentationBindingError(RuntimeError):
pass
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise DocumentationBindingError(f"duplicate JSON key: {key}")
value[key] = item
return value
def digest(raw: bytes) -> str:
return hashlib.sha256(raw).hexdigest()
def positive_int_from_env(name: str) -> int:
try:
value = int(os.environ[name])
except (KeyError, ValueError) as error:
raise DocumentationBindingError(f"invalid required bound: {name}") from error
if value < 1:
raise DocumentationBindingError(f"invalid required bound: {name}")
return value
def capture_regular(path: Path, maximum: int) -> bytes:
if not hasattr(os, "O_NOFOLLOW"):
raise DocumentationBindingError("platform cannot enforce no-follow capture")
descriptor = os.open(
path,
os.O_RDONLY | os.O_NOFOLLOW | getattr(os, "O_CLOEXEC", 0)
| getattr(os, "O_BINARY", 0),
)
try:
before = os.fstat(descriptor)
if (
not stat.S_ISREG(before.st_mode)
or before.st_size < 1
or before.st_size > maximum
):
raise DocumentationBindingError(f"artifact type or size is invalid: {path}")
chunks, total = [], 0
while True:
chunk = os.read(descriptor, min(65536, maximum + 1 - total))
if not chunk:
break
chunks.append(chunk)
total += len(chunk)
if total > maximum:
raise DocumentationBindingError(f"artifact exceeds byte bound: {path}")
if total != before.st_size:
raise DocumentationBindingError(f"artifact changed during capture: {path}")
return b"".join(chunks)
finally:
os.close(descriptor)
def write_private(path: Path, raw: bytes) -> None:
descriptor = os.open(
path,
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
| getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_BINARY", 0),
0o400,
)
try:
view = memoryview(raw)
while view:
written = os.write(descriptor, view)
if written <= 0:
raise DocumentationBindingError("short snapshot write")
view = view[written:]
os.fsync(descriptor)
finally:
os.close(descriptor)
@dataclass(frozen=True)
class Artifact:
kind: str
local_path: Path
remote_path: str
raw: bytes
def evidence(self) -> dict:
return {
"kind": self.kind,
"remote_path": self.remote_path,
"sha256": digest(self.raw),
"size_bytes": len(self.raw),
}
manifest_max = positive_int_from_env("MODEL_DOCUMENTATION_MANIFEST_MAX_BYTES")
bundle_max = positive_int_from_env("MODEL_DOCUMENTATION_BUNDLE_MAX_BYTES")
document_max = positive_int_from_env("MODEL_DOCUMENTATION_DOCUMENT_MAX_BYTES")
verify_timeout = positive_int_from_env(
"MODEL_DOCUMENTATION_VERIFY_TIMEOUT_SECONDS"
)
verify_key = os.environ["MODEL_DOCUMENTATION_VERIFY_KEY"]
if not verify_key:
raise DocumentationBindingError("documentation verification key is absent")
manifest_source = Path(os.environ["MODEL_DOCUMENTATION_MANIFEST"])
bundle_source = Path(os.environ["MODEL_DOCUMENTATION_BUNDLE"])
manifest_raw = capture_regular(manifest_source, manifest_max)
bundle_raw = capture_regular(bundle_source, bundle_max)
with tempfile.TemporaryDirectory(prefix="aidefend-mlflow-docs-") as directory:
root = Path(directory)
root.chmod(0o700)
manifest_snapshot = root / "documentation-manifest.json"
bundle_snapshot = root / "documentation-manifest.sigstore.json"
write_private(manifest_snapshot, manifest_raw)
write_private(bundle_snapshot, bundle_raw)
subprocess.run(
[
"cosign", "verify-blob", "--key", verify_key,
"--bundle", str(bundle_snapshot), str(manifest_snapshot),
],
check=True,
capture_output=True,
text=True,
timeout=verify_timeout,
env={"PATH": os.environ.get("PATH", ""), "LANG": "C.UTF-8"},
)
if (
capture_regular(manifest_snapshot, manifest_max) != manifest_raw
or capture_regular(bundle_snapshot, bundle_max) != bundle_raw
):
raise DocumentationBindingError("verified manifest snapshot changed")
manifest = json.loads(
manifest_raw.decode("utf-8", errors="strict"),
object_pairs_hook=unique_object,
parse_constant=lambda value: (_ for _ in ()).throw(
DocumentationBindingError(f"non-finite JSON value: {value}")
),
)
required = {
"schema_version", "policy_version", "tracking_uri", "model_name",
"model_version", "source_run_id", "documents",
}
if not isinstance(manifest, dict) or set(manifest) != required:
raise DocumentationBindingError("documentation manifest fields differ")
if manifest["schema_version"] != "aidefend.model-documentation-binding.v1":
raise DocumentationBindingError("documentation manifest schema differs")
if any(
not isinstance(manifest[key], str) or not manifest[key]
for key in required - {"documents"}
):
raise DocumentationBindingError("documentation manifest binding is empty")
uri = urlparse(manifest["tracking_uri"])
if uri.scheme == "http" or not uri.scheme:
raise DocumentationBindingError(
"production MLflow URI requires authenticated secure transport"
)
documents = manifest["documents"]
if not isinstance(documents, list) or len(documents) != 2:
raise DocumentationBindingError("both documentation artifacts are required")
if (
any(not isinstance(item, dict) for item in documents)
or {item.get("kind") for item in documents} != KINDS
):
raise DocumentationBindingError("documentation population differs")
manifest_sha256 = digest(manifest_raw)
artifact_root = (
f"documentation/{manifest['model_version']}/{manifest_sha256}"
)
artifacts = [
Artifact(
"manifest",
manifest_snapshot,
f"{artifact_root}/{manifest_snapshot.name}",
manifest_raw,
),
Artifact(
"signature_bundle",
bundle_snapshot,
f"{artifact_root}/{bundle_snapshot.name}",
bundle_raw,
),
]
for item in documents:
if (
set(item) != {"kind", "path", "sha256"}
or not isinstance(item["kind"], str)
or item["kind"] not in KINDS
or not isinstance(item["path"], str)
or not item["path"]
or not isinstance(item["sha256"], str)
or not SHA256.fullmatch(item["sha256"])
):
raise DocumentationBindingError("documentation entry schema differs")
source = Path(item["path"])
name = source.name
if not name or name in {".", ".."}:
raise DocumentationBindingError("documentation filename is invalid")
raw = capture_regular(source, document_max)
if digest(raw) != item["sha256"]:
raise DocumentationBindingError(
f"documentation digest differs: {item['kind']}"
)
snapshot_directory = root / item["kind"]
snapshot_directory.mkdir(mode=0o700)
snapshot = snapshot_directory / name
write_private(snapshot, raw)
if capture_regular(snapshot, document_max) != raw:
raise DocumentationBindingError(
f"documentation snapshot differs: {item['kind']}"
)
artifacts.append(
Artifact(
item["kind"],
snapshot,
f"{artifact_root}/{item['kind']}/{name}",
raw,
)
)
if len(artifacts) != 4 or {item.kind for item in artifacts} != {
"manifest", "signature_bundle", "model_card", "dataset_datasheet"
}:
raise DocumentationBindingError("captured artifact population differs")
client = MlflowClient(tracking_uri=manifest["tracking_uri"])
version = client.get_model_version(
manifest["model_name"], manifest["model_version"]
)
if version.run_id != manifest["source_run_id"]:
raise DocumentationBindingError(
"registered model version differs from declared source run"
)
for artifact in artifacts:
destination = str(Path(artifact.remote_path).parent).replace("\\", "/")
client.log_artifact(
version.run_id,
str(artifact.local_path),
artifact_path=destination,
)
download_root = root / "readback"
download_root.mkdir(mode=0o700)
for artifact in artifacts:
downloaded = Path(
client.download_artifacts(
version.run_id,
artifact.remote_path,
str(download_root),
)
)
readback_raw = capture_regular(
downloaded,
max(
manifest_max,
bundle_max,
document_max,
),
)
if (
readback_raw != artifact.raw
or digest(readback_raw) != digest(artifact.raw)
):
raise DocumentationBindingError(
f"remote artifact readback differs: {artifact.kind}"
)
population = sorted(
(artifact.evidence() for artifact in artifacts),
key=lambda item: item["kind"],
)
population_raw = json.dumps(
population,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
).encode("utf-8")
population_sha256 = digest(population_raw)
client.set_model_version_tag(
manifest["model_name"],
manifest["model_version"],
"aidefend.documentation_manifest_sha256",
manifest_sha256,
)
client.set_model_version_tag(
manifest["model_name"],
manifest["model_version"],
"aidefend.documentation_population_sha256",
population_sha256,
)
registry_readback = client.get_model_version(
manifest["model_name"], manifest["model_version"]
)
if (
registry_readback.run_id != manifest["source_run_id"]
or registry_readback.tags.get(
"aidefend.documentation_manifest_sha256"
) != manifest_sha256
or registry_readback.tags.get(
"aidefend.documentation_population_sha256"
) != population_sha256
):
raise DocumentationBindingError(
"model-version documentation binding readback differs"
)
result = {
"schema_version": "aidefend.model-documentation-publication.v1",
"model_name": manifest["model_name"],
"model_version": manifest["model_version"],
"source_run_id": manifest["source_run_id"],
"documentation_manifest_sha256": manifest_sha256,
"artifact_population_sha256": population_sha256,
"artifact_count": len(population),
"artifacts": population,
}
print(json.dumps(result, sort_keys=True, separators=(",", ":")))
Adversarial fixture
After the four captures complete, atomically replace every original pathname with different bytes before Cosign verification and MLflow upload. The fixture must prove that verification, upload, remote download, and the four-entry population report still use only the private snapshots. Also require failure for a symlink input, a changed descriptor during capture, a document digest mismatch, a missing manifest or bundle readback, a byte-different remote artifact, and an incomplete or duplicated artifact population.
Action: Promotion may assign its policy-defined deployment alias only after an independent verifier re-downloads all four remote artifacts, verifies the manifest signature from the downloaded manifest/bundle tuple, recalculates both document digests, and matches the complete population commitment. A tag, alias, original path, partial readback, or successful upload alone is not attachment proof.
AID-M-003.001-G005
Purpose and boundary
This method publishes one independently verifiable model-card evidence package. It consumes, but does not recreate, signed boundary, fairness, conformance, and deployed-release receipts.
Step 1: Produce and sign the release model card
Use CycloneDX 1.7 AI/ML-BOM. The model component must be machine-learning-model, include modelCard, and carry the SHA-256 of the exact base-model weights from the signed release bundle. Generate the card from release evidence; do not manually copy aggregate claims into a document.
cyclonedx-cli validate --input-file evidence/model-card.cdx.json --input-format json --input-version v1_7 --fail-on-errors
cosign sign-blob --yes --key env://MODEL_CARD_SIGNING_KEY --bundle evidence/model-card.cdx.sig evidence/model-card.cdx.jsonThe binding verifier consumes these exact signed dependencies:
| Artifact | Required local claim | Verification key |
|---|---|---|
deployed-release-bundle.json and readback | Exact model digest and fresh deployed candidate | release-bundle.pub, deployment-verifier.pub |
safety-boundary.json | Approved boundary version and owner | safety-policy.pub |
safety-conformance-verification.json | Complete deterministic conformance result | safety-verifier.pub |
fairness-evaluation.json | Population, metrics, thresholds, and state | fairness-verifier.pub |
model-card.cdx.json | CycloneDX model-card bytes | model-card.pub |
Step 2: Validate and bind the complete evidence package
from __future__ import annotations
import math as _aidefend_math
import os as _aidefend_os
# Admission injects these required values from one signature-verified, versioned runtime profile.
def _aidefend_required(name):
value = _aidefend_os.environ.get(name)
if value is None or not value.strip():
raise RuntimeError(f"required runtime-profile field is absent: {name}")
return value.strip()
def _aidefend_positive_float(name):
try:
value = float(_aidefend_required(name))
except ValueError as error:
raise RuntimeError(f"runtime-profile field is not numeric: {name}") from error
if not _aidefend_math.isfinite(value) or value <= 0:
raise RuntimeError(f"runtime-profile field must be finite and positive: {name}")
return value
def _aidefend_positive_int(name):
try:
value = int(_aidefend_required(name))
except ValueError as error:
raise RuntimeError(f"runtime-profile field is not an integer: {name}") from error
if value < 1:
raise RuntimeError(f"runtime-profile field must be positive: {name}")
return value
def _aidefend_nonnegative_int(name):
try:
value = int(_aidefend_required(name))
except ValueError as error:
raise RuntimeError(f"runtime-profile field is not an integer: {name}") from error
if value < 0:
raise RuntimeError(f"runtime-profile field cannot be negative: {name}")
return value
def _aidefend_fraction(name):
value = _aidefend_positive_float(name)
if value > 1:
raise RuntimeError(f"runtime-profile field must be in (0, 1]: {name}")
return value
RUNTIME_PROFILE_VERSION = _aidefend_required("AIDEFEND_RUNTIME_PROFILE_VERSION")
RUNTIME_PROFILE_SHA256 = _aidefend_required("AIDEFEND_RUNTIME_PROFILE_SHA256").lower()
if (len(RUNTIME_PROFILE_SHA256) != 64
or set(RUNTIME_PROFILE_SHA256) - set("0123456789abcdef")):
raise RuntimeError("runtime-profile digest must be lowercase SHA-256")
MODEL_CARD_COMMAND_TIMEOUT_SECONDS = _aidefend_positive_float("M007_MODEL_CARD_COMMAND_TIMEOUT_SECONDS")
# File: evidence/build_model_card_binding.py
import hashlib
import json
import os
import stat
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path
BUNDLE = Path("evidence/deployed-release-bundle.json")
READBACK = Path("evidence/deployed-release-readback.json")
BOUNDARY = Path("safety/safety-boundary.json")
CONFORMANCE = Path("evidence/safety-conformance-verification.json")
FAIRNESS = Path("evidence/fairness-evaluation.json")
MODEL_CARD = Path("evidence/model-card.cdx.json")
OUTPUT = Path("evidence/safety-model-card-binding.json")
class InsufficientData(Exception):
pass
_CAPTURED_BYTES: dict[Path, bytes] = {}
def regular_bytes(path: Path) -> bytes:
descriptor = os.open(
path,
os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0),
)
with os.fdopen(descriptor, "rb") as handle:
status = os.fstat(handle.fileno())
if not stat.S_ISREG(status.st_mode):
raise ValueError(f"input is not a regular file: {path}")
value = handle.read()
if not value:
raise ValueError(f"input is empty: {path}")
return value
def captured_bytes(path: Path) -> bytes:
if path not in _CAPTURED_BYTES:
_CAPTURED_BYTES[path] = regular_bytes(path)
return _CAPTURED_BYTES[path]
def digest(path: Path) -> str:
return hashlib.sha256(captured_bytes(path)).hexdigest()
def verify(path: Path, signature: Path, key: Path) -> None:
payload = regular_bytes(path)
signature_bytes = regular_bytes(signature)
with tempfile.TemporaryDirectory(prefix="aidefend-model-card-signature-") as stage_name:
stage = Path(stage_name)
os.chmod(stage, 0o700)
payload_snapshot = stage / "payload"
signature_snapshot = stage / "payload.sig"
payload_snapshot.write_bytes(payload)
signature_snapshot.write_bytes(signature_bytes)
os.chmod(payload_snapshot, 0o400)
os.chmod(signature_snapshot, 0o400)
subprocess.run(
[
"cosign", "verify-blob", "--key", str(key),
"--bundle", str(signature_snapshot), str(payload_snapshot),
],
check=True, capture_output=True, text=True,
timeout=MODEL_CARD_COMMAND_TIMEOUT_SECONDS,
)
if (
regular_bytes(payload_snapshot) != payload
or regular_bytes(signature_snapshot) != signature_bytes
):
raise ValueError("private signed-input snapshot changed during verification")
_CAPTURED_BYTES[path] = payload
def unique_object(pairs):
value = {}
for key, item in pairs:
if key in value:
raise ValueError(f"duplicate JSON key: {key}")
value[key] = item
return value
def reject_nonfinite(value):
raise ValueError(f"non-finite JSON number: {value}")
def load_json(path: Path) -> object:
return json.loads(
captured_bytes(path).decode("utf-8", errors="strict"),
object_pairs_hook=unique_object,
parse_constant=reject_nonfinite,
)
def when(value: str) -> datetime:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
raise ValueError("timestamp lacks timezone")
return parsed.astimezone(timezone.utc)
now = datetime.now(timezone.utc)
outcome = "ERROR"
reasons: list[str] = []
component_ref = None
upstream_states: dict[str, str] = {}
fairness: dict = {}
fairness_applicable = False
try:
artifacts = [
(BUNDLE, Path("evidence/deployed-release-bundle.sig"), Path("keys/release-bundle.pub")),
(READBACK, Path("evidence/deployed-release-readback.sig"), Path("keys/deployment-verifier.pub")),
(BOUNDARY, Path("safety/safety-boundary.sig"), Path("keys/safety-policy.pub")),
(CONFORMANCE, Path("evidence/safety-conformance-verification.sig"), Path("keys/safety-verifier.pub")),
(MODEL_CARD, Path("evidence/model-card.cdx.sig"), Path("keys/model-card.pub")),
]
for path, signature, key in artifacts:
verify(path, signature, key)
with tempfile.TemporaryDirectory(prefix="aidefend-model-card-validation-") as stage_name:
stage = Path(stage_name)
os.chmod(stage, 0o700)
model_card_snapshot = stage / "model-card.cdx.json"
model_card_snapshot.write_bytes(captured_bytes(MODEL_CARD))
os.chmod(model_card_snapshot, 0o400)
subprocess.run(
[
"cyclonedx-cli", "validate", "--input-file", str(model_card_snapshot),
"--input-format", "json", "--input-version", "v1_7",
"--fail-on-errors",
],
check=True, capture_output=True, text=True,
timeout=MODEL_CARD_COMMAND_TIMEOUT_SECONDS,
)
if regular_bytes(model_card_snapshot) != captured_bytes(MODEL_CARD):
raise ValueError("private model-card snapshot changed during validation")
bundle = load_json(BUNDLE)
readback = load_json(READBACK)
boundary = load_json(BOUNDARY)
fairness_applicable = "protected_group_fairness" in set(boundary.get("applicable_dimensions", []))
if fairness_applicable:
verify(FAIRNESS, Path("evidence/fairness-evaluation.sig"), Path("keys/fairness-verifier.pub"))
fairness = load_json(FAIRNESS)
conformance = load_json(CONFORMANCE)
model_card = load_json(MODEL_CARD)
if readback.get("outcome") != "PASS" or now >= when(readback["expires_at"]):
raise InsufficientData("deployed release readback is not a fresh PASS")
if readback.get("bundle_sha256") != digest(BUNDLE):
raise ValueError("release bundle and readback differ")
upstream_states = {
"conformance": str(conformance.get("outcome")),
"fairness": str(fairness.get("outcome")) if fairness_applicable else "NOT_APPLICABLE",
}
if now >= when(conformance["expires_at"]):
raise InsufficientData("safety conformance evidence is stale")
if fairness_applicable and now >= when(fairness["expires_at"]):
raise InsufficientData("fairness evidence is stale")
if upstream_states["conformance"] == "FAIL" or upstream_states["fairness"] == "FAIL":
outcome = "FAIL"
reasons.append("an upstream safety or fairness gate failed")
elif upstream_states["conformance"] != "PASS" or upstream_states["fairness"] not in {"PASS", "NOT_APPLICABLE"}:
raise InsufficientData("upstream safety evidence is incomplete")
else:
if model_card.get("bomFormat") != "CycloneDX" or model_card.get("specVersion") != "1.7":
raise ValueError("CycloneDX version differs")
model_components = [
item for item in model_card.get("components", [])
if item.get("type") == "machine-learning-model"
]
if len(model_components) != 1:
raise ValueError("model-card BOM must contain one model component")
component = model_components[0]
if not component.get("bom-ref") or not isinstance(component.get("modelCard"), dict):
raise ValueError("model component lacks bom-ref or modelCard")
component_ref = component["bom-ref"]
hashes = {
item.get("alg"): item.get("content")
for item in component.get("hashes", [])
}
expected_model_sha = bundle["components"]["base_model_weights"]["sha256"]
if hashes.get("SHA-256") != expected_model_sha:
raise ValueError("model card is not bound to release weights")
if boundary.get("schema_version") != "aidefend.safety_boundary.v3":
raise ValueError("boundary schema differs")
if conformance.get("policy_id") != boundary.get("policy_id"):
raise ValueError("conformance policy identity differs")
if conformance.get("boundary_sha256") != digest(BOUNDARY):
raise ValueError("conformance boundary digest differs")
if upstream_states["fairness"] == "PASS":
if fairness.get("release_readback_sha256") != digest(READBACK):
raise ValueError("fairness PASS is not bound to this release")
elif fairness.get("release_readback_sha256") is not None:
raise ValueError("fairness NOT_APPLICABLE must not claim release evidence")
outcome = "PASS"
except InsufficientData as error:
outcome = "INSUFFICIENT_DATA"
reasons.append(str(error))
except (OSError, subprocess.SubprocessError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error:
outcome = "ERROR"
reasons.append(str(error))
binding = {
"schema_version": "aidefend.safety_model_card_binding.v1",
"control": "AID-M-007",
"model_component_ref": component_ref,
"release_bundle_sha256": digest(BUNDLE) if BUNDLE.is_file() else None,
"release_readback_sha256": digest(READBACK) if READBACK.is_file() else None,
"safety_boundary_sha256": digest(BOUNDARY) if BOUNDARY.is_file() else None,
"conformance_sha256": digest(CONFORMANCE) if CONFORMANCE.is_file() else None,
"fairness_sha256": digest(FAIRNESS) if fairness_applicable and FAIRNESS.is_file() else None,
"model_card_sha256": digest(MODEL_CARD) if MODEL_CARD.is_file() else None,
"upstream_states": upstream_states,
"outcome": outcome,
"reasons": reasons,
"generated_at": now.isoformat(),
}
OUTPUT.write_text(
json.dumps(binding, sort_keys=True, separators=(",", ":"), allow_nan=False) + "\n",
encoding="utf-8",
)
if outcome != "PASS":
raise SystemExit(f"model-card binding did not pass: {outcome}")python evidence/build_model_card_binding.py
cosign sign-blob --yes --key env://MODEL_CARD_BINDING_SIGNING_KEY --bundle evidence/safety-model-card-binding.sig evidence/safety-model-card-binding.json
cosign verify-blob --key keys/model-card-binding.pub --bundle evidence/safety-model-card-binding.sig evidence/safety-model-card-binding.json
Verify by independent readback
Publish the card, binding, and signatures to immutable release storage. A reader identity outside the build pipeline retrieves the published bytes, re-verifies every signature and digest, validates the CycloneDX document, and reproduces the binding. Retain the published readback receipt with the release. Action: Block promotion when the model card cannot be independently tied to the exact release and its safety evidence.
Open-source tools
- CycloneDX CLI
- DVC
- MLflow
- huggingface_hub (ModelCard and DatasetCard utilities)
- Sphinx
- MkDocs
Commercial / hosted tools
- Google Vertex AI Model Registry
- Amazon SageMaker AI Model Registry
- Databricks Unity Catalog
- Hugging Face Hub
- Alation Data Intelligence Platform
- Collibra Data Intelligence Platform
- Credo AI
- IBM watsonx.governance
Threat mappings
MITRE ATLAS
- N/A
MAESTRO
- Inaccurate Agent Capability Description (L7)
OWASP LLM Top 10 2026
- N/A
OWASP ML Top 10 2023
- N/A
OWASP Top 10 for Agentic Applications 2026
- N/A
NIST Adversarial Machine Learning 2025
- N/A
Cisco Integrated AI Security and Safety Framework
- N/A
Google Secure AI Framework 2.0 - Risks
- N/A
Databricks AI Security Framework 3.0
- N/A