# Approval consumption (PEP) Status: engine consumer and the PIP claim/validate join are implemented; live production remains fail-closed until approval-engine and access-engine actually serve the durable objects. Normative protocol: `gate-house/docs/contracts/approval-consumption.md` (`GH-DEC-2026-003`). Implementation surface: `approval-engine/docs/approval-consumption.md`. This document records how secrets-engine, as the PEP for OpenBao writes, consumes that protocol. It does not redefine it. ## Sequence ```text 1. PIP GET /v1/approvals/{id}/claim 2. PDP access-engine Check / decide → ALLOW 3. PEP POST /v1/approvals/{id}/consume → CAS 4. PEP OpenBao call → only after consume succeeds ``` Step 1 is `resolve_consume_binding` (`approval_consume.py`): it reproduces the exact CheckRequest with `build_action_request`, fetches the durable `ActionAuthorization` from `GET /v1/approvals/{id}/claim`, and validates it with `validate_action_authorization` before returning a consume binding. Until 2026-09-06 that function was a `return None` stub and the validator was unreachable from `src/`; the join now exists. The approval-engine object id is never inferred from a State Hub decision UUID. It comes from catalog `approval.authorization_id` or a served `decision.authorization_id`. The Check request `id` is an opaque correlator the PEP cannot regenerate, so the served one is adopted; every security-relevant field is still compared exactly and the binding digest is recomputed against the served request. Every live privileged production handler passes `_require_lane_approval`, which calls `require_production_consume` before `OpenBaoClient.resolve`. Dry-run and `plan` do not consume. Build/test remain fail-open relative to approval-engine. The three-factor unsafe-demo exception is not a consume path. ## Fail closed | Condition | Result | | --- | --- | | No served consume binding | refuse; no OpenBao | | URL/token/authorization id all unset | binding is `None` → refuse; no OpenBao | | Partially configured join (missing subject or policy pin) | raise; never degrade to "unconfigured" | | Claim digest, action, or field set mismatch | refuse; no OpenBao | | Missing `SECRETS_ENGINE_APPROVAL_URL` or token file | refuse; no OpenBao | | HTTP 409 / different digest | refuse; no OpenBao | | Same digest after consume | idempotent success; OpenBao may proceed | | 401/403/404/503/unreachable | refuse; no OpenBao | | Side effect fails after consume | approval is spent; no unconsume | The consume response is mutation evidence (`status=consumed` plus the presented digest). It is not a permission. Evidence records approval id, digest, idempotence, and consumed-at only. No token, secret, or accessor. ## Required configuration The join is absent by default, so an unconfigured engine behaves exactly as it did before. Production additionally needs: | Variable | Meaning | | --- | --- | | `SECRETS_ENGINE_APPROVAL_URL` | approval-engine base URL (claim + consume) | | `SECRETS_ENGINE_APPROVAL_TOKEN_FILE` | mode-0600 credential, outside Git | | `SECRETS_ENGINE_AUTHORIZATION_SUBJECT_ID` / `_SUBJECT_TYPE` | the acting principal | | `SECRETS_ENGINE_AUTHORIZATION_POLICY_PACKAGE` / `_VERSION` | the live pin | | `SECRETS_ENGINE_AUTHORIZATION_MIN_APPROVALS` | distinct-approver threshold | There is deliberately no default policy pin. flex-auth stated that the published `secrets-engine.lifecycle` / `v1` names are example vocabulary on the envelope, not a live package, so treating them as a default would pin production to a package nobody publishes. ## What this does not do - It does not enable live production. Unreachable-engine stance still fail-closes production until an access-engine decision record is served (`SECRETS-WP-0007-T04` / `SECRETS-WP-0008-T02`). - It does not render or cache an authorization decision. - It does not infer consumption from a decision record or from local evidence.