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 name | Meaning |
|---|---|
evidence.eu.artifact.generated | A regulatory report, packet, capsule, or export was generated. |
evidence.eu.artifact.exported | A deterministic export bundle was materialized. |
evidence.eu.control.lifecycle_recorded | A Policy-owned regulatory control lifecycle event was projected as audit evidence by hash/ref. |
evidence.eu.control.snapshot_published | A Policy-owned control-register snapshot was pinned for regulatory replay. |
evidence.eu.artifact.approved | A customer/operator or Stella manufacturer approval was recorded. |
evidence.eu.artifact.rejected | An approval/review step rejected the artifact. |
evidence.eu.retention.override_recorded | A retention override or legal-hold change was recorded. |
evidence.eu.schema_pin.changed | A schema/taxonomy package pin changed. |
evidence.eu.artifact.signed | A signer key/fingerprint was bound to an artifact. |
evidence.eu.artifact.verified | Offline or runtime verification was attempted. |
evidence.eu.artifact.handed_off | A signed artifact was handed off for operator-controlled submission. |
evidence.eu.submit.attempted | A tenant-enabled submit attempt occurred. |
evidence.eu.public_metadata.published | Public 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.
actorKind(RegulatoryActorKinds, DBCHECK):stella-manufacturer,customer-operator.regime(RegulatoryRegimes, DBCHECK):cra,nis2,dora,standards. (NOTE: TLPT and RoI are not regimes — they are carried as artifact typestlpt-evidence-packanddora-roi-snapshotunder thedoraregime; standards-mapping exports use thestandardsregime.)artifactType(RegulatoryArtifactTypes, free text in DB but validated against this list by producers):cra-advisory-report,cra-technical-file,cra-conformity-dossier,nis2-incident-packet,nis2-control-register-control,nis2-control-register-snapshot,nis2-statement-of-applicability,dora-incident-report,dora-roi-snapshot,tlpt-evidence-pack,standards-mapping-export,public-product-metadata.RegulatoryPropertyClassificationenum:None = 0,Personal = 1,Sensitive = 2.
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:
eventId: UUID; must be non-empty. Together withtenantIdit forms the composite primary key (audit_event_id,tenant_id).tenantId: UUID tenant id (typedTenantId).eventName: one of the catalog names above.actorKind:stella-manufacturerorcustomer-operator.actorRef: opaque actor/service reference.regime:cra,nis2,dora, orstandards.artifactType: see artifact enum above (also documented ineu-regulatory-artifact-ledger-v1).artifactHash:sha256:<64 lowercase hex>.schemaPinId: Stella schema or taxonomy pin id (required).occurredAt: event timestamp; must not be the default value. Serialized as UTCyyyy-MM-dd'T'HH:mm:ss.fffffff'Z'.
Conditional / optional fields:
taxonomyPinId: optional external/local taxonomy package id.signerKeyIdandsignerFingerprint: at least one is required forevidence.eu.artifact.signedevents.approverId: required forevidence.eu.artifact.approvedandevidence.eu.artifact.rejectedevents.priorValueHashandnewValueHash: both required forevidence.eu.retention.override_recordedandevidence.eu.schema_pin.changedevents.retention: optionalRegulatoryRetentionMetadatawithretentionPolicyId(required when present),retentionYears,retainUntil, optionallegalHoldUntil,overrideAuditEventId, andshorteningApprovalRef. Validated byRegulatoryRetentionRules.ValidateMetadata.properties[]: optionalRegulatoryClassifiedPropertyname/value pairs classified asNone,Personal, orSensitive.
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
- Optional string fields that are blank are written as JSON
null(not omitted). retentionis written asnullwhen absent; otherwise an object with keyslegalHoldUntil, overrideAuditEventId, retainUntil, retentionPolicyId, retentionYears, shorteningApprovalRef.propertiesis always an array (empty when none). Each item is{ "classification": ..., "name": ..., "value": ... }, where blank-named properties are dropped and the remaining items are sorted byname(ordinal).classificationis the enum nameNone/Personal/Sensitive.- The event hash is
sha256:<lowercase hex>of the SHA-256 over the UTF-8 canonical JSON (RegulatoryAuditEventRecord.EventHash).
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):
None: value is retained verbatim.Personal: value is replaced bysha256:<hash>(SHA-256 of the value).Sensitive: value is replaced by[redacted].
Two distinct guards protect actor/approver identity:
Opaque-reference check (
ValidateOpaqueReference):actorRefandapproverIdare rejected withArgumentExceptiononly 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.Write-time PII guard (
CapsulePiiGuard.AssertNoPii, invoked over the full canonical JSON before the hash is computed): rejects the entire event withCapsulePiiViolationExceptionif any string value — including aNone- classified property value — matches a PII shape. Detected shapes (theViolationKind):email,ipv4,ipv6,user-agent(Mozilla/AppleWebKit/ Chrome substrings), andlong-name(a value > 64 chars under aname/displayName/fullName/usernamekey). The rejected value itself is never surfaced — only its JSON path and the violation kind.IMPORTANT consequence: classifying a property as
Nonedoes not exempt it from the PII guard. Raw IPs, emails, or user agents must be placed inPersonal/Sensitiveproperties (so they are hashed or[redacted]before the guard runs) or kept out of the event entirely. See the regression testRegulatoryAuditEventTests.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:
- Composite primary key
(audit_event_id, tenant_id). The repository writeseventIdinto theaudit_event_idcolumn. CHECKconstraints enforce theactor_kind,regime,artifact_hash, andevent_hashenums/regexes described above.propertiesisjsonb NOT NULL DEFAULT '[]';retentionis nullablejsonb.- Both
canonical_jsonandevent_hashare persisted alongside the structured columns. - Indexes:
ix_regulatory_audit_events_tenant_regime_artifact(tenant_id, regime, artifact_type, artifact_hash)andix_regulatory_audit_events_tenant_event_time(tenant_id, event_name, occurred_at DESC).
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):
- Authorization:
RequireTenant()+ theEvidenceReadresource-server policy (scopeevidence:read,StellaOpsScopes.EvidenceRead). - Query parameters:
regime,artifactType,reportingYear,limit(default 100, clamped 1–500),cursor(base64offset:<n>), andrequireMatch(whentrue,regime+artifactType+reportingYearare mandatory and an empty result returns404 artifact_not_found). - Response
RegulatoryArtifactLedgerResponse:schemaVersion = "eu-regulatory-artifact-ledger.v1",sourceOfTruth = "EvidenceLocker",items[], and an opaquecursor. Each item exposesartifactId,regime,artifactType,reportingYear,artifactHash,evidenceRefs[],auditEventIds[](the non-null approval / generation / signing audit event ids),schemaPinId,taxonomyPinId,signatureRef,signerKeyId,retentionRecordId,createdAt.
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.
