Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
104 lines
5.4 KiB
Markdown
104 lines
5.4 KiB
Markdown
# 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
|
|
|
|
```text
|
|
POST /v1/approvals/{id}/consume
|
|
```
|
|
|
|
```json
|
|
{
|
|
"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`](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`](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.
|