approval-engine/docs/approval-claim.md

256 lines
12 KiB
Markdown
Raw Normal View History

# 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*. |
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
| `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` |
State pdp_digest explicitly; decline to publish a vocabulary mapping flex-auth asked whether this engine should publish an action/target mapping between the claim binding's vocabulary (secrets.kv.destroy, {"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...), since their package makes no cross-check that a claim was approved for the action being decided. Answered no. A PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, over vocabularies it does not own, and the failure mode is asymmetric: a wrong mapping silently accepts a claim approved for a different action, which is worse than no mapping. binding.pdp_digest is the correspondence and sidesteps vocabulary entirely -- it compares the PDP's own digest to the PDP's own digest, with no translation by anyone. Implemented the part that was ours. pdp_digest was emitted only when recorded, so a consumer could not distinguish "not issued against a decision" from "we forgot to look". It is now always present and null in that case, required-but-nullable in the schema, and documented as something a PEP on a privileged lane must refuse. This engine states the fact; enforcing the lane's policy stays with the consumer. Both published examples were already contradicting the updated schema by omitting the field -- the same fixture-versus-contract defect flex-auth hit twice this week and that secrets-engine implemented. Fixed both, made them cover the PDP-bound and unbound shapes so neither is inferred from the other, and added tests/test_examples.py to validate every example against the schema so the class cannot recur here. jsonschema added as a dev dependency. 94 tests pass (6 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 09:32:11 +02:00
### 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
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
entirely: it is the PDP's own `NewDecisionBinding` digest, recorded at issue
time, so comparing
State pdp_digest explicitly; decline to publish a vocabulary mapping flex-auth asked whether this engine should publish an action/target mapping between the claim binding's vocabulary (secrets.kv.destroy, {"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...), since their package makes no cross-check that a claim was approved for the action being decided. Answered no. A PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, over vocabularies it does not own, and the failure mode is asymmetric: a wrong mapping silently accepts a claim approved for a different action, which is worse than no mapping. binding.pdp_digest is the correspondence and sidesteps vocabulary entirely -- it compares the PDP's own digest to the PDP's own digest, with no translation by anyone. Implemented the part that was ours. pdp_digest was emitted only when recorded, so a consumer could not distinguish "not issued against a decision" from "we forgot to look". It is now always present and null in that case, required-but-nullable in the schema, and documented as something a PEP on a privileged lane must refuse. This engine states the fact; enforcing the lane's policy stays with the consumer. Both published examples were already contradicting the updated schema by omitting the field -- the same fixture-versus-contract defect flex-auth hit twice this week and that secrets-engine implemented. Fixed both, made them cover the PDP-bound and unbound shapes so neither is inferred from the other, and added tests/test_examples.py to validate every example against the schema so the class cannot recur here. jsonschema added as a dev dependency. 94 tests pass (6 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 09:32:11 +02:00
```text
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
claim.binding.pdp_digest == decision.binding.approval_binding_digest
State pdp_digest explicitly; decline to publish a vocabulary mapping flex-auth asked whether this engine should publish an action/target mapping between the claim binding's vocabulary (secrets.kv.destroy, {"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...), since their package makes no cross-check that a claim was approved for the action being decided. Answered no. A PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, over vocabularies it does not own, and the failure mode is asymmetric: a wrong mapping silently accepts a claim approved for a different action, which is worse than no mapping. binding.pdp_digest is the correspondence and sidesteps vocabulary entirely -- it compares the PDP's own digest to the PDP's own digest, with no translation by anyone. Implemented the part that was ours. pdp_digest was emitted only when recorded, so a consumer could not distinguish "not issued against a decision" from "we forgot to look". It is now always present and null in that case, required-but-nullable in the schema, and documented as something a PEP on a privileged lane must refuse. This engine states the fact; enforcing the lane's policy stays with the consumer. Both published examples were already contradicting the updated schema by omitting the field -- the same fixture-versus-contract defect flex-auth hit twice this week and that secrets-engine implemented. Fixed both, made them cover the PDP-bound and unbound shapes so neither is inferred from the other, and added tests/test_examples.py to validate every example against the schema so the class cannot recur here. jsonschema added as a dev dependency. 94 tests pass (6 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 09:32:11 +02:00
```
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
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
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.
State pdp_digest explicitly; decline to publish a vocabulary mapping flex-auth asked whether this engine should publish an action/target mapping between the claim binding's vocabulary (secrets.kv.destroy, {"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...), since their package makes no cross-check that a claim was approved for the action being decided. Answered no. A PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, over vocabularies it does not own, and the failure mode is asymmetric: a wrong mapping silently accepts a claim approved for a different action, which is worse than no mapping. binding.pdp_digest is the correspondence and sidesteps vocabulary entirely -- it compares the PDP's own digest to the PDP's own digest, with no translation by anyone. Implemented the part that was ours. pdp_digest was emitted only when recorded, so a consumer could not distinguish "not issued against a decision" from "we forgot to look". It is now always present and null in that case, required-but-nullable in the schema, and documented as something a PEP on a privileged lane must refuse. This engine states the fact; enforcing the lane's policy stays with the consumer. Both published examples were already contradicting the updated schema by omitting the field -- the same fixture-versus-contract defect flex-auth hit twice this week and that secrets-engine implemented. Fixed both, made them cover the PDP-bound and unbound shapes so neither is inferred from the other, and added tests/test_examples.py to validate every example against the schema so the class cannot recur here. jsonschema added as a dev dependency. 94 tests pass (6 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 09:32:11 +02:00
`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.
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
### 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.
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
### 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.
State pdp_digest explicitly; decline to publish a vocabulary mapping flex-auth asked whether this engine should publish an action/target mapping between the claim binding's vocabulary (secrets.kv.destroy, {"id": "lane-openbao-root"}) and a policy package's (destroy, lane:...), since their package makes no cross-check that a claim was approved for the action being decided. Answered no. A PIP asserting that one vocabulary's action means another's would author policy semantics it does not own, over vocabularies it does not own, and the failure mode is asymmetric: a wrong mapping silently accepts a claim approved for a different action, which is worse than no mapping. binding.pdp_digest is the correspondence and sidesteps vocabulary entirely -- it compares the PDP's own digest to the PDP's own digest, with no translation by anyone. Implemented the part that was ours. pdp_digest was emitted only when recorded, so a consumer could not distinguish "not issued against a decision" from "we forgot to look". It is now always present and null in that case, required-but-nullable in the schema, and documented as something a PEP on a privileged lane must refuse. This engine states the fact; enforcing the lane's policy stays with the consumer. Both published examples were already contradicting the updated schema by omitting the field -- the same fixture-versus-contract defect flex-auth hit twice this week and that secrets-engine implemented. Fixed both, made them cover the PDP-bound and unbound shapes so neither is inferred from the other, and added tests/test_examples.py to validate every example against the schema so the class cannot recur here. jsonschema added as a dev dependency. 94 tests pass (6 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 09:32:11 +02:00
**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
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
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
Name the execute-time digest target; record the tenant collision flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) after secrets-engine found that a claim-bearing request's request_digest covers the carried claim, so it can never equal a pdp_digest recorded before that claim existed. A consumer obeying GH-DEC-2026-008 against request_digest would have failed closed permanently on every claim rather than on a bad one. The value recorded at issue was already correct, so no code changes. What was wrong was this repo's description of the comparison target: a reader would reach for request_digest and fail closed forever. The schema and docs/approval-claim.md now name approval_binding_digest as the execute-time target and state that request_digest is never it, while leaving the issue-time description as it stood. Separately, docs/keycape-service-registrations.md now records a live collision in the deployment inputs: the manifest serves --tenant platform while the requested registrations issue tenant:coulomb, and ApiApplication.identity compares them with exact string equality before any object lookup, so those tokens would be denied 403 on every non-health route. flex-auth's tenant:platform is a PDP subject this engine never reads and cannot bridge the two. The values are left as-is on purpose — resolving it needs an owner statement on whether the two name the same layer, and guessing grants cross-tenant access to the approval store. The doc's stale issuer is corrected to the live https://kc.coulomb.social from 06544b0. 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-06 20:35:59 +02:00
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).