Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR.

### Fixed

- **The schema's `subject` pattern was a prefix test where the model requires a full identity, so a record could be schema-conformant and refused by `model_validate`.** `^(spiffe://|did:)` constrains only a leading `spiffe://` or `did:`; `models.py` requires `^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$`. `spiffe://bernstein.run` names a trust domain and no workload, and passed the published artifact a producer in any language validates against. The schema is tightened to the model rather than the model loosened: a bare prefix is not a constraint on an identity, and the model's rule matches DID Core section 3.1, whose ABNF fixes `method-char` to `%x61-7A / DIGIT`, so `did:X:abc` is not a conformant DID rather than a DID this package happens to refuse. Both schema copies carry the change and both now state the rule in prose instead of leaving it to be read off a regex. The differential in `tests/test_the_schema_and_the_models_agree.py` could not see this: `subject`'s pattern was a prefix test, so every value in its matrix failed it too and the two validators agreed by both rejecting. Four values that pass the prefix and fail the shape are added, and they are the only pair of patterns among the schema's ten that differed. The two surfaces parted at `84df8ef` ("security: pre-launch hardening", #49, 2026-06-18), which tightened the model's pattern from `^(spiffe://|did:)` to the current one and left the schema on the old string; `docs/schema.md` still described the prefix reading and is corrected here too.

- **The exported `SCHEMA` was the live object the validator reads.** `_schema()` is `lru_cache`d and `_validator()` is built over whatever it returns, so the name exposed "for downstream tooling that needs the raw dict" and the validator's schema were one object: lowering `SCHEMA["properties"]["iat"]["minimum"]` made a record dated 1970 valid to `validate_json()`, to `iter_errors()`, and to the structural gate inside `sign.verify_record()`, for every later call in the process. Nothing about the call site looks wrong, since adapting the raw dict is the use the comment invites. It is a deep copy now; a shallow one would leave the nested `properties` dicts shared and the same edit would still land.

- **All eight `"format": "uri"` declarations in the schema were inert, and `"not a uri"` validated.** `jsonschema` treats `format` as an annotation rather than an assertion unless a checker for that format is installed, and `FormatChecker().checkers` does not carry `uri` without an optional dependency the project did not declare. The wiring in `validate.py` was correct and the behaviour was a no-op, which is the shape that survives review. `rfc3986-validator` is now a dependency, chosen over `jsonschema[format]` because that pulls `rfc3987`, which is GPLv3, into an Apache-2.0 package's dependency tree. Turning the constraint on changes nothing about the existing corpus: the suite passed unchanged before any new test was added.
Expand Down
2 changes: 1 addition & 1 deletion docs/schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ can share one. A value that needs to be larger is carried as a string. The same
|---|---|---|---|
| `eat_profile` | string | **yes** | EAT profile URI. Must be `tag:agentrust-io.com,2026:trace-v0.2` |
| `iat` | integer | **yes** | Issued-at timestamp (Unix epoch seconds) |
| `subject` | string | **yes** | Workload identity. SPIFFE SVID (`spiffe://`) or DID (`did:`) |
| `subject` | string | **yes** | Workload identity. A SPIFFE SVID naming a trust domain and a workload path within it, or a DID with a lowercase method name and a method-specific identifier. A bare prefix is not an identity |
| `model` | object | **yes** | Model artifact binding |
| `runtime` | object | **yes** | Execution environment binding |
| `policy` | object | **yes** | Governance policy binding |
Expand Down
4 changes: 2 additions & 2 deletions schema/trace-claim.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@
},
"subject": {
"type": "string",
"description": "Workload identity as a SPIFFE SVID URI or DID URI.",
"pattern": "^(spiffe://|did:)"
"description": "Workload identity as a SPIFFE SVID URI or DID URI. A SPIFFE ID carries a trust domain and a workload path within it; a DID carries a lowercase method name, per DID Core section 3.1, and a method-specific identifier. A prefix alone is not an identity: spiffe://example.org names a trust domain and no workload.",
"pattern": "^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$"
},
"model": {
"type": "object",
Expand Down
13 changes: 12 additions & 1 deletion src/agentrust_trace/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,17 @@
)

_DIGEST_RE = r"^sha(256:[0-9a-f]{64}|384:[0-9a-f]{96})$"

# A workload identity, not a namespace. A SPIFFE ID names a trust domain *and* a
# workload path within it, and a DID names a method and an identifier within that
# method, so neither is complete at its prefix: `spiffe://example.org` names a trust
# domain and no workload, and `did:` names nothing at all. The method name is held to
# lowercase because DID Core section 3.1 fixes `method-char` to `%x61-7A / DIGIT` and
# requires every DID to conform to that ABNF; the method-specific identifier is not
# constrained here, so `did:key:z6Mk...` keeps its case. Mirrored verbatim in
# `schema/trace-claim.json` and its copy, and held there by
# `tests/test_the_schema_and_the_models_agree.py`.
_SUBJECT_RE = r"^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$"
# 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
Expand Down Expand Up @@ -340,7 +351,7 @@ class TrustRecord(_TraceModel):

eat_profile: Literal["tag:agentrust-io.com,2026:trace-v0.2"]
iat: Annotated[JsonInt, Field(ge=1700000000, le=JCS_SAFE_INTEGER)]
subject: Annotated[str, Field(pattern=r"^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$")]
subject: Annotated[str, Field(pattern=_SUBJECT_RE)]
model: ModelInfo
runtime: RuntimeInfo
policy: PolicyInfo
Expand Down
4 changes: 2 additions & 2 deletions src/agentrust_trace/schema/trace-v0.2.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@
},
"subject": {
"type": "string",
"description": "Workload identity as a SPIFFE SVID URI or DID URI.",
"pattern": "^(spiffe://|did:)"
"description": "Workload identity as a SPIFFE SVID URI or DID URI. A SPIFFE ID carries a trust domain and a workload path within it; a DID carries a lowercase method name, per DID Core section 3.1, and a method-specific identifier. A prefix alone is not an identity: spiffe://example.org names a trust domain and no workload.",
"pattern": "^(spiffe://[^/]+/.+|did:[a-z0-9]+:.+)$"
},
"model": {
"type": "object",
Expand Down
14 changes: 14 additions & 0 deletions tests/test_the_schema_and_the_models_agree.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,15 @@
The remaining four disagreements are one question and are declared below rather than fixed,
because answering it is a change to the published schema or a break for Python callers, and
neither is a test's decision to make.

A fifth was found later, by a producer rather than by this file, and is the reason the matrix
carries four more values. ``subject``'s schema pattern was ``^(spiffe://|did:)``, a test on a
leading ``spiffe://`` or ``did:``, while the model required a full SPIFFE or DID shape.
Nothing in the matrix could see it: every value here fails the prefix as well, so both
validators rejected and agreed. Splitting them needs a value that passes the prefix and fails
the shape, and ``spiffe://bernstein.run`` is one a real producer wrote. The schema is
tightened to the model in the same change; the four values stay as the guard that would have
caught it.
"""
from __future__ import annotations

Expand Down Expand Up @@ -54,6 +63,11 @@
10**20, 9007199254740992, "sha256:" + "z" * 64, "sha1:" + "a" * 40,
"SHA256:" + "a" * 64, " ", "\t", "x" * 5000, "https://", "not-a-uri",
"sha256:" + "A" * 64, "sha256:" + "a" * 63, "sha256:" + "a" * 65,
# A prefix the schema accepted and the model refused. The first matrix could not
# reach this class at all: `subject`'s schema pattern was a prefix test, so every
# value above failed it too and the two validators agreed by both rejecting. A
# value has to pass the prefix and fail on the shape to split them, and none did.
"spiffe://bernstein.run", "spiffe://", "did:X:abc", "did:",
)

#: (path, repr of the value) the two are known to disagree about, and why it is not
Expand Down