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
This commit is contained in:
tegwick 2026-09-06 20:35:59 +02:00
parent 06544b0fb4
commit 5c87ba8610
4 changed files with 115 additions and 12 deletions

View file

@ -47,7 +47,7 @@ contract.
| --- | --- |
| `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 `NewDecisionBinding.request_digest`. |
| `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. |
@ -106,16 +106,18 @@ 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.request_digest`, recorded at
issue time, so comparing
entirely: it is the PDP's own `NewDecisionBinding` digest, recorded at issue
time, so comparing
```text
claim.binding.pdp_digest == decision.binding.request_digest
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.
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
@ -146,6 +148,38 @@ 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.
### 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
@ -178,7 +212,7 @@ 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.request_digest
claim.binding.pdp_digest == decision.binding.approval_binding_digest
```
and still require `claim.approval_id` to match the approval named on the
@ -204,10 +238,12 @@ A production consumer of this claim, before treating it as an input, checks:
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
`NewDecisionBinding` digest of that request. 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" above.
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`.