fix(reward)!: refuse the certificate claims this version cannot check - #40
Merged
Conversation
A reward certificate was trusted on its shape. Four properties it asserted were never checked anywhere: * `calibration_scope: production`. The validator refused only the pair `self_signed` + `production`, so `issuer="organization"` bought a receipt reading `certified: true, calibration_scope: production, production_certified: true` with no worker, no oracle, and no read. * `issuer: organization`. There is no issuer key registry, so nothing can resolve `issuer_key_id` to a key anyone trusts. * The contract's own `certificate_policy`. `RewardCertificateV1.satisfies` had exactly one caller, a test. A certificate measured at epsilon 0.248885 against a contract demanding 0.05 still produced `certified`. * Revocation. `grep -i revok` over the reward code in types, evals, and flow returns nothing, and the certificate docstring described revocation as an existing mechanism checked by the issuer. The narrowing: * `RewardCalibrationScopeV1` keeps `SYNTHETIC` only, and `RewardCertificateIssuerV1` keeps `SELF_SIGNED` only. Both stay enums, so adding a member back once a registry exists is not a breaking change. * `RewardEvidenceReceiptV1.production_certified` is gone. With one scope it could only ever return False, and its name promised a distinction the type cannot draw. * `score()` takes the contract as a required keyword and drops `scoring`. It reads the scalar from `contract.scoring` and certifies only a certificate that names this contract by digest and clears `contract.certificate_policy`. `RewardScoreV1` gains `certification_refusals`, so a false `certified` says why. * `RewardCertificateV1.unmet(policy)` lists each shortfall; `satisfies` is now `not unmet(...)` and is live code on the certification path. * `RewardEvidenceReceiptV1.certification_refusals(contract, certificate)` lets a reader who holds both recheck a receipt's flag. The receipt carries digests, so it cannot check itself during validation. * The docstrings drop the revocation sentence and say plainly that `signature` is checked for encoding and length only. Breaking for `score()` callers and for anything that reads `production_certified` or constructs a production-scope certificate. Nothing outside tests constructs one today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
abrichr
force-pushed
the
claude/certificate-narrowing
branch
from
September 3, 2026 21:33
17c6457 to
250ecd1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this refuses
A reward certificate was trusted on its shape. Four properties it asserted were never checked, anywhere, by anything. Each of the four reproduced against published
openadapt-evals0.97.0 andopenadapt-types0.17.0, with no worker, no oracle, and no read:issue_certificate(issuer="organization", calibration_scope="production")certified: true, calibration_scope: production, production_certified: trueproductionis not a member ofRewardCalibrationScopeV1;organizationis not a member ofRewardCertificateIssuerV1satisfies()returns False and the receipt still readscertifiedscore()returnscertifiedfalse and names the shortfallreward_contract_digestis another contract'scertifiedscore()refuses itgrep -i revokacross the reward code in types, evals and flowThe
productionscope was reachable because the validator refused exactly one pair,self_signed+production, andissueris an unauthenticated string.RewardCertificateV1.satisfieshad one caller in the repository and it was a test.The shape
Narrowing, not cryptography. There is no key registry here and this change does not build one. It makes the type refuse what nothing can verify.
RewardCalibrationScopeV1keepsSYNTHETIC.RewardCertificateIssuerV1keepsSELF_SIGNED. Both stay enums rather than becomingLiteral, so the import surface is unchanged and adding a member back once a registry exists is a widening, not a break.RewardEvidenceReceiptV1.production_certifiedis gone. With one scope it could only return False, and the name promised a distinction the type cannot draw.score()takescontractas a required keyword and dropsscoring. It reads the scalar fromcontract.scoringand certifies only a certificate that names this contract by digest and clearscontract.certificate_policy. Requiring the contract is what stops a caller certifying against a policy nobody read; an optional argument would have left the old path open.RewardScoreV1gainscertification_refusals, so a falsecertifiedsays why instead of being a bare boolean.RewardCertificateV1.unmet(policy)lists each shortfall andsatisfiesis nownot unmet(...). It is live on the certification path.RewardEvidenceReceiptV1.certification_refusals(contract, certificate)lets a reader who holds both recheck a receipt. The receipt carries digests, so it cannot check its own flag during validation, and this is the honest way to say so.signatureis checked for encoding and length only.What breaks
This is breaking.
major_on_zero = false, so semantic-release reads the!as a minor bump to 0.18.0.score()callers. The reward worker inopenadapt-flowcalls it withscoring=self.contract.scoringand already holds the contract, so its fix is one line.production_certified. Two assertions inopenadapt-flow'stests/test_reward_worker.py.openadapt-flowpinsopenadapt-types>=0.17.0,<0.18.0, so this release does not reach it until someone widens that bound.openadapt-evalspinned an unbounded floor; a companion PR there bounds it to<0.18.0for the same reason.Hold
Do not merge until the founder names the version. Merging this repository's
mainpublishes to PyPI, and the narrowing is breaking, so the release level is his call and not this session's. Merging also wants sequencing against theopenadapt-flowworker work already in flight.Opened by an agent session, not the founder.
🤖 Generated with Claude Code