Adopt approval-engine's outbox wire as Gate House doctrine, specify the heartbeat-and-reconciliation detection surface, and settle consumption ordering: the PEP consumes by CAS before the side effect. T05's §11 check is written here and queued for statute v0.8. Assistant: grok Assistant-Session: 01a04d89-aaa5-7443-945e-b3055cd4b7e4
4 KiB
Approval-engine transactional outbox contract
Owner: gate-house
Implementer: approval-engine
Evidence engine: audit-core
Status: normative for GH-WP-0002-T02
Date: 2026-08-29
Statute: net-kingdom/canon/standards/security-layer-model_v0.7.md §9.4, §9.6
Failure mode: GH-DEC-2026-002
Source draft: approval-engine/docs/outbox-contract.md (adopted)
This is the wire. Boundary and locality are in the statute and in
approval-engine/INTENT.md. An implementer MUST NOT 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 approval-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 approval-emission-detection.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 |
object becomes consumed (public consume under approval-consumption.md) |
approval.use |
supersession |
object becomes superseded |
approval.supersession |
revocation |
object becomes revoked |
approval.revocation |
heartbeat |
signed nothing to report plus counts | approval.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 audit-core.event.v1alpha1 record, ready to POST. |
created_at |
RFC 3339 UTC | |
drained_at |
RFC 3339 UTC or null | Set after a successful audit-core ack. |
Payload (audit-core v1alpha1)
{
"schema_version": "audit-core.event.v1alpha1",
"event_id": "<same as outbox.event_id>",
"observed_at": "<created_at>",
"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:…",
"request_digest": null,
"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.
For use events, details.request_digest is the digest the PEP presented at
consume (see the consumption contract). It is null on other classes.
Drain
- Select undrained rows, oldest first.
- POST each payload to
audit-core. - On success, set
drained_at. - On failure, leave the row; 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. That is the whole of GH-DEC-2026-002.
Forbidden shapes
audit-coreHTTP (or any client) insideBEGIN…COMMITof a mutation.- Best-effort publish after commit with no row.
- A second, non-local queue as the durability mechanism.
- Deduping in
approval-engineinstead of relying onevent_idataudit-core.