-
Notifications
You must be signed in to change notification settings - Fork 6
Event Driven Ledger
RoboLedger treats business events as the durable truth and the general ledger as something derived from them. A sale, a payment, an asset disposal — those happen in the world; the debits and credits that record them are a downstream interpretation. The Event Block is the single write surface that carries an event into the ledger, and handlers turn it into balanced journal entries.
- The Thesis: Events Explain, the Ledger Is Derived
- The Three-Level Ledger
- REA: Resources, Events, Agents
- The Event Block Envelope
- The Event Lifecycle
- Handlers: How Events Become GL Entries
- The REA Graph Projection
- Posting an Event to the Ledger
- Related Documentation
- Support
A traditional accounting system asks the caller to know which accounts to debit and which to credit. RoboLedger inverts that. The caller records what happened — an invoice was issued, a payment cleared, equipment was retired — as a single event. A handler then derives the journal entry. The ledger is the projection; the event is the source.
This has three consequences that shape the whole subsystem:
-
One write surface. There is no raw
POST /eventsendpoint and no direct journal-posting API. Adapters (QuickBooks, bank feeds), AI operators, and the UI all create the same Event Block. Every write goes through one validated, atomic path. -
Amounts are signed integer cents from the graph-owner's perspective: inflows positive, outflows negative.
$2,500.00is250000, never a float and never dollars. - Capture and posting are separable. An event can be captured for review without touching the ledger, then posted later — or it can fire its handler and post the GL in the same call. Same envelope, two modes.
The ledger has three layers, each with a distinct job. They replace the older two-level (transaction → line item) shape so that the business event and the accounting interpretation are first-class and separate.
Transaction what happened in the world (the business event)
│ TRANSACTION_HAS_ENTRY
▼
Entry the accounting interpretation — MUST balance (Σ debits = Σ credits)
│ ENTRY_HAS_LINE_ITEM
▼
LineItem one debit OR one credit against a chart-of-accounts element
| Level | ID prefix | Role | Key fields |
|---|---|---|---|
| Transaction | txn_ |
The business event as it occurred in the real world |
type, amount (cents), date, currency, source, status (pending / posted / void), triggered_by_event_id
|
| Entry | je_ |
The journal entry — an accounting interpretation that must balance |
transaction_id, type (standard / adjusting / closing / reversing), posting_date, status (draft / posted / reversed), provenance, triggered_by_event_id, memo
|
| LineItem | li_ |
A single debit or a single credit |
entry_id, element_id, debit_amount, credit_amount (cents; exactly one non-zero), line_order
|
The balance rule. Every Entry must satisfy Σ debits = Σ credits. An entry needs at least two line items, and each line item carries exactly one non-zero side. Unbalanced entries are rejected before anything is written.
The chart of accounts is the Element node — there is no separate Account table. A LineItem points at the element_id it debits or credits, and the same association machinery that drives XBRL taxonomy rollups maps those elements up to reporting concepts.
One Transaction can carry one Entry, or many. For pre-journalized sources such as QuickBooks, where the upstream system already produced a journal entry, an Entry is 1:1 with its Transaction. Sources that supply only the raw business event let a handler synthesize the Entry.
The event model rests on the REA accounting ontology (Resources, Events, Agents), introduced by William McCarthy in 1982 and later standardized as part of ISO 15944-4. REA is the intellectual grounding for the Event Block; you do not need to know the theory to use it, but the vocabulary it gives the envelope is exactly its vocabulary.
| REA concept | In RoboLedger | Meaning |
|---|---|---|
| Agent |
Agent node (agt_) |
A counterparty — customer, vendor, employee, anyone the reporting entity transacts with |
| Event |
Event node (evt_) / Event Block |
A business occurrence that exchanges value |
| Resource |
resource_type + optional resource_element_id
|
What is exchanged — goods, services, money, and so on |
The reporting self is the Entity; everyone else is an Agent. An Event involves an Agent and affects a Resource.
The action verb vocabulary. An event can name the economic action it performs via event_action, drawn from a fixed set of 19 verbs (string values aligned with Valueflows v1.0):
produce, raise, consume, lower, use, cite, work, deliverService,
pickup, dropoff, accept, transferCustody, transferAllRights,
transfer, move, modify, combine, separate, copy
event_action is optional — capture-first events frequently leave it null and let the handler infer the accounting effect from event_type.
The Event Block is the envelope every write carries. It is created via the CQRS command surface at POST /extensions/roboledger/{graph_id}/operations/create-event-block, and the response is an EventBlockEnvelope wrapped in a standard OperationEnvelope (ULID operationId, Idempotency-Key support). The full request/response schema is published in the live API reference — see the OpenAPI docs.
What the envelope carries:
| Group | Fields | Notes |
|---|---|---|
| Identity |
event_type (open string), event_category (enum, class-conditional), event_class (economic / support, default economic), event_action (nullable) |
event_type is free-form; the category/class pair is constrained |
| REA |
agent_id (nullable counterparty), resource_type (nullable), resource_element_id (nullable) |
The who and the what |
| Timing |
occurred_at (required), effective_at (nullable) |
occurred_at = when it happened; effective_at = accounting recognition date |
| Provenance |
source (required), external_id (nullable dedup key), external_url (nullable) |
The origin system and its identifier for the event |
| Value |
amount (nullable, signed cents), currency (default USD) |
Signed from the owner's perspective |
| Payload |
description, metadata (free dict), dimension_ids (list) |
metadata is handler-validated when posting |
| Duality / correction |
obligated_by_event_id, discharges_event_id, replaced_by_event_id, replaces_event_id (all nullable) |
Link an event to the obligation it discharges. The obligated_by / discharges pair is settable on create; the replaces pair is populated by the supersede flow, not supplied on a plain create |
| Control |
apply_handlers (bool, default False) |
Whether to fire a handler and post the GL in this call |
The envelope adds id, status, created_at, and created_by to the response.
Two enumerated dimensions worth memorizing. resource_type is one of goods, services, money, right, obligation, information, labor (or null). source is one of manual, system, schedule, quickbooks, xero, plaid.
event_category is gated by event_class at the database level. Economic events use sales, purchase, financing, payroll, treasury, adjustment, recognition, other; support events use control, approval, reconciliation, inquiry. A sales category on a support event is rejected by a CHECK constraint — the class and category must agree.
The three-phase validation pipeline. Every create runs schema validation (Pydantic types and enums), then category/class consistency (the DB CHECK), and — only when apply_handlers=true — handler validation of metadata and the resulting entries. A capture-only event clears the first two phases and is stored without ever touching handler logic.
Idempotency is (source, external_id). A partial unique index over events with a non-null external_id means re-posting the same external_id from the same source will not duplicate the event. Omit external_id and you give up that guarantee — supply it for anything an adapter might retry.
An event moves through a small, constrained state machine. There are seven statuses and three of them are terminal. Every non-terminal status can move to voided or superseded; the forward path runs toward fulfilled.
captured ──▶ committed ──▶ pending ──▶ fulfilled (terminal)
classified ─▶ committed | pending | fulfilled
every non-terminal status also ──▶ voided (terminal)
and ──▶ superseded (terminal)
| From | Allowed transitions |
|---|---|
captured |
committed, voided, superseded
|
classified |
committed, pending, fulfilled, voided, superseded
|
committed |
pending, fulfilled, voided, superseded
|
pending |
fulfilled, voided, superseded
|
fulfilled |
terminal — no transitions |
voided |
terminal — no transitions |
superseded |
terminal — no transitions |
Two creation modes determine the landing status:
-
Capture-only (
apply_handlers=false, the default): the event is recorded for review and lands atcaptured. No GL is written. This is the inbox path — adapters and the UI drop raw events here and let a human or an operator classify them later. -
Fire-handler (
apply_handlers=true): a handler resolves and posts the GL atomically in the same call. Where the event lands depends on the work it did — a journal whosemetadata.statusispostedreachesfulfilled, while adraftjournal lands atclassified(it still needs a period close to post).
There is no standalone void or supersede operation. Both transitions go through update-event-block with a transition_to field; moving to superseded additionally requires a superseded_by_id.
A handler is the piece that knows the accounting. It receives the event and produces one or more balanced entries. RoboLedger resolves handlers from a hybrid registry with two layers:
- Python handlers for complex, multi-step workflows. A fixed set of seven hub-defined handlers covers the core economic events (recording a journal entry, reversing one, receiving a payment, paying a bill, disposing of an asset, and the two schedule handlers). Python handlers win when both layers could match.
-
DSL handlers in a database-backed
event_handlerstable. Each row is a declarative template that maps an event to debit/credit entries — simple enough that a tenant can configure new GL patterns without code. This is the fallback layer.
The DSL template shape. A handler's transaction_template is a JSON document describing the entries to build:
{
"transactions": [
{
"entry_template": {
"debit": { "element_id": "elem_x", "amount": "{{ event.amount }}" },
"credit": { "element_id": "elem_y", "amount": "{{ event.amount }}" }
}
}
]
}Templates interpolate values from the event and the handler:
| Token | Resolves to |
|---|---|
{{ event.amount }} |
The event's signed-cents amount |
{{ event.metadata.foo }} |
A field from the event's metadata dict |
{{ handler.metadata.bar }} |
A field from the handler's own metadata |
A trailing / N suffix performs integer division (for example, splitting an amount across entries). The engine enforces that every entry it builds is balanced (debit == credit) and that all amounts are non-negative.
Resolution can fail, and that is an error — not a silent capture. Posting an event whose event_type has no registered handler (with apply_handlers=true) returns a handler-not-found error rather than quietly storing the event.
You can register a DSL handler through the command surface at POST /extensions/roboledger/{graph_id}/operations/create-event-handler.
Events and the entries they produce live first in the extensions OLTP database. They are then materialized into the graph, where the audit chain becomes queryable in Cypher. The split is deliberate: the graph holds durable business truth, the OLTP store holds operational state.
Agent and Event are promoted into the graph's base schema — they are not RoboLedger-specific, because the REA model is general. The base layer also carries the duality and correction edges:
| Base node / edge | Meaning |
|---|---|
Agent, Event
|
The REA party and occurrence |
ENTITY_HAS_AGENT, ENTITY_HAS_EVENT
|
An entity owns its counterparties and events |
EVENT_INVOLVES_AGENT |
An event names its counterparty |
EVENT_AFFECTS_RESOURCE |
An event touches a resource |
EVENT_OBLIGATED_BY_EVENT, EVENT_DISCHARGES_EVENT
|
Obligation duality between events |
EVENT_REPLACES_EVENT |
Correction lineage |
The RoboLedger extension adds the bridge edges that connect an event to the ledger it produced:
| Extension edge | Meaning |
|---|---|
EVENT_TRIGGERS_TRANSACTION |
The McCarthy bridge — links an Event to the Transaction it caused |
TRANSACTION_HAS_ENTRY |
A transaction's journal entries |
ENTRY_HAS_LINE_ITEM |
An entry's debits and credits |
LINE_ITEM_RELATES_TO_ELEMENT |
A line item's chart-of-accounts element |
EVENT_TRIGGERS_TRANSACTION is materialized from each transaction's triggered_by_event_id, so the full event → transaction → entry → line-item trail is walkable in one query. This edge only exists after materialization — until then the relationship lives in the OLTP column.
This is the contribution path: how you actually put an event into a graph and watch it become GL. All examples target a local stack at http://localhost:8000, authenticate with X-API-Key, and use the RoboLedger graph from .local/config.json.
Prerequisites:
- A running local stack with
ROBOLEDGER_ENABLED=true. - A RoboLedger graph and an API key. Run
just demo-roboledgerto provision one with synthetic data (it writes thegraph_idandapi_keyinto.local/config.json), orjust demo-roboledger --skeletonfor an empty graph plusjust demo-userfor credentials. - Set
GRAPH_IDto your graph and read the key from config:
API_KEY=$(jq -r .api_key .local/config.json)
GRAPH_ID=<your roboledger graph id>The elem_* element IDs and agt_* agent ID below are illustrative placeholders. A live graph resolves real ULIDs from its own chart of accounts; list them first with a GraphQL read of your accounts before posting against real elements.
A plain create defaults to apply_handlers=false, so the event lands in the inbox at status captured and writes no GL. This is how an adapter records a raw business event for later classification.
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/create-event-block" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "invoice_issued",
"event_category": "sales",
"event_class": "economic",
"agent_id": "agt_customer_acme",
"resource_type": "services",
"occurred_at": "2026-05-01T14:30:00Z",
"source": "manual",
"external_id": "demo-inv-0421",
"amount": 250000,
"currency": "USD",
"description": "April consulting retainer — Acme Corp",
"metadata": {"invoice_number": "INV-2026-0421", "terms": "net_30"}
}'The response is an EventBlockEnvelope with a fresh evt_-prefixed id and status: "captured".
To post in a single call, set apply_handlers: true. This fires the journal_entry_recorded Python handler, which reads the flat single-entry metadata shape (posting_date, memo, status, type, and a balanced line_items array) and posts the GL atomically. Amounts are in cents, and the entry must balance.
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/create-event-block" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "journal_entry_recorded",
"event_category": "adjustment",
"event_class": "economic",
"occurred_at": "2026-05-31T00:00:00Z",
"source": "manual",
"amount": 12500,
"currency": "USD",
"description": "Office supplies accrual",
"apply_handlers": true,
"metadata": {
"posting_date": "2026-05-31",
"memo": "May office supplies accrual",
"status": "draft",
"type": "adjusting",
"line_items": [
{"element_id": "elem_coa_office_supplies", "debit_amount": 12500},
{"element_id": "elem_coa_accounts_payable", "credit_amount": 12500}
]
}
}'With "status": "draft" the event lands classified — the entry is created but still needs a period close to post. Use "status": "posted" and the event reaches fulfilled.
preview-event-block takes the same body as a create but writes nothing. It returns the planned debit/credit lines and a would_succeed flag — a safe dry run for an operator or UI before it commits.
curl -X POST "http://localhost:8000/extensions/roboledger/$GRAPH_ID/operations/preview-event-block" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{
"event_type": "journal_entry_recorded",
"event_category": "adjustment",
"occurred_at": "2026-05-31T00:00:00Z",
"source": "manual",
"amount": 12500,
"metadata": {
"posting_date": "2026-05-31", "memo": "preview", "status": "draft",
"line_items": [
{"element_id": "elem_coa_office_supplies", "debit_amount": 12500},
{"element_id": "elem_coa_accounts_payable", "credit_amount": 12500}
]
}
}'Reads go through the typed GraphQL surface at POST /extensions/{graph_id}/graphql. The graph_id is the URL — never a query argument. The eventBlocks field filters by eventType, eventCategory, status, agentId, source, plus limit / offset; eventBlock(id) fetches a single event.
curl -X POST "http://localhost:8000/extensions/$GRAPH_ID/graphql" \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"query": "{ eventBlocks(status: \"captured\", source: \"manual\", limit: 10) { id eventType eventCategory status amount currency occurredAt agentId } }"}'In dev the same endpoint renders GraphiQL with introspection, so you can explore the full event schema interactively.
After the events are materialized, the McCarthy bridge is queryable. This walks an event all the way to its line items, proving the event → GL trail:
MATCH (e:Event)-[:EVENT_TRIGGERS_TRANSACTION]->(t:Transaction)
-[:TRANSACTION_HAS_ENTRY]->(en:Entry)
-[:ENTRY_HAS_LINE_ITEM]->(li:LineItem)
RETURN e.event_type, t.amount, en.status, li.debit_amount, li.credit_amount
LIMIT 10Run it directly against LadybugDB:
just lbug-query $GRAPH_ID "MATCH (e:Event)-[:EVENT_TRIGGERS_TRANSACTION]->(t:Transaction)-[:TRANSACTION_HAS_ENTRY]->(en:Entry)-[:ENTRY_HAS_LINE_ITEM]->(li:LineItem) RETURN e.event_type, t.amount, en.status, li.debit_amount, li.credit_amount LIMIT 10"If the query returns nothing, the events have not been materialized yet — EVENT_TRIGGERS_TRANSACTION is built from transactions.triggered_by_event_id during materialization, not at write time.
The same paths are available to AI clients. Write tools create-event-block and update-event-block are auto-generated from the operation specs, and read tools get-event-block and list-event-blocks mirror the GraphQL reads. An operator captures, previews, and posts events through these tools exactly as the curl examples above do.
| Pitfall | Fix |
|---|---|
| Plain create wrote no GL |
apply_handlers defaults to false. Pass apply_handlers: true to post in one call. |
event_category rejected |
The category must match event_class. Economic categories need economic; support categories need support. |
| Amount looks 100× too small/large |
amount is signed integer cents, not dollars and not a float. |
| Handler-not-found error | An unrecognized event_type with apply_handlers=true is an error, not a silent capture. |
| Unbalanced-entry error | A journal needs at least two line items, each with exactly one non-zero side, and Σ debits = Σ credits. |
| Duplicate events from retries | Supply external_id; idempotency keys on (source, external_id). Omit it and you lose the guarantee. |
GraphQL error about graphId
|
GraphQL takes no graphId argument; scope is the URL /extensions/{graph_id}/graphql. |
Can't find a void operation |
There isn't one. Use update-event-block with transition_to; superseded also needs superseded_by_id. |
| Cypher trace returns nothing | The graph edge only exists after materialization. |
Wiki Guides:
- Information Blocks - The Event Block sits at the bottom of the block stack; events produce the facts that populate Information Blocks
-
RoboLedger Operations - The command-write surface (
create-event-block,close-period, and the rest) and the operations kernel these writes flow through - Architecture Overview - How the extensions surface, GraphQL reads, and CQRS command writes fit the platform
Codebase Documentation:
- API Reference - Live OpenAPI spec for every operation, request model, and response envelope
- Operations - Business workflow orchestration and the reads/commands kernel
- GraphQL Extensions - The typed read surface and resolver patterns
- Extensions Models - RoboLedger OLTP SQLAlchemy models with schema-per-graph tenancy
© 2026 RFS LLC
- Authentication & API Keys
- Graphs & Multi-Tenancy
- Shared Repositories
- Graph Operations
- Querying the Analytical Graph
- Credits & Billing
- AI Operators & MCP
- Pipeline Guide
- Extensions Surface Overview
- GraphQL Reads
- RoboLedger Operations
- RoboInvestor Operations
- Connecting QuickBooks Locally