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"]