This document explains what you are actually depending on when you add ex4pm_core and/or
ex4pm_contracts to your app's mix.exs, and why the library's API is shaped the way it is.
It is written for engineers deciding whether and how to depend on ex4pm, not for ex4pm's own
contributors — it does not cover umbrella layout, internal Mix tasks, or the qualification
suite. If you want task-oriented "how do I call X" recipes, look for a HOWTO doc instead; this
one is about the underlying model.
What ex4pm actually is
ex4pm is a BEAM-native Elixir library, not a network service. When your app depends on it,
Ex4pm.* modules run in your own BEAM VM, in your own process, using your own supervision
tree if you choose to start one (for example Ex4pm.stream/2, which starts a Broadway
pipeline under whatever supervisor you give it). There is no ex4pm daemon to stand up, no port
to open, and no client/server protocol between your app and ex4pm's core logic.
This matters for how you reason about it operationally:
- Calling
Ex4pm.discover/2is a function call, not an RPC. Latency, backpressure, and failure modes are the ones you already understand for in-process Elixir code — not network timeouts or serialization boundaries. - There is nothing to deploy or version-skew against at runtime. The version you get is
whatever
ex4pm_core/ex4pm_contractsversion yourmix.lockpins, resolved the same way as any other hex dependency. - Some operations do reach outside your VM —
Ex4pm.plan/2talks to the pinnedex4pm-plancloud planning worker, andEx4pm.cmca/2crosses a pinnedwasm4pmbridge. Those are the exceptions, not the rule, and they are explicit at the call site (see The DfCM engine-candidate model below) rather than hidden inside an otherwise-local API.
Concretely, the surface you depend on is Ex4pm (the top-level orchestration API in
lib/ex4pm.ex) plus supporting modules: Ex4pm.OCEL/Ex4pm.XES for ingestion,
Ex4pm.OCEL2 for object-centric views, Ex4pm.POWL for building workflow models,
Ex4pm.Evidence.* for the receipt ledger, Ex4pm.Engine.* for engine introspection, and
Ex4pm.Contracts for the canonical ontology/SHACL/WIT/JSON-Schema artifacts.
Ingesting your event data
Before running any analysis, your raw event data has to become a canonical
Ex4pm.EventLog. ex4pm gives you three ways in:
{:ok, log} = Ex4pm.ingest(raw_ocel_map) # OCEL-v2-tolerant map/list input
{:ok, log} = Ex4pm.ingest_xes(xml_binary) # IEEE XES XML
{:ok, log} = Ex4pm.OCEL.normalize(raw_ocel_map) # lower-level, same normalizationAll three converge on the same Ex4pm.EventLog IR — the shape every downstream Ex4pm.*
operation (discover, conform, simulate, optimize) expects. Ex4pm.OCEL2 then lets you
derive object-centric views (object_trace/2, attribute_history/3,
object_relationships_for/2) from an already-ingested log without ex4pm introducing a second,
competing representation for you to reconcile.
Malformed input does not raise — it returns {:error, %Ex4pm.Refusal{}}, a typed struct
(code, message, subject, details) you pattern-match on like any other tagged result.
SELECT / CONSTRUCT / DO: why operate/3 is different from everything else
Every other top-level Ex4pm function — discover/2, conform/3, simulate/2,
optimize/3, plan/2, cmca/2 — is analytical: it reads your data (and, for plan/
cmca, an admitted problem description) and returns a result. Nothing in your system of
record changes because you called it. ex4pm's internal vocabulary calls this SELECT/CONSTRUCT:
querying and deriving facts.
Ex4pm.operate/3 is different. It compiles a Ex4pm.POWL workflow model (or an already
compiled Ex4pm.Runtime.Plan) and executes it — this is the one call in the public API
that can produce a real, consequential state change (invoking a task callback that does
something in the world, not just computes a value). ex4pm's internal vocabulary calls this DO.
The practical consequence for you as a caller: operate/3 requires an explicit authority
argument, and the actual invocation happens under Ex4pm.Evidence.BRCE — the single boundary
in ex4pm that is permitted to run a state-changing callback. You do not call BRCE directly
in normal use (operate/3 does that for you), but understanding that it's there explains two
things you'll observe:
- Authority is not optional or ambient.
operate/3'sauthorityargument is checked against the operation before your workflow's callbacks run at all (Ex4pm.Evidence.BRCE.admit/2is the underlying check). If your authority doesn't cover the operation, you get a refusal back — not a partially-executed workflow. - You cannot accidentally trigger a DO from an analytical call.
discover/2,conform/3,simulate/2,optimize/3,plan/2, andcmca/2never take anauthorityargument and never reach BRCE, precisely because they are CONSTRUCT-only. If you need read-only insight plus no risk of side effects — even by mistake — those are the calls to use;operate/3is the one to call deliberately, and only when you mean to execute something.
For you, this mostly changes one thing: don't reach for operate/3 when you want an answer,
and expect operate/3 calls to need an authority map you construct deliberately, not one that
defaults to "allow everything."
Why results come back receipted instead of as bare values
Every analytical operation (discover, conform, simulate, optimize, plan, cmca)
returns {:ok, %Ex4pm.Run{}}, not a bare result:
%Ex4pm.Run{
operation: :discover,
subject_hash: "...",
standing: :alive,
value: %{...}, # the actual analytical result
receipt: %Ex4pm.Evidence.Receipt{...},
pending: %Ex4pm.Evidence.Receipt{...},
engine_result: %Ex4pm.Engine.Result{...},
projections: []
}value is your answer; the rest is the evidence trail. Before the engine ran, ex4pm persisted
a pending receipt recording the intent (subject hash, operation, engine). After it
finished, ex4pm persisted an outcome receipt recording the actual standing (:alive,
:blocked, etc.) and result. Both are stored in the receipt ledger
(Ex4pm.Evidence.Store), and the outcome receipt is what you get back as run.receipt.
Why this matters to you as a consumer, not just as internal bookkeeping:
- You can independently re-verify a result later, without re-running the analysis or
trusting the original call, via
Ex4pm.replay/2(or the lower-levelEx4pm.Evidence.Replay.verify/1): it recomputes the receipt's hash and confirms the standing still checks out, returning a typed error (:invalid_receipt/:replay_mismatch) if it doesn't. This is useful if you persist a receipt hash in your own system and want to prove, at audit time, that the recorded outcome hasn't been tampered with or silently invalidated. - You get a query trail, not just a point value.
Ex4pm.Evidence.Store.get_by_subject/1andget_by_parent/1let you look up every receipt tied to a subject, or trace a pending → outcome chain, which is useful if your app needs to show "what analyses have run against this dataset" rather than only "what's the latest answer." standingis a first-class part of the result, not something you infer from whether the call raised. A:blockedstanding on an otherwise-successful function return tells you the engine ran but the result shouldn't be trusted as fully conclusive — see the next section for why that happens.
If you don't need the audit trail, you can just pattern-match on {:ok, %Ex4pm.Run{value: value}} and use value — the receipt is always there, but nothing forces you to inspect it.
The DfCM engine-candidate model (:beam/:wasm/:nim/:plan/:remote)
Every analytical operation is actually executed by one of several candidate engines —
ex4pm calls this DfCM (discover-from-candidate-model). Candidates you may see selected
include :beam (native deterministic Elixir), a WASM engine running via Wasmtime, a
configured native NIF, the pinned :ex4pm_plan cloud worker, or a configured :remote
callback. Ex4pm.Engine.Registry holds the ordered list of candidates for each operation.
You don't normally choose an engine — Ex4pm.Engine.execute/3 (invoked internally by
Ex4pm.discover/2 and friends) selects one for you, evidence-ranked, unless you pass
engine: :some_id explicitly in opts. What you should understand as a consumer is what
happens when an engine isn't available:
Ex4pm.capabilities/2(orEx4pm.Engine.candidates/2) lists every candidate for an operation along with its capability standing::unsupported,:blocked,:partial_alive, or:alive. Call this before you depend on a specific engine being present in your deployment.- If the engine your call resolves to isn't actually usable — e.g. no WASM runtime configured,
no NIF loaded, the
ex4pm_planbridge not reachable — you get back a typed:unsupported/:blockedresult or refusal, not a crash. ex4pm treats "this engine edge isn't available in your environment" as a normal, inspectable outcome, the same way a missing optional dependency should behave, rather than letting an absent engine surface as an unhandled exception deep in your call stack. - This is also why
run.standingcan be something other than:aliveeven when the function call itself returns{:ok, run}: the engine that ran may have run in a degraded or partial mode (:partial_alive), and that's visible to you in the receipt rather than silently collapsed into "success." Ex4pm.differential/5lets you compare two engines' results for the same operation and subject — useful if your app cares whether a faster/cheaper engine (e.g.:beam) agrees with a more expensive one (e.g.:ex4pm_plan) before you trust the cheaper one in production.
The practical upshot: don't assume every engine candidate is present in every deployment of
your app. Check Ex4pm.capabilities/2 for the operations you rely on, and handle
:unsupported/:blocked standings as an expected branch in your code, not an error path you
bolt on after the fact.
Building and executing POWL workflow models
If your app needs to model and run a workflow (as opposed to only analyzing existing event
logs), Ex4pm.POWL.new/3 builds a validated partial-order workflow from tasks and edges —
duplicate, cycle, and unknown-reference checks happen at construction time, returning
{:error, %Ex4pm.Refusal{}} rather than letting an invalid model reach execution.
Ex4pm.POWL.layers/1 gives you the topological layering of a valid model, useful for
scheduling or displaying execution order before you commit to running it.
To actually run one, pass the %Ex4pm.POWL{} (or an already-compiled %Ex4pm.Runtime.Plan{})
to Ex4pm.operate/3 with an explicit authority — see
SELECT / CONSTRUCT / DO
above.
The contracts artifacts
Ex4pm.Contracts.verify/0 (wrapped as Ex4pm.contracts/0) gives you the canonical RDF/Turtle
ontology, SHACL shapes, WIT component-world contract, and receipt JSON Schema, plus a combined
hash covering all four. If your app generates code from ex4pm's ontology (for example with
ggen) or validates payloads against its SHACL shapes, treat these files —
resolvable via Ex4pm.Contracts.artifacts/0 and readable via Ex4pm.Contracts.read/1 — as
the source of truth rather than hand-copying schema definitions into your own repo. The
combined contract_hash returned by verify/0 lets you detect when the version of ex4pm
you've pinned has actually changed its semantic surface, independent of the hex package
version string.
See Also
lib/ex4pm.ex— the full top-levelEx4pmmodule this document is grounded inEx4pm.Evidence.BRCE,Ex4pm.Evidence.Receipt,Ex4pm.Evidence.Store— the receipt/ authority machinery referenced aboveEx4pm.Engine.Registry— the DfCM candidate engine list and selection logicEx4pm.Contracts— the canonical ontology/SHACL/WIT/JSON-Schema artifact API