Event Sourcing
The Accounting API uses event sourcing as its persistence model. Every action produces an immutable event that is appended to the event store. The event store is the audit log — there is no separate audit table.
Why Event Sourcing
| Traditional audit table | Event sourcing |
|---|---|
| Separate from business data | Events ARE the data |
| Can drift from actual state | State is derived from events |
| Undo = delete or flag | Undo = compensating event |
| Compliance bolted on | Compliance built in |
| Limited replay capability | Full replay and projection |
For a Norwegian accounting system bound by bokforingsloven (5-year retention, no deletion), event sourcing is a natural fit. Events are immutable. State is derived. Nothing is ever lost.
Event Types
| Event | Description | Reversible |
|---|---|---|
TaskReceived |
A task arrived at the Accounting API from OpenClaw | No |
TaskClassified |
ClassifyEngine determined the task type | No |
ApiCallMade |
An API call to Tripletex/Fiken was initiated | No |
ApiCallSucceeded |
The API call returned a success response | No |
ApiCallFailed |
The API call returned an error | No |
TaskCompleted |
The full task finished (success or error) | No |
FactsUpdated |
User facts were updated in structured memory | No |
NotificationSent |
A message was sent to a channel (Slack, email, etc.) | No |
All events are append-only. No event is ever updated or deleted.
Event Schema
Every event follows this structure:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"sequence_number": 42,
"event_type": "ApiCallSucceeded",
"aggregate_id": "task:12345",
"timestamp": "2026-03-22T14:30:00.000Z",
"actor": "lars@firma.no",
"company": "invotek-as",
"provider": "tripletex",
"api_calls": [
{
"method": "POST",
"path": "/invoice",
"status": 201,
"duration_ms": 340
}
],
"input": {
"task": "Create invoice for Nordvik AS, 25000kr, Konsulentbistand"
},
"result": {
"invoice_id": 1234,
"voucher_number": "V-2026-0345"
},
"reversible": true,
"reverse_event": null
}
| Field | Type | Description |
|---|---|---|
id |
UUID | Unique event identifier |
sequence_number |
BIGINT | Monotonically increasing, gap-free |
event_type |
string | One of the event types above |
aggregate_id |
string | Groups events for a single task (e.g., task:12345) |
timestamp |
ISO 8601 | When the event occurred (UTC) |
actor |
string | Email of the user who triggered the action |
company |
string | Company identifier |
provider |
string | tripletex or fiken (null for non-provider events) |
api_calls |
array | HTTP calls made to the accounting provider |
input |
object | What was requested |
result |
object | What happened |
reversible |
boolean | Whether this action can be undone |
reverse_event |
UUID | Points to the compensating event if undone |
Projections
Events are the source of truth. Projections derive useful views from the event stream:
| Projection | Purpose | Built from |
|---|---|---|
| Current state | What is the latest status of each task | TaskCompleted events |
| Audit trail view | Chronological log per company | All events filtered by company |
| Analytics | Task counts, success rates, avg duration | TaskCompleted + ApiCallMade events |
| Compliance reports | Bokforingsloven adherence | All events, checked against rules |
| User activity | What each user has done | All events filtered by actor |
Projections are read models — rebuilt from events on demand or maintained incrementally. If a projection has a bug, fix the code and replay all events to rebuild it.
Undo = Compensating Event
The system never deletes events. To "undo" an action, a compensating event is created:
Original event:
TaskCompleted — Invoice #1234 created (V-2026-0345)
Debit 1500 (25,000 kr), Credit 3000 (25,000 kr)
Compensating event:
TaskCompleted — Invoice #1234 reversed (V-2026-0346)
Debit 3000 (25,000 kr), Credit 1500 (25,000 kr)
reverse_event: points to original event ID
Both events remain in the store permanently. The original event's reverse_event field is set to the compensating event's ID (this is the only "update" — it is logically an annotation, not a mutation).
Event Store Technology
Primary choice: PostgreSQL with an append-only events table.
CREATE TABLE events (
id UUID PRIMARY KEY,
sequence_number BIGSERIAL,
event_type VARCHAR(100) NOT NULL,
aggregate_id VARCHAR(100) NOT NULL,
actor_email VARCHAR(255) NOT NULL,
company_id VARCHAR(100) NOT NULL,
provider VARCHAR(20),
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Append-only: no UPDATE or DELETE
Alternative: EventStoreDB — a purpose-built event store with built-in projections and subscriptions. Better for high-throughput scenarios but adds operational complexity.
PostgreSQL is preferred for Phase 0-2 (simpler ops, existing infrastructure). EventStoreDB is an option for Phase 3+ if event volume demands it.
Indexes
CREATE INDEX idx_events_aggregate ON events (aggregate_id, sequence_number);
CREATE INDEX idx_events_company_time ON events (company_id, created_at);
CREATE INDEX idx_events_type ON events (event_type, created_at);
CREATE INDEX idx_events_actor ON events (actor_email, created_at);
Retention
Events are immutable and never deleted. Bokforingsloven requires 5 years minimum retention for accounting records (10 years for primary documentation). Since events are small (< 1 KB each) and append-only, storage cost is negligible even over decades.
Estimated volume: 1,000 tasks/month per company x 5 events/task = 5,000 events/month = ~5 MB/month/company.