Skip to content

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.

Event Flow Diagram

Event Sourcing Flow