approval-engine/docs/approval-claim.md
tegwick 3ab497e0a7 Record that pdp_digest does not authenticate the decision
flex-auth reported that its response channel is unauthenticated: pins serve
plain HTTP and the envelope carries no signature, so a responder knowing the
published package id and version can return a well-formed allow that passes
every check a consumer makes. Their framing is the useful one -- fail-closed
protects against a PDP that is absent, not against one that lies.

That lands directly on this engine's correspondence chain. Matching pdp_digest
proves two artifacts describe one request; it proves nothing about whether the
decision is genuine, and 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.

Our doc already said what pdp_digest cannot cover but did not say this, which is
the limit most easily mistaken for coverage -- the digest chain looks like it
closes authenticity and does not. Stated plainly, with the fix named as the
PDP's to make (FLEX-WP-0024, signing the envelope) rather than something a
consumer can recover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PM5HnEAhokxdfcPqBNpT7D

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715850@bnt-lap001
Assistant-Session: eb557e93-7cb1-45d0-9e57-7d15b3edc60e
2026-09-07 00:20:59 +02:00

276 lines
13 KiB
Markdown

# Approval claim contract
**Schema:** [`../schemas/approval_claim.schema.json`](../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
```text
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:
1. enough distinct authenticated approvers have been recorded;
2. now is inside `validity.not_before``validity.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:
```text
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` digest, recorded at issue
time, so comparing
```text
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:
```text
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:
```text
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:
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 PDP's
claim-excluded digest for that request — `binding.approval_binding_digest`
on a claim-bearing flex-auth decision, never `binding.request_digest`. 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" and "The comparison target at execute time" 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`](../examples/claim.valid.json) and
[`../examples/claim.revoked.json`](../examples/claim.revoked.json).