Add capability-scoped revocation conformance vectors - #35
Conversation
Nine vectors for authorizations that were valid when written and have since been withdrawn: six refusals and three positive controls, with a reference adapter that answers them and a runner that scores them. Each vector declares the capability it needs and each implementation declares what it has, so an implementation without a revocation mechanism reports an unfilled gap rather than a red result. A capability that is declared but has no positive control fails the suite, so declaring one is not a way to opt out of being measured. The runner hands the adapter the vector's input and withholds the expected verdict, which leaves an adapter that would answer from the answer key with nothing to answer from. Reported entry points are compared for equality and never parsed, and an absent or blank one is a refusal rather than a pass. Two failure codes are added. A mandate revoked inside its freshness bound is not stale, and an unreadable revocation registry is not a found withdrawal, so neither collapses into an existing code. Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
Fifteen cases the suite must handle: one implementation that must pass, five fake adapters that must be refused, seven single-line mutants of the reference adapter that must be killed, a capability declared with no positive control, and a vector file edited away from its pinned digest. A gate that has never been observed to fail is indistinguishable from one that is not running, and a suite only ever run against an implementation that passes it has not been shown to discriminate. Two of the mutants survived when this was first run, against the seven vectors the suite started with. One trusted a timestamp the presenting party controls and the other rejected on the presence of an identifier in the revoked list without measuring the withdrawal window at all. Both survived because no vector exercised the branch, so the two vectors that exercise them were written. Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
What each vector covers, what the two failure codes mean and why neither collapses into a staleness code, how capability profiles work, and what the adapter contract is. States plainly what the entry-point rule can and cannot do. It catches an adapter answering the must-pass inputs from a different path than the must-reject ones, and it cannot catch one naming a path it never called. A runner that overstates what it checks is worse than one that checks less. Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
Validates the vectors against their schema, runs the reference adapter, runs the negative controls, and checks every file against its pinned digest. Scoped by path to this suite, so it stays green independently of any other conformance work in the repository. Signed-off-by: Sankalp Gilda <sankalp.gilda@gmail.com>
|
Ran it before writing, same as you did for #30. Fresh clone, your branch at The suite runs as announced. The answer-key family cannot be expressed against your runner, and I proved it with the exact adapter from your description. Your six-line answer-key adapter, run against The digest gate bites, and it names both sides. One character edited in Both no-silent-third-state properties hold, with the exit codes to match. An adapter declaring no And "shares no file with #30" is exact: the intersection of One observation rather than a request: your On your #30 verdict: accepted as measured, and acted on. The three changes - the question-without-answer handoff, the single predicate, the dead conditional - are built and proven in an isolated clone of the branch: reference adapter still 18/18 exit 0, your answer-key adapter drops from 18/18 exit 0 to 0/18 exit 1, and positive_control is now read in exactly one place. The README's adapter contract and its answer-key note move in the same change, since both describe the old contract and the branch should not contradict itself. Not pushed yet: your own line was "take it whenever this PR is otherwise settled", and I am keeping to that order. Avp9 |
|
This pull request's own reference adapter fails the check I am about to propose, and so does the one in #30. That is the argument for the check, so I am leading with it. Both adapters declare an entry point ending in Nobody acted in bad faith. The field cannot be wrong. That is a different problem and a worse one, because a field that cannot be wrong reads exactly like a field that happens to be right. The fix is a vantage, not a stricter rule. Arm The demonstration is two adapters with identical decision logic, differing only in whether the entry point each declares is one it actually calls, so the check is exercised in both directions on inputs that agree about everything else. The dishonest adapter decides every vector correctly and names a function it never calls. Under the current runner it scores 9 of 9 and exits zero. Under the vantage it scores 0 of 9 and names the step, the declared entry point and how many functions actually ran. An honest one, same logic and a truthful name, scores 9 of 9 either way, so the gate passes as readily as it fails. In the vocabulary of #36, what changes is that Two boundaries. Of six evasions I tried, four die against the check as described. Two win. Define a real function called The second boundary is narrower: the check compares a name against execution and nothing else. It says nothing about whether the function that ran is the one that made the decision, which is the same gap one level up. I can push the runner, the two demonstration adapters and the six evasions to this branch. Say the word if you would rather see them separately. |
|
Ran the vantage before writing, on CPython 3.13.15. The demonstration pair is the right shape and the boundary you already name is real. One result goes against the sentence the design rests on, and one constraint lands on your CI. In-process suppression works. I armed It does not depend on the adapter knowing your tool id. Checking the state afterwards does not catch it. My first thought was that the runner could read So the gap is not that a function named Which is why the answer is already in your own #36 comment: interception records from the substrate rather than the runtime, something the observed party executes inside and cannot suppress, plus a seal at run end proving the vantage stayed armed for the whole run. That seal is precisely the arm-observe-disarm attack you named there, and the The CI constraint, separately. Happy to hand over the probes, they are short and each carries its own positive control. The answer-key change is on #30 now, at Four holes, all measured before the change and re-measured against the branch afterwards. Your answer-key adapter now drops from 18/18 exit 0 to 0/18 exit 1 on One I could not close, and the README now says so instead of promising it. Avp9. |
What changed
This is the separate pull request I promised in #30. It adds revocation conformance vectors for category 3, with a reference adapter that answers them. It also adds a runner that scopes vectors to a declared capability, and negative controls that show the scoring can fail.
Everything lands under
conformance/revocation/. It shares no file with #30. The two are reviewable independently and can land in either order.#30 declared the revocation half of category 3 an open gap. It asked for contributions from implementations that exercise revocation. This is that contribution. I have deliberately not touched anything #30 already claims.
Type of change
Tooling and CI. Nothing under specification/ or docs/spec/ is touched, and no schema, hook, event or AgBOM definition changes.
The two failure codes below live in the new directory's README as a proposed enumeration, the way #30 handles its own. Moving either into normative text is a maintainers' call, and this pull request does not ask for it.
What is here
Nine vectors: six refusals and three positive controls. They cover withdrawal inside the freshness window, an unreachable registry, and a registry stale past its own declared publication interval. They also cover a back-dated revocation window, a re-issued mandate naming no predecessor, and a revoked mandate with no authority-signed instant to measure against.
Two failure codes.
MANDATE_REVOKEDis a withdrawal that was read and found.REVOCATION_UNCHECKABLEis a withdrawal state that could not be read. Neither collapses into a staleness code. A mandate revoked inside its freshness bound is not old, and calling it stale sends an operator to look at clocks. That is the measured-and-wrong versus could-not-measure rule, applied to a second axis.A capability mechanism, which answers the integration question I raised on #30. Each vector declares what it needs with
requires. Each implementation declares what it has, either asCAPABILITIESin the adapter or as--profileon the command line. The runner runs the intersection and reports the rest as out of profile. An implementation with no revocation mechanism gets an unfilled gap rather than a red result.The flag is not a way to opt out of being measured. A capability inside the profile that no positive control exercises fails the suite outright. Declaring revocation therefore obliges you to pass the must-pass vectors, not only to refuse the must-reject ones. There is no third state either. An adapter that declares nothing, given no profile, is a fatal error rather than a silent run of everything.
Seven vectors became nine, and the reason is the interesting part
I described seven vectors in my review. There are nine here.
selftest.pymutates the reference adapter, one deleted check per mutant, and requires the suite to notice each one. Two mutants survived against the original seven. One trusted a timestamp the presenting party controls. The other rejected on the presence of an identifier in the revoked list, without ever measuring the withdrawal window.That second one is a real bug in the fail-closed direction. It refuses every act a later-revoked mandate ever authorized, which breaks recovery.
Both survived because no vector exercised the branch. A surviving mutant is a vector nobody wrote, so I wrote the two.
pos-cat3-004is the control that separates an implementation measuring the window from one rejecting on list membership. I would rather report that the count moved than quietly ship the number I announced.What the entry-point requirement does, and what it does not
Your entry-point fix is the right mechanism and I kept it. Both constraints from my last comment are in. The runner compares the reported value for equality and never parses it. A blank or absent value is a refusal rather than a pass. Empty equals empty, so the check would otherwise hold vacuously in the case it exists to fire on.
It does not close the answer-key hole. I said I would settle that with a run rather than by reading the diff, so here it is, against your branch at
459ec820:Here is the whole of the second adapter, which enforces nothing:
The output is byte for byte the reference adapter's. That is the same result as before the fix, because a self-reported name compared only against itself costs one line to satisfy.
The cheap structural answer is to stop handing the adapter the answer. My runner passes the vector identifier, its category and its input, and keeps the expected verdict and the control flag to itself:
An adapter that would derive its verdict from the expected one then has nothing to derive it from. The whole family stops being something to detect and becomes something that cannot be expressed. That is why my runner is a separate file rather than a patch to yours. Folding the same change into
conformance/negative/runner.pyis yours to make whenever #30 is otherwise settled.One smaller thing, and it is a review note rather than a request.
b3ef6a13fixed the positive-control predicate where the structural gate counts. The entry-point bucketing a few lines below still reads the declared flag directly. A must-reject vector carrying that flag is therefore refused a place in the count, and still lands in the positive bucket for the entry-point comparison. Deciding it once, in one function both sites call, is what I did here.Verification
The fifteen are one implementation that must pass and five fake adapters that must be refused. Then seven single-line mutants of the reference adapter that must be killed. Then a capability declared with no positive control, and a vector file edited away from its pinned digest.
A gate nobody has watched fail is indistinguishable from one that is not running. So the suite ships with evidence that it discriminates, rather than the assertion.
MANIFEST.jsoncarries a SHA-256 for every file in the directory. The runner refuses a vector file whose digest does not match. A suite editable between publication and use proves nothing about the implementation that ran it.CI runs all of it on any change under the new directory. It also validates the vectors against their schema and checks that every file still matches its pinned digest. The workflow is path-scoped, so it stays green independently of anything else in the repository.
Checklist
On the last one: nothing here is under docs/ or in the mkdocs config, so the site build takes no input from this change. Say the word if you want the directory in the docs tree, and I will wire it up and run the build.
Security
This change has no security impact. It adds test vectors, a reference adapter, and a runner. Nothing here executes against a live system, and nothing changes what an implementation does in production.
Sankalp.