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
276 lines
13 KiB
Markdown
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).
|