The Factos Core Model
factos is the store-independent part of Factos. It defines the types and pure
computations shared by applications, simulations, and storage backends.
Read Start here first if commands, events, decision functions, decision contexts, or Dynamic Consistency Boundaries are new to you.
Model
A Model combines three pure decision values with the event codec:
initialis the empty temporary state;evolvefolds one historical event into that state;tryreceives the folded state and command, then returns new events or a domain error.
pub const incremented = factos.EventType("Incremented")
type Command {
Increment
}
type Event {
Incremented
}
fn try(
state: Int,
command: Command,
) -> Result(List(Event), Nil) {
case state, command {
value, Increment -> Ok([Incremented])
}
}
fn evolve(state: Int, event: Event) -> Int {
case state, event {
value, Incremented -> value
}
}
fn encode(event: Event) -> factos.Event(json.Json) {
case event {
Incremented -> factos.event(type_: incremented, version: 1, data: json.null())
}
}
fn decode(type_: factos.EventType, version: Int) {
case version {
1 if type_ == incremented ->
Ok(decode.success(Incremented))
_ -> Error(Nil)
}
}
let model =
factos.model(
initial: 0,
try:,
evolve:,
encode:,
decode:,
)
One model uses one state type across its commands. The state should contain the smallest shape needed to evaluate those commands from their selected history.
The encoder returns Event(json.Json). The decoder receives the stored
EventType and schema version, then returns a dynamic decoder for that
historical schema. Missing schemas become InvalidSchema; payload decode
failures become DecodeError.
Decision contexts
Every dispatch names the facts that can change its answer:
NoContextreads no history and permits an unconditional append;AllEventsreads and protects the complete event log;Matching(items:)selects events by type and tags.
factos.Matching(items: [
factos.Item(
types: [factos.EventType("TicketSold")],
tags: [factos.Tag("event:gleamconf-2026")],
),
])
Matching items are OR-combined. Types inside one item are OR-combined; tags are AND-combined. An empty type list means any event type and an empty tag list adds no tag constraint.
A backend folds the matching records and tracks the highest selected global position. The resulting append condition is:
factos.FailIfEventsMatch(
decision_context: context,
after: position,
)
The backend must not append from the stale decision if a relevant fact appeared after that position.
Events and recorded events
The application proposes an event envelope with a stable type and explicit schema version:
factos.event(
type_: factos.EventType("TicketSold"),
version: 1,
data: json.object([#("buyer", json.string(buyer))]),
)
|> factos.with_tags(tags: [
factos.Tag("event:" <> event_id),
])
|> factos.with_metadata(metadata:
factos.metadata([#("correlation_id", correlation_id)]),
)
Event(payload) contains the payload and descriptor. The descriptor carries:
- the store-visible event type;
- schema version;
- tags participating in context selection;
- application metadata that does not participate in matching.
Recorded(event) adds the application event id and globally ordered sequence
position assigned at commit. Factos has no core stream or per-stream revision
type.
Tags should expose only the stable domain values required for selection. Consider normalization and sensitivity before placing raw values in an indexed tag.
Dispatch results and failures
A successful backend dispatch returns Dispatch(position:, events:). The event
list preserves append order; position is the final global position, or
NoPosition when the decision produced no events.
The shared error model distinguishes:
DomainErrorwhen the model rejects the command;SubscriptionErrorwhen transactional application work fails;StoreErrorfor backend failures;AppendConditionFailedwhen the observed context changed;DecodeErrorfor malformed historical payloads;InvalidSchemawhen no decoder supports the stored type and version.
Backends decide which transaction conflicts are retried and how a final conflict is represented. Read the chosen adapter’s documentation.
Simulation
factos/simulate runs deterministic domain and codec scenarios without a
database:
let simulation =
simulate.new(model, with: [
TicketSold(buyer: "renata"),
])
|> simulate.dispatch(
decision_context: sale_context(),
command: BuyTicket(buyer: "lucy"),
)
|> simulate.tap(fn(simulation) {
assert simulate.events(simulation)
== Ok([
TicketSold(buyer: "renata"),
TicketSold(buyer: "lucy"),
])
})
assert simulate.errors(simulation) == []
Initial events are encoded, recorded in simulated order, decoded, and folded through the model. This exercises domain decisions and codec compatibility.
Simulation does not prove backend isolation, append atomicity, or retry behavior. Use backend dispatch integration tests for those guarantees.
PostgreSQL and SQLite adapters also provide projection simulators. They retain event history in memory but execute real application subscriptions against a projection database transaction.
Subscriptions
factos.subscription defines application work applied to each committed event
inside an interactive backend transaction:
let projection =
factos.subscription(apply: fn(transaction, recorded) {
use _ <- result.try(projection.insert(transaction, recorded))
Ok(transaction)
})
For a planning backend, the transaction value can itself be an immutable plan; the same callback returns that plan with the event’s mutations added. The core subscription type supports both models without pretending their transaction semantics are identical.
Keep callbacks retry-safe. External IO belongs after commit; persist durable intent in the transaction when needed.
Responsibilities by package
| Package | Responsibility |
|---|---|
factos | Domain model, codecs, contexts, event types, subscriptions, simulation |
factos_pog | PostgreSQL reads, serializable dispatch, append, recovery pages |
factos_sqlight | SQLite reads, dispatch, append, recovery pages |
factos_cf | Cloudflare D1 reads and immutable transaction plans |
The application still owns bounded-context design, event evolution, connection lifecycle, projections, recovery policy, external effects, and deployment.