gate-house (GH-DEC-2026-013) was right that our wording implied a fact about the person. The tenant comparison is store isolation — does this caller belong to the store this engine serves — and a registration- supplied claim satisfies that while satisfying no doctrine about the approver's own membership. Exact equality cannot see the difference, so state it, and note that provenance gets recorded on the entry the way v4 records principal_type once the claim carries it. Answer informed-decision's R3 in the claim contract: view_hash and binding.digest answer different questions and must not be merged. Three hashes, three questions. Recommend their binding document carry our digest rather than re-canonicalize the same five fields, so the act has one canonicalization and a mismatch is detectable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HyybaE7DUXrWYrhbnESCTe Assistant: claude-code Assistant-Model: opus Assistant-Process: 1275879@bnt-lap001 Assistant-Session: eb464208-f821-41b2-bc5a-a6c33d92a8ad
328 lines
16 KiB
Markdown
328 lines
16 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.
|
|
|
|
### 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.
|