STORY-F-016: Event store table + immutability trigger + hash chain¶
Epic: Core Data Priority: Must Have Story Points: 3 Status: Not Started Assigned To: Unassigned Created: 2026-04-17 Sprint: 4
User Story¶
As an auditor (and any future IRAP assessor),
I want an append-only events.domain_events table with a DB-level trigger blocking UPDATE and DELETE, plus a hash chain making tampering detectable,
so that the architectural invariant #14 (event store immutability) is enforced at the storage engine and tamper-evidence (SE-21, IR-10) is genuine — not just documented.
Description¶
Background¶
ADR-005-F makes events-first cross-domain communication. Architecture §Data Architecture specifies the event store as a single append-only table with monthly partitioning, trigger-enforced immutability, and a hash chain per-org for tamper evidence. Phase 0 decision (architecture Part 5) locked in the hash chain from day one — backfilling hashes across millions of rows later would be painful.
This story delivers it. From this sprint onwards, every write in every domain emits an event alongside its domain row within the same Repo transaction, and every retroactive modification breaks the chain detectably.
Scope¶
In scope:
- Migration:
eventsschema,events.domain_eventsparent partitioned table, monthly partitions for current + next 12 months, indexes perdata.mdspec (org_id + domain + event_type + inserted_at, aggregate_id, correlation_id) - Immutability trigger
events.prevent_modification()(PL/pgSQL): BEFORE UPDATE OR DELETE ON events.domain_events FOR EACH ROW- Raises
P0001exception with message "Events are immutable. UPDATE and DELETE are not allowed." - Hash chain trigger
events.compute_hash()(PL/pgSQL): BEFORE INSERT ON events.domain_events FOR EACH ROW- Reads last hash for
NEW.org_id; setsNEW.prev_hash = last_hash,NEW.hash = sha256(prev_hash || id || org_id || domain || event_type || aggregate_id || payload || inserted_at) - Chain is per-org (not global) — means an org's events form their own chain; simpler retention
- Monthly partition auto-creation Oban cron: on 25th of each month, create partition for month+1
FinnestCore.EventStoreGenServer:append/1— insert event within caller's transaction; validates required fields (domain, event_type, org_id, payload)verify_chain/1— walks chain for given org_id; asserts integrity; returns{:ok, tip_hash}or{:error, {:break_at, event_id}}FinnestCore.Eventstruct — standardised event shape; used by all emitters:%Event{id, domain, event_type, aggregate_id, org_id, payload, metadata: %{correlation_id, causation_id, actor_id, agent_id, classification, schema_version}}- Ecto schema
FinnestCore.DomainEvent— read-only Ecto schema for querying events (never used forinsert— always raw SQL orEventStore.append/1) - Integration with existing stories: wire EventStore into Tenant plug (F-008), auth events (F-006 was stubbed), user creation (F-007)
- Hash chain verification script — runs monthly via Oban cron; reports integrity to ops via log + Sentry if break detected
- Architecture test
event_store_immutability_test.exs: insert event → attempt UPDATE → raises; attempt DELETE → raises - Architecture test
event_hash_chain_test.exs: insert 3 events; verify_chain returns ok; manually modify an event's payload (bypass trigger for test) → verify_chain returns error at that event
Out of scope:
- Event subscribers / PubSub broadcast (Scout+Verify sprints — first subscribers land with real domain events)
- Event replay tool (defer to ops tooling sprint)
- Long-term partition archival to S3 Parquet (data.md DA-OI-03 — Phase 4)
- Event schema versioning / migration tooling (defer)
Technical Notes¶
- Partitioning by
RANGE (inserted_at)per month is standard Postgres pattern; enables fast retention (drop old partition) and query locality - Hash chain per-org means sorting + verification is O(N events per org), not O(N total) — scales
- Trigger implementation:
BEFOREnotAFTERso raise propagates to the transaction cleanly - Hash computation uses
digest()frompgcryptoextension — enableCREATE EXTENSION IF NOT EXISTS pgcryptoin migration - Event store queries in application code always use
Repo.all(from e in DomainEvent, where: e.org_id == ^org_id, ...)— relies on prepare_query (F-008) allowlist;DomainEventmust be on reference-data allowlist OR queries must explicitly scope by org_id EventStore.append/1must be called within aRepo.transaction/1— asserted via a transaction-context check; outside a txn raises- For bootstrap: first event in an org's chain has
prev_hash = NULL; trigger handles this via COALESCE - Verification script: runs in a read-only transaction; does NOT acquire locks; can run concurrently with inserts
- Module namespace: modules live under
FinnestCore.*(flat — notFinnest.Core.*). Boundary library cannot classify the dotted form under the existingFinnestCoreboundary block; F-003/F-006/F-007/F-012..F-015 all ship with the flat namespace. Same rationale as Sprint 3 D4 resolution.
Dependencies¶
- Blocked by: STORY-F-003 (Repo + supervisor), STORY-F-008 (tenant enforcement)
- Impacts: F-006 auth events, F-007 user/org/office CRUD events — these get wired to emit via EventStore after F-016
Acceptance Criteria¶
- Migration creates
eventsschema +events.domain_events+ monthly partitions + indexes -
pgcryptoextension enabled - Immutability trigger active:
UPDATE events.domain_events SET ...raises clear error - Immutability trigger active:
DELETE FROM events.domain_events WHERE ...raises clear error - Hash chain trigger active: INSERT populates
prev_hash+hashcolumns automatically - Multiple inserts for same org chain correctly (event N's
prev_hash= event N-1'shash) - Different orgs have independent chains (insert events for org A and org B interleaved; verify each chain independently intact)
-
FinnestCore.EventStore.append/1called outside a transaction raises -
FinnestCore.EventStore.verify_chain/1for a clean org returns{:ok, tip_hash} -
FinnestCore.EventStore.verify_chain/1after a deliberate corruption (test-only bypass of trigger) returns{:error, {:break_at, event_id}} - Auth events (from F-006) wired to EventStore; user registration emits
user_registeredevent visible inevents.domain_events - Org + User CRUD events (from F-007) wired:
organisation_created,user_createdevents emitted - Monthly partition auto-creation Oban cron job registered (runs on 25th of month)
- Hash chain verification Oban cron registered (runs monthly; logs result)
- Architecture tests pass (immutability + hash chain)
-
mix format,credo --strict,dialyzer,mix boundaryall green
Testing Requirements¶
- Unit: EventStore.append happy path + validation error paths
- Integration: insert → query → manually trigger UPDATE → raises
- Chain integrity: insert 100 events across 3 orgs; verify_chain for each org returns ok
- Corruption test: deliberate hash byte flip → verify_chain detects and reports which event
- Performance: 1000 event inserts in a single transaction in <100ms (triggers are fast)
References¶
../architecture/architecture.md§Architectural Invariant #14, NFR-010../architecture/data.md§Event Store (full SQL spec)../adrs/adr-005-F-event-driven-cross-domain-communication.md../10-GUARDRAILS.mdAR-17, DA-14, DA-16, SE-21, IR-10