Skip to content

schema: put the references block in the schema and the model - #199

Merged
imran-siddique merged 4 commits into
agentrust-io:mainfrom
lywinged:schema/references-block
Aug 23, 2026
Merged

schema: put the references block in the schema and the model#199
imran-siddique merged 4 commits into
agentrust-io:mainfrom
lywinged:schema/references-block

Conversation

@lywinged

@lywinged lywinged commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Spec section 3.1.2 landed in #198 as text. The schema was not part of it, and text alone did not make the field usable:

  • schema/trace-claim.json sets additionalProperties: false at the top level, so references is rejected outright.
  • TrustRecord is extra="forbid", so the reference model rejects it too.

examples/tpm2.json validates as committed. Add one references entry and it failed against the base with Additional properties are not allowed ('references' was unexpected). Both artifacts a producer would validate against rejected a record the specification permits, and nothing failed, because nothing connects the field table in §3.1 to the schema.

This adds the block to both, plus the reference model, docs/schema.md, and a test file. Additions only — +493 −0 across seven files.

Shape, after review

references is an array of entries. §3.1.2 says "A references entry is a pointer, not evidence", "an entry in references", and "MUST omit the entry", and its field table describes the fields of an entry rather than of the block.

Two constraints were in the first revision and are not here, both removed on review because the schema was asserting what the normative text does not:

  • rel is not an enum. §3.1.1 says of the neighbouring kind that it is closed "because the value of the field is that a verifier can key on it"; §3.1.2 says only "Registered rel values" and declines to make that claim. Closing it would make every new relation a schema change and a spec change at once. The three registered values live in the schema description and in docs/schema.md, documented rather than enforced.
  • No minItems. Rule 4 requires a producer that cannot name a resolver to omit the entry, not the block, and nothing in §3.1.2 says the array must be non-empty.

rel does carry minLength: 1, added on review: open about which relation is named, strict that one is. An empty rel is not a future registered relation, and id and resolver were already held to the same.

What is tested, and what cannot be

Of the four rules in §3.1.2, two are properties of a record:

  • Rule 1references MUST NOT affect runtime.platform. A hardware record that adds references and no origin keeps the platform it earned. The converse is tested too: adding references must not relax the origin if/then.
  • Rule 2 — the signature MUST cover references. Nine tamper cases — each member, an entry appended, an entry removed, order reversed, the block removed — each fail verification.

Rules 3 and 4 bind verifiers rather than records, and no schema can express either. The schema description says that rather than implying it enforces them. They belong in agentrust-trace-tests.

resolver is constrained on presence and not on value: rule 4 requires a producer that cannot name one to omit the entry, and whether an identifier is self-asserted is not decidable from the record.

retention

An ISO 8601 duration, written out in alternation rather than with a negative lookahead. That is not style: pydantic's default regex engine is Rust's, which has no look-around, so a lookahead form builds in JSON Schema and raises at import in the model — the two files would have to disagree. One string serves both, and a test asserts they are byte-equal.

Accepts P30D, P1Y6M15D, P1Y15D, P2W, PT1H30M15S, P1DT12H. Rejects P and PT, P1DT, P1H, P1D1Y, P1Y2W, P2WT12H. It also rejects fractional components such as PT0.5H, which ISO 8601 permits on the smallest component.

"format": "duration" is deliberately absent. iter_errors runs with a FormatChecker attached, and format: duration asserts only when an optional dependency is installed — the same record would validate differently on two machines, and the field would look checked where it was not.

Drift

Both schema copies change together; test_validate.py already fails when the packaged copy drifts from the normative file. Three guards in the same spirit:

  • the retention and digest pattern strings must be byte-equal between the model and the schema;
  • the three registered rel values must stay named in both the schema description and docs/schema.md, since with the enum gone documentation is the whole of the registry;
  • docs/schema.md's table must match the schema's field set and required set.

Verification

716 passed, 1 skipped. ruff check src tests scripts and mypy src/agentrust_trace clean. Every guard was checked by reverting the thing it guards:

put back / removed result
the schema block, both copies 60 failed
the model field and its export 23 failed
docs/schema.md 3 failed
the strict duration pattern, replaced with a naive one 10 failed
the duration pattern made to differ between model and schema the parity guard failed
minLength removed from the schema copies the schema half of the empty-rel case failed, the model half stayed green
minLength removed from the model the reverse
a docs/schema.md row edited three ways the doc guard failed each time
the baseline example broken instead of the block 54 failed — the rejection cases are attributable to references, not to something else going wrong

A differential across 29 cases between the pre-review revision and this one changes exactly one verdict: an empty rel moves from accepted to rejected. The other 28 are unchanged.

Contribution basis

No sponsoring organization: under CONTRIBUTING this is a schema change tracking an already-merged spec change, plus tests and docs. It adds no normative rule of its own. The uppercase keywords in the schema description are quotations of §3.1.2 and name it in the same sentence.

@lywinged
lywinged requested a review from a team as a code owner August 22, 2026 01:26
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Contributor Check: UNKNOWN

Check Result
Profile UNKNOWN
Credential LOW
Overall UNKNOWN

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:UNKNOWN Contributor check flagged UNKNOWN risk label Aug 22, 2026
@imran-siddique

Copy link
Copy Markdown
Member

Both rulings go the same way, toward the text. Thank you for surfacing them as questions rather than shipping them as decisions, because I would have missed the second one on a read of the diff.

rel opens. Make it a plain string, keep the three registered values in the description and in docs/schema.md, and drop the enum and the Literal. Reasoning: §3.1.1 says of the neighbouring field that "kind is a closed set, because the value of the field is that a verifier can key on it", and §3.1.2 deliberately does not say that about rel. It says "Registered rel values", which is a registry that grows. You matched origin.kind for consistency and that was a reasonable read, but the two sections differ on purpose, and closing rel would make every new relation a schema change and a spec change at once.

minItems: 1 goes. Rule 4 says a producer that cannot name a resolver MUST omit the entry, not the block, and nothing in §3.1.2 says the array must be non-empty. I take your point that an empty array is a record claiming to point at something and pointing at nothing, and if I wanted that I would fix it in the spec rather than in the schema. As it stands the schema asserts something the normative text does not, which is the same drift class this PR exists to close, just in the other direction.

On the array shape you did not ask about: it is right. "a references entry", "an entry in references" and "MUST omit the entry" only parse as an array. No change.

Everything else stands as written. Two things worth naming because they are the kind of decision that usually goes the other way and then costs someone a day:

  • writing the duration pattern in alternation rather than with a negative lookahead, because pydantic's Rust engine has no look-around and the two files would otherwise have to carry different strings. The test asserting they are byte-equal is what makes that hold.
  • leaving "format": "duration" out. A keyword that only asserts when an optional dependency happens to be installed is worse than no keyword, because the field reads as checked.

Rules 3 and 4 belong in the conformance suite, yes. agentrust-trace-tests is the right home; open it there.

I cannot push to your fork, so this needs the two edits from you. Nothing else blocks it.

@lywinged

Copy link
Copy Markdown
Collaborator Author

Rebased onto main after #184 landed.

The only conflict was CHANGELOG.md, and it was line endings rather than content: #184's merge left that file LF on main while this branch still had it CRLF, so no line on either side could align to any other. Resolved by taking main's copy and re-inserting the entry in LF. The diff is unchanged at +467 −0 across seven files, and every other file here still matches main's current line endings exactly.

Ordering, since it is not visible from the PR page: this touches four of the ten files #201 normalizes. It merges into main cleanly today, and into main with #201 applied it conflicts on docs/schema.md, schema/trace-claim.json, src/agentrust_trace/__init__.py and src/agentrust_trace/models.py — whole-file, for the same reason. Either order works: if #201 lands first I rebase again, and if this lands first #201 normalizes these additions along with everything else.

ruff check src tests scripts, mypy src/agentrust_trace and the full suite all pass on the rebase: 713 passed, 1 skipped. The description above says 604, which was the count before #184 added the delegation-link suite.

@lywinged

Copy link
Copy Markdown
Collaborator Author

Both edits are in, bad5f4f.

rel is a plain string now, with the three registered values in the schema description and in docs/schema.md, and models.py carries the reasoning instead of the constraint — that 3.1.1 says of kind what 3.1.2 declines to say of rel. minItems is gone, and so is the Field(min_length=1) that mirrored it on the model.

The enum was doing a second job I had not separated from the first: it made the registry mechanically checkable. What replaces it is a test that the three values stay named in both the schema description and docs/schema.md, since documentation is now the whole of it. Both new guards were checked by putting the old constraint back and confirming they fail.

One question rather than a decision, because dropping the enum is what created it. rel has no minLength, so {"rel": "", "id": "x", "resolver": "y"} is schema-valid, while an empty id or resolver is rejected by the minLength: 1 on each. I left it exactly as you specified — a plain string — because adding a constraint you did not ask for is how the other two got there. minLength: 1 would close the asymmetry without closing the registry, and it is one line in each of the two schema copies if you want it.

Rules 3 and 4 to agentrust-trace-tests, agreed. I would rather open it after the status question on trace-tests#69 settles, since "what does a verifier record when it cannot resolve what a record cites" is the same question one layer down and I would rather not coin a second answer to it.

714 passed now rather than 713, from the two case-table rows that changed direction. ruff check src tests scripts and mypy src/agentrust_trace still clean, and it still merges into main with no conflicts.

imran-siddique added a commit that referenced this pull request Aug 23, 2026
trace-spec had no .gitattributes, so line endings were whatever each
contributor's editor produced. #184 landed a one-line CHANGELOG entry as
+265-264, with all 259 removed lines ending in CR and none of the added ones,
rewriting the blame on a file nobody had edited. The same class cost two
listing PRs in awesome-ai-governance before it.

Pins *.md, *.json, *.py, *.yml and *.yaml to LF and normalises the ten text
files still stored with CRLF. The normalisation is line endings only:
git diff --cached --ignore-all-space reports no content change on any of them.

Doing it here rather than in a contributor's PR keeps the blame churn in one
commit that is obviously mechanical, instead of spread across whoever next
edits docs/schema.md, schema/trace-claim.json or src/agentrust_trace/models.py.
Those three are exactly the files #199 is currently touching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Spec section 3.1.2 landed in agentrust-io#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.

713 tests pass on this rebase onto aa4e3b5 (604 when first written; agentrust-io#184 added the delegation-link suite). Each guard was checked by reverting what it guards.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
Two constraints the schema asserted that the normative text does not.

`rel` was a closed enum, matching `origin.kind`. Section 3.1.1 says of `kind`
that it is closed "because the value of the field is that a verifier can key on
it"; section 3.1.2 says only "Registered rel values" and does not make that
claim, which is a distinction the two sections draw on purpose. Closing it here
would make every new relation a schema change and a spec change at once. It is
now a plain string, with the three registered values named in the schema
description and in docs/schema.md.

`minItems: 1` is gone. Rule 4 requires a producer that cannot name a resolver to
omit the entry, not the block, and nothing in 3.1.2 says the array must be
non-empty. An empty array does look like a record claiming to point somewhere
and pointing nowhere, but that belongs in the spec if it belongs anywhere, and a
schema asserting what the text does not is this PR's own argument reversed.

The enum was also a guard: it made the registry mechanically checkable. What
replaces it is a test that the three values stay named in both the schema
description and docs/schema.md, since documentation is now all there is. Both
new guards were checked by putting the constraint back and confirming they fail.

The two case-table rows that asserted the old behaviour now assert the new: an
unregistered `rel` is accepted, and so is an empty array.

714 passed, 1 skipped. ruff and mypy clean.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
@lywinged
lywinged force-pushed the schema/references-block branch from bad5f4f to 3f4f05e Compare August 23, 2026 06:38

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The schema/model synchronization, array shape, open rel registry, absence of minItems, signature-tamper coverage, and schema/model drift guards are sound. One remaining blocker from the latest review note: rel is required but currently accepts the empty string, while id and resolver both require non-empty values. An empty rel is not a future registered relation; it carries no relation at all. Please add minLength/min_length 1 to rel in both schema copies and the model, plus an attributable rejection case and the existing parity coverage. No enum should return, and the array should remain allowed to be empty.

`rel` was open but not required to be present in any meaningful sense: with the
enum gone it accepted the empty string, while `id` and `resolver` were both held
to `minLength: 1`. An empty `rel` is not a future registered relation. It carries
no relation at all, so it is now rejected the way an empty `id` or `resolver` is.

`minLength: 1` in both schema copies and `min_length=1` on the model. The enum
does not return, and the array stays allowed to be empty: open about which
relation is named, strict that one is.

The rejection case goes in the case table rather than in a test of its own,
because the table runs every case against both the schema and the model and
asserts the error path starts at `references`. That makes it the attributable
rejection and the parity coverage in one, which was checked in both directions:
removing `minLength` from the schema copies fails the schema half and leaves the
model half green, and removing `min_length` from the model does the reverse.

`docs/schema.md` said the schema does not constrain `rel`, which stopped being
true with this commit. It now says the schema does not restrict which relation
is named, only that one is.

716 passed, 1 skipped. ruff and mypy clean.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
The entry was written when `rel` was an enum, and its parenthetical list still
reads as the permitted values. It is now the registry's current contents, and a
reader who took the CHANGELOG at its word would expect the schema to reject a
fourth relation. It does not.

Restated: the three are what 3.1.2 registers today, the schema holds `rel` to
being non-empty rather than to that list, and the array may be empty because
rule 4 tells a producer to omit the entry rather than the block.

No behaviour change. Found by re-reading the prose written before the enum was
dropped rather than by a test, since nothing checks a changelog.

Signed-off-by: Louielunz <48041247+lywinged@users.noreply.github.com>
@lywinged

Copy link
Copy Markdown
Collaborator Author

@imran-siddique Done, b722d78. minLength: 1 in both schema copies, min_length=1 on the model, no enum, and the array still allowed to be empty.

The rejection case is in the case table rather than in a test of its own, because that table runs every case against both the schema and the model and asserts the error path starts at references. That makes it the attributable rejection and the parity coverage for this constraint in one, and I checked it in both directions: removing minLength from the schema copies fails the schema half and leaves the model half green, and removing min_length from the model does the reverse.

A differential over 29 cases between the previous revision and this one changes exactly one verdict — an empty rel from accepted to rejected. The other 28 are unchanged.

Two things I did not decide.

docs/schema.md said "the schema does not constrain it" about rel, which stopped being true with this commit, so it now says the schema does not restrict which relation is named, only that one is. That is a correction to a statement my own change falsified rather than a new claim.

The other is a question. minLength: 1 is not the same as naming a relation: " ", a tab, and a zero-width space all pass it. So do the same values in id and resolver, which have carried minLength: 1 since the first revision — so this is not something the change introduces, and rel is now exactly as strict as its two siblings, which is what you asked for. Tightening it would mean a pattern on three fields including two you called sound, so I have left it. Say the word if you want it closed, and it should be closed for all three at once or not at all.

716 passed, 1 skipped. ruff check src tests scripts and mypy src/agentrust_trace clean, and it still merges into main with no conflicts.

I have also rewritten the PR description. It still argued for minItems and a closed rel as open choices and carried evidence-table counts from two revisions ago, so it read as though your rulings had not been applied.

@imran-siddique imran-siddique left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed schema/model parity, strict field constraints, documentation, and negative coverage. The references block is optional at the record level and strict when present.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review:UNKNOWN Contributor check flagged UNKNOWN risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants