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:

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:

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:

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:

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

PackageResponsibility
factosDomain model, codecs, contexts, event types, subscriptions, simulation
factos_pogPostgreSQL reads, serializable dispatch, append, recovery pages
factos_sqlightSQLite reads, dispatch, append, recovery pages
factos_cfCloudflare D1 reads and immutable transaction plans

The application still owns bounded-context design, event evolution, connection lifecycle, projections, recovery policy, external effects, and deployment.

Related guides

Search Document