AID-H-007.006: Post-Training Optimization & Format-Conversion Safety Regression

Version 1.20261015 · Actionable sub-technique

Treat every derived deployment artifact or serving-time model/interface pairing as a security-relevant change. Require signed source-to-candidate lineage and a pre/post behavioral regression gate. This includes quantization, pruning, distillation, weight or adapter merges, ONNX/GGUF/TensorRT conversion, speculative-decoding pairs, and changes to the tokenizer, special tokens, chat template, generation configuration, or tool/reasoning parser. Fail promotion when lineage or required evaluation coverage is missing, or when task quality, jailbreak, prompt injection, leakage, harmful content, structured output, reasoning extraction, or tool policy regresses beyond policy.

Pillars: model, infra, app

Lifecycle: building, validation

Scope boundary

Owns source-to-candidate lineage and pre/post behavioral regression for quantization, pruning, distillation, merges, format conversion, runtime engine compilation, and serving-time model/interface pairings derived from an approved source.

AID-H-007.005: AID-H-007.006 evaluates safety change from post-training optimization, conversion, or derived serving artifacts; AID-H-007.005 evaluates safety erosion from fine-tuning and behavioral customization. Distillation belongs here when it creates a deployment-derived student from an approved source, even though its derivation uses training.

AID-H-003.006: AID-H-007.006 verifies transformation-specific lineage and behavioral delta; AID-H-003.006 owns the general model SBOM and provenance attestation. The transformation gate adds source-to-candidate evidence without becoming the canonical composition record.

Implementation guidance

AID-H-007.006-G001

Bind each transformed candidate to approved source bytes and transformation settings, compare the source and candidate on the same signed task and security suite, and fail promotion on missing evidence or excessive regression.
Concept:

A converted, optimized, distilled, merged, or paired model is a new deployable artifact. Build lineage from the exact bytes consumed and produced by the derivation job, independently verify the approved source manifest before reading it, and compare source and candidate results over one actual case-set digest. Never infer lineage from a model name or let aggregate quality hide a security-category regression.

Step 1: Supply one concrete signed transformation policy

The separately administered policy authority publishes transform/post-transform-policy.json and its Cosign verification bundle with concrete integer sample floors and finite per-category regression ceilings. This is an input artifact, not a template. The gate in Step 2 verifies it under the baked-in policy key, enforces the exact schema, rejects nonnumeric or out-of-range values, checks the policy against stricter local ceilings, and binds source/candidate artifacts, toolchain, parameters, case set, and independently signed evaluations. Allowed transform types are quantization, pruning, distillation, model_merge, onnx_export, gguf_conversion, tensorrt_engine, and speculative_draft_pair; trust anchors for policy, source manifest, evaluator evidence, and the final report remain distinct.

Step 2: Verify policy, source approval, independent evaluations, and raw evidence before enforcing the delta gate
# File: transform/post_transform_gate.py
from __future__ import annotations
import os

import hashlib
import json
import math
import re
import subprocess
import tempfile
from datetime import datetime, timezone
from pathlib import Path

def _required_unit_interval(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or not 0 < value <= 1:
        raise RuntimeError(f"{name} must be in the interval (0, 1]")
    return value

TASK_QUALITY_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_TASK_QUALITY_MAX_REGRESSION")
HARMFUL_CONTENT_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_HARMFUL_CONTENT_MAX_REGRESSION")
JAILBREAK_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_JAILBREAK_MAX_REGRESSION")
PROMPT_INJECTION_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_PROMPT_INJECTION_MAX_REGRESSION")
SENSITIVE_DATA_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_SENSITIVE_DATA_MAX_REGRESSION")
TOOL_POLICY_MAX_REGRESSION = _required_unit_interval("VERIFIED_H007_TOOL_POLICY_MAX_REGRESSION")


def _required_positive_int(name: str) -> int:
    value = int(os.environ[name])
    if value <= 0:
        raise RuntimeError(f"{name} must be a positive integer")
    return value

LOCAL_MINIMUM_SAMPLES_PER_CATEGORY = _required_positive_int("VERIFIED_H007_TRANSFORM_MINIMUM_SAMPLES_PER_CATEGORY")

def _required_positive_seconds(name: str) -> float:
    value = float(os.environ[name])
    if not math.isfinite(value) or value <= 0: raise RuntimeError(f"{name} must be finite and positive")
    return value

TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS = _required_positive_seconds("VERIFIED_H007_TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS")



POLICY_SCHEMA = "aidefend.post_transform_policy.v1"
EVALUATION_SCHEMA = "aidefend.transform_eval.v1"
POLICY_PUBLIC_KEY = "keys/post-transform-policy.pub"
SOURCE_MANIFEST_PUBLIC_KEY = "keys/model-source.pub"
EVALUATION_PUBLIC_KEY = "keys/independent-model-evaluation.pub"
LOCAL_REQUIRED_CATEGORY_RULES = {
    "task_quality": ("success_rate", "higher_is_better", TASK_QUALITY_MAX_REGRESSION),
    "harmful_content": ("violation_rate", "lower_is_better", HARMFUL_CONTENT_MAX_REGRESSION),
    "jailbreak": ("attack_success_rate", "lower_is_better", JAILBREAK_MAX_REGRESSION),
    "prompt_injection": ("attack_success_rate", "lower_is_better", PROMPT_INJECTION_MAX_REGRESSION),
    "sensitive_data": ("leakage_rate", "lower_is_better", SENSITIVE_DATA_MAX_REGRESSION),
    "tool_policy": ("violation_rate", "lower_is_better", TOOL_POLICY_MAX_REGRESSION),
}
ALLOWED_TRANSFORMS = {
    "quantization", "pruning", "distillation", "model_merge",
    "onnx_export", "gguf_conversion", "tensorrt_engine", "speculative_draft_pair",
}
SHA256_RE = re.compile(r"^[0-9a-f]{64}$")


def load(raw: bytes, label: str) -> dict:
    def unique_pairs(pairs):
        value = {}
        for key, item in pairs:
            if key in value:
                raise ValueError(f"duplicate {label} key: {key}")
            value[key] = item
        return value

    def reject_constant(value):
        raise ValueError(f"non-finite {label} number: {value}")

    value = json.loads(
        raw.decode("utf-8", errors="strict"),
        object_pairs_hook=unique_pairs, parse_constant=reject_constant,
    )
    if not isinstance(value, dict):
        raise ValueError(f"JSON object required: {label}")
    return value


def digest(path: str) -> str:
    h = hashlib.sha256()
    with Path(path).open("rb") as handle:
        for chunk in iter(lambda: handle.read(1024 * 1024), b""):
            h.update(chunk)
    return h.hexdigest()


def digest_bytes(raw: bytes) -> str:
    return hashlib.sha256(raw).hexdigest()


def require_nonempty_string(value: object, field: str) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(field + " must be a non-empty string")
    return value


def require_sha256(value: object, field: str) -> str:
    normalized = require_nonempty_string(value, field).removeprefix("sha256:")
    if not SHA256_RE.fullmatch(normalized):
        raise ValueError(field + " must be one lowercase SHA-256 digest")
    return normalized


def require_integer(value: object, field: str, minimum: int = 0) -> int:
    if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
        raise ValueError(f"{field} must be an integer >= {minimum}")
    return value


def require_rate(value: object, field: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(field + " must be numeric")
    result = float(value)
    if not math.isfinite(result) or not 0.0 <= result <= 1.0:
        raise ValueError(field + " must be finite and within [0,1]")
    return result


def verify_signed_blob(path: str, bundle: str, public_key: str) -> tuple[bytes, bytes]:
    payload_raw = Path(path).read_bytes()
    bundle_raw = Path(bundle).read_bytes()
    public_key_raw = Path(public_key).read_bytes()
    if not payload_raw or not bundle_raw or not public_key_raw:
        raise ValueError("signed payload, bundle, and trust key must be non-empty")
    with tempfile.TemporaryDirectory(prefix="post-transform-input-") as directory:
        root = Path(directory)
        os.chmod(root, 0o700)
        payload_snapshot = root / "payload.json"
        bundle_snapshot = root / "bundle.sigstore.json"
        public_key_snapshot = root / "verification.pub"
        captured = (
            (payload_snapshot, payload_raw),
            (bundle_snapshot, bundle_raw),
            (public_key_snapshot, public_key_raw),
        )
        for target, raw in captured:
            target.write_bytes(raw)
            os.chmod(target, 0o400)
        subprocess.run([
            "cosign", "verify-blob", "--key", str(public_key_snapshot),
            "--bundle", str(bundle_snapshot), str(payload_snapshot),
        ], check=True, capture_output=True, text=True,
           timeout=TRANSFORM_SIGNATURE_VERIFY_TIMEOUT_SECONDS)
        if any(target.read_bytes() != raw for target, raw in captured):
            raise ValueError("signed-input verification snapshot changed")
    return payload_raw, bundle_raw


def validate_policy(policy: dict) -> None:
    if policy.get("schema_version") != POLICY_SCHEMA:
        raise ValueError("unsupported post-transform policy schema")
    if policy.get("transform_type") not in ALLOWED_TRANSFORMS:
        raise ValueError("unapproved transform_type")
    require_nonempty_string(policy.get("transform_id"), "transform_id")
    require_nonempty_string(policy.get("policy_version"), "policy_version")
    minimum_n = require_integer(
        policy.get("minimum_samples_per_category"),
        "minimum_samples_per_category",
        minimum=LOCAL_MINIMUM_SAMPLES_PER_CATEGORY,
    )
    toolchain = policy.get("toolchain")
    if not isinstance(toolchain, list) or not toolchain:
        raise ValueError("toolchain name and version are required")
    for index, tool in enumerate(toolchain):
        if not isinstance(tool, dict):
            raise ValueError(f"toolchain[{index}] must be an object")
        require_nonempty_string(tool.get("name"), f"toolchain[{index}].name")
        require_nonempty_string(tool.get("version"), f"toolchain[{index}].version")
    categories = policy.get("required_categories")
    if not isinstance(categories, dict) or not categories:
        raise ValueError("required_categories cannot be empty")
    for category, rule in categories.items():
        require_nonempty_string(category, "category")
        if not isinstance(rule, dict):
            raise ValueError("category rule must be an object: " + category)
        require_nonempty_string(rule.get("metric"), category + ".metric")
        if rule.get("direction") not in {"lower_is_better", "higher_is_better"}:
            raise ValueError("unknown metric direction for " + category)
        require_rate(rule.get("max_regression"), category + ".max_regression")
    for category, local_rule in LOCAL_REQUIRED_CATEGORY_RULES.items():
        rule = categories.get(category)
        if not isinstance(rule, dict):
            raise ValueError("locally required category is missing: " + category)
        expected_metric, expected_direction, maximum_ceiling = local_rule
        if rule.get("metric") != expected_metric:
            raise ValueError("metric differs from local profile: " + category)
        if rule.get("direction") != expected_direction:
            raise ValueError("direction differs from local profile: " + category)
        configured_maximum = require_rate(
            rule.get("max_regression"), category + ".max_regression"
        )
        if configured_maximum > maximum_ceiling:
            raise ValueError(
                f"{category}.max_regression exceeds local ceiling {maximum_ceiling}"
            )
    if minimum_n < LOCAL_MINIMUM_SAMPLES_PER_CATEGORY:
        raise ValueError("minimum sample count is below the local floor")


def validate_evaluation(name: str, evaluation: dict, policy: dict) -> None:
    if evaluation.get("schema_version") != EVALUATION_SCHEMA:
        raise ValueError(name + " evaluation schema is unsupported")
    require_sha256(evaluation.get("artifact_sha256"), name + ".artifact_sha256")
    require_sha256(evaluation.get("case_set_sha256"), name + ".case_set_sha256")
    require_nonempty_string(evaluation.get("suite_id"), name + ".suite_id")
    categories = evaluation.get("categories")
    if not isinstance(categories, dict):
        raise ValueError(name + ".categories must be an object")
    for category, rule in policy["required_categories"].items():
        result = categories.get(category)
        if result is None:
            continue
        if not isinstance(result, dict):
            raise ValueError(f"{name}.{category} must be an object")
        require_integer(result.get("n"), f"{name}.{category}.n")
        metric = rule["metric"]
        if metric in result:
            require_rate(result[metric], f"{name}.{category}.{metric}")


def run(policy_path: str, policy_signature_path: str) -> dict:
    # The policy trust anchor is baked into the gate image and is not policy-controlled.
    policy_raw, policy_bundle_raw = verify_signed_blob(
        policy_path, policy_signature_path, POLICY_PUBLIC_KEY
    )
    policy = load(policy_raw, "post-transform policy")
    validate_policy(policy)

    source_manifest_path = require_nonempty_string(
        policy.get("approved_source_manifest"), "approved_source_manifest"
    )
    source_manifest_raw, source_manifest_bundle_raw = verify_signed_blob(
        source_manifest_path,
        require_nonempty_string(
            policy.get("approved_source_manifest_signature"),
            "approved_source_manifest_signature",
        ),
        SOURCE_MANIFEST_PUBLIC_KEY,
    )
    source_manifest = load(source_manifest_raw, "approved source manifest")

    case_set_path = require_nonempty_string(policy.get("case_set_path"), "case_set_path")
    if Path(case_set_path).stat().st_size == 0:
        raise ValueError("case set is empty")
    source_eval_path = require_nonempty_string(
        policy.get("source_evaluation_path"), "source_evaluation_path"
    )
    candidate_eval_path = require_nonempty_string(
        policy.get("candidate_evaluation_path"), "candidate_evaluation_path"
    )
    source_eval_signature = require_nonempty_string(
        policy.get("source_evaluation_signature"), "source_evaluation_signature"
    )
    candidate_eval_signature = require_nonempty_string(
        policy.get("candidate_evaluation_signature"), "candidate_evaluation_signature"
    )
    # Evaluation signatures come from an independently administered workflow key,
    # distinct from policy, source-manifest, and transform-report signing keys.
    source_eval_raw, source_eval_bundle_raw = verify_signed_blob(
        source_eval_path, source_eval_signature, EVALUATION_PUBLIC_KEY
    )
    candidate_eval_raw, candidate_eval_bundle_raw = verify_signed_blob(
        candidate_eval_path, candidate_eval_signature, EVALUATION_PUBLIC_KEY
    )
    baseline = load(source_eval_raw, "source evaluation")
    candidate = load(candidate_eval_raw, "candidate evaluation")
    validate_evaluation("source", baseline, policy)
    validate_evaluation("candidate", candidate, policy)

    source_sha = digest(policy["source_artifact"])
    candidate_sha = digest(policy["candidate_artifact"])
    case_set_sha = digest(case_set_path)
    approved_sha = require_sha256(source_manifest.get("artifactDigest"), "artifactDigest")
    failures: list[dict] = []
    insufficient: list[dict] = []

    if source_sha != approved_sha:
        failures.append({"issue": "source_digest_not_approved"})
    if require_sha256(baseline["artifact_sha256"], "source.artifact_sha256") != source_sha:
        failures.append({"issue": "baseline_not_bound_to_source"})
    if require_sha256(candidate["artifact_sha256"], "candidate.artifact_sha256") != candidate_sha:
        failures.append({"issue": "candidate_not_bound_to_output"})
    if baseline["suite_id"] != candidate["suite_id"]:
        failures.append({"issue": "suite_id_mismatch"})
    if require_sha256(baseline["case_set_sha256"], "source.case_set_sha256") != case_set_sha:
        failures.append({"issue": "source_evaluation_not_bound_to_case_set"})
    if require_sha256(candidate["case_set_sha256"], "candidate.case_set_sha256") != case_set_sha:
        failures.append({"issue": "candidate_evaluation_not_bound_to_case_set"})

    draft_path = policy.get("draft_model_artifact")
    draft_sha = None
    if policy["transform_type"] == "speculative_draft_pair":
        draft_path = require_nonempty_string(draft_path, "draft_model_artifact")
        draft_sha = digest(draft_path)
        reported_draft = require_sha256(
            candidate.get("draft_model_sha256"), "candidate.draft_model_sha256"
        )
        if reported_draft != draft_sha:
            failures.append({"issue": "candidate_not_bound_to_draft_model"})
    elif draft_path is not None or candidate.get("draft_model_sha256") is not None:
        raise ValueError("draft model fields are allowed only for speculative_draft_pair")

    findings = []
    minimum_n = policy["minimum_samples_per_category"]
    for category, rule in policy["required_categories"].items():
        base = baseline.get("categories", {}).get(category)
        cand = candidate.get("categories", {}).get(category)
        if base is None or cand is None:
            insufficient.append({"category": category, "issue": "missing_category"})
            continue
        base_n = require_integer(base.get("n"), f"source.{category}.n")
        candidate_n = require_integer(cand.get("n"), f"candidate.{category}.n")
        if base_n < minimum_n or candidate_n < minimum_n:
            insufficient.append({
                "category": category, "issue": "insufficient_samples",
                "baseline_n": base_n, "candidate_n": candidate_n,
                "minimum": minimum_n,
            })
            continue
        metric = rule["metric"]
        if metric not in base or metric not in cand:
            insufficient.append({"category": category, "issue": "missing_metric", "metric": metric})
            continue
        base_value = require_rate(base[metric], f"source.{category}.{metric}")
        candidate_value = require_rate(cand[metric], f"candidate.{category}.{metric}")
        maximum_regression = require_rate(
            rule["max_regression"], category + ".max_regression"
        )
        regression = (
            candidate_value - base_value
            if rule["direction"] == "lower_is_better"
            else base_value - candidate_value
        )
        passed = regression <= maximum_regression
        finding = {
            "category": category, "metric": metric, "baseline": base_value,
            "candidate": candidate_value, "regression": round(regression, 6),
            "maximum_regression": maximum_regression, "passed": passed,
        }
        findings.append(finding)
        if not passed:
            failures.append(finding)

    evidence_digests = {
        "policy_sha256": digest_bytes(policy_raw),
        "policy_signature_sha256": digest_bytes(policy_bundle_raw),
        "approved_source_manifest_sha256": digest_bytes(source_manifest_raw),
        "approved_source_manifest_signature_sha256": digest_bytes(
            source_manifest_bundle_raw
        ),
        "case_set_sha256": case_set_sha,
        "source_evaluation_sha256": digest_bytes(source_eval_raw),
        "source_evaluation_signature_sha256": digest_bytes(source_eval_bundle_raw),
        "candidate_evaluation_sha256": digest_bytes(candidate_eval_raw),
        "candidate_evaluation_signature_sha256": digest_bytes(candidate_eval_bundle_raw),
        "source_artifact_sha256": source_sha,
        "candidate_artifact_sha256": candidate_sha,
        "draft_model_artifact_sha256": draft_sha,
    }
    lineage = {
        "transform_id": policy["transform_id"],
        "transform_type": policy["transform_type"],
        "policy_signature_verified": True,
        "approved_source_signature_verified": True,
        "independent_evaluation_signatures_verified": True,
        "toolchain": policy["toolchain"],
        "parameters": policy.get("parameters", {}),
    }
    status = "insufficient_data" if insufficient else ("block" if failures else "pass")
    return {
        "schema_version": "aidefend.post_transform_gate_report.v1",
        "status": status,
        "policy_version": policy["policy_version"],
        "lineage": lineage,
        "suite_id": candidate["suite_id"],
        "evidence_digests": evidence_digests,
        "findings": findings,
        "insufficient_populations": insufficient,
        "failures": failures,
        "generated_at": datetime.now(timezone.utc).isoformat(),
    }


if __name__ == "__main__":
    output = Path("artifacts/post-transform-gate-report.json")
    output.parent.mkdir(parents=True, exist_ok=True)
    report = run(
        "transform/post-transform-policy.json",
        "transform/post-transform-policy.sig",
    )
    output.write_text(
        json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8"
    )
    raise SystemExit(0 if report["status"] == "pass" else 1)
Step 3: Sign lineage-bearing evidence and bind it to promotion
set -euo pipefail
python transform/post_transform_gate.py
cosign sign-blob --yes --bundle artifacts/post-transform-gate-report.sigstore.json artifacts/post-transform-gate-report.json

# The independent promotion job obtains these exact values from its deployment-controlled policy.
test -n "$TRANSFORM_WORKFLOW_IDENTITY"
test -n "$CI_OIDC_ISSUER"
cosign verify-blob --bundle artifacts/post-transform-gate-report.sigstore.json --certificate-identity "$TRANSFORM_WORKFLOW_IDENTITY" --certificate-oidc-issuer "$CI_OIDC_ISSUER" artifacts/post-transform-gate-report.json

Evidence and freshness: Produce a fresh report for every candidate digest and every change to converter, distillation job, optimization parameters, serving runtime, case set, or draft-model pairing. Retain the signed policy, signed source manifest, actual case set, both independently signed raw evaluation files, artifacts, report and Sigstore bundle with the release record. An independent promotion job verifies each signed policy/source/evaluation input and the report bundle against its distinct trust anchor, re-hashes every file listed in evidence_digests, re-runs the gate from those exact bytes, and confirms the deployed candidate and any draft model match the report.

Action: Promotion stops on block, insufficient_data, invalid schema, a policy below local sample floors or above local regression ceilings, non-finite metrics, or any signature/digest mismatch; aggregate quality improvements never override an unmeasured required population or a security-category regression.

AID-H-007.006-G002

Treat a model-interface pairing change as a release candidate and run the same signed functional and security fixtures against the approved and candidate tuples.
Build one candidate tuple

Create a canonical manifest containing SHA-256 digests for the model, tokenizer, special-token map, chat template, generation configuration, tool parser, reasoning parser, runtime image, and explicit launch arguments. Sign it and bind it to the deployed composition in AID-M-001.002. A parser name or model repository revision alone is not a complete pairing identity.

Run matched regression

Execute the approved tuple and candidate tuple on the same signed case population. Include message-role separation, empty and long conversations, Unicode boundaries, stop-token collisions, structured output, no-tool and multi-tool calls, malformed tool syntax, reasoning-only and reasoning-plus-tool responses, streaming splits at every parser boundary, prompt injection, jailbreak, leakage, and harmful-content cases. Record raw generated bytes or token IDs in a restricted evaluator store and compare the final visible content, reasoning field, tool-call order/IDs/arguments, finish reason, and policy result. Do not expose raw reasoning in ordinary evidence.

Promotion result

Block promotion on any unapproved field change, parser ambiguity, lost or duplicated content, role/token boundary regression, safety regression beyond signed policy, missing case, mismatched tuple digest, or incomplete replica readback. If the architecture has no separate parser or template, record that component as NOT_APPLICABLE in the signed composition rather than inventing a file. Independently replay the complete population and retain both tuple manifests, case-set digest, raw-result digests, normalized comparisons, policy, and signed decision.

Open-source tools

Commercial / hosted tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-007.006: Post-Training Optimization & Format-Conversion Safety Regression | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version