approval-engine/docs/outbox-contract.md
tegwick 477c683991 fix(audit): retain authenticated consume identity and exact binding
Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
2026-10-16 07:51:39 +02:00

161 lines
7.2 KiB
Markdown

# Local transactional outbox contract
Coordinates with `GH-WP-0002-T02`. Boundary and locality are in `INTENT.md`
and statute §9.4; this is the wire.
An implementer **cannot** satisfy this contract by emitting synchronously to
`audit-core` inside the state-change transaction. That path is atomic and is
forbidden: an audit outage would become an inability to revoke.
## Rule
Every issuance, use, supersession, and revocation **inserts an outbox row in
the same transaction** that mutates the approval object. The durable queue
lives in this engine's own store. Drain is asynchronous, at-least-once.
`audit-core` dedupes on `event_id`; a replay does not fork the chain.
If the outbox insert cannot be committed, the mutation does not commit.
Emit-after-commit is a defect.
Heartbeat rows (see [`emission-cadence.md`](emission-cadence.md)) use the
same table and the same at-least-once drain. They are not coupled to an
object mutation.
## Event classes
| Class | When | `audit-core` `action` |
| --- | --- | --- |
| `issuance` | object becomes `approved` (threshold met) | `approval.issuance` |
| `use` | public CAS accepts first `request_digest` and object becomes `consumed` | `approval.use` |
| `supersession` | object becomes `superseded` | `approval.supersession` |
| `revocation` | object becomes `revoked` | `approval.revocation` |
| `heartbeat` | authenticated per-class *nothing to report* plus counts | `audit-core.heartbeat` |
Expiry is a clock crossing, persisted on observation, and is **not** an
emitted class. The validity window is already on the object.
## Outbox row
| Field | Type | Notes |
| --- | --- | --- |
| `event_id` | UUID | Stable. Drain retries reuse it. |
| `class` | enum above | |
| `approval_id` | UUID or null | Null only for heartbeat. |
| `payload` | object | The source record adapted to audit-core's HTTP envelope at drain time. |
| `created_at` | RFC 3339 UTC | |
| `drained_at` | RFC 3339 UTC or null | Set after a successful audit-core ack. |
| `attempts` | integer | Delivery attempts, including the successful attempt. |
| `last_attempt_at` | RFC 3339 UTC or null | Most recent delivery attempt. |
| `last_error` | class name or null | Bounded failure category; never exception text. |
## Payload (audit-core v1alpha1)
```json
{
"schema_version": "audit-core.event.v1alpha1",
"event_id": "<same as outbox.event_id>",
"observed_at": "<created_at>",
"tenant": "tenant:platform",
"scope": "netkingdom-approvals",
"source": "approval-engine",
"actor": "<actor or null for heartbeat>",
"action": "approval.revocation",
"resource": "approval:<approval_id>",
"outcome": "success",
"reason": null,
"details": {
"class": "revocation",
"approval_id": "<uuid>",
"binding_digest": "sha256:…",
"superseded_by": null
}
}
```
No secret values. `details` may add non-secret identifiers; it must not add a
validity verdict for consumers to branch on. `audit-core` MUST NOT expose an
approval-validity query; this payload does not invite one.
## Drain
1. Select undrained rows, oldest first.
2. Adapt it to audit-core's `{id,type,source,subject,tenant,correlation_id,
occurred_at,data}` envelope, using `event_id` as both `id` and the
`Idempotency-Key` header.
3. POST with the mounted sender credential, reread on every attempt.
4. On `202 accepted` or `200 duplicate`, set `drained_at`.
5. On failure, leave the row and record only a bounded failure class; retry
later. **Do not** roll back the object
mutation — it already committed with the row.
An `audit-core` outage therefore cannot block a revocation. This engine's
own store being unavailable can, and should: the change could not have been
recorded anyway.
## Forbidden shapes
- `audit-core` HTTP (or any client) inside `BEGIN` … `COMMIT` of a mutation.
- Best-effort publish after commit with no row.
- A second, non-local queue as the durability mechanism.
- Deduping in this engine instead of relying on `event_id` at `audit-core`.
## Threshold evidence on `issuance` and `use`
`GH-DEC-2026-005` moved the distinct-approver check off the PEP: a consumer
reads `valid_now` and trusts this engine's evaluation of everything folded into
it. Gate House accepted that as correct on layering **and** as a genuine
reduction in defence in depth, and named the compensating control — not a
second check at the PEP, which is the duplication the split removes, but
**reconstructability at the issuer** under §9.6. Detection, not prevention.
So `approval.issuance` and `approval.use` both carry a `threshold` object in
`details`:
| Field | Meaning |
| --- | --- |
| `required_count` | the threshold in force on the object at that moment |
| `distinct_approver_count` | distinct subjects who had recorded an entry |
| `threshold_met` | whether the evaluation passed |
| `approvers` | `subject_id`, `approved_at`, `principal_type`, nullable verified `tenant_source`, and `assurance` / `evidence_ref` when recorded |
An auditor holding only the `use` row can recompute the evaluation without
reading live rows — which matters because those rows may since have been
superseded, revoked, or expired.
**Identities are here and not on the claim.** The claim is consumer-facing and
discloses the least it can; the outbox is the evidence path to audit-core,
where the identities are the point. A consumer that wants the threshold reads
`valid_now`.
Distinctness itself is a storage invariant rather than a recomputation:
`entries` is UNIQUE on `(approval_id, subject_id)`, so a repeat approver is
refused at insert.
## Exact use provenance (source refinement, 2026-10-16)
New `approval.use` rows additionally retain `details.binding` (the native
five-field act), its existing `binding_digest`, nullable `pdp_digest` and
explicit `pdp_path`. `request_digest` remains the full decision-bound request,
and `decision_id` remains caller-supplied correlation, not a verified PDP verdict.
These distinct digests must never be substituted for each other.
`details.consumer` records the authenticated consume caller's `subject_id`,
`issuer`, `principal_type`, exact `tenant`, nullable verified `tenant_source` and
token-fingerprint `evidence_ref`. It comes solely from the HTTP authenticator;
body fields cannot override it. Top-level `actor` retains its existing meaning:
the actor captured on the approved act. Consumer provenance distinguishes the
service spending the object from that initiating actor. Bearer values, scopes,
roles and assurance are not copied into this record.
In-process calls without an authenticated caller record `consumer: null`.
Existing outbox rows remain unchanged; missing/null consumer evidence cannot be
backfilled as authenticated. Same-digest retries preserve the first use row and
never replace its original consumer or decision correspondence. All fields are
inserted atomically with the consumption CAS, survive restart, and pass unchanged
through the existing asynchronous audit adapter. No schema migration is needed.
This source evidence is not a portable signed receipt, execution grant or
server-identity proof. Consumer confirmation still uses the existing trusted
authenticated read/consume transport and a separate live execution journal.
Audit source admission, deployed issuer/profile, lifecycle readback and actual
served-PEP proof remain APPROVAL-WP-0002-T01/T04/T05 native gates.