Skip to content

fix(catalog): judge approvals at the approval instant, not the wall clock (#533) - #547

Open
zohebk8s wants to merge 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/catalog-approval-validity-clock
Open

fix(catalog): judge approvals at the approval instant, not the wall clock (#533)#547
zohebk8s wants to merge 2 commits into
agentrust-io:mainfrom
zohebk8s:fix/catalog-approval-validity-clock

Conversation

@zohebk8s

Copy link
Copy Markdown
Collaborator

Closes the clock half of #533. Stacked on #546, so review the second commit; the first is that PR unchanged.

Why

verify_catalog_change judged approved_at and expires_at against time.time(). A record that was valid when the catalog was approved stopped verifying the moment its approvals expired, so the chain #517 exists to let an auditor replay could not be replayed. That makes the record an authorization token with a lifetime rather than a provenance record.

What

The interval is an assertion about when the signature could have been produced, not a lifetime on the record. The caller passes validity_instant, a pinned checkpoint or transparency-receipt timestamp where it has one. Where it passes nothing, each approval is judged at its own approved_at, so the record stays verifiable indefinitely.

Requiring a pin instead would mean an auditor cannot verify a record without also holding the pin, which is a worse default than the one it replaces.

now is gone with the wall clock, and time is no longer imported. The rename is deliberate: the semantics changed, and a parameter still called now invites a caller to keep passing the verification clock into it.

Revocation stays at verification time, with the reason stated where the check is rather than only in the doc, since the two read as the same check to whoever touches this next:

# Revocation is deliberately not judged at validity_instant. Whether a key was
# valid when it signed and whether it is revoked now are different questions.

The spec doc carries both rules, including that the interval bounds when the signature could have been produced.

Tests

tests/unit/test_catalog_approval.py     37 passed
with test_catalog_canonical_json.py     50 passed
coverage of approval.py                 148 statements, 0 missing, 100%
ruff / mypy --strict                    clean
full unit suite                         no new failures

The replay test is the one that would have failed before this change: approvals valid for [100, 200), verified with no instant supplied, verified at 150, refused at 10**9. A second pins that a revoked key is refused whatever instant the approvals are judged at.

Verified by mutation:

back to the wall clock              -> 2 failed, 35 passed
drop the window check               -> 3 failed, 34 passed
revocation at the approval instant  -> 2 failed, 35 passed

One thing worth your eye

TrustedReviewer carries principal_id, issuer, key and role, and no validity window. So "reviewer key validity is checked against an instant inside that window" has nothing to check against beyond the approval's own interval, and that is what this implements: the instant governs the approval interval, not a key lifetime.

…ecords

schemas/catalog-approval.schema.json shipped in the repository and nowhere else.
verify_catalog_change reimplemented structural validation by hand, the two
drifted, and the file was absent from the wheel force-include, so an installed
distribution did not carry it at all. agentrust-io#531 could only document one divergence,
a negative approved_at that the schema rejects and the verifier accepted, as a
strict xfail.

The record is now validated against the schema before any other check, and
verification refuses outright when the schema is missing from the installation,
following loader.py rather than falling back to hand-written checks.

Duplicated checks are deleted rather than left unreachable: field presence and
unknown members on the record, the policy and each approval, digest shapes,
integer and boolean types, string emptiness, and the signature alphabet.
_require_str and _require_int go with them. What stays is what JSON Schema
cannot express:

* the runtime catalog hash binding and the caller's chain checkpoints
* the policy pin, including that policy_hash covers its own body
* reviewer identity, role, revocation and key reuse
* the validity interval ordering
* the signatures

The xfail is now a plain assertion, since the case fails closed.

previous_record_hash on the first record in a chain is the all-zero digest,
sha256: followed by 64 zeros, written into the schema and the spec doc. It
validated against the pattern before and meant nothing.

Packaging: the schema is force-included beside catalog-entry.schema.json, and
verify_python_distribution.py checks both resolve to a file inside the installed
distribution, so a wheel that drops one fails the release smoke test rather than
a verifier at runtime. A unit test asserts the force-include mapping, since that
line is the only thing putting these files next to the code.

loader.py's _CATALOG_ENTRY_SCHEMA_PATH is now CATALOG_ENTRY_SCHEMA_PATH, since
the distribution check imports it.

Verified by mutation: dropping the schema call fails 7 tests, accepting a
missing schema fails 1, dropping the 64-byte signature check fails 1. Coverage
of approval.py is 149 statements with none missing.

Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
…lock

verify_catalog_change checked approved_at and expires_at against time.time(), so
a record that was valid when the catalog was approved stopped verifying once its
approvals expired. The chain agentrust-io#517 exists to let an auditor replay could not be
replayed, which makes the record an authorization token with a lifetime rather
than a provenance record.

The interval is now what it says it is: an assertion about when the signature
could have been produced. The caller passes validity_instant, a pinned
checkpoint or transparency-receipt timestamp where it has one. Where it passes
nothing, each approval is judged at its own approved_at, so the record stays
verifiable indefinitely. Requiring a pin would mean an auditor cannot verify a
record without also holding the pin, which is a worse default than the one it
replaces.

The `now` parameter goes with the wall clock, and `time` is no longer imported.
The rename is deliberate: the semantics changed, and a parameter still called
`now` would invite callers to keep passing the verification clock.

Revocation is untouched and stays at verification time. The reason is now stated
where the check is, not only in the doc: whether a key was valid when it signed
and whether it is revoked now are different questions.

Tests: a record with approvals valid for [100, 200) verifies with no instant
supplied, verifies at 150, and is refused at 10**9; and a revoked key is refused
whatever instant the approvals are judged at.

Verified by mutation: restoring the wall clock fails 2 tests, dropping the
window check fails 3, moving revocation off verification time fails 2.

Signed-off-by: Mohammed Zoheb Shaik <zoheb.shaik7@gmail.com>
@zohebk8s
zohebk8s requested a review from a team as a code owner August 22, 2026 17:27
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

🟡 Contributor Check: MEDIUM

Check Result
Profile MEDIUM
Credential LOW
Overall MEDIUM

Automated check by AgenTrust Contributor Check.

@github-actions github-actions Bot added the needs-review:MEDIUM Contributor check flagged MEDIUM risk label Aug 22, 2026
@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@zohebk8s

Copy link
Copy Markdown
Collaborator Author

One consequence of the clock change is worth stating explicitly, since it is not a defect and I do not want it to arrive as a surprise later.

With the interval no longer acting as a lifetime, a record replays indefinitely. That means revocation is now the only mechanism that stops a historical approval from counting. Before this change, an approval signed by a key that later turned out to be compromised aged out on its own once expires_at passed, which was accidental rather than designed, but it was there. Now the revocation list carries that weight alone.

That follows from your decision rather than working against it, and it is the reason revocation stays at verification time. It does mean an operator's revocation list is load-bearing rather than a backstop, which is worth a sentence in the spec doc if you agree.

Related, and only as information: TrustedReviewer has no not_before or not_after, so nothing checks whether a key was valid at the instant it signed. Membership of trusted_reviewers plus revocation is the whole of the key-state check today. If that is the intended boundary, this PR is complete as it stands.

@imran-siddique

Copy link
Copy Markdown
Member

#546 is merged, so this needs a rebase onto main before it can be reviewed on its own terms.

Worth saying exactly why, because it is not a conflict you caused. I diffed the two branches against each other before merging: all 139 added lines in #546 appear verbatim in this PR, so #546 was a strict subset. Rebasing should drop the whole schema-wiring half and leave only the validity-clock change, roughly 71 lines.

What becomes redundant on the rebase, so nothing gets hand-reconciled twice:

  • the entire schemas/catalog-approval.schema.json, pyproject.toml force-include and scripts/verify_python_distribution.py change, all landed in fix(catalog): make the schema the structural authority for approval records (#533) #546
  • the loader.py and test_catalog.py edits, same
  • the schema-authority section of docs/spec/catalog-approval-provenance.md, same, though your clock section is new and should stay
  • most of the test_catalog_approval.py additions. The two that are genuinely yours and should survive are test_a_record_stays_verifiable_after_its_approvals_expire and test_revocation_is_judged_now_not_at_the_approval_instant

That leaves this PR as the answer to decision 1 in #533, which is the half that needs a maintainer ruling on which instant governs rather than a code review. Splitting it out this way makes that ruling reviewable on its own, which is the point.

Your note about revocation becoming the only mechanism that stops a historical approval is the right thing to have raised before it landed, not after. It goes in the spec doc alongside the clock change.

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

Labels

needs-review:MEDIUM Contributor check flagged MEDIUM risk

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants