# 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` | 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-` 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. |