informed-decision asked whether its binding.principal and ours name the same field, since both GH rulings said its slice canonicalizes "two of the five". They do not: ours is the party on whose behalf the act is performed, taken from the decision request's subject, while theirs is the approver — which appears here only as an entry and never in binding. Only target overlaps. Records the four-role distinction normatively in docs/approval-claim.md and guards it with a test asserting the act digest is insensitive to the approver while the approver stays recorded on the entry. Folding approver identity into the digest would now break view_hash's assumption here rather than silently in that repository. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QDzGbdDFnVJBxDgdp7RvpH Assistant: claude-code Assistant-Model: opus Assistant-Process: 2191554@bnt-lap001 Assistant-Session: d69bb7c3-b7b2-41c4-8287-6baef48c0993
20 KiB
Approval claim contract
Schema: ../schemas/approval_claim.schema.json
Version: 0.1
Issuer: approval-engine
Consumers: access-engine as PDP (flex-auth until the governed rename);
PEP-shaped consumers as step 1 of the GH-DEC-2026-003 consumption path
This is the input claim access-engine consumes under statute §6.2. It is a
fact about an approval object. It is not a decision. An implementer can
satisfy this document without reading this engine's source.
This is the step-1 artifact. GH-DEC-2026-005 (2026-09-06) confirms that
GET /v1/approvals/{id}/claim serves this claim on the GH-DEC-2026-003 path,
that no other envelope is required or may be served from this endpoint, and
that a PEP validates across two artifacts: this claim for the approval
fact, and the step-2 flex-auth DecisionEnvelope for exact CheckRequest
match and the policy package/version pin. Each artifact is validated against
the layer that owns its data; a PIP does not republish the PDP's decision. The
"Required verification" section below is the supported path for both consumers.
The authority for the approval fact is this engine — issuer: "approval-engine". State Hub is a read model and issues no approval; a
consumer requiring a state-hub authority on this claim fails closed against
every correctly issued response. GH-DEC-2026-005 struck that requirement
explicitly.
Yields to the Taxonomy request-claim schema (statute §17) when that artifact exists and is assented. This local shape is not permanent.
Fetch
GET /v1/approvals/{id}/claim
Fail-closed when this engine's store is unavailable (HTTP 503). An audit-core
outage does not affect this read.
There is no /v1/check, no /authorize, and no field named effect,
decision, allow, or deny. If a response contains those, it is out of
contract.
What the claim carries
| Field | Why |
|---|---|
approval_id |
Reconstructable from the decision record. |
binding.digest |
Distinguishes approved from approved for this exact request. |
binding.pdp_digest |
Optional. When recorded at issue, compare to the PDP's claim-excluded digest (binding.approval_binding_digest), not request_digest. |
issuer |
Always approval-engine. |
freshness |
So the PDP can state a deadline for this input class (§9.7.2), not a single fiction covering every source. |
valid_now |
Current-state predicate. Not permission. |
reason_code |
Why valid_now is false, when it is. |
valid_now is true only when all of:
- enough distinct authenticated approvers have been recorded;
- now is inside
validity.not_before…validity.expires_at; - the object is not consumed, superseded, revoked, or expired.
Holding a claim with valid_now: true is not authority to act. It is one
input the decision point weighs.
Canonical binding digest
The native digest is:
sha256: + hex( SHA-256( canonical_json({action, actor, principal, purpose, target}) ) )
canonical_json is UTF-8 JSON with sorted keys at every object level and no
insignificant whitespace (separators=(',', ':')). target is an object; its
keys are sorted too.
Wrong-action, wrong-target, and wrong-scope are distinguishable because they change that JSON and therefore the digest. A decision rendered against approval A for request R cannot be replayed for request R' if the consumer compares digests.
Presentation exclusion — GH-DEC-2026-015 §4
binding.digest MUST cover exactly the five act fields above and MUST NOT cover
presentation material: view_hash, brief, packet, highlights, locale, UI release
or a presentation wrapper. Those belong outside the act binding. Callers MUST
NOT smuggle presentation into target or another act field; target remains the
actual effect scope, including its nested scope fields. The engine does not
classify arbitrary target values as presentation or silently discard scope.
This is a compatibility constraint, not design intent. Widening the digest to
presentation would change approval identity on a UI release and could introduce
a mutual hash dependency. tests/test_claim_contract.py::test_presentation_changes_cannot_change_the_approved_act
asserts through durable issue/claim that changing presentation leaves the digest
and stored act unchanged, while changing any of the five act fields changes the
digest. A mutation that hashes the whole supplied binding must fail that test.
This supplies GH-DEC-2026-015's condition for informed-decision to carry the
engine's digest in view_hash; consumer adoption and its own verification remain
with that repository. GH-DEC-2026-016's declared human-control bind enforcement
is a separate requirement and is not implemented by this contract assertion.
What this digest is not — answering INFD-IN-0001 R3
informed-decision asked whether its view_hash and this digest are the same
hash, since both are described as canonicalizing a binding. They are not, and
they must not be merged. Three hashes, three questions:
| Hash | Preimage | Answers |
|---|---|---|
binding.digest (here) |
five fields: action, actor, principal, purpose, target |
which act is approved |
binding.pdp_digest |
the PDP's own NewDecisionBinding digest, recorded at issue |
which decision request this approval corresponds to |
view_hash (informed-decision) |
a canonicalized binding document additionally covering brief, packet, highlights, locale and UI release | what a person was shown when they bound themselves |
The overlap is only that all three canonicalize something. This engine's digest deliberately covers five fields and no more: it exists so that wrong-action, wrong-target and wrong-scope are distinguishable, and it has no opinion about presentation, which does not exist for a machine-to-machine approval. Widening it to cover a brief or a locale would change the digest of an act whose act did not change.
This repository already keeps two digests apart for exactly this reason rather
than as a matter of taste — binding.digest and pdp_digest answer different
questions and are stored separately precisely so that a reader cannot infer one
property from the other. A third hash answering a third question is the same
pattern, not a duplication of it.
The recommended relationship, which avoids two canonicalizations of one act:
view_hash's binding document should carry this digest as a field rather than
re-canonicalize action/actor/principal/purpose/target itself. Then
there is exactly one canonicalization of the act, computed here, referenced by
the presentation hash — and a mismatch is detectable rather than being two
independently correct answers about the same approval.
principal is the party on whose behalf, not the approver
informed-decision asked (principal_role_overlap) whether this engine's
binding.principal and its own binding.principal name the same field, since
both rulings used the shorthand that its binding slice canonicalizes "two of the
five". They are different roles, and the shorthand covered only one of them.
| Field | Role | Shape |
|---|---|---|
binding.principal (here) |
the party on whose behalf the act is performed — the requesting side, taken from the decision request's subject | scalar identifier |
binding.actor (here) |
the identity that may use the approval | scalar identifier |
| approver | the identity that bound itself to the act, with its verified principal_type and assurance |
an entry, one per approver, never part of the binding |
binding.principal (informed-decision) |
the person being bound — the approver | party object |
Only target overlaps between the two binding slices. The approver does not
appear in this engine's binding at all, and therefore does not enter
binding.digest: two approvals of the same act bound by different people share
one digest. A presentation hash that must commit to who was shown this cannot
obtain that from our digest, so view_hash keeping its own approver field is
correct and is not a second canonicalization of the act.
tests/test_claim_contract.py::test_approver_identity_is_not_in_the_act_digest
asserts both halves: the digest is insensitive to the approver, and the approver
remains separately recorded on the entry. A future change that folded approver
identity into the digest would break informed-decision's assumption here
rather than silently in that repository.
Mapping from a flex-auth CheckRequest
| Claim binding | CheckRequest |
|---|---|
action |
action |
target |
resource (object) |
actor |
subject.id |
principal |
subject.attributes.principal if present, else subject.id |
purpose |
context.purpose |
Action and target vocabulary — there is no published mapping, by design
The table above maps fields, not values. The claim's action and target
carry whatever vocabulary the approval's creator used
(secrets.kv.destroy, {"id": "lane-openbao-root", "stage": "prod"}); a policy
package may use its own (destroy, lane:...). This engine does not publish
a translation between them and will not.
This is a layer boundary, not an omission. A PIP that asserted
secrets.kv.destroy means destroy would be authoring policy semantics it
does not own, over vocabularies it does not own. The failure mode is also
asymmetric: a wrong mapping silently accepts a claim approved for a
different action, which is worse than no mapping at all. A consumer that
finds itself wanting one should read that as a signal it is about to compare
the wrong two things.
binding.pdp_digest is the correspondence. It sidesteps vocabulary
entirely: it is the PDP's own NewDecisionBinding digest, recorded at issue
time, so comparing
claim.binding.pdp_digest == decision.binding.approval_binding_digest
compares the PDP's digest to the PDP's digest, in one vocabulary, with no
translation by anyone. That is strictly stronger than a name-to-name mapping
could be. On the execute-time side the target is the PDP's claim-excluded
digest, not request_digest — see "The comparison target at execute time"
below.
pdp_digest is always present on the claim and is null when the approval
was not issued against a PDP decision — a stated fact rather than a missing
key, so a consumer cannot read absence as an oversight. It is not required on
every approval, because approvals legitimately exist that no decision preceded.
What pdp_digest can and cannot cover
pdp_digest is recorded at issue, and issue precedes the decision — the
claim is step 1, the decision is step 2. So it is necessarily the digest of the
underlying action request as it stands before any approval claim is embedded
in it. That is not a design preference; it is forced by ordering. A claim
cannot carry the digest of a document that contains that claim: the value would
have to be known before it could be computed.
This matters because a PDP may hash context into its request digest while a
dual-control pattern carries the claim in context.approval. Embedding the
claim then changes the digest of the request carrying it, and a pdp_digest
recorded at issue will match neither the full request nor, necessarily, any
particular reconstruction of it.
The exact rule — which fields the PDP excludes when computing the digest a claim is bound to, or whether the claim travels alongside the hashed context rather than inside it — belongs to the PDP's digest contract, not here. This engine records what it was given at issue and does not compute it. A consumer must not guess the exclusion. Computing a digest under an assumed rule produces a confident wrong answer, and comparing two digests derived under different rules fails open in the direction of accepting a claim bound to a different request.
pdp_digest does not authenticate the decision. This is the limit most
easily mistaken for coverage, so it is stated plainly: matching
claim.binding.pdp_digest against the PDP's published digest proves the two
artifacts describe one request. It proves nothing about whether the decision
is genuine. An approval whose pdp_digest matches a forged decision matches
perfectly — every input to the comparison is either sent by the caller or
published by the PDP, so a responder that knows a package id and version can
return a well-formed allow that passes every check a consumer makes.
Fail-closed protects against a PDP that is absent, not against one that lies. The distinction matters because the digest chain looks like it closes this and does not: a consumer that verified correspondence has verified correspondence, not authenticity.
Closing it is the PDP's to do, by signing the decision envelope —
FLEX-WP-0024, reported by flex-auth against its own interest after finding its
response channel served plain HTTP with no envelope signature. Until a signature
exists and a consumer verifies it, treat a matching pdp_digest as evidence
that the right request was approved, never as evidence that the decision came
from the PDP.
The comparison target at execute time
The issue-time description above is unchanged: this engine records the
NewDecisionBinding digest it was handed at issue. What changed is the field a
consumer compares it against at execute time.
flex-auth's request_digest hashes context, and the dual-control pattern
carries the claim in context.approval. So a claim-bearing request's
request_digest covers the claim itself, and can never equal a digest recorded
before that claim existed. A consumer obeying GH-DEC-2026-008 literally
against request_digest would fail closed permanently — not on a bad claim, but
on every claim, forever.
flex-auth resolved this by publishing binding.approval_binding_digest
(FLEX-DEC-2026-007): the same digest material with context.approval
removed, emitted only when a claim was carried. An approval issued against a
claim-free Check records that Check's request_digest, and the claim-bearing
request reproduces the identical value in the new field. So:
claim.binding.pdp_digest == decision.binding.approval_binding_digest
request_digest keeps covering the claim on purpose — it is the replay
identity, and two requests differing only in which approval was presented must
not share one when their decisions differ.
No change is required in this engine. The value recorded at issue was
already the right one; only the consumer-side target needed naming. This
section names it so a reader of pdp_digest does not reach for
request_digest and fail closed forever.
Declared path intent — binding.pdp_path
GH-DEC-2026-008 requires pdp_digest on the GH-DEC-2026-003 path. This
engine enforces that at issue, not at consume: an approval declared with
pdp_path: true and no pdp_digest is refused at create. Discovering an
unusable approval at the moment of the protected side effect is the worst place
to find out.
So binding.pdp_path is a guarantee, not a hint: pdp_path: true implies
pdp_digest is non-null. A consumer on that path MUST require pdp_path
true, and MUST NOT infer path intent from a pdp_digest that merely happens to
be present — a digest recorded for another reason is not a declaration that
anybody made.
Intent is declared by the requester and never back-filled. Approvals issued
before schema v3 carry pdp_path: false regardless of any digest they hold,
and a successor created by supersession inherits its predecessor's declaration.
A PEP on a privileged lane MUST refuse a claim whose pdp_digest is
null. Such a claim proves an approval exists; it does not prove the
approval was issued against the request now being decided, and no vocabulary
comparison recovers that. Requiring it is the consumer's own gate — this engine
states the fact and does not enforce the lane's policy.
Go's json.Marshal of a CheckRequest is not this canonical JSON (field
order and omitempty differ). Do not hash a CheckRequest with this function
and expect it to equal NewDecisionBinding.request_digest.
When the issuer recorded the PDP digest at issue time, it is in
binding.pdp_digest. Prefer that comparison at decision time:
claim.binding.pdp_digest == decision.binding.approval_binding_digest
and still require claim.approval_id to match the approval named on the
request. Native binding.digest remains the identifier of this engine's
binding, and is what T-06 uses when no PDP digest was recorded.
Freshness
Default TTL is 30 seconds from observed_at. not_after is
observed_at + ttl_seconds. A PDP that needs a different deadline for the
approval input class states it in its own provenance; it must not invent
freshness this engine did not publish.
A claim used after freshness.not_after is stale. Stale is not the same as
valid_now: false — the object may still be valid; the observation is old.
Re-fetch.
Required verification (consumer)
A production consumer of this claim, before treating it as an input, checks:
- The claim resolved from this engine; an outage fails the action closed.
issuerisapproval-engine.valid_nowis true andconsumedis false.binding.digestequals the digest of the binding the consumer computed from the proposed action, orbinding.pdp_digestequals the PDP's claim-excluded digest for that request —binding.approval_binding_digeston a claim-bearing flex-auth decision, neverbinding.request_digest. On a privileged lane, take the second: requirebinding.pdp_digestto be non-null and equal, and refuse the claim otherwise. See "Action and target vocabulary" and "The comparison target at execute time" above.freshness.not_afteris still in the future.reason_codeisok.
Local fixtures, workplan ids, and prose are not this claim.
Examples
| Example | Shape it publishes |
|---|---|
claim.valid.json |
valid, on the PDP path — pdp_path: true, pdp_digest non-null |
claim.valid.no-pdp.json |
valid, with no PDP binding — valid_now: true, reason_code: ok, pdp_path: false, pdp_digest: null |
claim.revoked.json |
revoked — valid_now: false, reason_code: revoked |
The middle one is the shape most likely to be missing from a consumer's tests,
and it is the one that matters most on a privileged lane: the approval is
genuinely valid and genuinely usable — for a consumer comparing the native
binding.digest — while being unusable on the GH-DEC-2026-003 path, where
a PEP MUST refuse it. valid_now: true is not permission to proceed on that
lane.
It is published because the first two examples alone would confound two
independent dimensions. With only a valid claim carrying a digest and a revoked
claim carrying none, a reader can reasonably infer that pdp_digest is null
because the claim is revoked, or that pdp_path tracks validity. Both are
false, and the example set is what would have taught them — the failure
security-layer-model §11's both-shapes clause exists to catch.
tests/test_examples.py asserts the decorrelation, not merely that both values
appear somewhere.
Declared human judgment — binding.human_control
New producers always state this boolean. true records that the requester
explicitly declared a human-in-the-loop or dual-control requirement at issue;
valid_now: true then also requires the declared count of distinct verified
human approvers. Non-human binds are refused before insertion. Undeclared objects
remain useful for service approvals. Historical absence is undeclared, never
proof of human judgment; a consumer needing the property must require exactly
true, rather than infer it from an approver's name or from a valid generic claim.
The declaration stays separate from the five act fields and does not change the native binding digest. It is inherited on supersession, emitted on audit events, and cannot be downgraded by linking an existing successor. The additive claim property stays within schema 0.1; old consumers that do not require this property retain their existing behavior. Consumer adoption of the new requirement and native issuer/deployment proof remain necessary before the factory human path.
claim.valid-human-control.json shows a valid declared human control;
claim.valid.json shows a valid ordinary approval. For an inconsistent persisted
human-control object, valid_now is false with human_control_unsatisfied and
consume refuses. Neither the declaration nor a claim is an authorization verdict.