approval-engine/docs/approval-claim.md
tegwick 7e756773de Implement GH-DEC-2026-008: declared PDP-path intent, enforced at issue
Gate House ruled binding.pdp_digest is the binding correspondence on the
GH-DEC-2026-003 path and is required there, having rejected a vocabulary
mapping for the reasons we gave. It asked this engine to record the PDP
digest at issue for approvals intended for that path, and to have the
claim state which approvals those are rather than leaving it to the
requester's memory.

Schema v3 adds approvals.pdp_path. create() refuses pdp_path true without
a pdp_digest, so an approval that would be unusable on the path fails at
issue rather than at the protected side effect. The claim exposes
binding.pdp_path, which makes it a guarantee rather than a hint: pdp_path
true implies pdp_digest is non-null.

Intent is declared and never inferred. A pdp_digest that happens to be
present is not a declaration anybody made, so a recorded digest alone
leaves pdp_path false, legacy rows migrate to false rather than being
back-filled from their digests, and a successor inherits its
predecessor's declaration. Approvals issued before the ruling stay usable
by consumers in this engine's own vocabulary and are simply not usable on
the PDP path -- the ruling's intended cost, stated as such.

Schema, both published examples, a v2-to-v3 migration test asserting
survivors keep their digest while declaring no path intent, and tests for
refusal at issue, claim exposure, non-inference, and successor
inheritance. 102 tests pass (8 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 14:51:23 +02:00

8.4 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.

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.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.