flex-auth asked whether this engine should publish an action/target
mapping between the claim binding's vocabulary (secrets.kv.destroy,
{"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...),
since their package makes no cross-check that a claim was approved for
the action being decided.
Answered no. A PIP asserting that one vocabulary's action means
another's would author policy semantics it does not own, over
vocabularies it does not own, and the failure mode is asymmetric: a wrong
mapping silently accepts a claim approved for a different action, which
is worse than no mapping. binding.pdp_digest is the correspondence and
sidesteps vocabulary entirely -- it compares the PDP's own digest to the
PDP's own digest, with no translation by anyone.
Implemented the part that was ours. pdp_digest was emitted only when
recorded, so a consumer could not distinguish "not issued against a
decision" from "we forgot to look". It is now always present and null in
that case, required-but-nullable in the schema, and documented as
something a PEP on a privileged lane must refuse. This engine states the
fact; enforcing the lane's policy stays with the consumer.
Both published examples were already contradicting the updated schema by
omitting the field -- the same fixture-versus-contract defect flex-auth
hit twice this week and that secrets-engine implemented. Fixed both, made
them cover the PDP-bound and unbound shapes so neither is inferred from
the other, and added tests/test_examples.py to validate every example
against the schema so the class cannot recur here. jsonschema added as a
dev dependency.
94 tests pass (6 new).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TvyJPAaVCGsVheVhcCwNND
Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 411227@bnt-lap001
Assistant-Session: d566f6d3-bcaf-43c3-bc5e-3ddd0f64b535
7.5 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 NewDecisionBinding.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.
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.request_digest, recorded at
issue time, so comparing
claim.binding.pdp_digest == decision.binding.request_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.
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.
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.request_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 theNewDecisionBindingdigest of that request. 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" above.freshness.not_afteris still in the future.reason_codeisok.
Local fixtures, workplan ids, and prose are not this claim.
Examples
See ../examples/claim.valid.json and
../examples/claim.revoked.json.