Scenario harness — ox run-scenario
An .ar package declares the ontology; it does not carry data. So a built .oxbin queried with no fixtures answers against an empty store. A scenario is a small .toml script that seeds the store and then reads it back, so a model ships with realistic, reproducible state — and, with assertions, with executable checks.
A scenario file lists an ordered sequence of steps. Each [[step]] is tagged by do:
[[step]]
do = "mutate" # invoke a declared mutation
path = "norms::register"
args = { p = "alice", age = 25 }
[[step]]
do = "query" # run a declared query, print its rows
path = "norms::findEmployeeNamesOfCompany"
args = { c = "acme" }
label = "employees of acme" # optional, echoed in the output
[[step]]
do = "derive" # dump a derived predicate's extent
name = "spouses" # the rule head's short name
explain = true # optional: annotate each tuple with its proof tag
[[step]]
do = "compute" # invoke a declared `fn`, print its single result
path = "norms::getSpouse"
args = { p = "alice" }
Document order is execution order. A scenario interleaves writes and reads: populate, look, mutate again, look again — each read sees the state at that point. The four kinds map to the same operations as the standalone commands: a mutate step runs a declared mutation (with the same check-guard handling as a write over HTTP); a query step runs a declared pub query and prints its rows; a derive step dumps a derivation’s extent by its rule-head short name, the read ox derive <oxbin> <rule> performs (with explain = true adding the Rule atom — fn, derive, query, mutate, check proof tag per tuple); a compute step evaluates a declared pub fn and prints the single value it returns.
A query step’s args is what makes a parameterized declared query do a keyed lookup. A pub query findEmployeeNamesOfCompany(c: Company) -> … whose body binds c looks c up only when c is supplied; with no args, the parameter degrades to a free join variable and the query enumerates the whole extent. A derive step takes no args — a derivation has no parameters, so it always dumps the full extent.
Argument typing
Each args table maps a parameter name to a TOML value, typed into the runtime’s value space by one shared rule (the same path expect tuples use, below):
| TOML form | Runtime value |
|---|---|
bare string "alice" | an individual (hashed by name, identical to pub fact P(alice);) |
{ individual = "alice" } | the same individual (explicit form) |
{ text = "active" } | a String value (use this for a string-typed field, since a bare string is an individual) |
integer 25 | Int |
boolean true | Bool |
{ decimal = "0.22" } / { money = "100.50" } / { real = "…" }, or a bare float 0.22 | an exact Decimal / Money / Real, parsed exactly (never via f64) |
{ date = "2024-01-01" } | a Date |
A bare string defaulting to an individual is the load-bearing convention: the same name in the source and in the scenario refers to the same entity. Array values and a non-finite float are rejected with a clear diagnostic.
Assertions — expect
Add an expect table to a step and it stops being a printer and becomes a test: its result is diffed against the expectation and a PASS/FAIL verdict is reported. Which sub-keys apply depends on the step kind:
[[step]]
do = "query"
path = "norms::findEmployeeNamesOfCompany"
args = { c = "acme" }
expect = { equals = [ [{ text = "Dave" }], [{ text = "Alice" }] ] }
[[step]]
do = "derive"
name = "isDisenfranchised"
expect = { empty = true }
[[step]]
do = "compute"
path = "norms::getSpouse"
args = { p = "alice" }
expect = { value = "dave" } # a single expected value
[[step]]
do = "mutate"
path = "norms::register"
args = { p = "bob", age = 12 }
expect = { rejected = "Norms::E001" } # assert a check guard rejects it
- A
queryorderivestep takes the row-set sub-keys:rows = N(exact count),contains = [tuple, …](every listed tuple is present, count-sensitive),equals = [tuple, …](the result set matches exactly, order-insensitive multiset equality),empty = true(no rows). - A
computestep takesvalue = <toml value>: a single expected value. - A
mutatestep takesrejected = "<CODE>": assert the mutation is rejected by acheckguard carrying that user code. A rejection by that code is a PASS and the run continues (the rejected transaction committed nothing); a success or a rejection by a different code is a FAIL. Withoutrejected, a guard rejection halts the scenario.
The correctness crux is that an expected value is typed through the same rule as the step args, and the diff is by value identity — never by rendered string. So an entity-valued result column matches a bare-string or { individual = "…" } expectation, but never a { text = "…" } one; a Text "dave" is never equal to the individual dave. A sub-key applied to the wrong kind (value on a query, rows on a compute, an unknown key) is a loud scenario error, not a silent no-op.
Files, discovery, and running
A package carries scenarios as a sibling demo.toml and/or any number of files under a scenarios/ subdirectory:
ox run-scenario <pkg> # run every discovered scenario
ox run-scenario <pkg> --scenario voting # run scenarios/voting.toml (or a sibling voting.toml)
ox run-scenario <pkg> --scenario path/to.toml
ox run-scenario <pkg> --extent norms::Voter # also print a concept extent after the run
With no --scenario, run-scenario discovers every scenario — the sibling demo.toml plus every scenarios/*.toml, filename-sorted for determinism — and runs them all. Each scenario runs against its own freshly-loaded, freshly-seeded store re-derived from the artifact bytes, so a mutation in one scenario cannot leak into another’s reads and the order across files is irrelevant. --scenario runs exactly one: a path verbatim, or a bare name resolving to scenarios/<name>.toml then a sibling <name>.toml. --extent <concept> prints that concept’s extent once the scenario has run.
run-scenario collects every failed assertion (it does not stop at the first) and exits non-zero if any assertion — or any scenario — failed, naming the offending file. A scenario therefore doubles as a CI-usable check.
A single file uses either [[step]] or the older flat [[mutate]] list (a pure population script, applied in order), never both — the whole point of [[step]] is interleaving, which a split across two arrays cannot express. The flat [[mutate]] form is also what ox query and ox derive apply when they find a sibling demo.toml: they seed from [[mutate]] fixtures, then run their own read. A [[step]] scenario beside one of those commands is not run by them (they have nowhere to surface the interleaved reads); they print a warning pointing at ox run-scenario.
The scenarios/ directory is the integration-.toml surface, distinct from tests/, which holds the in-language test atom (The test atom — ox test). The two are complementary: scenarios/ scripts the runtime from outside the language with a .toml harness; the test atom expresses checks inside the language.