EU Regulatory Audit Events v1

Version: eu-regulatory-audit-events.v1 (documentation contract id; no matching const exists in code — the sibling artifact-ledger API does carry a schemaVersion of eu-regulatory-artifact-ledger.v1, see RegulatoryArtifactLedgerResponse) Owner: EvidenceLocker Status: initial implementation Sprint: SPRINT_20260430_200

Part of the Stella Ops Contracts index. Audience: implementers and reviewers of the EvidenceLocker durable audit catalog for EU regulatory artifacts. Companion contract: eu-regulatory-artifact-ledger-v1.md(the read-side replay index that back-references these events).

Purpose

EvidenceLocker owns the durable audit catalog for EU regulatory artifacts. CRA, NIS2, DORA, TLPT, RoI, and standards-mapping producers record actions here instead of creating module-local audit tables. The contract is offline-first: events are canonical JSON, content hashed, tenant scoped, and replayable from the PostgreSQL ledger without fetching regulator endpoints.

Ground-truth types live in src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Core/Regulatory/RegulatoryAuditEvent.cs (RegulatoryAuditEvent, RegulatoryAuditEventNames, RegulatoryActorKinds, RegulatoryRegimes, RegulatoryArtifactTypes, RegulatoryPropertyClassification).

Event Names

Catalog constants are defined in RegulatoryAuditEventNames.

Event nameMeaning
evidence.eu.artifact.generatedA regulatory report, packet, capsule, or export was generated.
evidence.eu.artifact.exportedA deterministic export bundle was materialized.
evidence.eu.control.lifecycle_recordedA Policy-owned regulatory control lifecycle event was projected as audit evidence by hash/ref.
evidence.eu.control.snapshot_publishedA Policy-owned control-register snapshot was pinned for regulatory replay.
evidence.eu.artifact.approvedA customer/operator or Stella manufacturer approval was recorded.
evidence.eu.artifact.rejectedAn approval/review step rejected the artifact.
evidence.eu.retention.override_recordedA retention override or legal-hold change was recorded.
evidence.eu.schema_pin.changedA schema/taxonomy package pin changed.
evidence.eu.artifact.signedA signer key/fingerprint was bound to an artifact.
evidence.eu.artifact.verifiedOffline or runtime verification was attempted.
evidence.eu.artifact.handed_offA signed artifact was handed off for operator-controlled submission.
evidence.eu.submit.attemptedA tenant-enabled submit attempt occurred.
evidence.eu.public_metadata.publishedPublic CRA/product metadata or Router regulatory-feed route publication changed.

Enumerated Values

These closed enums are enforced both in code (the Regulatory* static classes) and by PostgreSQL CHECK constraints in migration 007_regulatory_audit_retention_ledger.sql.

regime and artifactType are normalized to lower case before persistence (RegulatoryValueNormalizer.NormalizeRequired); actorKind is also lowercased in the canonical JSON. artifactHash (and the derived event_hash) must match the regex ^sha256:[0-9a-f]{64}$.

Required Fields

The domain record is RegulatoryAuditEvent. Every event carries:

Conditional / optional fields:

Validation (constructor-time, in RegulatoryAuditEventCanonicalizer.Validate) throws ArgumentException when these invariants are violated.

Canonical JSON Shape And Ordering

RegulatoryAuditEventCanonicalizer.Canonicalize writes a single non-indented JSON object whose keys are emitted in ordinal-sorted order:

actorKind, actorRef, approverId, artifactHash, artifactType, eventId, eventName,
newValueHash, occurredAt, priorValueHash, properties, regime, retention,
schemaPinId, signerFingerprint, signerKeyId, taxonomyPinId, tenantId

Redaction And Canonicalization

Canonical event JSON is generated by RegulatoryAuditEventCanonicalizer in src/EvidenceLocker/StellaOps.EvidenceLocker/StellaOps.EvidenceLocker.Core/Regulatory/. Object keys are ordinal sorted, properties[] is sorted by name, timestamps are UTC, and the event hash is SHA-256 over UTF-8 canonical JSON.

Classified properties[] values are redacted before hashing/persistence (RegulatoryAuditEventCanonicalizer.Redact):

Two distinct guards protect actor/approver identity:

  1. Opaque-reference check (ValidateOpaqueReference): actorRef and approverId are rejected with ArgumentException only when they contain an @ character (i.e. an email-shaped value). This is a narrow check — it does not by itself block other human-name shapes.

  2. Write-time PII guard (CapsulePiiGuard.AssertNoPii, invoked over the full canonical JSON before the hash is computed): rejects the entire event with CapsulePiiViolationException if any string value — including a None- classified property value — matches a PII shape. Detected shapes (the ViolationKind): email, ipv4, ipv6, user-agent (Mozilla/AppleWebKit/ Chrome substrings), and long-name (a value > 64 chars under a name/displayName/fullName/username key). The rejected value itself is never surfaced — only its JSON path and the violation kind.

    IMPORTANT consequence: classifying a property as None does not exempt it from the PII guard. Raw IPs, emails, or user agents must be placed in Personal/Sensitive properties (so they are hashed or [redacted] before the guard runs) or kept out of the event entirely. See the regression test RegulatoryAuditEventTests.Canonicalize_RejectsUnclassifiedPiiProperties.

Actor and approver fields must therefore be opaque ids or service refs, not email addresses, IPs, user agents, or human names. Downstream modules that only have PII must place it in classified properties[] (and not as a None classification for PII-shaped values), never in actorRef or approverId.

Persistence

Events are stored in evidence_locker.regulatory_audit_events by embedded migration 007_regulatory_audit_retention_ledger.sql. The same migration also creates the companion evidence_locker.regulatory_retention_records and evidence_locker.regulatory_artifact_ledger tables, and adds nullable regulatory_retention_record_id / regulatory_artifact_ledger_id columns to evidence_locker.evidence_bundles and evidence_locker.decision_capsules.

Schema highlights for regulatory_audit_events:

Writes are idempotent: RegulatoryEvidenceRepository.RecordAuditEventAsync uses INSERT ... ON CONFLICT (audit_event_id, tenant_id) DO UPDATE and returns the stored created_at.

Row-level security: the table is ENABLE/FORCE ROW LEVEL SECURITY with policy regulatory_audit_events_isolation, whose predicate is tenant_id = evidence_locker_app.require_current_tenant(). That SQL function reads the session GUC app.current_tenant, which EvidenceLockerDataSource.OpenConnectionAsync sets via SELECT set_config('app.current_tenant', @tenant, false) per connection (and raises if it is unset). Startup convergence uses the existing EvidenceLocker migration hosted service (EvidenceLockerMigrationHostedService + EvidenceLockerMigrationRunner, with Db\Migrations\*.sql embedded as resources); no manual postgres-init step is authoritative.

Read API (Artifact Ledger)

Audit events themselves have no read HTTP endpoint; replay consumers query the companion artifact ledger, which back-references the audit event ids.

GET /api/v1/regulatory/artifact-ledger (RegulatoryArtifactLedgerEndpoints):

Replay Expectations

Replay consumers use (tenantId, regime, artifactType, artifactHash) and the event hash to reconstruct regulatory state. This tuple is the unique constraint uq_regulatory_artifact_lookup on regulatory_artifact_ledger (tenant_id, regime, artifact_type, content_hash) and the leading audit-event index. Events intentionally store content hashes, schema pins, signer ids, and opaque refs instead of mutable report state.