approval-engine/docs/approval-consumption.md
tegwick d40e842052 Preserve fractional approval expiry through atomic consumption
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 17:35:53 +02:00

5.4 KiB

Approval consumption API

Status: implemented under Gate House decision GH-DEC-2026-003.

The normative protocol is gate-house/docs/contracts/approval-consumption.md. This document records the approval-engine implementation surface; it does not redefine the protocol.

Endpoint

POST /v1/approvals/{id}/consume
{
  "request_digest": "sha256:<64 lowercase hex>",
  "decision_id": "decision:optional-provenance"
}

The caller is the PEP that is about to perform the protected side effect. It calls consume after an ALLOW and before that side effect. request_digest is the canonical digest from the PDP decision binding, not a newly serialized request and not approval-engine's native binding digest.

Results

  • First valid consume: atomically stores the digest, changes approved to consumed, and inserts one approval.use outbox row in the same transaction.
  • Same digest after consumption: 200 idempotent success and no second outbox row.
  • Different digest after consumption: 409 conflict; the PEP must not act.
  • Revoked, superseded, expired, outside-window, requested, or unknown object: conflict or not-found; the PEP must not act.
  • Outbox insert/store failure: 503; the transaction rolls back and the PEP must not act.

There is no unconsume, release, or reserve. If the protected side effect fails after consumption, the approval remains spent and a retry needs a new approval.

The response is mutation evidence, not a permission decision. It contains no effect, allow, deny, or decision result.

Authoritative time and fractional precision

New source preserves the actual aware UTC clock's microseconds rather than rounding down to whole seconds. The first consume samples one instant after BEGIN IMMEDIATE acquires its write lock, then uses that instant for the not-before/inclusive and expiry/exclusive check, persisted consumed_at and updated_at, and use-event occurrence/creation time. Lock wait cannot borrow an earlier validity instant or stamp evidence before actual admission.

Existing whole-second ISO strings remain valid and are never backfilled with invented fractions. Idempotent responses and object readback retain the original recorded timestamp/decision exactly, even after expiry; they do not renew execution authority. The store schema, API keys, CAS/digest correspondence and no-unconsume rule are unchanged. Claim observation/freshness and other new source timestamps also retain actual clock precision. Consumers must parse aware ISO/RFC3339 timestamps including fractional seconds instead of assuming a fixed-width whole-second string. No clock skew or early-use tolerance is added.

The claim response is not an ActionAuthorization

GET /v1/approvals/{id}/claim serves the approval-claim envelope defined in approval-claim.md. It is not the ActionAuthorization object that secrets-engine's validate_action_authorization currently expects, and a PEP that points that validator at this URL fails closed for the wrong reason.

Both envelopes carry schema_version: "0.1", so the version check passes and the mismatch surfaces later as a missing-field or wrong-authority error. Do not read that failure as an approval-engine outage.

Validator expectation What the claim actually serves
id (canonical UUID) approval_id — the object id, same value, different key
status == "approved" state (approved, consumed, revoked, superseded, expired, requested) plus the valid_now predicate
superseded_by absent; supersession appears as state: "superseded"
provenance.authority == "state-hub" issuer: "approval-engine" — this engine is the authority for the approval object; the State Hub is a read model and never issues one
request (full CheckRequest) binding (action, actor, principal, purpose, target) plus binding.digest, and binding.pdp_digest when recorded at issue
approvals.required_count / entries not exposed; the distinct-approver threshold is already folded into valid_now, with reason_code: "insufficient_approvers" when unmet
policy package/version pin not carried; the policy pin belongs to the access-engine decision, not to the approval fact

The omissions are deliberate. A claim is a fact about an approval object, not a decision and not a permission; approver identities and policy pins are not republished to consumers. The consumer checks in approval-claim.md ("Required verification") are the supported validation path.

Settled 2026-09-06 by GH-DEC-2026-005 (with FLEX-DEC-2026-006). The approval-claim is the step-1 artifact; ActionAuthorization is not required on this path and MUST NOT be served from the claim endpoint. A PEP validates across two artifacts: the claim for the approval fact, and the step-2 flex-auth DecisionEnvelope for exact CheckRequest match and the policy pin.

Gate House recorded the split as doctrine rather than as an implementation convenience: each artifact is validated against the layer that owns its data, and a PIP MUST NOT republish the PDP's decision. The provenance.authority == "state-hub" requirement was struck explicitly — State Hub is a read model holding no runtime approval authority, so a validator requiring it fails closed against every correctly issued claim.

This engine's claim schema is unchanged by the ruling.