All checks were successful
Build and Publish Container Image / build-and-push (push) Successful in 43s
audit-core registered our sender (AUDIT-IN-0002) and, reviewing the emitter, found that no event we sent could ever have been accepted. envelope_for sent five of the eight required fields under its own names -- event_id, action, resource, observed_at, details -- and omitted correlation_id entirely. normalize() rejects that whole, 400. Our drain treated 400 as terminal, so every event would have been marked handled here while audit-core held only an unchained dead letter: lost on both sides, silently, with the integration looking healthy. - envelope_for emits exactly the eight required fields and none of the six audit-core derives. The acting principal moves into `data`, where it reads as our claim rather than the archive's finding. - Thread correlation_id through create / revoke / plan, which had no such field. Optional on those three bodies for compatibility; when a caller supplies none this engine mints req-<uuid> for the operation it performed and returns it. The store mints op-<uuid> as a floor for direct callers, written into the local payload so both records agree. - Send Idempotency-Key equal to the body id. - A 400 no longer dead-letters. The row stays pending with the reason recorded on it: a 400 is an integration defect to fix, not a delivery outcome to record. - wire_envelope upgrades outbox rows written in the old shape at send time, and refuses to send one whose correlation cannot be recovered from its payload rather than inventing one. Verified by running all nine event types, through the API, through audit-core's actual normalize() with a matching SenderIdentity -- all accepted. A live non-production 202 still needs the token, so TEN-WP-0012-T01 stays `wait`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DFmHM6fugwfqoobUCp9GiQ Assistant: claude-code Assistant-Model: opus Assistant-Process: 2106375@bnt-lap001 Assistant-Session: aa26c34d-71e8-4478-a962-c79c74694dc8
114 lines
5 KiB
Markdown
114 lines
5 KiB
Markdown
# Mutation evidence emission (TEN-WP-0011-T04)
|
|
|
|
Statute §9.6. Companion §6. Current event classes are **attributive**:
|
|
no control in this engine branches on an event's presence. Role grants
|
|
as *facts* are live PIP input; the *events* are the trail.
|
|
|
|
## Bound
|
|
|
|
The local `events` table and the local `audit_outbox` prove that the
|
|
records they hold were not altered or truncated after arrival. They do
|
|
not prove an event happened, and absence is not evidence of
|
|
non-occurrence. They share this service's runtime database credential,
|
|
so they are not independent custody.
|
|
|
|
Independent custody belongs to `audit-core`. This engine POSTs
|
|
`/v1/events` and holds no SQL, no admin, and no rewrite path against
|
|
audit-core's store. The external copy therefore cannot be rewritten
|
|
through tenant-engine's database credential.
|
|
|
|
## Trade (declared)
|
|
|
|
| Step | Atomic with mutation? | If it fails |
|
|
| --- | --- | --- |
|
|
| Insert local `events` row | yes (same transaction) | mutation rolls back |
|
|
| Insert local `audit_outbox` row | yes (same transaction) | mutation rolls back |
|
|
| Drain outbox to audit-core | **no** — after commit | mutation already succeeded; row stays pending |
|
|
|
|
Emission is **non-blocking**. Unavailable audit-core MUST NOT fail-open
|
|
a mutation (the fact is already written) and MUST NOT fail-closed a
|
|
mutation (attributive evidence is not load-bearing). Completeness is
|
|
not claimed. If a future control starts branching on these events, that
|
|
class must be reclassified load-bearing before it ships, and this trade
|
|
revisited.
|
|
|
|
## Envelope
|
|
|
|
The wire contract is audit-core's `docs/event-envelope.md`, not a schema
|
|
name. There is **no version negotiation** at that receiver: an envelope
|
|
either carries all eight required fields, truthy, or it is rejected
|
|
whole. `tenant_engine.audit_core.envelope_for` builds it.
|
|
|
|
| Field | This engine sends |
|
|
| --- | --- |
|
|
| `id` | the event id, repeated in the `Idempotency-Key` header (a mismatch is rejected) |
|
|
| `type` | the domain event type (`tenant_created`, `role_granted`, …) |
|
|
| `source` | `tenant-engine` |
|
|
| `subject` | `tenant:<tenant_id>` |
|
|
| `tenant` | the affected tenant id |
|
|
| `correlation_id` | the caller's, or one this engine minted for the operation |
|
|
| `occurred_at` | RFC 3339 **with an explicit offset** — a naive timestamp is rejected |
|
|
| `data` | the event payload, including the acting principal |
|
|
|
|
`observed_at`, `action`, `resource`, `scope`, `outcome`, and `actor` are
|
|
**derived by audit-core** and deliberately not sent: sending them would
|
|
suggest this engine controls values it does not. The acting principal
|
|
travels inside `data`, where it reads as this service's claim rather than
|
|
the archive's finding.
|
|
|
|
An earlier emitter sent its own names (`event_id`, `action`, `resource`,
|
|
`observed_at`, `details`) and no `correlation_id` at all. Every event
|
|
would have been rejected. `wire_envelope` upgrades any such row still
|
|
sitting in an outbox at send time, and refuses to send one whose
|
|
correlation cannot be recovered from its payload rather than inventing
|
|
one.
|
|
|
|
### Correlation
|
|
|
|
audit-core requires a `correlation_id` and will not synthesize one — an
|
|
id the *archive* invented would tie an event to an operation it never
|
|
observed. This engine is differently placed: it performed the operation.
|
|
Every mutation route accepts a `correlation_id`; the three older routes
|
|
(create, revoke, plan) treat it as optional for compatibility and mint
|
|
`req-<uuid>` when a caller supplies none, returning it in the response.
|
|
The minted id is written into the local event payload too, so the local
|
|
and external records agree.
|
|
|
|
### Delivery outcomes
|
|
|
|
| Response | Outbox row |
|
|
| --- | --- |
|
|
| `202` | delivered |
|
|
| `200` | duplicate id, already stored — not retried |
|
|
| `400` | **stays pending**, reason recorded on the row |
|
|
| `409` | dead-lettered |
|
|
| `401` / `403` / `503` / transport | retried |
|
|
|
|
A `400` is deliberately **not** terminal. It means the event is not in
|
|
the archive — audit-core holds only an unchained dead letter, which is a
|
|
diagnostic queue and not custody. Marking our row handled would lose the
|
|
event on both sides, silently, while the integration looked healthy. A
|
|
400 is an integration defect to fix; the row stays pending so it is
|
|
delivered once the defect is.
|
|
|
|
## Credentials
|
|
|
|
No secret in Git. Production sender token is projected as
|
|
`TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE`, routed through `warden route`
|
|
(`audit-core` sender registration), never through a State Hub message.
|
|
Sender registration itself is requested as `AUDIT-IN-0002`.
|
|
|
|
## Backfill
|
|
|
|
**Decision:** no backfill of pre-cutover event classes. Those rows stay
|
|
in the local table. Reconstructing them into audit-core would mint
|
|
evidence this engine cannot prove was complete at the time. New
|
|
mutations from this workplan onward enqueue the outbox.
|
|
|
|
## Configuration
|
|
|
|
| Env var | Default | Meaning |
|
|
| --- | --- | --- |
|
|
| `TENANT_ENGINE_AUDIT_CORE_URL` | unset | Drain is skipped; outbox rows stay pending. |
|
|
| `TENANT_ENGINE_AUDIT_CORE_TOKEN_FILE` | unset | Bearer token file for POST /v1/events. |
|
|
| `TENANT_ENGINE_AUDIT_CORE_TIMEOUT_SECONDS` | `3` | Drain timeout. |
|