Unified Audit Architecture

Technical architecture specification for the platform-wide audit surface: the shared StellaOps.Audit.Emission library (the producer contract) and its relationship to the Timeline unified audit store (the system of record).

Audience: service implementers wiring audit emission into an endpoint, and operators / auditors reasoning about where the audit record actually lives and what it guarantees.

Scope split (read this first):

  • This dossier owns the cross-cutting emission surface: the library, the event contract, the trust boundary, the DEPRECATE-002 proxy topology, and which service is the system of record.
  • Timeline owns the sink: the hash-chained store, retention, data classification, anomaly detection. See also audit retention.
  • AUDIT_EMISSION_GUIDE.mdis the step-by-step how-to for adding emission to a service. This file explains why and what the guarantees are.

Audit is deliberately not the same pipe as Eventing (StellaOps.Eventing → Valkey streams → Timeline/Notify). Audit is a durable, hash-chained, HTTP-ingested compliance record; eventing is a stream envelope. Do not conflate them.


1. Overview

Every mutating operator action in Stella Ops should land in one tamper-evident, tenant-scoped, GDPR-redactable log. The mechanism is deliberately boring:

  1. A service tags an endpoint with AuditActionAttribute metadata and the AuditActionFilter endpoint filter (src/__Libraries/StellaOps.Audit.Emission/).
  2. After the endpoint returns, the filter builds an AuditEventPayload and hands it to IAuditEventEmitter.
  3. HttpAuditEventEmitter POSTs it, fire-and-forget, to Timeline POST /api/v1/audit/ingest, authenticated with a signed service identity envelope carrying the audit:ingest scope (HttpAuditEventEmitter.AttachIdentityEnvelope).
  4. Timeline verifies the envelope, checks the caller really is authenticated and scoped (UnifiedAuditIngestGuard), and persists the event into timeline.unified_audit_events with a per-tenant SHA-256 hash chain (PostgresUnifiedAuditEventStore.AddAsync, PostgresUnifiedAuditEventStore.cs:52-105).

Timeline is the system of record for audit — not EvidenceLocker. That boundary is intentional (§8) and load-bearing: EvidenceLocker owns sealed evidence packs; Timeline owns the hash-chained operational audit log. Do not re-home audit without a decision record.

Adoption is broad — re-derive it, do not trust a number in a doc. Counts rot; the commands do not:

# projects referencing the emission library
grep -rl "StellaOps.Audit.Emission.csproj" --include=*.csproj src | wc -l
# service hosts that actually wire it up
grep -rl "AddAuditEmission" --include=Program.cs src | wc -l

(As of 2026-07-12 those were 42 and 37 respectively — i.e. essentially every WebService in the platform, plus the EvidenceLocker worker.)


2. Design principles

  1. Emission must never break the request. The filter emits after the endpoint has produced its result, on a detached task (AuditActionFilter.cs:93). Failure containment is two-layer: HttpAuditEventEmitter swallows the expected transport failures — timeout, HttpRequestException, JsonException, non-2xx (HttpAuditEventEmitter.cs:70-93) — and the filter’s outer catch (Exception) swallows everything else, including a malformed TimelineBaseUrl (AuditActionFilter.cs:205-215). Nothing escapes into the request. Consequence: emission is best-effort, not at-least-once — see §9.
  2. No compile-time coupling to Timeline. AuditEventPayload lives in the emission library and is wire-compatible with Timeline’s UnifiedAuditIngestRequest (UnifiedAuditEndpoints.cs:821-836), so no service references Timeline to be audited.
  3. Tenancy comes from the claim. The filter reads the actor’s tenant from the stellaops:tenant claim (falling back to a bare tenant claim type), never from a caller-supplied header, query, or body value (AuditActionFilter.cs:235).
  4. PII is redacted at the producer. The captured request body runs through AuditPiiRedactor before it ever leaves the emitting process (AuditActionFilter.cs:146-149). Note the scope limit in §5.
  5. One store, one truth. Post-DEPRECATE-002 there is exactly one audit store; per-service audit LIST endpoints proxy to it rather than being polled by it (§7).

3. Components

src/__Libraries/StellaOps.Audit.Emission/          # the producer surface (this dossier)
├── AuditActionAttribute.cs                        # endpoint metadata: Module, Action, ResourceType,
│                                                  #   CaptureBody, SensitiveFields
├── AuditActionFilter.cs                           # IEndpointFilter: builds + fires the event
├── AuditedRouteGroupExtensions.cs                 # .Audited(module, action) / .WithAuditFilter()
├── AuditEmissionServiceExtensions.cs              # AddAuditEmission(configuration)
├── AuditEmissionOptions.cs                        # AuditEmission:* config binding
├── AuditEventPayload.cs                           # the wire contract
├── AuditModules.cs / AuditActions.cs              # well-known module/action constants
├── AuditPiiRedactor.cs                            # key-pattern JSON redaction
├── IAuditEventEmitter.cs                          # the producer port
├── HttpAuditEventEmitter.cs                       #   → Timeline ingest
├── NullAuditEventEmitter.cs                       #   → test/offline no-op
├── ITimelineAuditQueryClient.cs                   # DEPRECATE-002 read-side proxy port
├── HttpTimelineAuditQueryClient.cs                #   → Timeline query
├── DeprecatedAuditEndpoint.cs                     # Sunset/Deprecation/Link headers
├── IAuditBeforeStateProvider.cs                   # per-module before-state capture hook
├── IAuditResourceEnricher.cs                      # per-module resource-name enrichment hook
└── CorrelationId.cs / ICorrelationIdAccessor.cs   # correlation-id contract
    + HttpContextCorrelationIdAccessor.cs / AmbientAsyncLocalCorrelationIdAccessor.cs

src/Timeline/StellaOps.Timeline.WebService/Audit/  # the sink — see ../timeline/architecture.md
src/Authority/.../StellaOps.Authority/Audit/       # the Authority-native auth-event path (§6)

AddAuditEmission (AuditEmissionServiceExtensions.cs:39-102) registers the filter (scoped), both HTTP clients, IAuditEventEmitterHttpAuditEventEmitter (singleton), and ITimelineAuditQueryClientHttpTimelineAuditQueryClient (singleton). The query client gets a deliberately longer timeout than the write emitter (max(5, Timeout*3) vs Timeout, :82-95), because LIST queries over a large tenant legitimately outlast a 3 s write budget.

Not in scope of this dossier (different concerns that merely share the word “audit”): src/__Libraries/StellaOps.Audit.ReplayToken (replay tokens for triage/scoring decisions) and src/__Libraries/StellaOps.AuditPack (audit-pack bundling).


4. Configuration (AuditEmission:*)

Bound in AuditEmissionServiceExtensions.cs:43-69; defaults in AuditEmissionOptions.cs.

KeyDefaultNotes
AuditEmission:TimelineBaseUrlhttp://timeline.stella-ops.localAlso settable via STELLAOPS_TIMELINE_URL. Internal hostname — no public URL default (offline-first).
AuditEmission:Enabledtruefalse ⇒ the emitter returns immediately and the event is dropped silently (HttpAuditEventEmitter.cs:49-53).
AuditEmission:TimeoutSeconds3HTTP timeout for the ingest POST.
AuditEmission:MaxBodySizeBytes65536Bodies over the limit are replaced by {"_truncated":true,"_originalSizeBytes":N} — the content is not clipped, it is dropped (AuditActionFilter.cs:118-126).
AuditEmission:RedactedFieldPatterns(unset)When set, replaces AuditPiiRedactor.DefaultRedactedPatterns entirely — see the footgun in §10.
AuditEmission:IdentityEnvelopeSigningKeyfalls back to Router:IdentityEnvelopeSigningKey, then STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEYHMAC key used to sign the ingest hop’s service identity envelope. The same key every service already holds to verify the gateway envelope — supplied to all compose services by the router-microservice-defaults anchor (docker-compose.stella-services.yml:77), so nothing new is distributed. Unset ⇒ the POST is unauthenticated, Timeline answers 401, the event is lost, and the emitter logs a one-time Warning naming the key.
AuditEmission:ServiceIdentityentry assembly nameEnvelope Subject — which service claims to have produced the event.
AuditEmission:IdentityEnvelopeLifetimeSeconds120Envelope lifetime; the verifier additionally allows 5 min clock skew.

5. Data flow (the HTTP-endpoint path)

[endpoint executes] → AuditActionFilter → AuditEventPayload → HttpAuditEventEmitter
    → POST /api/v1/audit/ingest (Timeline)
    → PostgresUnifiedAuditEventStore.AddAsync  (SERIALIZABLE, hash-chained)
    → timeline.unified_audit_events

What the filter puts in the payload (AuditActionFilter.BuildAuditEventAsync, AuditActionFilter.cs:217-350):

FieldSourceNotes
module / actionAuditActionAttributelower-cased (:331-332)
actor.id / .name / .emailsub / name / email claims (:232-234)id falls back to "system"; name falls back to id
actor.typeuser | service | system (:666-681)service = a client_id with no sub (client credentials); system = unauthenticated
tenantIdstellaops:tenant claim, else tenant (:235)never a header/query/body value
resource.typeattribute override, else inferred from the path (:571-594)first non-api/non-version segment
resource.idroute values, else first GUID route value, else the response body’s id (:519-569, :407-460)"unknown" if nothing resolves
severityHTTP status: 2xx=info, 4xx=warning, 5xx=error (:596-605)
correlationIdHttpContext.Items["correlation_id"]X-StellaOps-Correlation-IdX-Correlation-IdTraceIdentifier (:607-625)header values are accepted only if they parse as a GUID or a ULID (CorrelationId.TryNormalize); anything else silently falls through to the trace id
details.requestBodyPOST/PUT/PATCH JSON body, PII-redacted, size-capped (:98-161)non-JSON content types are skipped
details.beforeStateIAuditBeforeStateProvider for the module, 2 s budget (:163-189)optional; not redacted — see below
details.onBehalfOfverified operator behind a service-account token (:238-254, :304-311)audit-only; never widens authz

Redaction scope (important). AuditPiiRedactor is applied to the captured request body only. details.beforeState (from IAuditBeforeStateProvider) and resource.name (from IAuditResourceEnricher) are inserted into the payload un-redacted — a module hook that returns a secret-bearing field will ship it to the audit store. Keep those providers PII-clean at the source.

On-behalf-of preference (AuditActionFilter.cs:238-254). When the Router’s OnBehalfOfActorMiddleware has cryptographically verified a relayed operator identity, the audit author becomes the real operator, and the service account is preserved as details.onBehalfOf.delegate. The value is read duck-typed from HttpContext.Items["stellaops:onBehalfOf"] (:642) so the shared library takes no dependency on the Router.

Resource enrichment. IAuditResourceEnricher (per module, 2 s budget, :370-401) turns a bare id into a human-readable resource.name. Skipped when the id is "unknown".


6. The second producer path: Authority auth events

The endpoint filter is not the only producer. Authority’s AuthorityAuditSink implements IAuthEventSink (StellaOps.Cryptography.Audit) and, alongside its own login-attempt store, maps each auth event to an AuditEventPayload with Module = "authority" and pushes it through the same IAuditEventEmitter (AuthorityAuditSink.cs:20, :71, :110-114). The emitter dependency is optional (IAuditEventEmitter? timelineEmitter = null, :30, :36): if it is not registered, Authority still records the login attempt in its own store but emits no unified audit row. Authority also ships AuthorityAuditBeforeStateProvider, AuthorityAuditResourceEnricher, and AuthorityAuditTenantComplianceProfileProvider — the per-module hooks from §5.

Timeline’s own TimelineAuthorizationAuditSink is a different thing: it implements the same IAuthEventSink interface but only writes a structured log line, not an audit row (TimelineAuthorizationAuditSink.cs:11-25). Do not read it as a second store.


7. The sink, and the DEPRECATE-002 topology

Ingest. POST /api/v1/audit/ingest is mapped in its own route group without RequireTenant() — the tenant travels inside the payload (put there from the emitting service’s claim) and is bound to the caller by the envelope (below).

Authentication and authorization are two independent checks (SPRINT_20260712_008 / W3-C):

  1. The Timeline.AuditIngest policy — satisfied by either audit:ingest (the narrow machine scope the emitter presents) or timeline:write (retained for operator tooling) (TimelinePolicies.AuditIngestProgram.cs AddStellaOpsAnyScopePolicy).
  2. UnifiedAuditIngestGuard.Evaluate(HttpContext.User) — the caller must be an authenticated principal that actually carries one of those scopes. 401 audit_ingest_unauthenticated / 403 audit_ingest_scope_required.

Check 2 is not redundant. StellaOpsBypassEvaluator.ShouldBypass force-satisfies any scope requirement for an in-CIDR caller that sends no Authorization header — without authenticating the principal — and timeline-web lists 172.16.0.0/12, the whole compose network. W3-B’s Strict bypass mode deliberately preserves that path for anonymous, envelope-less intra-stack callers, which is exactly the shape of an attacker on the compose network. The guard reads the principal, which the bypass cannot fabricate, so ingest holds the line on its own.

A missing module or action is rejected 400 module_and_action_required; success is 202 Accepted with { eventId, status: "accepted" }; a persistence failure is 503 — the ingest hop itself does not silently drop.

Store. PostgresUnifiedAuditEventStore writes under SERIALIZABLE isolation: it takes the next per-tenant sequence + previous hash (timeline.next_unified_audit_sequence), computes a SHA-256 content_hash over a canonical, key-sorted JSON projection of the event (:337), stamps data_classification via AuditDataClassifier.Classify (:311), inserts, and advances the chain head. Events with no tenant land in "default" (:56). Schema (timeline.*, auto-migrated on startup via AddStartupMigrations(schemaName: "timeline", moduleName: "TimelineAudit", …), Program.cs:136-140; DDL in 001_v1_timeline_core_baseline.sql, all CREATE … IF NOT EXISTS):

ObjectPurpose
timeline.unified_audit_eventsthe audit rows + content_hash / previous_entry_hash / sequence_number / data_classification (:87, classification column added at :312)
timeline.unified_audit_sequencesper-tenant chain head (:167)
timeline.next_unified_audit_sequence / update_unified_audit_sequence_hash / verify_unified_audit_chainchain functions (:176, :203, :217)
timeline.audit_retention_policies + resolve_audit_retention_days / purge_expired_audit_eventsper-classification retention (:326, :348, :375)
timeline.redact_actor_piiGDPR Art. 17 erasure preserving the chain (:421)

Read/query. All under /api/v1/audit, in a RequireTenant() group requiring Timeline.Read (timeline:read): GET /events, /events/{eventId}, /stats, /timeline/search, /correlations, /correlations/{correlationId}, /chain/verify, /anomalies (+ /anomalies/rules, /anomalies/{alertId}/acknowledge, dispatch bookkeeping), /export, /export/{exportId}, /retention-policies. A request-supplied tenant that disagrees with the claim is rejected 403 tenant_claim_mismatch (UnifiedAuditEndpoints.cs:686-704). DELETE /actors/{actorId}/pii (GDPR erasure) requires Timeline.Admintimeline:admin.

DEPRECATE-002 (SPRINT_20260408_005) inverted the read topology. Timeline used to poll per-service audit endpoints and merge them. It no longer does: HttpUnifiedAuditEventProvider.GetEventsAsync is neutered and returns an empty set (HttpUnifiedAuditEventProvider.cs:31-48); polling would now self-loop, because the surviving per-service endpoints proxy to Timeline via ITimelineAuditQueryClient and stamp Sunset/Deprecation/Link headers (DeprecatedAuditEndpoint.DeprecatedForTimeline). The proxying endpoints are:

ServiceEndpointWiringDeprecation headers
Authority/console/admin/auditConsole/Admin/ConsoleAdminEndpointExtensions.cs:254yes
Policy (Gateway)/api/v1/governance/audit/eventsPolicy.Gateway/Endpoints/GovernanceEndpoints.cs:116,125yes
Policy (Engine)/api/v1/governance/audit/*Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs:335,381no — proxies, but never stamps
Notify/api/v1/notify/auditNotify.WebService/Program.cs:1773yes
ReleaseOrchestrator/api/v1/release-orchestrator/auditEndpoints/AuditEndpoints.cs:38yes

EvidenceLocker is not on that list — its audit endpoint was deleted, not proxied. The /api/v1/evidence/audit slice that the legacy poller used to read was removed outright in SPRINT_20260709_001 (EF-0) as a fabricated-data endpoint; only the repository-backed pack reads remain in EvidenceAuditEndpoints.cs, whose remarks now redirect callers to Timeline. (That redirect has a bug — see §12.1.)

The CompositeUnifiedAuditEventProvider is still registered (Program.cs:146-149) but, with the HTTP side returning empty, it is now a pass-through to the Postgres store.

IngestAuditEventStore (the old in-memory ring buffer) is dead code: it is registered nowhere and referenced only by two comments. Any doc claiming audit lives in a 10 000-event in-memory buffer is stale.

Gateway. ^/api/v1/audit(.*)http://timeline.stella-ops.local/api/v1/audit$1 (src/Router/StellaOps.Gateway.WebService/appsettings.json:146; mirrored in devops/compose/router-gateway-local.json:525-527).


8. Invariants

  1. Timeline is the audit system of record. Not EvidenceLocker, not per-service tables. A service may keep its own operational log, but the audit answer of record is timeline.unified_audit_events.
  2. The hash chain is per tenant and append-only. content_hash covers the sequence number and the tenant; GDPR erasure blanks PII columns but never actor_id or content_hash, so verify_unified_audit_chain still passes after an erasure.
  3. Tenant identity is claim-derived. The producer reads stellaops:tenant; the query surface reads RequireTenant(). A request-supplied tenant may only agree, never override.
  4. Emission never affects the audited response. No audit failure may change a status code, a body, or the latency budget of the audited endpoint.
  5. Audit ≠ eventing. Audit does not travel on Valkey streams and carries no event envelope; do not bridge them without a decision record.
  6. Only an authenticated caller may append. Network position is not an identity. Ingest requires a verified principal carrying audit:ingest (or timeline:write) — a bypass-network caller with no credential is refused even though the bypass already satisfied the scope policy (§7, §10).
  7. A query filter is honoured or refused, never ignored. An off-catalog ?modules= / ?actions= / ?severities= value is a 400. Silently discarding it would answer an operator’s scoped question with unscoped data (§12.1).

9. Reliability, and where events can be lost (read before you trust a gap)

Emission is best-effort. Concretely, an audit event is dropped, silently from the caller’s point of view, when:

There is no local spool, no retry, and no dead-letter. That is a deliberate latency trade-off, but it means “the audit log has no row for X” is not proof that X did not happen. Operators who need a hard guarantee should monitor the emitter’s Warning logs (§11) rather than infer from absence.


10. Security considerations


11. Observability


12. Known constraints (verified against src/, 2026-07-12)

12.1 The ?modules= filter is strict, and the two module lists are back in parity — (FIXED, W3-C)

History (what this used to say). There were two module lists that nobody kept in sync, and the query API silently ignored whatever it did not recognise. ParseCsvSet dropped unknown values, and an emptied filter collapsed to null — which the query layer reads as no module filter. So GET /api/v1/audit/events?modules=<off-catalog> did not return zero rows: it returned the unfiltered set of every module. Seven modules that live endpoints emit (concelier, findings, evidence, platform, graph, notifier, release-orchestrator) were missing from the catalog, so filtering on any of them quietly answered with the whole store. A filter that is silently ignored is a correctness trap for anyone reading an audit answer.

Now (SPRINT_20260712_008 / W3-C):

Two deliberate near-duplicates are not typos: notifiernotify, and release-orchestratorrelease (the ReleaseOrchestrator service emits both).

Catalog entries with no live emitter (jobengine, evidencelocker, issuerdirectory, packsregistry, zastava) are retained: they answer honestly with zero rows, and dropping them would turn a legacy-row query into a 400.

Still owed (one line, outside this task’s scope). EvidenceAuditEndpoints.cs:22 still redirects callers to /api/v1/audit/events?modules=evidencelocker, but EvidenceLocker emits module evidenceevidencelocker is the catalog-only legacy name from the pre-DEPRECATE-002 HTTP mapper. Post-fix, ?modules=evidence is now the correct query (it filters properly instead of returning everything); the stale pointer in that remark should be updated to match.

Rule for implementers: when you introduce a module, add it to both lists — and prefer an AuditModules constant over a literal.

12.2 The query path reads the whole store into memory

UnifiedAuditAggregationService.GetFilteredEventsAsync (:430-444) asks the provider for all events and filters in process (MatchesQuery, :446+). The provider is the composite, whose Postgres side is PostgresUnifiedAuditEventStore.GetAllAsync() — a system connection reading the newest 10 000 rows across all tenants (PostgresUnifiedAuditEventStore.cs:110-139). The tenant facet is then applied in memory (:530-533).

Correctness is preserved (the tenant filter is applied before anything is returned), but beyond ~10 000 platform-wide rows a tenant’s older events become invisible to the query API even though they remain in the table and in the hash chain. Chain verification and retention purge are unaffected — they run in SQL.

12.3 Dead code still in the tree

IngestAuditEventStore.cs (unregistered) and the retained legacy polling helpers in HttpUnifiedAuditEventProvider.cs:52-79 (GetEventsLegacyAsync, marked [Obsolete], plus the per-module mappers it calls). DEPRECATE-003 is meant to delete them along with the per-service LIST endpoints.


13. References