diff --git a/CHANGELOG.md b/CHANGELOG.md index 38eba0f0..7463855c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,16 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ## [Unreleased] +### Added + +- **`enforcement_mode: "declared"`.** The three existing modes all assert that *something evaluated the policy*: `enforce` acted on the result, `advisory` did not, `silent` acted with the log lines suppressed. `declared` asserts less — the policy is named and bound into the signed record, and nothing evaluated it. + + That is not a corner case, it is the common one. An agent framework has no policy engine, so a record built by observing a LangChain or LlamaIndex run has a policy the operator declares and no evaluation of it anywhere. With three values, such a record had to claim an evaluation that never happened; both framework adapters refused to default the field and documented the overstatement instead, which is honest and still leaves every framework record marginally untrue. + + `declared` is the weakest value and is never a default. A producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked. A verifier appraising for enforcement SHOULD treat it as it treats an absent enforcement claim. + + Additive to a closed enum in both the model and the JSON schema, so unknown values are still rejected. Same one-directional consequence as `origin`: a verifier older than this release rejects a record carrying `declared`. + ## [0.8.0] — 2026-08-09 ### Added diff --git a/schema/trace-claim.json b/schema/trace-claim.json index 2c7a08b3..738ad27b 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -129,10 +129,11 @@ "enum": [ "enforce", "advisory", - "silent" + "silent", + "declared" ], "default": "enforce", - "description": "How policy decisions were applied: enforce (block on deny), advisory (log and allow), silent (allow and suppress operational logs; the audit chain still records every would-have-denied decision). Gateways MUST default to enforce. A deployment MUST explicitly configure silent mode; it MUST NOT be the default." + "description": "How the policy named by bundle_hash related to this execution. enforce: evaluated and blocked on deny. advisory: evaluated, logged, allowed. silent: evaluated and enforced with operational logs suppressed; the audit chain still records every would-have-denied decision. declared: the policy is named and bound into the signed record and NOTHING evaluated it, which is the honest value for a producer with no policy engine, such as an agent framework. Gateways MUST default to enforce. A deployment MUST explicitly configure silent. declared is never a default, a producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked." }, "version": { "type": "string", diff --git a/spec/trace-v0.2.md b/spec/trace-v0.2.md index ea074fc7..976ec044 100644 --- a/spec/trace-v0.2.md +++ b/spec/trace-v0.2.md @@ -374,6 +374,10 @@ TRACE is a **profile**, not a parallel stack. It binds existing primitives into These components exist in their respective ecosystems. TRACE adds the binding rule that places each into a hardware-attested envelope: - **`policy` claim.** Policy artifacts (OPA bundles, Cedar policies, custom DSLs) and policy hashing are established. TRACE adds the binding: the policy bundle hash is sealed to the TEE measurement, the enforcement mode is recorded, and substituting the policy invalidates the runtime claim. Gateways MUST default `enforcement_mode` to `enforce`. A deployment MUST explicitly configure `silent` mode; `silent` MUST NOT be the default. In `silent` mode, the audit chain still records every would-have-denied decision; only operational log lines are suppressed. + + **`enforcement_mode: "declared"`.** The three modes above all assert that *something evaluated the policy*. `declared` asserts less: the policy is named and bound into the signed record, and nothing evaluated it. That is not a corner case, it is the common one for a producer with no policy engine — an agent framework observed by an adapter has a policy the operator declares and no evaluation of it anywhere, and with only three values such a record had to claim an evaluation that never happened. + + `declared` is the weakest value and MUST NOT be a default. A producer that evaluates policy MUST NOT use it. A consumer MUST NOT read it as evidence that any rule was checked; it says only that this is the policy the deployment states it was operating under. A verifier appraising for enforcement SHOULD treat `declared` as it treats an absent enforcement claim. - **`data_class` claim.** Data classification schemes are established (DLP labels, NIST SP 800-60, sensitivity tags). TRACE adds: a classification label is attached to inputs and outputs at the per-call layer and recorded in the Trust Record alongside the runtime evidence. - **`tool_transcript` claim.** MCP and A2A transcripts exist at the protocol layer. TRACE adds cryptographic binding of the transcript hash into the EAT envelope and per-call parameter classification. - **AI-agent execution profile.** A profile registry that pins the claim set, evidence requirements, and verification rules for AI-agent workloads specifically. diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index c7250112..23795634 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -52,7 +52,27 @@ class PolicyInfo(BaseModel): model_config = ConfigDict(extra="forbid") bundle_hash: DigestStr - enforcement_mode: Literal["enforce", "advisory", "silent"] + enforcement_mode: Literal["enforce", "advisory", "silent", "declared"] + """How the policy named by ``bundle_hash`` related to this execution. + + The first three all assert that **something evaluated the policy**: + ``enforce`` acted on the result, ``advisory`` did not, ``silent`` acted and + suppressed the log lines. ``declared`` asserts less than any of them: the + policy is named and bound into the signed record, and nothing evaluated it. + + That case is not hypothetical, it is the common one. An agent framework has + no policy engine, so a record produced by observing a LangChain or LlamaIndex + run has a policy the operator declares and no evaluation of it anywhere. With + only three values, such a record had to overstate: ``advisory`` claims an + evaluation that did not happen. The adapters refused to default the field and + documented the overstatement, which is honest and leaves every framework + record marginally untrue. + + ``declared`` is deliberately the weakest value and is never a default. A + producer that evaluates policy must not use it, and a consumer must not read + it as evidence that any rule was checked. It says only: this is the policy + this deployment says it was operating under. + """ version: str | None = None policy_uri: str | None = None diff --git a/src/agentrust_trace/schema/trace-v0.2.json b/src/agentrust_trace/schema/trace-v0.2.json index 6755312b..6ac21536 100644 --- a/src/agentrust_trace/schema/trace-v0.2.json +++ b/src/agentrust_trace/schema/trace-v0.2.json @@ -129,10 +129,11 @@ "enum": [ "enforce", "advisory", - "silent" + "silent", + "declared" ], "default": "enforce", - "description": "How policy decisions were applied: enforce (block on deny), advisory (log and allow), silent (allow and suppress operational logs; the audit chain still records every would-have-denied decision). Gateways MUST default to enforce. A deployment MUST explicitly configure silent mode; it MUST NOT be the default." + "description": "How the policy named by bundle_hash related to this execution. enforce: evaluated and blocked on deny. advisory: evaluated, logged, allowed. silent: evaluated and enforced with operational logs suppressed; the audit chain still records every would-have-denied decision. declared: the policy is named and bound into the signed record and NOTHING evaluated it, which is the honest value for a producer with no policy engine, such as an agent framework. Gateways MUST default to enforce. A deployment MUST explicitly configure silent. declared is never a default, a producer that evaluates policy MUST NOT use it, and a consumer MUST NOT read it as evidence that any rule was checked." }, "version": { "type": "string", diff --git a/tests/test_models.py b/tests/test_models.py index cbbc4b80..9e9035cb 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -311,3 +311,29 @@ def test_origin_rejects_unknown_fields() -> None: data["origin"] = {"kind": "log-import", "producer": "x/1.0", "assurance": "operator-asserted"} with pytest.raises(ValidationError): TrustRecord.model_validate(data) + + +# --- enforcement_mode: declared ------------------------------------------- + + +def test_declared_enforcement_mode_parses() -> None: + """The honest value for a producer with no policy engine.""" + data = _load("intel-tdx.json") + data["policy"]["enforcement_mode"] = "declared" + record = TrustRecord.model_validate(data) + assert record.policy.enforcement_mode == "declared" + + +def test_enforcement_mode_stays_closed() -> None: + """Adding a value must not turn the field into free text.""" + data = _load("intel-tdx.json") + data["policy"]["enforcement_mode"] = "monitor" + with pytest.raises(ValidationError): + TrustRecord.model_validate(data) + + +def test_the_three_evaluating_modes_still_parse() -> None: + for mode in ("enforce", "advisory", "silent"): + data = _load("intel-tdx.json") + data["policy"]["enforcement_mode"] = mode + assert TrustRecord.model_validate(data).policy.enforcement_mode == mode diff --git a/tests/test_validate.py b/tests/test_validate.py index eb4abb51..b9c47bee 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -167,3 +167,15 @@ def test_both_subject_forms_pass_validate_json(subject: str) -> None: data = _load("intel-tdx.json") data["subject"] = subject assert iter_errors(data) == [] + + +def test_declared_enforcement_mode_passes_json_schema() -> None: + data = _load("intel-tdx.json") + data["policy"]["enforcement_mode"] = "declared" + assert iter_errors(data) == [] + + +def test_unknown_enforcement_mode_fails_json_schema() -> None: + data = _load("intel-tdx.json") + data["policy"]["enforcement_mode"] = "monitor" + assert iter_errors(data)