feat: bind the destroy gate to approval_binding_digest and pdp_path
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

The vocabulary mapping this path was waiting on is not coming: gate-house
rejected it in GH-DEC-2026-008, because a translation can be confidently
wrong and fails open by accepting a claim approved for a different action.
The stronger option arrived instead, and both halves are enforced here.

flex-auth published binding.approval_binding_digest (FLEX-DEC-2026-007) to
fix the circularity this repo reported: a pdp_digest recorded at issue time
can never equal the request_digest of the request that carries the claim in
its hashed context, so with GH-DEC-2026-008 requiring that equality, destroy
would have failed closed forever on a check no correct record could pass.

- authorization.approval_binding_digest implements the published exclusion
  rule, including Go's context,omitempty behaviour when stripping empties
  the context; digest_material drops an empty context for the same reason.
- validate_decision_envelope recomputes the field rather than trusting it,
  refuses a claim-bearing request whose decision records none, and compares
  the claim's digest from step 1 against it -- never against request_digest,
  which still covers the claim so it stays a sound replay identity.
- validate_approval_claim requires binding.pdp_path true before using
  pdp_digest at all. Path intent is never inferred from a digest that
  happens to be present; pre-schema-v3 approvals carry pdp_path false
  regardless of any digest they hold.

Replay fixtures re-vendored from dd3ce4c. The destroy pins moved a second
and final time; approval_binding_digest did not, which is the point. The
fixture now demonstrates the property instead of asserting it: we rederive
fa07becf... from its own request through our canonical implementation,
proving we hash the same material flex-auth does rather than pinning a
constant we cannot reproduce.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E4tNMAYcSQmZWUE4wqP4ij

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 715726@bnt-lap001
Assistant-Session: 80a42b32-cba6-4b23-8be0-68819b1a6092
This commit is contained in:
tegwick 2026-09-06 20:39:59 +02:00
parent 67b28f48a8
commit c44306b1b2
11 changed files with 411 additions and 40 deletions

View file

@ -481,13 +481,75 @@ to end; what remains is deployment only.
pattern: unit tests with self-consistent fakes hid all three, and each was
found only by a real artifact or a real chain.
Destroy gate closed 2026-09-06 (inbound `FLEX-DEC-2026-007`, `GH-DEC-2026-008`,
approval-engine schema v3). The vocabulary mapping this task was waiting on is
not coming, and it should not have been waited on: gate-house rejected it
outright, for the reason flex-auth and approval-engine both gave independently —
a translation can be confidently wrong and fails **open** by accepting a claim
approved for a different action. The stronger option arrived instead, and both
halves of it are now enforced here.
- **The circularity we reported is fixed at source.** A `pdp_digest` recorded at
approval-issue time can never equal the `request_digest` of a request that
carries the claim in its hashed context. With `GH-DEC-2026-008` requiring that
equality on the PDP path, destroy would have been permanently un-allowable,
failing closed forever on a check no correct record could pass. flex-auth
published `binding.approval_binding_digest` (commit `dd3ce4c`): the same
canonical digest with `context.approval` removed, stable across attaching the
claim. The comparison is against that field, never `request_digest`.
- **`request_digest` deliberately still covers the claim**, so the two digests
disagree on a claim-bearing request and that disagreement is load-bearing: two
requests differing only in which approval was presented must not share a
replay identity, or an allow obtained with a valid claim could be replayed
against a request carrying none. `approval_binding_digest` is not a replay
identity and is not used as one.
- `authorization.approval_binding_digest` implements the published exclusion
rule, including flex-auth's `json:"context,omitempty"` behaviour of dropping a
context that strips empty. `digest_material` now drops an empty context for the
same reason. `validate_decision_envelope` recomputes the field when present
rather than trusting it, refuses a claim-bearing request whose decision records
none, and takes the claim's digest from step 1 as `expected_approval_binding_digest`.
- **`binding.pdp_path` is required true** before `pdp_digest` is used at all.
It is approval-engine's declaration that the approval was requested against a
bound CheckRequest; their `create()` refuses `pdp_path` true without a digest,
so the declaration guarantees the digest. The converse is not inferred: a
digest present for some other reason is no declaration, and pre-v3 approvals
carry `pdp_path` false regardless of any digest they hold.
- The cost is accepted rather than engineered around: an approval requested
without a bound CheckRequest is not usable on this path and will not become
usable later. If a real destroy workflow cannot bind at issue, that is the
falsifier gate-house wrote into the reversal and it gets raised, not
worked around.
The replay fixtures are re-vendored from `dd3ce4c`, and the pins moved a second
and final time (`request_digest` `sha256:c749ee2d…`, context input-claim digest
`sha256:8b73d29e…`; `approval_binding_digest` `sha256:fa07becf…`, which did
**not** move — that is the point). The fixture now demonstrates the property
instead of asserting it: `test_pdp_digest_equals_the_published_approval_binding_digest`
rederives `fa07becf…` from the fixture's own request through our canonical
implementation, which is a hermetic proof that this engine hashes the same
material flex-auth does. A pin alone would only have asserted the constant.
`decision_rotate.json` is unchanged and carries no `approval_binding_digest`;
a test pins that omission so the field is not silently duplicated onto ordinary
decisions.
Our own CheckRequest stays claim-free (`context` is `{"purpose": …}`), so the
identity holds transitively today; the code is already correct for the day we
carry a claim in context.
Still open and still ours: the T04 consumer-shape question flex-auth has asked
twice — operator CLI reaching an in-cluster pin, a workload we do not have yet,
or no pin at all. It is a deployment-shape answer, not engine work, and it is
the reply owed on that thread.
Remaining to go live is configuration and deployment, not engine work:
`SECRETS_ENGINE_PDP_URL`/`_PDP_TOKEN_FILE` (awaiting the `flex-auth-secrets-engine`
pin, `FLEX-WP-0021-T04`/`T05`), `SECRETS_ENGINE_APPROVAL_URL`/`_TOKEN_FILE`
(awaiting `APPROVAL-WP-0002-T03`), the policy pin (published but not to be set
until T05), a KeyCape RS256 credential in place of the static Bearer token, and
`approval.authorization_id` on each lane. Destroy additionally needs the
vocabulary mapping or a `pdp_digest` guarantee.
`approval.authorization_id` on each lane. Destroy's additional requirement is
now met in code: the `pdp_path` + `approval_binding_digest` identity replaced the
vocabulary mapping that was never going to exist.
Define and enforce the decision contract needed by production commands. A
resolved approval must bind at least: