Unified Audit Architecture
Technical architecture specification for the platform-wide audit surface: the shared
StellaOps.Audit.Emissionlibrary (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:
- A service tags an endpoint with
AuditActionAttributemetadata and theAuditActionFilterendpoint filter (src/__Libraries/StellaOps.Audit.Emission/). - After the endpoint returns, the filter builds an
AuditEventPayloadand hands it toIAuditEventEmitter. HttpAuditEventEmitterPOSTs it, fire-and-forget, to TimelinePOST /api/v1/audit/ingest, authenticated with a signed service identity envelope carrying theaudit:ingestscope (HttpAuditEventEmitter.AttachIdentityEnvelope).- Timeline verifies the envelope, checks the caller really is authenticated and scoped (
UnifiedAuditIngestGuard), and persists the event intotimeline.unified_audit_eventswith 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
- 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:HttpAuditEventEmitterswallows the expected transport failures — timeout,HttpRequestException,JsonException, non-2xx (HttpAuditEventEmitter.cs:70-93) — and the filter’s outercatch (Exception)swallows everything else, including a malformedTimelineBaseUrl(AuditActionFilter.cs:205-215). Nothing escapes into the request. Consequence: emission is best-effort, not at-least-once — see §9. - No compile-time coupling to Timeline.
AuditEventPayloadlives in the emission library and is wire-compatible with Timeline’sUnifiedAuditIngestRequest(UnifiedAuditEndpoints.cs:821-836), so no service references Timeline to be audited. - Tenancy comes from the claim. The filter reads the actor’s tenant from the
stellaops:tenantclaim (falling back to a baretenantclaim type), never from a caller-supplied header, query, or body value (AuditActionFilter.cs:235). - PII is redacted at the producer. The captured request body runs through
AuditPiiRedactorbefore it ever leaves the emitting process (AuditActionFilter.cs:146-149). Note the scope limit in §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, IAuditEventEmitter → HttpAuditEventEmitter (singleton), and ITimelineAuditQueryClient → HttpTimelineAuditQueryClient (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.
| Key | Default | Notes |
|---|---|---|
AuditEmission:TimelineBaseUrl | http://timeline.stella-ops.local | Also settable via STELLAOPS_TIMELINE_URL. Internal hostname — no public URL default (offline-first). |
AuditEmission:Enabled | true | false ⇒ the emitter returns immediately and the event is dropped silently (HttpAuditEventEmitter.cs:49-53). |
AuditEmission:TimeoutSeconds | 3 | HTTP timeout for the ingest POST. |
AuditEmission:MaxBodySizeBytes | 65536 | Bodies 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:IdentityEnvelopeSigningKey | falls back to Router:IdentityEnvelopeSigningKey, then STELLAOPS_IDENTITY_ENVELOPE_SIGNING_KEY | HMAC 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:ServiceIdentity | entry assembly name | Envelope Subject — which service claims to have produced the event. |
AuditEmission:IdentityEnvelopeLifetimeSeconds | 120 | Envelope 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):
| Field | Source | Notes |
|---|---|---|
module / action | AuditActionAttribute | lower-cased (:331-332) |
actor.id / .name / .email | sub / name / email claims (:232-234) | id falls back to "system"; name falls back to id |
actor.type | user | service | system (:666-681) | service = a client_id with no sub (client credentials); system = unauthenticated |
tenantId | stellaops:tenant claim, else tenant (:235) | never a header/query/body value |
resource.type | attribute override, else inferred from the path (:571-594) | first non-api/non-version segment |
resource.id | route values, else first GUID route value, else the response body’s id (:519-569, :407-460) | "unknown" if nothing resolves |
severity | HTTP status: 2xx=info, 4xx=warning, 5xx=error (:596-605) | |
correlationId | HttpContext.Items["correlation_id"] → X-StellaOps-Correlation-Id → X-Correlation-Id → TraceIdentifier (: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.requestBody | POST/PUT/PATCH JSON body, PII-redacted, size-capped (:98-161) | non-JSON content types are skipped |
details.beforeState | IAuditBeforeStateProvider for the module, 2 s budget (:163-189) | optional; not redacted — see below |
details.onBehalfOf | verified 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):
- The
Timeline.AuditIngestpolicy — satisfied by eitheraudit:ingest(the narrow machine scope the emitter presents) ortimeline:write(retained for operator tooling) (TimelinePolicies.AuditIngest→Program.csAddStellaOpsAnyScopePolicy). 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):
| Object | Purpose |
|---|---|
timeline.unified_audit_events | the audit rows + content_hash / previous_entry_hash / sequence_number / data_classification (:87, classification column added at :312) |
timeline.unified_audit_sequences | per-tenant chain head (:167) |
timeline.next_unified_audit_sequence / update_unified_audit_sequence_hash / verify_unified_audit_chain | chain functions (:176, :203, :217) |
timeline.audit_retention_policies + resolve_audit_retention_days / purge_expired_audit_events | per-classification retention (:326, :348, :375) |
timeline.redact_actor_pii | GDPR 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.Admin → timeline: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:
| Service | Endpoint | Wiring | Deprecation headers |
|---|---|---|---|
| Authority | /console/admin/audit | Console/Admin/ConsoleAdminEndpointExtensions.cs:254 | yes |
| Policy (Gateway) | /api/v1/governance/audit/events | Policy.Gateway/Endpoints/GovernanceEndpoints.cs:116,125 | yes |
| Policy (Engine) | /api/v1/governance/audit/* | Policy.Engine/Endpoints/Gateway/GovernanceEndpoints.cs:335,381 | no — proxies, but never stamps |
| Notify | /api/v1/notify/audit | Notify.WebService/Program.cs:1773 | yes |
| ReleaseOrchestrator | /api/v1/release-orchestrator/audit | Endpoints/AuditEndpoints.cs:38 | yes |
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
- 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. - The hash chain is per tenant and append-only.
content_hashcovers the sequence number and the tenant; GDPR erasure blanks PII columns but neveractor_idorcontent_hash, soverify_unified_audit_chainstill passes after an erasure. - Tenant identity is claim-derived. The producer reads
stellaops:tenant; the query surface readsRequireTenant(). A request-supplied tenant may only agree, never override. - Emission never affects the audited response. No audit failure may change a status code, a body, or the latency budget of the audited endpoint.
- Audit ≠ eventing. Audit does not travel on Valkey streams and carries no event envelope; do not bridge them without a decision record.
- Only an authenticated caller may append. Network position is not an identity. Ingest requires a verified principal carrying
audit:ingest(ortimeline:write) — a bypass-network caller with no credential is refused even though the bypass already satisfied the scope policy (§7, §10). - A query filter is honoured or refused, never ignored. An off-catalog
?modules=/?actions=/?severities=value is a400. 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:
AuditEmission:Enabled=false— the emitter returns immediately (HttpAuditEventEmitter.cs:49-53).- Timeline is unreachable, times out (default 3 s), or answers non-2xx — logged at Warning, not retried (
HttpAuditEventEmitter.cs:70-93). - The emitting process dies between the endpoint’s response and the detached emit task (
AuditActionFilter.cs:93— the task is not awaited and not tracked on shutdown). NullAuditEventEmitteris registered (test factories, offline benches).
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
- The ingest hop is authenticated by a signed service identity envelope (SPRINT_20260712_008 / W3-C). Every POST carries
X-StellaOps-Identity-Envelope+-Signature, minted per event byHttpAuditEventEmittervia the canonicalGatewayIdentityEnvelopeCodec.Signand verified by Timeline’sUseIdentityEnvelopeAuthenticationmiddleware, which hydrates a real principal whose scope isaudit:ingestand whose tenant is the event’s tenant. TheOnMessageReceivedenvelope bridge (Auth.ServerIntegration/ServiceCollectionExtensions.cs) then accepts that principal as aStellaOpsBeareridentity, so the scope policy sees a genuine caller. No new secret is distributed: the key is the one every service already holds to verify the gateway’s envelope.- The emitter deliberately sends no
Authorizationheader — its presence would disable the bypass path for unrelated callers, and the envelope is the platform’s canonical service-to-service identity primitive. - What this closes. Previously the emitter sent no credential at all and ingest succeeded only because
StellaOpsBypassEvaluator.ShouldBypassforce-satisfies every scope requirement for an in-CIDR caller with noAuthorizationheader (timeline-web lists172.16.0.0/12— the whole compose network). Anything that could reach timeline-web could forge an audit event, tenant included, into the tamper-evident compliance record. Both halves are now fixed: the producer authenticates, and the endpoint refuses any unauthenticated caller (UnifiedAuditIngestGuard) even when the bypass has already satisfied the policy. An attacker on the compose network without the signing key gets 401. - Residual (honest): the signing key is symmetric and shared, so any service holding it can mint an audit identity for any tenant. This is not a new exposure — every service already holds the key in order to verify gateway envelopes, so a compromised service could always forge one — but it does mean audit authorship is trusted at the stack boundary, not per-service. A per-service asymmetric identity is the next step, not something this change delivers. Keep the ingest port off untrusted networks and keep the bypass CIDRs tight regardless.
- The emitter deliberately sends no
- PII redaction is producer-side, pattern-based, and body-only. Defaults:
password,secret,token,apikey,connectionstring,credential,privatekey,signingkey(AuditPiiRedactor.cs:19-29); matching normalizes separators and isContains-based, soapi_key,apiKey, andapi-keyall matchapikey. Per-endpoint extras go inAuditActionAttribute.SensitiveFields(additive). A deployment-wideAuditEmission:RedactedFieldPatternsreplaces the defaults entirely (AuditPiiRedactor.cs:77) — an easy way to accidentally un-redactpassword.beforeStateandresource.nameare never redacted (§5). - Right to erasure is
DELETE /api/v1/audit/actors/{actorId}/pii,timeline:admin, tenant-scoped from the claim.
11. Observability
- Producer:
HttpAuditEventEmitterlogs Debug on success, Warning on timeout / non-2xx / transport failure;AuditActionFilterlogs Warning if payload construction throws. A rising rate of “Audit emission failed/timed out” is the only signal that audit rows are being lost. - Sink:
PostgresUnifiedAuditEventStorelogs Debug per persisted event (with sequence + hash prefix) and Error on persistence failure; ingest returns 503 in that case. - Integrity:
GET /api/v1/audit/chain/verifyrunstimeline.verify_unified_audit_chainand reportsverified/brokenAt/brokenEventId. - Retention:
AuditRetentionPurgeService(hosted,Program.cs:118-122, sectionAuditRetentionPurge:Enabled,DryRun,InitialDelay,Interval) purges per classification.
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.
ParseCsvSetdropped unknown values, and an emptied filter collapsed tonull— which the query layer reads as no module filter. SoGET /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):
- Unknown facet values are rejected, never dropped.
TryParseCsvSet(UnifiedAuditEndpoints.cs) returns400witherror_code: unknown_modules_filter, the offending values, and the accepted set. The same strictness applies to?actions=and?severities=(both catalog-backed and subject to the identical defect).?tags=is free-form and stays permissive. - The catalog now covers what is actually emitted. The seven names above were added to
UnifiedAuditCatalog.Modulesas emitted, not by renaming the producers: rows already persisted under those strings are immutable audit history, and renaming the emitter would orphan them behind a filter that no longer matches.AuditModulesgained the matching constants (Concelier,Graph,Notifier,ReleaseOrchestrator) so new call sites stop reaching for literals. - The drift cannot silently return.
UnifiedAuditModuleFilterTests.EveryAuditModulesConstant_IsQueryableasserts everyAuditModulesconstant is present inUnifiedAuditCatalog.Modules. Adding one without the other fails the suite.
Two deliberate near-duplicates are not typos: notifier ≠ notify, and release-orchestrator ≠ release (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 evidence— evidencelocker 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
- Audit Event Emission Guide — how to wire a service.
- Timeline architecture — the sink, in depth.
- Timeline audit retention & right-to-erasure — classification ladder.
- Timeline audit emitter coverage — per-service adoption matrix.
- Module index — cross-cutting concepts table.
