Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a140c4-354a-7823-8f0d-8f717397f786
161 lines
7.2 KiB
Markdown
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.
|