AID-H-006.001: Structured Output Enforcement

Version 1.20261015 · Actionable sub-technique

Require model output and any extracted tool-call or reasoning fields to conform to a strict predefined schema and bounded parser contract before downstream use. Reject ambiguous, duplicated, malformed, truncated, or out-of-contract structures instead of repairing them into an action.

Pillars: app

Lifecycle: building, operation

Scope boundary

Owns deterministic conformance of model output to a predefined structured schema and verifies the bounded parser that extracts tool calls, reasoning fields, and visible content from raw generation before downstream use.

AID-H-006.002: AID-H-006.001 proves structural schema conformance; AID-H-006.002 applies sink-specific encoding, URL policy, redaction, remote-subresource removal, streaming holdback, and final text or markup release. Schema-valid content can still be unsafe for its destination.

Implementation guidance

AID-H-006.001-G001

Enforce schema-bound structured outputs using either native function-calling or response-model wrappers.
Concept:

Schema-bound output is one control even when teams implement it through different SDKs. The core requirement is the same: do not let downstream systems parse free-form model text when the response is supposed to conform to a contract. Use either a wrapper such as Instructor/Pydantic or a vendor-native function-calling API, but treat both as equivalent implementations of one structured-output boundary.

Step 1: Define one canonical response schema
# File: schemas/output_schemas.py
from pydantic import BaseModel, ConfigDict, Field
from typing import Literal

# aidefend.structured-output.v1 requires non-empty reasoning; this is a wire-schema invariant.
NON_EMPTY_REASONING_LENGTH = 1

class UserSentiment(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)

    sentiment: Literal["positive", "negative", "neutral"]
    confidence: float = Field(ge=0.0, le=1.0)
    reasoning: str = Field(min_length=NON_EMPTY_REASONING_LENGTH)
Step 2: Wrapper path using Instructor or an equivalent response-model library
# File: analysis/sentiment_analyzer.py
import instructor
from openai import OpenAI
from schemas.output_schemas import UserSentiment

client = instructor.patch(OpenAI())

def get_structured_sentiment(text: str) -> UserSentiment:
    return client.chat.completions.create(
        model="gpt-4.1",
        response_model=UserSentiment,
        messages=[
            {"role": "user", "content": f"Analyze the sentiment of this text: {text}"}
        ],
    )
Step 3: Native function-calling path with the same schema semantics
# File: tools/schemas.py
tools = [
    {
        "type": "function",
        "function": {
            "name": "get_user_sentiment",
            "description": "Return structured sentiment analysis",
            "parameters": {
                "type": "object",
                "properties": {
                    "sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
                    "confidence": {"type": "number", "minimum": 0.0, "maximum": 1.0},
                    "reasoning": {"type": "string", "minLength": 1}
                },
                "required": ["sentiment", "confidence", "reasoning"],
                "additionalProperties": False
            }
        }
    }
]
# File: analysis/tool_call_sentiment.py
from openai import OpenAI
import json
from schemas.output_schemas import UserSentiment
from tools.schemas import tools

client = OpenAI()

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_constant(value):
    raise ValueError(f"non-finite JSON number: {value}")

def get_sentiment_via_tool_call(text: str) -> UserSentiment:
    response = client.chat.completions.create(
        model="gpt-4.1",
        messages=[{"role": "user", "content": f"Analyze the sentiment of this text: {text}"}],
        tools=tools,
        tool_choice={"type": "function", "function": {"name": "get_user_sentiment"}},
    )
    tool_call = response.choices[0].message.tool_calls[0]
    payload = json.loads(
        tool_call.function.arguments,
        object_pairs_hook=_unique_object,
        parse_constant=_reject_constant,
    )
    return UserSentiment.model_validate(payload, strict=True)
Operational notes

Action: Make structured-output enforcement a single recommendation unit. The implementation may vary by provider, but the security boundary is the shared schema contract.

Verify safely

A separately credentialed verifier independently reads back or replays the exact model/endpoint/schema version and complete response population requiring typed structured output, exercises positive, negative, boundary and dependency-failure fixtures, and reconciles the complete eligible population.

AID-H-006.001-G002

High-impact JSON Schema + Pydantic enforcement profile with fail-closed validation, bounded normalization, and audit persistence.
Concept:

For high-impact actions, the schema check must be a real execution gate. Define one canonical contract, allow only bounded normalization, persist the raw candidate and normalized payload for evidence, and fail closed before any tool or side effect runs.

Step 1: Define one strict high-impact contract
# File: output_contracts/high_impact.py
from __future__ import annotations
import math
import os

from typing import Any, Literal

from pydantic import BaseModel, ConfigDict, Field, model_validator

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

JUSTIFICATION_MIN_LENGTH = _required_positive_int("VERIFIED_H006_JUSTIFICATION_MIN_LENGTH")
JUSTIFICATION_MAX_LENGTH = _required_positive_int("VERIFIED_H006_JUSTIFICATION_MAX_LENGTH")
PROVENANCE_MIN_ITEMS = _required_positive_int("VERIFIED_H006_PROVENANCE_MIN_ITEMS")
PROVENANCE_MAX_ITEMS = _required_positive_int("VERIFIED_H006_PROVENANCE_MAX_ITEMS")



if JUSTIFICATION_MAX_LENGTH < JUSTIFICATION_MIN_LENGTH or PROVENANCE_MAX_ITEMS < PROVENANCE_MIN_ITEMS:
    raise RuntimeError("verified high-impact schema bounds are invalid")


ACTION_PARAM_FIELDS = {
    "pay_vendor_invoice": {"amount_usd", "vendor_id", "invoice_id"},
    "rotate_credential": {"credential_id"},
    "update_k8s_config": {"cluster_id", "namespace", "manifest_sha256"},
}

class HighImpactRequest(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True)

    action: Literal["pay_vendor_invoice", "rotate_credential", "update_k8s_config"]
    riskTier: Literal["low", "medium", "high"]
    justification: str = Field(min_length=JUSTIFICATION_MIN_LENGTH, max_length=JUSTIFICATION_MAX_LENGTH)
    provenance: list[str] = Field(min_length=PROVENANCE_MIN_ITEMS, max_length=PROVENANCE_MAX_ITEMS)
    params: dict[str, Any]

    @model_validator(mode="after")
    def validate_action_bounds(self) -> "HighImpactRequest":
        expected = ACTION_PARAM_FIELDS[self.action]
        if set(self.params) != expected:
            raise ValueError("action_parameter_schema_mismatch")
        if any(not isinstance(key, str) for key in self.params):
            raise ValueError("parameter_key_type_invalid")
        if self.action == "pay_vendor_invoice":
            amount = self.params.get("amount_usd")
            if (isinstance(amount, bool) or not isinstance(amount, (int, float)) or
                    not math.isfinite(amount) or amount <= 0 or amount > 10_000):
                raise ValueError("amount_out_of_policy_bounds")
        if self.action == "update_k8s_config":
            digest = self.params.get("manifest_sha256")
            if not isinstance(digest, str) or __import__("re").fullmatch(r"[0-9a-f]{64}", digest) is None:
                raise ValueError("manifest_digest_invalid")
        for key, value in self.params.items():
            if key != "amount_usd" and (not isinstance(value, str) or not value.strip()):
                raise ValueError("action_parameter_value_invalid")
        return self
Step 2: Normalize only the fields you can repair deterministically

Do not invent missing actions, provenance, or parameters. Bounded normalization is limited to things like trimming strings, lowercasing enumerated values, and dropping empty provenance entries.

# File: output_contracts/enforcer.py
from __future__ import annotations

import hashlib
import json
from pathlib import Path

from pydantic import ValidationError

from output_contracts.high_impact import HighImpactRequest


class ContractViolation(RuntimeError):
    pass


def normalize_candidate_payload(payload: dict) -> dict:
    normalized = dict(payload)
    if isinstance(normalized.get("riskTier"), str):
        normalized["riskTier"] = normalized["riskTier"].strip().lower()
    if isinstance(normalized.get("justification"), str):
        normalized["justification"] = normalized["justification"].strip()
    if (isinstance(normalized.get("provenance"), list) and
            all(isinstance(item, str) for item in normalized["provenance"])):
        normalized["provenance"] = [
            item.strip() for item in normalized["provenance"] if item.strip()
        ]
    return normalized


def append_contract_audit(audit_path: str, record: dict) -> None:
    path = Path(audit_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as handle:
        handle.write(json.dumps(record, sort_keys=True) + "\n")


def validate_high_impact_payload(raw_payload: dict, *, audit_path: str) -> HighImpactRequest:
    normalized = normalize_candidate_payload(raw_payload)
    raw_sha256 = hashlib.sha256(
        json.dumps(raw_payload, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
    ).hexdigest()

    try:
        parsed = HighImpactRequest.model_validate(normalized)
    except ValidationError as exc:
        append_contract_audit(
            audit_path,
            {
                "status": "rejected",
                "reason": "schema_validation_failed",
                "raw_payload_sha256": raw_sha256,
                "normalized_payload_sha256": hashlib.sha256(
                    json.dumps(normalized, sort_keys=True, separators=(",", ":"), allow_nan=False).encode("utf-8")
                ).hexdigest(),
                "errors": json.loads(exc.json()),
            },
        )
        raise ContractViolation("high_impact_schema_rejected") from exc

    append_contract_audit(
        audit_path,
        {
            "status": "accepted",
            "raw_payload_sha256": raw_sha256,
            "normalized_payload_sha256": hashlib.sha256(
                parsed.model_dump_json().encode("utf-8")
            ).hexdigest(),
        },
    )
    return parsed
Step 3: Gate the dispatcher on the validator result
# File: output_contracts/dispatch.py
from output_contracts.enforcer import ContractViolation, validate_high_impact_payload


def dispatch_high_impact(raw_payload: dict, *, audit_path: str, tool_handlers: dict, authorize):
    validated = validate_high_impact_payload(raw_payload, audit_path=audit_path)
    decision = authorize(validated.model_dump())
    if not isinstance(decision, dict) or decision.get("outcome") != "ALLOW":
        raise PermissionError("high_impact_authorization_denied")
    handler = tool_handlers.get(validated.action)
    if handler is None:
        raise ContractViolation("approved_action_has_no_handler")
    return handler(validated.params)
Operational notes

Action: Put one fail-closed schema enforcer in front of every high-impact action path. If the payload cannot be deterministically normalized into the approved contract, or the separate authorization owner does not return an exact ALLOW decision for the validated action and parameters, block execution and retain a minimized rejection record.

Verify safely

A separately credentialed verifier independently reads back or replays the exact validated output schema plus action-specific semantic, authorization and policy constraints for every proposed action, exercises positive, negative, boundary and dependency-failure fixtures, and reconciles the complete eligible population.

AID-H-006.001-G003

Verify the deployed tool-call and reasoning parsers against a signed positive and negative fixture population before their extracted fields reach downstream code.
Use a parser contract, not parser-name trust

Expose a release-test adapter with the contract parse(chunks: list[str]) -> object; it must drive the deployed parser through the same incremental callbacks and finalization path used in production. Bind fixtures to the exact model, tokenizer, chat template, runtime image, parser implementation, and parser configuration recorded by AID-M-001.002. The parser must preserve visible content, tool-call order and arguments, and reasoning boundaries; ambiguous, duplicated, truncated, nested, or mixed marker forms must fail closed.

# File: tests/verify_serving_parser.py
from __future__ import annotations

import hashlib
import importlib
import json
import os
from pathlib import Path
from typing import Any

MAX_FIXTURE_BYTES = int(os.environ["VERIFIED_H006_PARSER_FIXTURE_MAX_BYTES"])
ENTRYPOINT = os.environ["VERIFIED_H006_PARSER_ENTRYPOINT"]
EXPECTED_FIXTURE_SHA256 = os.environ["VERIFIED_H006_PARSER_FIXTURE_SHA256"].lower()
INTERFACE_TUPLE_SHA256 = os.environ["VERIFIED_H006_INTERFACE_TUPLE_SHA256"].lower()
for name, value in {"fixture": EXPECTED_FIXTURE_SHA256,
                    "interface tuple": INTERFACE_TUPLE_SHA256}.items():
    if len(value) != 64 or set(value) - set("0123456789abcdef"):
        raise RuntimeError(name + " digest is invalid")


def strict_json(raw: bytes) -> Any:
    def unique(pairs):
        result = {}
        for key, value in pairs:
            if key in result:
                raise ValueError("duplicate JSON key: " + key)
            result[key] = value
        return result
    return json.loads(
        raw.decode("utf-8", errors="strict"),
        object_pairs_hook=unique,
        parse_constant=lambda value: (_ for _ in ()).throw(
            ValueError("non-finite JSON number: " + value)
        ),
    )


def load_parser():
    module_name, separator, function_name = ENTRYPOINT.partition(":")
    if not separator or not module_name or not function_name:
        raise ValueError("parser entry point must be module:function")
    parser = getattr(importlib.import_module(module_name), function_name)
    if not callable(parser):
        raise TypeError("parser entry point is not callable")
    return parser


def main() -> None:
    fixture_path = Path("tests/serving-parser-fixtures.json")
    raw = fixture_path.read_bytes()
    if not raw or MAX_FIXTURE_BYTES < 1 or len(raw) > MAX_FIXTURE_BYTES:
        raise ValueError("fixture population is empty or exceeds policy")
    fixture_sha256 = hashlib.sha256(raw).hexdigest()
    if fixture_sha256 != EXPECTED_FIXTURE_SHA256:
        raise PermissionError("fixture differs from the signature-verified release digest")
    document = strict_json(raw)
    if not isinstance(document, dict) or set(document) != {"schema_version", "cases"}:
        raise ValueError("fixture schema differs")
    cases = document["cases"]
    if document["schema_version"] != "aidefend.serving-parser-fixtures.v1" or not isinstance(cases, list) or not cases:
        raise ValueError("fixture population is invalid")
    parser = load_parser()
    seen = set()
    results = []
    for case in cases:
        if not isinstance(case, dict) or set(case) != {"id", "chunks", "expected", "must_reject"}:
            raise ValueError("case schema differs")
        case_id = case["id"]
        if not isinstance(case_id, str) or not case_id or case_id in seen:
            raise ValueError("case ID is empty or duplicated")
        seen.add(case_id)
        must_reject = case["must_reject"]
        chunks = case["chunks"]
        if (type(must_reject) is not bool or not isinstance(chunks, list) or not chunks
                or any(not isinstance(chunk, str) for chunk in chunks)
                or sum(len(chunk.encode("utf-8")) for chunk in chunks) > MAX_FIXTURE_BYTES):
            raise ValueError("case types or streaming bounds differ")
        try:
            observed = parser(chunks)
        except (ValueError, TypeError, PermissionError):
            if not must_reject:
                raise AssertionError("valid parser fixture was rejected: " + case_id)
            results.append({"id": case_id, "result": "REJECTED_AS_EXPECTED"})
            continue
        if must_reject:
            raise AssertionError("malformed parser fixture was accepted: " + case_id)
        if observed != case["expected"]:
            raise AssertionError("parser output differs: " + case_id)
        results.append({"id": case_id, "result": "MATCH"})
    evidence = {
        "schema_version": "aidefend.serving-parser-evidence.v1",
        "fixture_sha256": fixture_sha256,
        "interface_tuple_sha256": INTERFACE_TUPLE_SHA256,
        "entrypoint": ENTRYPOINT,
        "case_count": len(cases),
        "results": results,
        "outcome": "PASS",
    }
    Path("evidence/serving-parser-evidence.json").write_text(
        json.dumps(evidence, sort_keys=True, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )


if __name__ == "__main__":
    main()

Run the verifier inside the release image after admission injects the digest of the exact signature-verified fixture and model-interface tuple; the code rejects any later substitution before parsing. Include valid no-tool, single-tool, multi-tool, reasoning-only, and reasoning-plus-tool cases plus malformed markers, duplicated tool IDs, invalid JSON, truncation, unknown fields, nesting, Unicode boundary, and streaming-split cases. Sign the evidence and independently replay the same complete population. A missing case, parser exception on a valid case, acceptance of a negative case, or any field mismatch blocks promotion.

Action: Gate every parser release on the signed fixture replay and block downstream delivery when any positive, negative, split-stream, or malformed case fails.

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-006.001: Structured Output Enforcement | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version