From 2920caae88409ae60a58fb9658d2f49c2c626473 Mon Sep 17 00:00:00 2001 From: Louielunz <48041247+lywinged@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:11:56 +0000 Subject: [PATCH] schema: put the references block in the schema and the model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec section 3.1.2 landed in #198 as text. Text alone did not make the field usable: schema/trace-claim.json sets additionalProperties: false at the top level and TrustRecord is extra="forbid", so a record carrying references was rejected by both artifacts a producer validates against. examples/tpm2.json validates as committed and fails with "Additional properties are not allowed ('references' was unexpected)" the moment an entry is added. The block is an array. That is the reading the text supports rather than a decision made here: 3.1.2 says "a references entry", "an entry in references", and "omit the entry", and its field table describes the fields of an entry rather than of the block. Two of the four rules in 3.1.2 are properties of a record and are tested: rule 1, a record carrying references and no origin keeps the hardware runtime.platform it earned; rule 2, the signature covers the block, so rewriting any member of any entry in transit fails verification. Rules 3 and 4 bind verifiers rather than records — no schema can express them — and belong to the conformance suite. The schema description says so rather than implying it enforces them. resolver is constrained on presence and not on value. Rule 4 requires a producer that cannot name a resolver to omit the entry, and whether an identifier is self-asserted is not decidable from the record. retention is validated as an ISO 8601 duration by a pattern written out in alternation rather than with a negative lookahead, so the same string serves both files: pydantic's default regex engine has no look-around, and a lookahead form would build in JSON Schema and raise in the model, forcing the two apart. A test asserts the two pattern strings are byte-equal, and that the rel sets match, because a case table only catches a divergence it happens to sample. Both schema copies change together; tests/test_validate.py already fails on drift between them. docs/schema.md gets the field and a table, guarded the same way as the build_provenance table. 604 tests pass. Each guard was checked by reverting what it guards. Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com> --- CHANGELOG.md | 6 + docs/schema.md | 17 ++ schema/trace-claim.json | 45 +++ src/agentrust_trace/__init__.py | 2 + src/agentrust_trace/models.py | 48 ++++ src/agentrust_trace/schema/trace-v0.2.json | 45 +++ tests/test_references_block.py | 304 +++++++++++++++++++++ 7 files changed, 467 insertions(+) create mode 100644 tests/test_references_block.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d09c93d..e6dfc10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,12 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ### Added +- **The `references` block is now in the schema and the model.** Spec section 3.1.2 landed the block in [#198](https://github.com/agentrust-io/trace-spec/pull/198) as text only, and text alone did not make it usable: `schema/trace-claim.json` sets `additionalProperties: false` at the top level and `TrustRecord` is `extra="forbid"`, so a record carrying the field the specification permits was rejected by both artifacts a producer validates against. `references` is an array of entries with required `rel` (`authorized-intent`, `approval-outcome`, `behavior-trace`), `id` and `resolver`, and optional `retention` (ISO 8601 duration) and `digest`. + + Two of the four rules in section 3.1.2 are properties of a record and are tested here: a record carrying `references` and no `origin` keeps the hardware `runtime.platform` it earned, and the signature covers the block, so rewriting any member of any entry in transit fails verification. The other two — a verifier MUST NOT reject a record for an entry it cannot resolve, and MUST NOT treat a resolved entry as attested evidence — are verifier behaviour that no schema can express, and belong to the conformance suite. + + `resolver` is constrained on presence and not on value: section 3.1.2 requires a producer that cannot name a resolver to omit the entry rather than emit a self-asserted one, and whether an identifier is self-asserted is not decidable from the record. + - **Record-signing key revocation is anchored to transparency-log entry ordering, not to `iat`.** New spec section 3.2.3 defines the `TraceRevocation/1.0` claim type: a record from a revoked key is valid if and only if its SCITT inclusion entry ID is at or below `last_valid_entry_id` on the log the statement names. The intuitive time-based rule cannot work, because a compromised record-signing key also signs the `iat` it would be judged against, so an attacker backdates the record and the rule passes. Entry IDs are monotonic and bound to the Merkle structure, so ordering survives the compromise a timestamp does not. Distribution keeps section 3.3's no-callback property: statements are anchored in the same log as the records they govern, and verifiers cache a signed bundle carrying `valid_until`. An expired bundle is not a pass, and a verifier with none reports that it performed no revocation check rather than reporting an affirming appraisal. A revocation statement MUST be signed by a key above the revoked one in the section 3.2.1 hierarchy, or by a recovery key with an independent compromise domain, because a statement the compromised key could sign for itself is a tool for whoever stole it. Records with no usable inclusion entry ID fall back to binary revocation, which is the existing behaviour. Schemas: `schema/trace-revocation.json`, `schema/trace-revocation-bundle.json`. Resolves [#67](https://github.com/agentrust-io/trace-spec/issues/67). diff --git a/docs/schema.md b/docs/schema.md index e46b9c0..cadcc5d 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -16,6 +16,7 @@ JSON Schema for the TRACE v0.1 Trust Record. Source: [`schema/trace-claim.json`] | `tool_transcript` | object | **yes** | Tool-call audit summary | | `delegation` | object | no | A2A profile: link to the delegating hop's Trust Record | | `origin` | object | no | Where the evidence came from, when that is not this runtime | +| `references` | array | no | Facts outside this record that it points at. Assurance-neutral | | `build_provenance` | object | **yes** | Build-time artifact provenance | | `appraisal` | object | **yes** | Verifier judgment | | `transparency` | string | no | Registry or SCITT anchor for the record. Optional below Level 2, where an unanchored record has no receipt to name. Use `null`, never `""` | @@ -99,6 +100,22 @@ It exists because `runtime.platform: "software-only"` is ambiguous on its own: i A record whose `kind` is not `self` **must** carry `runtime.platform: "software-only"`. An importer holding someone else's log has no quote to present, so a hardware platform on such a record is untrue rather than stronger. Both the reference model and `schema/trace-claim.json` reject the combination. +## `references` + +An array of pointers to facts held outside this record: an authorization decided before execution, a human approval, a behavioural trace. What the signature attests is that this record points there, not the truth of what it points at. + +`origin` records where evidence *came from* and can lower assurance. `references` records what a record *points at* and cannot. Before the block existed, a record that needed to name something external had to use `origin` and take `runtime.platform: "software-only"` with it, which said something untrue about how the evidence was obtained. + +| Field | Type | Required | Description | +|---|---|---|---| +| `rel` | string | **yes** | `authorized-intent`, `approval-outcome`, or `behavior-trace` | +| `id` | string | **yes** | Identifier of the referenced fact within the resolver's system | +| `resolver` | string | **yes** | Identifier of the party obliged to resolve `id` | +| `retention` | string | no | ISO 8601 duration the resolver undertakes to keep `id` resolvable. An undertaking only; nothing enforces it | +| `digest` | string | no | `sha256:` or `sha384:` digest of the referenced object, when the producer holds it at issue time | + +Spec section 3.1.2 also binds verifiers: one **must not** reject a record because an entry cannot be resolved, and **must not** treat a resolved entry as attested evidence. A reference that could invalidate a record would hand whoever controls the target a way to invalidate evidence they do not hold. Both are verifier behaviour, so neither the schema nor the reference model can enforce them; they are conformance-suite rules. What the schema and the model do enforce is the shape, and that a producer who cannot name a `resolver` cannot emit an empty one. + ## `build_provenance` Build-time provenance binding the deployed artifact. diff --git a/schema/trace-claim.json b/schema/trace-claim.json index 97954c2..c2714ec 100644 --- a/schema/trace-claim.json +++ b/schema/trace-claim.json @@ -238,6 +238,51 @@ }, "additionalProperties": false }, + "references": { + "type": "array", + "minItems": 1, + "description": "Facts outside this record that it points at. Spec section 3.1.2. An entry is a pointer, not evidence: the signature attests that this record points there, not the truth of what it points at. The block is assurance-neutral and does not affect runtime.platform. Two further rules in 3.1.2 bind verifiers rather than records, so this schema cannot express them: a verifier MUST NOT reject a record because an entry cannot be resolved, and MUST NOT treat a resolved entry as attested evidence.", + "items": { + "type": "object", + "required": [ + "rel", + "id", + "resolver" + ], + "properties": { + "rel": { + "type": "string", + "enum": [ + "authorized-intent", + "approval-outcome", + "behavior-trace" + ], + "description": "authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence." + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Identifier of the referenced fact within the resolver's system." + }, + "resolver": { + "type": "string", + "minLength": 1, + "description": "Identifier of the party obliged to resolve id. A producer that cannot name one omits the entry. Which identifiers are self-asserted is not decidable from the record, so this constrains the field's presence and not its value." + }, + "retention": { + "type": "string", + "pattern": "^P(\\d+W|(\\d+Y(\\d+M)?(\\d+D)?|\\d+M(\\d+D)?|\\d+D)(T(\\d+H(\\d+M)?(\\d+S)?|\\d+M(\\d+S)?|\\d+S))?|T(\\d+H(\\d+M)?(\\d+S)?|\\d+M(\\d+S)?|\\d+S))$", + "description": "Period for which resolver undertakes to keep id resolvable, as an ISO 8601 duration. An undertaking only: nothing in this specification enforces it." + }, + "digest": { + "type": "string", + "pattern": "^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$", + "description": "SHA-256 or SHA-384 digest of the referenced object, when the producer holds it at issue time." + } + }, + "additionalProperties": false + } + }, "build_provenance": { "type": "object", "description": "SLSA provenance for the workload (agent code + container image).", diff --git a/src/agentrust_trace/__init__.py b/src/agentrust_trace/__init__.py index 84a1ae7..f8b9e06 100644 --- a/src/agentrust_trace/__init__.py +++ b/src/agentrust_trace/__init__.py @@ -18,6 +18,7 @@ JWK, ModelInfo, PolicyInfo, + Reference, RuntimeInfo, ToolTranscript, TrustRecord, @@ -66,6 +67,7 @@ "JWK", "ModelInfo", "PolicyInfo", + "Reference", "RuntimeInfo", "ToolTranscript", "TrustRecord", diff --git a/src/agentrust_trace/models.py b/src/agentrust_trace/models.py index b437aff..99e1d73 100644 --- a/src/agentrust_trace/models.py +++ b/src/agentrust_trace/models.py @@ -5,6 +5,15 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator _DIGEST_RE = r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$" +# ISO 8601 duration, spelled out by alternation rather than with a negative +# lookahead so that the same pattern string can be used here and in the JSON +# Schema: pydantic's default regex engine (Rust) has no look-around, so a +# lookahead form builds in JSON Schema and raises here, and the two files would +# have to disagree. What the alternation buys: at least one component ("P" and +# "PT" alone are rejected), components in order, and the week form standalone. +_DURATION_TIME = r"(\d+H(\d+M)?(\d+S)?|\d+M(\d+S)?|\d+S)" +_DURATION_DATE = r"(\d+Y(\d+M)?(\d+D)?|\d+M(\d+D)?|\d+D)" +_DURATION_RE = rf"^P(\d+W|{_DURATION_DATE}(T{_DURATION_TIME})?|T{_DURATION_TIME})$" DigestStr = Annotated[str, Field(pattern=_DIGEST_RE)] @@ -140,6 +149,44 @@ class Origin(BaseModel): ingested_at: Annotated[int, Field(ge=1700000000)] | None = None +class Reference(BaseModel): + """A fact outside this record that the record points at. Spec section 3.1.2. + + ``origin`` records where evidence *came from* and can lower assurance. + ``references`` records what a record *points at* and cannot. Before this + block existed, a record that needed to name something external had to use + ``origin`` and take ``runtime.platform: "software-only"`` with it, which said + something untrue about how the evidence was obtained. + + An entry is a pointer, not evidence. What the signature attests is that this + record points there, not the truth of what it points at. The pointer is + produced inside the boundary that produced the record; the target is not. + Two consequences the spec states as MUST NOT are verifier behaviour and so + are not expressible here: a verifier does not reject a record because an + entry cannot be resolved, and it does not treat a resolved entry as attested + evidence. Both live in the conformance suite. This model fixes the shape. + + ``rel`` is closed for the same reason ``Origin.kind`` is: the value of the + field is that a consumer can key on it. + + ``resolver`` names the party obliged to resolve ``id``. The specification + requires a producer that cannot name one to omit the entry rather than emit a + self-asserted resolver, and whether an identifier is self-asserted is not + decidable from the record, so the constraint here is presence and not value. + + ``retention`` states an undertaking that nothing in the specification + enforces. It is validated as an ISO 8601 duration and nothing more. + """ + + model_config = ConfigDict(extra="forbid") + + rel: Literal["authorized-intent", "approval-outcome", "behavior-trace"] + id: Annotated[str, Field(min_length=1)] + resolver: Annotated[str, Field(min_length=1)] + retention: Annotated[str, Field(pattern=_DURATION_RE)] | None = None + digest: DigestStr | None = None + + class BuildProvenance(BaseModel): model_config = ConfigDict(extra="forbid") @@ -220,6 +267,7 @@ class TrustRecord(BaseModel): tool_transcript: ToolTranscript | None = None delegation: Delegation | None = None origin: Origin | None = None + references: Annotated[list[Reference], Field(min_length=1)] | None = None build_provenance: BuildProvenance appraisal: Appraisal transparency: Annotated[str, Field(min_length=1)] | None = None diff --git a/src/agentrust_trace/schema/trace-v0.2.json b/src/agentrust_trace/schema/trace-v0.2.json index f564c43..f805616 100644 --- a/src/agentrust_trace/schema/trace-v0.2.json +++ b/src/agentrust_trace/schema/trace-v0.2.json @@ -238,6 +238,51 @@ }, "additionalProperties": false }, + "references": { + "type": "array", + "minItems": 1, + "description": "Facts outside this record that it points at. Spec section 3.1.2. An entry is a pointer, not evidence: the signature attests that this record points there, not the truth of what it points at. The block is assurance-neutral and does not affect runtime.platform. Two further rules in 3.1.2 bind verifiers rather than records, so this schema cannot express them: a verifier MUST NOT reject a record because an entry cannot be resolved, and MUST NOT treat a resolved entry as attested evidence.", + "items": { + "type": "object", + "required": [ + "rel", + "id", + "resolver" + ], + "properties": { + "rel": { + "type": "string", + "enum": [ + "authorized-intent", + "approval-outcome", + "behavior-trace" + ], + "description": "authorized-intent: an authorization decided before execution, held in another system. approval-outcome: an attributable human approval attached to a step-up or defer decision. behavior-trace: a behavioural record of what the agent did, of which this record is the environment evidence." + }, + "id": { + "type": "string", + "minLength": 1, + "description": "Identifier of the referenced fact within the resolver's system." + }, + "resolver": { + "type": "string", + "minLength": 1, + "description": "Identifier of the party obliged to resolve id. A producer that cannot name one omits the entry. Which identifiers are self-asserted is not decidable from the record, so this constrains the field's presence and not its value." + }, + "retention": { + "type": "string", + "pattern": "^P(\\d+W|(\\d+Y(\\d+M)?(\\d+D)?|\\d+M(\\d+D)?|\\d+D)(T(\\d+H(\\d+M)?(\\d+S)?|\\d+M(\\d+S)?|\\d+S))?|T(\\d+H(\\d+M)?(\\d+S)?|\\d+M(\\d+S)?|\\d+S))$", + "description": "Period for which resolver undertakes to keep id resolvable, as an ISO 8601 duration. An undertaking only: nothing in this specification enforces it." + }, + "digest": { + "type": "string", + "pattern": "^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$", + "description": "SHA-256 or SHA-384 digest of the referenced object, when the producer holds it at issue time." + } + }, + "additionalProperties": false + } + }, "build_provenance": { "type": "object", "description": "SLSA provenance for the workload (agent code + container image).", diff --git a/tests/test_references_block.py b/tests/test_references_block.py new file mode 100644 index 0000000..665d2ee --- /dev/null +++ b/tests/test_references_block.py @@ -0,0 +1,304 @@ +"""The `references` block: spec §3.1.2, merged in #198. + +The spec text landed without the schema, so nothing checked that a record +carrying the block is valid. It was not: `additionalProperties: false` at the +top level rejected `references` outright, and `TrustRecord` is `extra="forbid"`, +so a record the specification permits was rejected by both of the artifacts a +producer would test against. That is the failure mode this file exists to keep +shut, from both directions — a valid entry must be accepted, and each way of +getting one wrong must be rejected for its own reason. + +The schema and the model are checked against the *same* case table rather than +separately. Two artifacts that each pass their own tests can still disagree, and +a producer that validates against one and is consumed by the other only finds +out in production. `test_validate.py` makes the same argument for the packaged +copy of the schema. + +Of the four MUST/MUST NOT rules in §3.1.2, two are expressible here. Rule 1 +(`references` MUST NOT affect `runtime.platform`) and rule 2 (the signature MUST +cover `references`) are properties of a record and are asserted below. Rules 3 +and 4 are verifier behaviour — a verifier MUST NOT reject on an unresolvable +entry, and MUST NOT treat a resolved one as evidence — and a schema cannot say +either; they belong to the conformance suite. +""" + +from __future__ import annotations + +import copy +import json +import time +from pathlib import Path + +import pytest +from cryptography.exceptions import InvalidSignature +from pydantic import ValidationError + +from agentrust_trace import ( + TrustRecord, + generate_key, + iter_errors, + key_to_jwk, + sign_record, + verify_record, +) + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" + +# All fields populated, so a case that drops or corrupts one is isolating that +# field and not also tripping a missing-required error somewhere else. +FULL_ENTRY = { + "rel": "authorized-intent", + "id": "req-9f2c41", + "resolver": "https://approvals.example.com", + "retention": "P30D", + "digest": "sha256:" + "a" * 64, +} + +MINIMAL_ENTRY = {"rel": "behavior-trace", "id": "run-77", "resolver": "obs.example.com"} + + +def _entry(**overrides) -> dict: + return {**FULL_ENTRY, **overrides} + + +def _example() -> dict: + """intel-tdx.json: a hardware record with no `origin` and no `references`.""" + return json.loads((EXAMPLES / "intel-tdx.json").read_text(encoding="utf-8")) + + +def _with(references) -> dict: + record = _example() + record["references"] = references + return record + + +# (label, references value, accepted?) +CASES: list[tuple[str, object, bool]] = [ + ("full entry", [_entry()], True), + ("required fields only", [MINIMAL_ENTRY], True), + ("two entries", [_entry(), MINIMAL_ENTRY], True), + ("rel authorized-intent", [_entry(rel="authorized-intent")], True), + ("rel approval-outcome", [_entry(rel="approval-outcome")], True), + ("rel behavior-trace", [_entry(rel="behavior-trace")], True), + ("sha384 digest", [_entry(digest="sha384:" + "b" * 96)], True), + # rel is closed: an unregistered value is the case the enum exists for. + ("unregistered rel", [_entry(rel="policy-decision")], False), + ("rel absent", [{"id": "x", "resolver": "y"}], False), + ("id absent", [{"rel": "behavior-trace", "resolver": "y"}], False), + ("resolver absent", [{"rel": "behavior-trace", "id": "x"}], False), + # An empty resolver is the self-asserted entry rule 4 tells a producer to + # omit; an empty id is a pointer that points nowhere. Both look populated. + ("empty resolver", [_entry(resolver="")], False), + ("empty id", [_entry(id="")], False), + ("malformed digest", [_entry(digest="sha256:zz")], False), + ("uppercase digest hex", [_entry(digest="sha256:" + "A" * 64)], False), + ("unknown member in entry", [_entry(note="human comment")], False), + # An empty array is a record that says it points at something and does not. + ("empty array", [], False), + ("object instead of array", _entry(), False), + ("string instead of entry", ["req-9f2c41"], False), +] + +# `retention` is an ISO 8601 duration. The rejected column is the point: a +# pattern loose enough to accept "P" or "P1H" is not checking anything. +CASES += [(f"retention {v}", [_entry(retention=v)], True) for v in ( + "P30D", "P1Y", "P1Y6M", "P1Y6M15D", "P1Y15D", "P7D", "P2W", "P10Y", + "PT12H", "PT30M", "PT1H30M15S", "P1DT12H", +)] +CASES += [(f"retention {v!r}", [_entry(retention=v)], False) for v in ( + "P", # no components at all + "PT", # a time designator with no time + "P1DT", # same, after a valid date part + "30D", # no duration designator + "P1H", # hours outside the time part + "P1D1Y", # components out of order + "P1Y2W", # the week form does not combine + "P2WT12H", # nor with a time part + "P-1D", + "P1.5D", + "p30d", + "PT12h", + "P1D ", + "", +)] + +IDS = [label for label, _, _ in CASES] +PARAMS = [(references, accepted) for _, references, accepted in CASES] + + +@pytest.mark.parametrize(("references", "accepted"), PARAMS, ids=IDS) +def test_json_schema_agrees_with_the_case(references, accepted) -> None: + errors = iter_errors(_with(references)) + assert (not errors) == accepted, [e.message for e in errors[:2]] + if not accepted: + # Attributable, or this table would still pass if the base example broke + # and every case failed for a reason that has nothing to do with the block. + assert any( + e.absolute_path and e.absolute_path[0] == "references" for e in errors + ), f"rejected, but not because of references: {[e.message for e in errors[:2]]}" + + +@pytest.mark.parametrize(("references", "accepted"), PARAMS, ids=IDS) +def test_the_model_agrees_with_the_case(references, accepted) -> None: + record = _with(references) + try: + TrustRecord.model_validate(record) + except ValidationError as exc: + assert not accepted, "the model rejects a record the specification permits" + assert any( + e["loc"] and e["loc"][0] == "references" for e in exc.errors() + ), f"rejected, but not because of references: {exc.errors()[:2]}" + else: + assert accepted, "the model accepts a record the specification does not permit" + + +def test_the_unmodified_example_is_a_clean_baseline() -> None: + """Every case above is this record plus a `references` block. If it were not + valid to begin with, the rejection half of the table would prove nothing.""" + record = _example() + assert iter_errors(record) == [] + TrustRecord.model_validate(record) + + +def test_the_block_stays_optional() -> None: + """Every published example predates §3.1.2 and must keep validating.""" + for name in ("intel-tdx.json", "amd-sev-snp.json", "nvidia-h100.json"): + record = json.loads((EXAMPLES / name).read_text(encoding="utf-8")) + assert "references" not in record + assert iter_errors(record) == [] + + +def test_references_does_not_touch_the_platform() -> None: + """Rule 1. `references` is not `origin` and must not behave like it. + + The block exists because a record that needed to name something external had + to use `origin` and take `software-only` with it. A hardware record that adds + `references` and nothing else is still the hardware record it was. + """ + record = _with([_entry()]) + assert record["runtime"]["platform"] == "intel-tdx" + assert "origin" not in record + assert iter_errors(record) == [] + TrustRecord.model_validate(record) + + +def test_origin_still_constrains_the_platform_alongside_references() -> None: + """The converse of rule 1: adding `references` must not relax `origin`. + + Without this, "references does not lower assurance" could be satisfied by a + schema that had stopped enforcing anything on that record at all. + """ + record = _with([_entry()]) + record["origin"] = {"kind": "log-import", "producer": "siem/1.0"} + assert record["runtime"]["platform"] == "intel-tdx" + assert iter_errors(record), "a non-self origin on a hardware platform must still fail" + + +def _signed_with_references() -> tuple[dict, object]: + key = generate_key() + record = _example() + record.pop("signature", None) + record["cnf"] = {"jwk": key_to_jwk(key)} + record["iat"] = int(time.time()) # verify_record enforces freshness + record["references"] = [_entry(), MINIMAL_ENTRY] + return sign_record(record, key), key.public_key() + + +def test_the_signature_covers_references() -> None: + signed, public_key = _signed_with_references() + verify_record(signed, public_key) # must not raise + + +@pytest.mark.parametrize( + ("label", "tamper"), + [ + ("rel", lambda r: r["references"][0].update(rel="behavior-trace")), + ("id", lambda r: r["references"][0].update(id="req-000000")), + ("resolver", lambda r: r["references"][0].update(resolver="https://evil.example")), + ("retention", lambda r: r["references"][0].update(retention="P1D")), + ("digest", lambda r: r["references"][0].update(digest="sha256:" + "c" * 64)), + ("entry appended", lambda r: r["references"].append(MINIMAL_ENTRY)), + ("entry removed", lambda r: r["references"].pop()), + ("order swapped", lambda r: r["references"].reverse()), + ("block removed", lambda r: r.pop("references")), + ], + ids=lambda v: v if isinstance(v, str) else "", +) +def test_tampering_with_references_breaks_the_signature(label, tamper) -> None: + """Rule 2. Not automatic: it holds because canonicalisation covers the whole + record minus `signature`, and a block excluded from that would verify after + being rewritten in transit.""" + signed, public_key = _signed_with_references() + tampered = copy.deepcopy(signed) + tamper(tampered) + assert tampered != signed, f"the {label} case did not change the record" + with pytest.raises(InvalidSignature): + verify_record(tampered, public_key) + + +def test_the_model_and_the_schema_share_the_pattern_strings() -> None: + """The two constraints on `retention` and `digest` are one decision written + twice, and a case table only catches a divergence it happens to sample. + + Byte equality is the check that does not depend on sampling. It is possible + here only because the pattern avoids look-around: pydantic's default regex + engine has none, so a lookahead form would force the two files apart. + """ + from agentrust_trace import SCHEMA + from agentrust_trace.models import _DIGEST_RE, _DURATION_RE + + entry = SCHEMA["properties"]["references"]["items"]["properties"] + assert entry["retention"]["pattern"] == _DURATION_RE + assert entry["digest"]["pattern"] == _DIGEST_RE + + +def test_registered_rel_values_are_the_same_set_in_both() -> None: + from agentrust_trace import SCHEMA + from agentrust_trace.models import Reference + + schema_rels = SCHEMA["properties"]["references"]["items"]["properties"]["rel"]["enum"] + model_rels = Reference.model_fields["rel"].annotation.__args__ + assert sorted(schema_rels) == sorted(model_rels) + assert sorted(schema_rels) == ["approval-outcome", "authorized-intent", "behavior-trace"] + + +def _doc_table() -> dict[str, bool]: + """Field -> required, parsed from the `references` table in docs/schema.md. + + Same parser shape as ``test_build_provenance_depth_doc.py``, for the same + reason: the doc is the copy a reader consults, and it can drift from the + schema without anything failing, since both files stay individually valid. + """ + doc = (Path(__file__).resolve().parents[1] / "docs" / "schema.md").read_text(encoding="utf-8") + section = doc.split("## `references`", 1)[1].split("\n## ", 1)[0] + fields = {} + for line in section.splitlines(): + if not line.startswith("| `"): + continue + cells = [cell.strip() for cell in line.strip("|").split("|")] + fields[cells[0].strip("`")] = "yes" in cells[2].lower() + assert fields, "no references field rows found in docs/schema.md" + return fields + + +def test_the_doc_table_matches_the_schema() -> None: + from agentrust_trace import SCHEMA + + entry = SCHEMA["properties"]["references"]["items"] + documented = _doc_table() + assert set(documented) == set(entry["properties"]), "docs/schema.md lists a different field set" + for field, required in documented.items(): + assert required == (field in entry["required"]), f"docs/schema.md is wrong about {field}" + + +def test_the_doc_lists_references_as_an_optional_top_level_field() -> None: + from agentrust_trace import SCHEMA + + doc = (Path(__file__).resolve().parents[1] / "docs" / "schema.md").read_text(encoding="utf-8") + top = doc.split("## Top-level fields", 1)[1].split("\n## ", 1)[0] + rows = [line for line in top.splitlines() if line.startswith("| `references` |")] + assert len(rows) == 1, "docs/schema.md does not list references exactly once" + cells = [cell.strip() for cell in rows[0].strip("|").split("|")] + assert cells[1] == "array", "the doc and the schema disagree on the type" + assert "yes" not in cells[2].lower(), "references is optional" + assert "references" not in SCHEMA["required"]