approval-engine/docs/approval-claim.md
tegwick 6d0dfc8010 State pdp_digest explicitly; decline to publish a vocabulary mapping
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
2026-09-06 09:32:11 +02:00

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:

  1. enough distinct authenticated approvers have been recorded;
  2. now is inside validity.not_beforevalidity.expires_at;
  3. 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:

  1. The claim resolved from this engine; an outage fails the action closed.
  2. issuer is approval-engine.
  3. valid_now is true and consumed is false.
  4. binding.digest equals the digest of the binding the consumer computed from the proposed action, or binding.pdp_digest equals the NewDecisionBinding digest of that request. On a privileged lane, take the second: require binding.pdp_digest to be non-null and equal, and refuse the claim otherwise. See "Action and target vocabulary" above.
  5. freshness.not_after is still in the future.
  6. reason_code is ok.

Local fixtures, workplan ids, and prose are not this claim.

Examples

See ../examples/claim.valid.json and ../examples/claim.revoked.json.