approval-engine/docs/approval-claim.md
tegwick adb5cb0a9a test: enforce the presentation exclusion contract for approval digests
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc
2026-09-10 18:39:30 +02:00

349 lines
17 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.
### 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.
### 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
| Example | Shape it publishes |
| --- | --- |
| [`claim.valid.json`](../examples/claim.valid.json) | valid, on the PDP path — `pdp_path: true`, `pdp_digest` non-null |
| [`claim.valid.no-pdp.json`](../examples/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`](../examples/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.