From 054fba3e3f2b01d3971cbb887d42e11ca8e4a198 Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 13 Oct 2026 07:13:33 -0600 Subject: [PATCH] Threads function_providers through the steps statifier 2.15 lets a host register guard functions for cond and expr, but a loaded position carries none, so a host had to re-stamp the list itself before every resumed step. The step now does it. Executions.step/5 takes function_providers: and re-stamps it beside routes, invoke_types and send_types; the tripwire matches functions nil. create/4 takes it inside initialize:. Driver.new/3 takes it as a driver-level default each call may override, and a durable subchart child the driver creates does not inherit it. A list that does not resolve is refused at the entry, before any lock, load or write, with {:error, {:function_providers, reason}}. The statifier requirement moves to ~> 2.15 (locked at 2.15.0). ADR-0004 gains an Amendment at proposed; the README, the upgrading page and a changelog fragment say what a host sees. Refs: sp-aa9z --- README.md | 9 + changelog.d/sp-aa9z.md | 8 + ...fecycle-executor-seam-and-serialization.md | 100 ++++ docs/upgrading.md | 3 +- lib/statifier_persistence/driver.ex | 138 ++++-- lib/statifier_persistence/executions.ex | 92 +++- mix.exs | 12 +- mix.lock | 4 +- .../executions_opt_types_test.exs | 3 +- .../statifier_persistence/executions_test.exs | 16 +- .../function_providers_test.exs | 446 ++++++++++++++++++ test/support/guard_functions.ex | 21 + 12 files changed, 798 insertions(+), 54 deletions(-) create mode 100644 changelog.d/sp-aa9z.md create mode 100644 test/statifier_persistence/function_providers_test.exs create mode 100644 test/support/guard_functions.ex diff --git a/README.md b/README.md index a825595..4ada41f 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,15 @@ driver = The two registrations are given once, to `new/3`: the driver carries them inside `initialize:` on the create and stamps them on every step after it. +A host that registers guard functions a chart's `cond` and `expr` may call +(statifier's `:function_providers`) gives `function_providers:` to `new/3` +the same way, and the driver stamps the list on the create and on every +step, so a resumed execution evaluates them with no call to +`Statifier.MachineState.put_function_providers/2` of its own. A durable +subchart child the driver creates does not inherit the list. A list that +does not resolve is refused with `{:error, {:function_providers, reason}}` +before any lock, load or write. + One call is one durable step, every effect through your `effects:` executor, every `` through your `dispatch:` fun inside that same step, and then one further durable step per answer until the chart rests. diff --git a/changelog.d/sp-aa9z.md b/changelog.d/sp-aa9z.md new file mode 100644 index 0000000..5348e87 --- /dev/null +++ b/changelog.d/sp-aa9z.md @@ -0,0 +1,8 @@ +### Added + +- `function_providers:` on `StatifierPersistence.Executions.step/5`, inside `initialize:` on `create/4`, and on `StatifierPersistence.Driver.new/3` as a driver-level default each call may override: the host's guard functions are stamped onto the loaded position beside `routes:`, `invoke_types:` and `send_types:`, so a resumed execution evaluates a `cond` or `expr` that calls one without the host touching `Statifier.MachineState`. A durable subchart child the driver creates does not inherit the list. +- When `function_providers:` is passed, a list that does not resolve is refused at the entry with `{:error, {:function_providers, reason}}`, before any lock, load or write, on `step/5`, `create/4` and the driver's `create/3`, `send_event/4`, `done_invocation/5`, `failed_invocation/5` and `answer_parent/3`; `t:StatifierPersistence.Executions.error/0` gains the arm. + +### Changed + +- The `statifier` requirement moves from `~> 2.9` to `~> 2.15`, the first release carrying host function providers; a host on statifier 2.9 to 2.14 moves statifier with it. diff --git a/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md b/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md index 16b646a..b6b23ee 100644 --- a/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md +++ b/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md @@ -1137,3 +1137,103 @@ the "error re-entry (ADR-0004 decision 4)" group: The table's `data` column is not read by either test; it stays as the Note above reads it from the code. + +## Amendment (2026-10-13, sp-aa9z): decision 3's re-stamp gains `functions`, and a list that does not resolve is refused at the entry + +Status of this amendment: proposed (2026-10-13, sp-aa9z). The record +above stays accepted; this amendment is proposed until it is accepted. + +Pure addition: nothing above is edited. statifier 2.15.0 added host +function providers (st-ADR-0078): a host names `Predicator.FunctionProvider` +modules in a `:function_providers` list, and a chart's `cond` and `expr` +may call the functions they register. The resolved dispatch map lives in +`Statifier.MachineState`'s `functions` field, and `Statifier.Position` +drops that field on encode and blanks it on decode, as it does `routes`, +`invoke_types` and `send_types` (statifier 2.15.0, st-ADR-0078 decision 4). +A position this package loads therefore carries `functions: nil`, and a +host resuming an execution had to re-stamp the list itself with +`Statifier.MachineState.put_function_providers/2` before the step. This +Amendment makes the step do it, and says what a list that does not +resolve answers. The package's `statifier` requirement moves to +`~> 2.15` with it, decided by the conductor under a standing consent, +2026-10-12. + +**Decision 3's re-stamp gains a fourth field.** The step re-stamps +`routes`/`invoke_types`/`send_types`/`functions` unconditionally, the +last from the `function_providers:` option through +`Statifier.MachineState.put_function_providers/2`, and the st-ADR-0064 +tripwire matches all four fields `nil` before it stamps +(`step_loaded/8` in `StatifierPersistence.Executions`). The rest of +decision 3's order is unchanged. The option is host-supplied per call +and never read back from storage, like the other three; without it the +step stamps `nil`, which is what the decoded position already carries, +so a host that passes no `function_providers:` sees every answer it saw +before. + +**A list that does not resolve is refused at the entry.** A +`function_providers:` list is resolved once, through +`Statifier.Evaluator.Functions.from_providers/1`, at the entry of every +door that takes one, before any lock is taken, any position is loaded +or anything is written. A list that does not resolve answers +`{:error, {:function_providers, reason}}`, where `reason` is +`from_providers/1`'s own: `{:not_a_list, value}`, +`{:invalid_provider, entry, message}` or `{:shadowed_names, names}`. The +spelling is `Statifier.Session`'s own `start_link/2` refusal. It is a +new arm of `t:StatifierPersistence.Executions.error/0` +(`t:StatifierPersistence.Executions.function_providers_reason/0` names +the reasons). The step itself re-stamps through +`put_function_providers/2`, on a list already known to resolve. Ruled +by the operator, 2026-10-12. The alternative was to let +`put_function_providers/2` raise `ArgumentError` inside the step, as +`Statifier.MachineState.new/2` does on a create today; that raise would +come from inside the execution's serialization strategy, after the +load. The refusal sits beside sp-a2ee's re-entrancy refusal: both are +answered before the strategy is asked for anything, and the re-entrancy +check runs first. + +| Door | Where its list comes from | Refused before | +|---|---|---| +| `Executions.step/5` | `function_providers:` on the call | the serialization strategy, the load and every write | +| `Executions.create/4` | `function_providers:` inside `initialize:` | the metadata and retired-chart checks, `initialize/2`, the strategy and the insert | +| `Driver.create/3` | the call's own `function_providers:`, else the driver's | `Executions.create/4`, which checks the list carried inside `initialize:` again | +| `Driver.send_event/4` | the call's own, else the driver's | `Executions.step/5` | +| `Driver.done_invocation/5`, `Driver.failed_invocation/5` | the call's own, else the driver's | `Executions.step/5` | +| `Driver.answer_parent/3` | the driver's | the read of the child's linkage, and the write of the child's answer on its own record | + +**Where the option reaches.** `step/5` takes it per call. `create/4` +takes it inside `initialize:`, the only way a create reaches the core, +as for `invoke_types:` and `send_types:`. `StatifierPersistence.Driver.new/3` +takes it as a driver-level default: the driver carries it inside +`initialize:` on a create and puts it on every step it takes, and a +call's own `function_providers:` outranks it, an explicit `nil` +included. The answer path, where a driver steps a durable subchart's +parent, takes the same driver's list: one host, one registration. +Decided by the conductor under a standing consent, 2026-10-12. +`migrate/4` and the other migration doors take no step and are +untouched: the next step re-stamps the list. + +**A durable subchart child does not inherit it.** A `Statifier.Session` +child started for an `` does not inherit the list, and +inheritance is left to a later record (st-ADR-0078's consequences). +This package matches: the driver creates a child with +`function_providers: nil` on that create's own options +(`create_child/6` in `StatifierPersistence.Driver`), which outranks the +driver's list on the child's create and on every step of that drive. +The driver struct the child is created with keeps the list, because +the same struct answers the parent once the child is terminal. Decided +by the conductor under a standing consent, 2026-10-12. A child chart +that calls a host function does not compile on this path at all: +`Statifier.Invoke.Source.resolve/2` compiles the child with no +`:function_providers`, and statifier 2.15.0's compile refuses a call to +an undeclared name. + +**Tests.** `test/statifier_persistence/function_providers_test.exs` +carries each clause above: a resumed execution evaluates a guard through +`step/5` and through the driver, the create path through `initialize:` +and through `Driver.new/3`, the per-call override, the refusal on each +door with no exclusion asked for and nothing written, the child's own +steps stamped `nil` beside the parent's stamped list, and the parent's +answer stepped with the list. The tripwire test in +`test/statifier_persistence/executions_test.exs` ("the loaded position +carries send_types and functions nil, which is what the tripwire +asserts") pins the fourth field. diff --git a/docs/upgrading.md b/docs/upgrading.md index 8e6e28f..57b6da6 100644 --- a/docs/upgrading.md +++ b/docs/upgrading.md @@ -16,7 +16,8 @@ then `"~> 0.16.0"`, then `"~> 0.17.0"`, then `"~> 0.18.0"`, then `"~> 0.19.0"`, then `"~> 0.20.0"`, then `"~> 0.21.0"`, then `"~> 0.22.0"`, then `"~> 0.23.0"`, then `"~> 0.24.0"`. The `statifier` floor stays `~> 2.6` through 0.17.0; 0.18.0 moves it to -`~> 2.9`, and every later release keeps it there. +`~> 2.9`, and every later release through 0.24.2 keeps it there. The +release after 0.24.2 moves it to `~> 2.15`. ## Before you start: the database is at V07 diff --git a/lib/statifier_persistence/driver.ex b/lib/statifier_persistence/driver.ex index d18ad6e..d42c5e6 100644 --- a/lib/statifier_persistence/driver.ex +++ b/lib/statifier_persistence/driver.ex @@ -177,7 +177,7 @@ defmodule StatifierPersistence.Driver do """ alias Statifier.Effect.{CancelInvoke, Invoke} - alias Statifier.Evaluator.SystemVariables + alias Statifier.Evaluator.{Functions, SystemVariables} alias Statifier.{Event, Machine, MachineState} alias Statifier.Invoke.Source alias Statifier.Machine.Identity @@ -332,6 +332,7 @@ defmodule StatifierPersistence.Driver do :effects, :invoke_types, :send_types, + :function_providers, :serialization, :chart_resolver, :child_canceller, @@ -346,6 +347,7 @@ defmodule StatifierPersistence.Driver do effects: Executor.t() | nil, invoke_types: MachineState.invoke_types(), send_types: MachineState.send_types(), + function_providers: [module()] | nil, serialization: {module(), term()} | nil, chart_resolver: chart_resolver() | nil, child_canceller: child_canceller() | nil, @@ -390,6 +392,21 @@ defmodule StatifierPersistence.Driver do session's lifetime (st-ADR-0069). Defaults to `nil`, under which a `` carrying a non-built-in `type` is rejected with `error.execution`. + - `function_providers:` - the host's list of + `Predicator.FunctionProvider` modules whose functions a chart's `cond` + and `expr` may call (st-ADR-0078), stamped on every step and, + through `initialize:`, on the create, as + `t:StatifierPersistence.Executions.step_opt/0` describes. A + driver-level default for the same reason `send_types:` is one: one + host, one registration. The same list answers a durable subchart's + parent on this driver's answer path; the child itself does not + inherit it, so a child this driver creates runs with no host + function, as a `Statifier.Session` child does (st-ADR-0078). + Defaults to `nil`, "no host function". A list that does not resolve + is refused by `create/3`, `send_event/4`, `done_invocation/5`, + `failed_invocation/5` and `answer_parent/3` with `{:error, + {:function_providers, reason}}` before any lock, load or write + (`t:StatifierPersistence.Executions.error/0`). - `serialization:` - the `{module, config}` per-execution strategy every entry point runs inside (ADR-0004 decision 5). Defaults to whatever `StatifierPersistence.Executions` defaults to, the adapter's own @@ -439,6 +456,7 @@ defmodule StatifierPersistence.Driver do effects: Keyword.get(opts, :effects), invoke_types: Keyword.get(opts, :invoke_types), send_types: Keyword.get(opts, :send_types), + function_providers: Keyword.get(opts, :function_providers), serialization: Keyword.get(opts, :serialization), chart_resolver: Keyword.get(opts, :chart_resolver), child_canceller: Keyword.get(opts, :child_canceller), @@ -454,24 +472,49 @@ defmodule StatifierPersistence.Driver do the answer loop. `opts` takes everything `create/4` takes except `executor:`, which this module supplies - `initialize:`, `metadata:`, `routes:`, and per-call overrides of the driver's own `invoke_types:`, - `send_types:` and `serialization:`. + `send_types:`, `function_providers:` and `serialization:`. """ @spec create(driver :: t(), execution_id :: Executions.execution_id(), opts :: keyword()) :: result() def create(%__MODULE__{} = driver, execution_id, opts \\ []) do - ref = make_ref() - opts = driver |> create_opts(opts) |> Keyword.put_new(:entry, :create) + with :ok <- check_function_providers(driver, opts) do + ref = make_ref() + opts = driver |> create_opts(opts) |> Keyword.put_new(:entry, :create) - result = - driver.store - |> Executions.create(execution_id, driver.machine, execution_opts(driver, opts, ref)) - |> fire_after_step(driver, execution_id, opts, ref) + result = + driver.store + |> Executions.create(execution_id, driver.machine, execution_opts(driver, opts, ref)) + |> fire_after_step(driver, execution_id, opts, ref) - result = advance(driver, execution_id, opts, result, drain(ref, []), 0) + result = advance(driver, execution_id, opts, result, drain(ref, []), 0) - maybe_answer_parent(driver, execution_id, result) + maybe_answer_parent(driver, execution_id, result) + end end + # The entry refusal of a `function_providers:` list that does not + # resolve (`t:StatifierPersistence.Executions.error/0`), on every door + # that carries an event: the per-call list, or the driver's own where + # the call passes none, resolved once before any lock, load or write. + # `StatifierPersistence.Executions` refuses the same list again at its + # own entry; checking here as well keeps `answer_parent/3`'s write of a + # child's answer from landing ahead of a refused parent step. + @spec check_function_providers(t(), keyword()) :: + :ok | {:error, {:function_providers, Executions.function_providers_reason()}} + defp check_function_providers(driver, opts) do + case Functions.from_providers(function_providers(driver, opts)) do + {:ok, _registered} -> :ok + {:error, reason} -> {:error, {:function_providers, reason}} + end + end + + # The caller's own `function_providers:` outranks the driver's, the + # "the caller's opts win" shape every other driver default has; an + # explicit `nil` is a choice, not an absence. + @spec function_providers(t(), keyword()) :: [module()] | nil + defp function_providers(driver, opts), + do: Keyword.get(opts, :function_providers, driver.function_providers) + # `create/4`'s `invoke_types:` has to travel inside `initialize:`, not # beside it: a create has no stored position to stamp, so the snapshot # reaches the core through `Statifier.MachineState.new/2`'s own option @@ -486,14 +529,22 @@ defmodule StatifierPersistence.Driver do # `Statifier.MachineState.put_send_types/2` does not rewrite it, so a # create that stamped outside `initialize:` would leave `_ioprocessors` # missing the host's own types for the life of the execution. + # + # `function_providers:` travels the same way, because a create has no + # stored position to stamp it on either; unlike the other two, the + # caller's own per-call list outranks the driver's here as well as on + # the steps, so a create that names `function_providers: nil` creates + # with none (`create_child/6` relies on it). @spec create_opts(t(), keyword()) :: keyword() defp create_opts(%__MODULE__{} = driver, opts) do opts |> initialize_opt(:invoke_types, driver.invoke_types) |> initialize_opt(:send_types, driver.send_types) + |> initialize_opt(:function_providers, function_providers(driver, opts)) end - @spec initialize_opt(keyword(), :invoke_types | :send_types, term()) :: keyword() + @spec initialize_opt(keyword(), :invoke_types | :send_types | :function_providers, term()) :: + keyword() defp initialize_opt(opts, _key, nil), do: opts defp initialize_opt(opts, key, value) do @@ -522,12 +573,14 @@ defmodule StatifierPersistence.Driver do opts :: keyword() ) :: result() def send_event(%__MODULE__{} = driver, execution_id, %Event{} = event, opts \\ []) do - ref = make_ref() - opts = Keyword.put_new(opts, :entry, :step) - result = step(driver, execution_id, opts, event, ref) - result = advance(driver, execution_id, opts, result, drain(ref, []), 0) + with :ok <- check_function_providers(driver, opts) do + ref = make_ref() + opts = Keyword.put_new(opts, :entry, :step) + result = step(driver, execution_id, opts, event, ref) + result = advance(driver, execution_id, opts, result, drain(ref, []), 0) - maybe_answer_parent(driver, execution_id, result) + maybe_answer_parent(driver, execution_id, result) + end end @doc """ @@ -679,6 +732,17 @@ defmodule StatifierPersistence.Driver do ) :: result() | :ok | :no_parent | {:error, Storage.error()} def answer_parent(%__MODULE__{} = driver, child_execution_id, donedata_or_failure) when is_binary(child_execution_id) do + with :ok <- check_function_providers(driver, []) do + answer_linked_parent(driver, child_execution_id, donedata_or_failure) + end + end + + @spec answer_linked_parent( + t(), + Executions.execution_id(), + {:done, term()} | {:failed, keyword()} + ) :: result() | :ok | :no_parent | {:error, Storage.error()} + defp answer_linked_parent(driver, child_execution_id, donedata_or_failure) do case parent_link(driver.store, child_execution_id) do {:ok, %Linkage{child_count: nil} = linkage} -> record_single_answer(driver, child_execution_id, donedata_or_failure) @@ -1558,21 +1622,23 @@ defmodule StatifierPersistence.Driver do {:done, term()} | {:failed, keyword()} ) :: result() defp reenter(driver, execution_id, opts, invoke_id, answer) do - ref = make_ref() - opts = Keyword.put_new(opts, :entry, door(answer)) - builder = fn machine_state -> late_answer(machine_state, invoke_id, answer) end - - result = - advance( - driver, - execution_id, - opts, - step(driver, execution_id, opts, builder, ref), - drain(ref, []), - 0 - ) + with :ok <- check_function_providers(driver, opts) do + ref = make_ref() + opts = Keyword.put_new(opts, :entry, door(answer)) + builder = fn machine_state -> late_answer(machine_state, invoke_id, answer) end + + result = + advance( + driver, + execution_id, + opts, + step(driver, execution_id, opts, builder, ref), + drain(ref, []), + 0 + ) - maybe_answer_parent(driver, execution_id, result) + maybe_answer_parent(driver, execution_id, result) + end end # The public door's own 6.4.3 read. `live?/2` keys on the invocation's @@ -1745,6 +1811,7 @@ defmodule StatifierPersistence.Driver do |> Keyword.put(:executor, executor(driver, ref)) |> Keyword.put_new(:invoke_types, driver.invoke_types) |> Keyword.put_new(:send_types, driver.send_types) + |> Keyword.put_new(:function_providers, driver.function_providers) |> step_reporter_opt(after_step, ref) case driver.serialization do @@ -1971,6 +2038,14 @@ defmodule StatifierPersistence.Driver do # machine - through this module's own `create/3`, so a child whose own # initialization invokes gets the same treatment (decision 6's nesting, # with no extra code). + # + # One thing does not travel to the child: `function_providers:`. The + # create names `function_providers: nil`, which outranks the driver's + # list on the child's create and on every step of its drive, so the child + # runs with no host function, as a `Statifier.Session` child does + # (st-ADR-0078: inheritance is a later record). The driver + # itself keeps the list, because the same driver answers the parent + # once the child is terminal, and the parent's step takes it. @spec create_child(t(), Invoke.t(), dispatch_context(), Machine.t(), String.t(), fan_out()) :: :ok | {:refused, term()} defp create_child(driver, resolved, context, child_machine, content_hash, fan_out) do @@ -1984,7 +2059,8 @@ defmodule StatifierPersistence.Driver do case create(child_driver, child_execution_id, linkage: linkage, - initialize: [datamodel: datamodel] + initialize: [datamodel: datamodel], + function_providers: nil ) do {:ok, _execution, machine_state} -> report_started(child_execution_id, linkage, child_session_id(machine_state)) diff --git a/lib/statifier_persistence/executions.ex b/lib/statifier_persistence/executions.ex index 4d75f07..971dee5 100644 --- a/lib/statifier_persistence/executions.ex +++ b/lib/statifier_persistence/executions.ex @@ -5,7 +5,7 @@ defmodule StatifierPersistence.Executions do A step runs in ADR-0004 decision 3's order, and the order is the contract: liveness check on the execution record -> load (guarded) -> re-stamp - `routes`/`invoke_types`/`send_types` unconditionally (with the nil tripwire + `routes`/`invoke_types`/`send_types`/`functions` unconditionally (with the nil tripwire from st-ADR-0064: the fields are pattern-matched `nil` before stamping, so an upstream regression fails loudly here, not silently downstream) -> step via `Interpreter.handle_event/2` -> execute effects via the executor @@ -156,6 +156,7 @@ defmodule StatifierPersistence.Executions do SendDelayed } + alias Statifier.Evaluator.Functions alias Statifier.Machine.Identity # Family one's emitter (ADR-0009 decision 2), aliased rather than wrapped: @@ -241,7 +242,10 @@ defmodule StatifierPersistence.Executions do adapter with no `lock_execution/3`), plus `{:reentrant_step, execution_id}` from a door called for an execution whose executor is running in the calling process (the moduledoc's "A door called from - inside its own executor refuses"). + inside its own executor refuses"), plus `{:function_providers, reason}` + from a door handed a `function_providers:` list that does not resolve + (`t:function_providers_reason/0`), refused before any lock, load or + write. """ @type error :: Storage.error() @@ -250,6 +254,23 @@ defmodule StatifierPersistence.Executions do | {:serialization, term()} | {:pin_source_failed, {module(), PinSource.reason()}} | {:reentrant_step, execution_id()} + | {:function_providers, function_providers_reason()} + + @typedoc """ + Why a `function_providers:` list does not resolve: the reason + `Statifier.Evaluator.Functions.from_providers/1` answers, unchanged. + `{:not_a_list, value}` for an option that is not a list, + `{:invalid_provider, entry, message}` for an entry that is not a module + exporting `functions/0` and each function it names, and + `{:shadowed_names, names}` for a provider naming a function the base set + already has. The door that refuses answers `{:error, + {:function_providers, reason}}`, the spelling `Statifier.Session`'s own + `start_link/2` uses. + """ + @type function_providers_reason :: + {:not_a_list, term()} + | {:invalid_provider, term(), String.t()} + | {:shadowed_names, [String.t()]} @typedoc """ Options `create/4` accepts, and only those: an option `create/4` does @@ -264,8 +285,11 @@ defmodule StatifierPersistence.Executions do - `initialize:` - passed to `Statifier.Interpreter.initialize/2` unchanged. A create has no stored position to stamp, so the host's snapshots reach a new execution here and nowhere else: `routes:`, - `invoke_types:` and `send_types:` (`t:step_opt/0` says what each - one is) go inside this list, not beside it. For `send_types:` that + `invoke_types:`, `send_types:` and `function_providers:` + (`t:step_opt/0` says what each one is) go inside this list, not + beside it. A `function_providers:` list here that does not resolve + refuses the create with `{:error, {:function_providers, reason}}` + before any lock, load or write. For `send_types:` that is also the only way to get it right at all, because `Statifier.MachineState.new/2` is the one writer of the `_ioprocessors` entry each registered type gets and @@ -314,8 +338,20 @@ defmodule StatifierPersistence.Executions do I/O Processor types the host registers, stamped the same way (st-ADR-0069). Defaults to `nil`, under which every non-built-in `type` on a `` classifies as unsupported and the element is - rejected with `error.execution`. None of these three is a - `t:create_opt/0`: a create takes them inside `initialize:`. + rejected with `error.execution`. + - `function_providers:` - the host's list of `Predicator.FunctionProvider` + modules whose functions a chart's `cond` and `expr` may call + (st-ADR-0078), resolved and stamped onto the loaded position + the same way through `Statifier.MachineState.put_function_providers/2`; + host-supplied per call, never read back from storage, because a + decoded position carries none. Defaults to `nil`, "no host function". + A list that does not resolve through + `Statifier.Evaluator.Functions.from_providers/1` refuses the step + with `{:error, {:function_providers, reason}}` before any lock, load + or write. + + None of these four is a `t:create_opt/0`: a create takes them inside + `initialize:`. - `serialization:` - the `{module, config}` per-execution serialization strategy the fetch-to-persist tail runs inside (ADR-0004 decision 5; `create/4` and `fail/4` accept it too). Defaults to @@ -358,6 +394,7 @@ defmodule StatifierPersistence.Executions do | {:routes, MachineState.routes()} | {:invoke_types, MachineState.invoke_types()} | {:send_types, MachineState.send_types()} + | {:function_providers, [module()] | nil} | {:serialization, {module(), term()}} | {:entry, entry()} | {:invoke_id, String.t()} @@ -409,6 +446,11 @@ defmodule StatifierPersistence.Executions do `{:error, {:budget_exhausted, payload}}`, so the caller sees both the durable state and the reason. + A `function_providers:` list inside `initialize:` that does not resolve + is refused at the entry with `{:error, {:function_providers, reason}}`, + before any lock, load or write, rather than raising out of + `Statifier.MachineState.new/2`. + `metadata:` rides through to the inserted execution record unchanged (ADR-0006 decision 1). Create is the only place it is set - `step/5` and `fail/4` carry the stored map forward and take no `metadata:` of their own - and @@ -458,11 +500,27 @@ defmodule StatifierPersistence.Executions do ) :: {:ok, Execution.t(), MachineState.t()} | {:error, error()} def create(%Storage{} = store, execution_id, %Machine{} = machine, opts) do - with :ok <- not_in_step(execution_id) do + providers = opts |> Keyword.get(:initialize, []) |> Keyword.get(:function_providers) + + with :ok <- not_in_step(execution_id), + :ok <- check_function_providers(providers) do create_open(store, execution_id, machine, opts) end end + # The entry refusal for a `function_providers:` list (`t:error/0`): the + # list is resolved once here, before any lock, load or write, so a list + # that does not resolve never reaches `Statifier.MachineState.new/2` or + # `Statifier.MachineState.put_function_providers/2`, which raise on it + # inside the execution's exclusion. + @spec check_function_providers(term()) :: :ok | {:error, error()} + defp check_function_providers(providers) do + case Functions.from_providers(providers) do + {:ok, _registered} -> :ok + {:error, reason} -> {:error, {:function_providers, reason}} + end + end + @spec create_open(Storage.t(), execution_id(), Machine.t(), [create_opt()]) :: {:ok, Execution.t(), MachineState.t()} | {:error, error()} defp create_open(store, execution_id, machine, opts) do @@ -590,6 +648,15 @@ defmodule StatifierPersistence.Executions do alone and before any position decode: nothing is appended to the input log, no effect is executed and nothing is written (ADR-0014 decision 2). + A `function_providers:` list that does not resolve is refused at the + entry with `{:error, {:function_providers, reason}}`, before the + serialization strategy is entered: no lock is taken, no position is + loaded and nothing is written. A list that resolves is stamped onto the + loaded position beside `routes:`, `invoke_types:` and `send_types:` + (`t:step_opt/0`), so a `cond` or `expr` that calls a host function + evaluates on a resumed execution without the host touching the + `t:Statifier.MachineState.t/0`. + `event` may also be a `t:event_builder/0` - a fun the loaded position is handed, for an event only the position can build or decline. A builder that declines discards the delivery through the same `{:discarded, execution}` @@ -609,7 +676,8 @@ defmodule StatifierPersistence.Executions do {:ok, Execution.t(), MachineState.t()} | {:discarded, Execution.t()} | {:error, error()} def step(%Storage{} = store, execution_id, %Machine{} = machine, event, opts) when is_struct(event, Event) or is_function(event, 1) do - with :ok <- not_in_step(execution_id) do + with :ok <- not_in_step(execution_id), + :ok <- check_function_providers(opts[:function_providers]) do executor = Keyword.fetch!(opts, :executor) entry = entry(opts, :step) @@ -3048,16 +3116,20 @@ defmodule StatifierPersistence.Executions do entry ) do # The bare match IS the st-ADR-0064 tripwire: `from_binary/2` blanks - # all three fields unconditionally on decode, so if upstream ever stops, + # all four fields unconditionally on decode, so if upstream ever stops, # this fails loudly here rather than silently resuming a stale # snapshot downstream. - %MachineState{routes: nil, invoke_types: nil, send_types: nil} = machine_state + %MachineState{routes: nil, invoke_types: nil, send_types: nil, functions: nil} = + machine_state + # `function_providers:` already resolved at the entry + # (`check_function_providers/1`), so this stamp cannot raise. machine_state = machine_state |> MachineState.put_routes(opts[:routes]) |> MachineState.put_invoke_types(opts[:invoke_types]) |> MachineState.put_send_types(opts[:send_types]) + |> MachineState.put_function_providers(opts[:function_providers]) # The stamp a terminal write here would record, and the struct would # carry: the stored one where the row already holds one - the adapter diff --git a/mix.exs b/mix.exs index 24cdfcd..8a9a0e1 100644 --- a/mix.exs +++ b/mix.exs @@ -140,8 +140,14 @@ defmodule StatifierPersistence.MixProject do # change that spans both repos. It is an env var rather than a mix.exs edit # so the override never lands in a commit by accident. # - # The floor is 2.9: the first release whose `Statifier.MachineState` - # carries `last_selection`, which the step stop's `selection` key is read + # The floor is 2.15: the first release carrying host-registered guard + # functions (`:function_providers`). + # `Statifier.MachineState.put_function_providers/2` re-stamps them onto a + # decoded position, which drops them, and + # `Statifier.Evaluator.Functions.from_providers/1` resolves a list without + # raising, so a list that does not resolve is refused at the entry. It + # subsumes the previous 2.9 floor, the first release whose + # `Statifier.MachineState` carries `last_selection`, which the step stop's `selection` key is read # from (the ADR-0009 sp-qrkx amendment). It subsumes the previous 2.6 # floor, the first release carrying host-registered send types. # `Statifier.MachineState.put_send_types/2` is what stamps a `send_types:` @@ -155,7 +161,7 @@ defmodule StatifierPersistence.MixProject do defp statifier_dep do case System.get_env("STATIFIER_PATH") do nil -> - {:statifier, "~> 2.9"} + {:statifier, "~> 2.15"} path -> {:statifier, path: path, override: true} diff --git a/mix.lock b/mix.lock index cbde068..fe5590d 100644 --- a/mix.lock +++ b/mix.lock @@ -22,9 +22,9 @@ "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"}, - "predicator": {:hex, :predicator, "9.4.2", "2f4e52255330657d2bafada287b989357b4975be4c2052bf0d4a4c4a0666168a", [:mix], [], "hexpm", "893c35dc4f982c08d8d045ae742c510cd5b2c3d9d8a447c45ca0e4e52aec9b1e"}, + "predicator": {:hex, :predicator, "9.4.4", "870e4a54c0d6e07efa4bfa4c88db678feddaf7d4c1169b178f020b7730b34452", [:mix], [], "hexpm", "ecfb5ab95b377eb0812f48243844790ab9dbd19c4a9e74d3cfc4f4c060b1e1c2"}, "saxy": {:hex, :saxy, "1.6.1", "742eff28f553c066d0b54e84662dbf384a1d1f38595472ed15f6e0a33038bbe1", [:mix], [], "hexpm", "8989d504424ba29460a61950f8968380651413fa05e63b6118084db057da1a6b"}, - "statifier": {:hex, :statifier, "2.9.0", "ab7605abd7648026f49f940a7eb2bba17b9dff0a7050b70d018d5444356dd0ee", [:mix], [{:predicator, "~> 9.0", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0f790878618aec8f65f8780dae2485bb88ba3bee42b538eee6bad1fd5b90e969"}, + "statifier": {:hex, :statifier, "2.15.0", "26808e9eb7a40d788d8367c55d450e38e8d988a1dd94f3420113c91f5857e107", [:mix], [{:predicator, "~> 9.4", [hex: :predicator, repo: "hexpm", optional: false]}, {:saxy, "~> 1.6", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.3", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8a04b74a6c02f2fb7fc23ffacae03f42cf1e6de30a6cdaab382c4ac3e53e42f9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "uxid": {:hex, :uxid, "2.9.0", "e61508fa4d0f997995fd3958d0033acceb70f23e5ec85ea3144485b8b9683c61", [:mix], [{:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "af4a43db3e9f25b7b182eb1870c712224e9a205f39676d98e6cfaaf3fe6029c1"}, } diff --git a/test/statifier_persistence/executions_opt_types_test.exs b/test/statifier_persistence/executions_opt_types_test.exs index 3aaff29..4cadeb0 100644 --- a/test/statifier_persistence/executions_opt_types_test.exs +++ b/test/statifier_persistence/executions_opt_types_test.exs @@ -39,6 +39,7 @@ defmodule StatifierPersistence.ExecutionsOptTypesTest do :routes, :invoke_types, :send_types, + :function_providers, :serialization, :entry, :invoke_id, @@ -50,7 +51,7 @@ defmodule StatifierPersistence.ExecutionsOptTypesTest do # sabotage: the same `:send_types` arm added to `t:create_opt/0` -> red, # the two sets were no longer disjoint. Verified red, reverted. test "the options a create ignores are absent from create/4's type" do - ignored = MapSet.new([:routes, :invoke_types, :send_types, :entry]) + ignored = MapSet.new([:routes, :invoke_types, :send_types, :function_providers, :entry]) assert MapSet.disjoint?(option_keys(:create_opt), ignored) end diff --git a/test/statifier_persistence/executions_test.exs b/test/statifier_persistence/executions_test.exs index 637e6d2..a1857ed 100644 --- a/test/statifier_persistence/executions_test.exs +++ b/test/statifier_persistence/executions_test.exs @@ -56,7 +56,7 @@ defmodule StatifierPersistence.ExecutionsTest do alias StatifierPersistence.{Execution, Executions, Storage} alias StatifierPersistence.Execution.Linkage alias StatifierPersistence.Storage.InMemory - alias StatifierPersistence.Test.{FlakyAdapter, NoLockAdapter, RecordingExecutor} + alias StatifierPersistence.Test.{FlakyAdapter, GuardFunctions, NoLockAdapter, RecordingExecutor} alias StatifierPersistence.Testing.Charts # Reaches top-level final on "finish", executing two blocks in @@ -812,19 +812,23 @@ defmodule StatifierPersistence.ExecutionsTest do # sabotage: drop `:send_types` from both `Map.drop/2` lists in the # dependency's `Statifier.Position` (encode and decode) -> red: the # snapshot survives the round trip, and the tripwire match in - # step_loaded/6 stops proving anything - test "the loaded position carries send_types nil, which is what the tripwire asserts", + # step_loaded/8 stops proving anything + # sabotage: the same with `:functions` -> red: the stamped functions + # survive the round trip + test "the loaded position carries send_types and functions nil, which is what the tripwire asserts", %{store: store} do machine = compile!(@send_type_chart_source) send_types = SendTypes.from_send_types(%{"myapp:sink" => SinkProcessor}) - {:ok, _execution, _ms} = + {:ok, _execution, created} = Executions.create(store, "execution-1", machine, executor: RecordingExecutor, - initialize: [send_types: send_types] + initialize: [send_types: send_types, function_providers: [GuardFunctions]] ) - assert {:ok, %MachineState{routes: nil, invoke_types: nil, send_types: nil}} = + assert %{"Approved" => _entry} = created.functions + + assert {:ok, %MachineState{routes: nil, invoke_types: nil, send_types: nil, functions: nil}} = Storage.load_execution_position(store, "execution-1", machine) end diff --git a/test/statifier_persistence/function_providers_test.exs b/test/statifier_persistence/function_providers_test.exs new file mode 100644 index 0000000..e1e5af1 --- /dev/null +++ b/test/statifier_persistence/function_providers_test.exs @@ -0,0 +1,446 @@ +defmodule StatifierPersistence.FunctionProvidersTest do + @moduledoc """ + `function_providers:` on the durable path (ADR-0004's 2026-10-13 + Amendment): a host's guard functions reach a resumed execution through + `StatifierPersistence.Executions.step/5`, `create/4`'s `initialize:` and + `StatifierPersistence.Driver`, without the host touching + `Statifier.MachineState`; a list that does not resolve is refused at the + entry, before any lock, load or write; a durable subchart child does not + inherit the list. + """ + + use ExUnit.Case, async: true + + defmodule SpyStrategy do + @moduledoc false + # A pass-through serialization strategy whose config is the test pid: + # every exclusion it is asked for is reported, so a test can assert a + # refusal took none. + @behaviour StatifierPersistence.Serialization + + @impl StatifierPersistence.Serialization + def with_execution(test_pid, execution_id, fun) do + send(test_pid, {:with_execution, execution_id}) + {:ok, fun.()} + end + end + + alias Statifier.{Event, Machine, MachineState} + alias Statifier.Invoke.Types, as: InvokeTypes + alias StatifierPersistence.{Driver, Executions, Storage} + alias StatifierPersistence.Execution.Linkage + alias StatifierPersistence.Storage.InMemory + alias StatifierPersistence.Test.GuardFunctions + + # "decide" takes the guarded transition when `Approved('yes')` evaluates, + # and falls through to "declined" when it cannot: an unregistered + # function is an `error.execution`, and the `cond` reads false. + @guarded_source """ + + + + + + + + + """ + + # The guard on an eventless transition out of the initial state, so the + # create's own `initialize/2` is what evaluates it. + @eventless_source """ + + + + + + + """ + + # A durable subchart parent whose answer is guarded the same way. + @parent_source """ + + + + + + + + + + """ + + # The child: completes with donedata on "go". + @child_source """ + + + + + + + + + """ + + @providers [GuardFunctions] + + setup do + {:ok, store} = Storage.new(InMemory, []) + %{store: store} + end + + describe "Executions.step/5" do + # sabotage: dropped `put_function_providers/2` from `step_loaded/8`'s + # re-stamp -> red, the step fell through to "declined" + test "a resumed execution evaluates a cond calling a registered guard", %{store: store} do + machine = compile!(@guarded_source) + + {:ok, _execution, _ms} = Executions.create(store, "execution_1", machine, executor: &ok/2) + + assert {:ok, _execution, machine_state} = + Executions.step(store, "execution_1", machine, Event.external("decide"), + executor: &ok/2, + function_providers: @providers + ) + + assert leaves(machine_state) == ["approved"] + assert {:ok, reloaded} = Storage.load_execution_position(store, "execution_1", machine) + assert leaves(reloaded) == ["approved"] + end + + # sabotage: `step_loaded/8` stamped a default list when the option was + # absent -> red, the step reached "approved" + test "without the option the guard is not registered and the cond reads false", %{ + store: store + } do + machine = compile!(@guarded_source) + + {:ok, _execution, _ms} = Executions.create(store, "execution_1", machine, executor: &ok/2) + + assert {:ok, _execution, machine_state} = + Executions.step(store, "execution_1", machine, Event.external("decide"), + executor: &ok/2 + ) + + assert leaves(machine_state) == ["declined"] + assert machine_state.functions == nil + end + + # sabotage: `step/5` delivered another event when the option was + # passed -> red, the two answers differed + test "a chart calling no host function answers the same with the option and without", %{ + store: store + } do + {:ok, machine} = Statifier.compile(@child_source) + event = Event.external("go") + + results = + for {execution_id, opts} <- [ + {"plain", []}, + {"with_providers", [function_providers: @providers]} + ] do + {:ok, _execution, _ms} = + Executions.create(store, execution_id, machine, executor: &ok/2) + + {:ok, execution, machine_state} = + Executions.step(store, execution_id, machine, event, [executor: &ok/2] ++ opts) + + {execution.status, execution.donedata, leaves(machine_state)} + end + + assert [plain, with_providers] = results + assert plain == with_providers + assert plain == {:completed, "child-result", []} + end + + # sabotage: `check_function_providers/1` answered the bare reason -> + # red; and, separately, the check moved inside the serialization + # strategy's fun -> red, the spy reported an exclusion + test "a list that does not resolve is refused before any lock, load or write", %{ + store: store + } do + machine = compile!(@guarded_source) + + {:ok, _execution, _ms} = Executions.create(store, "execution_1", machine, executor: &ok/2) + {:ok, before} = Storage.fetch_execution(store, "execution_1") + + for {providers, reason} <- [ + {:nope, {:not_a_list, :nope}}, + {[String], {:invalid_provider, String, :_}} + ] do + assert {:error, {:function_providers, refused}} = + Executions.step(store, "execution_1", machine, Event.external("decide"), + executor: &ok/2, + function_providers: providers, + serialization: {SpyStrategy, self()} + ) + + assert same_reason?(refused, reason) + end + + refute_received {:with_execution, _execution_id} + assert {:ok, ^before} = Storage.fetch_execution(store, "execution_1") + end + end + + describe "Executions.create/4" do + # sabotage: `create_open/4` deleted `:function_providers` from the + # `initialize:` list -> red, the create stayed in "starting" + test "function_providers inside initialize: reaches the create's own initialize", %{ + store: store + } do + machine = compile!(@eventless_source) + + assert {:ok, _execution, machine_state} = + Executions.create(store, "execution_1", machine, + executor: &ok/2, + initialize: [function_providers: @providers] + ) + + assert leaves(machine_state) == ["approved"] + end + + # sabotage: `check_function_providers/1` answered the bare reason -> red + test "a list inside initialize: that does not resolve is refused before any lock or write", + %{store: store} do + machine = compile!(@eventless_source) + + assert {:error, {:function_providers, {:not_a_list, :nope}}} = + Executions.create(store, "execution_1", machine, + executor: &ok/2, + initialize: [function_providers: :nope], + serialization: {SpyStrategy, self()} + ) + + refute_received {:with_execution, _execution_id} + assert {:error, :execution_not_found} = Storage.fetch_execution(store, "execution_1") + end + end + + describe "Driver" do + # sabotage: dropped the `:function_providers` `put_new` from + # `execution_opts/3` -> red, the step reached "declined" + test "a driver-level list reaches every step of a resumed execution", %{store: store} do + driver = Driver.new(store, compile!(@guarded_source), dispatch: &no_dispatch/3) + driver = %{driver | function_providers: @providers} + + {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + assert {:ok, _execution, machine_state} = + Driver.send_event(driver, "execution_1", Event.external("decide")) + + assert leaves(machine_state) == ["approved"] + end + + # sabotage: dropped the `:function_providers` line from `create_opts/2` + # -> red, the create stayed in "starting" + test "Driver.new/3 reads the option and a create carries it into initialize:", %{ + store: store + } do + driver = + Driver.new(store, compile!(@eventless_source), + dispatch: &no_dispatch/3, + function_providers: @providers + ) + + assert driver.function_providers == @providers + assert {:ok, _execution, machine_state} = Driver.create(driver, "execution_1") + assert leaves(machine_state) == ["approved"] + end + + # sabotage: `execution_opts/3` wrote the driver's list with `put` in + # place of `put_new` -> red, the step reached "approved" + test "a per-call list outranks the driver's", %{store: store} do + driver = + Driver.new(store, compile!(@guarded_source), + dispatch: &no_dispatch/3, + function_providers: @providers + ) + + {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + assert {:ok, _execution, machine_state} = + Driver.send_event(driver, "execution_1", Event.external("decide"), + function_providers: nil + ) + + assert leaves(machine_state) == ["declined"] + end + + # sabotage: dropped `answer_parent/3`'s entry check -> red, that door + # answered `:no_parent` + test "every door that carries an event refuses a list that does not resolve", %{ + store: store + } do + good = + Driver.new(store, compile!(@guarded_source), + dispatch: &no_dispatch/3, + serialization: {SpyStrategy, self()} + ) + + {:ok, _execution, _ms} = Driver.create(good, "execution_1") + assert_received {:with_execution, "execution_1"} + {:ok, before} = Storage.fetch_execution(store, "execution_1") + + bad = %{good | function_providers: [String]} + + doors = [ + fn -> Driver.create(bad, "execution_2") end, + fn -> Driver.send_event(bad, "execution_1", Event.external("decide")) end, + fn -> Driver.done_invocation(bad, "execution_1", "call", nil) end, + fn -> Driver.failed_invocation(bad, "execution_1", "call", []) end, + fn -> Driver.answer_parent(bad, "execution_1", {:done, nil}) end, + fn -> + Driver.send_event(good, "execution_1", Event.external("decide"), + function_providers: :nope + ) + end + ] + + for door <- doors do + assert {:error, {:function_providers, _reason}} = door.() + end + + refute_received {:with_execution, _execution_id} + assert {:ok, ^before} = Storage.fetch_execution(store, "execution_1") + assert {:error, :execution_not_found} = Storage.fetch_execution(store, "execution_2") + end + end + + describe "durable subchart" do + # sabotage: dropped `function_providers: nil` from `create_child/6`'s + # create -> red, the child's create was stamped with the list + test "the child does not inherit the driver's list; the parent's own steps take it", %{ + store: store + } do + test_pid = self() + + after_step = fn execution_id, %MachineState{functions: functions}, _effects -> + send(test_pid, {:stepped, execution_id, functions}) + end + + driver = parent_driver(store, after_step: after_step) + + assert {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + child_execution_id = Linkage.child_execution_id("execution_1", "call", 0) + + assert_received {:stepped, ^child_execution_id, nil} + assert_received {:stepped, "execution_1", parent_functions} + assert is_map(parent_functions) + assert Map.has_key?(parent_functions, "Approved") + end + + # sabotage: dropped the `:function_providers` `put_new` from + # `execution_opts/3` -> red, the parent's answer reached "declined" + test "the answer path steps the parent with the same list", %{store: store} do + {:ok, parent_machine} = Statifier.compile(@parent_source, function_providers: @providers) + {:ok, child_machine} = Statifier.compile(@child_source) + + driver = parent_driver(store, chart_resolver: resolver(parent_machine)) + + assert {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + child_execution_id = Linkage.child_execution_id("execution_1", "call", 0) + + assert {:ok, %{status: :completed}, _child_ms} = + Driver.send_event( + %{driver | machine: child_machine}, + child_execution_id, + Event.external("go") + ) + + assert {:ok, parent} = Storage.load_execution_position(store, "execution_1", parent_machine) + assert leaves(parent) == ["approved"] + end + + # sabotage: dropped `answer_parent/3`'s entry check -> red, the + # child's answer was recorded before the parent's step refused + test "answer_parent/3 refuses a list that does not resolve before it records the answer", + %{store: store} do + {:ok, parent_machine} = Statifier.compile(@parent_source, function_providers: @providers) + {:ok, child_machine} = Statifier.compile(@child_source) + + driver = parent_driver(store) + assert {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + child_execution_id = Linkage.child_execution_id("execution_1", "call", 0) + + # No `chart_resolver:`, so the child's completion answers no parent + # and records no answer: the refusal below is the only call that + # could. + assert {:ok, %{status: :completed}, _child_ms} = + Driver.send_event( + %{driver | machine: child_machine}, + child_execution_id, + Event.external("go") + ) + + assert {:ok, %{outcome_blob: nil}} = Storage.fetch_execution(store, child_execution_id) + + bad = %{driver | machine: parent_machine, function_providers: [String]} + + assert {:error, {:function_providers, {:invalid_provider, String, _message}}} = + Driver.answer_parent(bad, child_execution_id, {:done, "child-result"}) + + assert {:ok, %{outcome_blob: nil}} = Storage.fetch_execution(store, child_execution_id) + assert {:ok, parent} = Storage.load_execution_position(store, "execution_1", parent_machine) + assert leaves(parent) == ["calling"] + end + end + + defp parent_driver(store, opts \\ []) do + {:ok, machine} = Statifier.compile(@parent_source, function_providers: @providers) + + Driver.new( + store, + machine, + Keyword.merge( + [ + dispatch: subchart_dispatch(), + invoke_types: InvokeTypes.new(types: ["myapp:subchart"]), + function_providers: @providers + ], + opts + ) + ) + end + + defp subchart_dispatch do + fn "myapp:subchart", _params, %{invoke: %Statifier.Effect.Invoke{} = invoke} -> + resolved = %{invoke | content: @child_source} + {:start_child, resolved, {:invoke, resolved}} + end + end + + defp resolver(parent_machine) do + parent_hash = Machine.identity(parent_machine).content_hash + + fn + ^parent_hash -> {:ok, parent_machine} + _other_hash -> :error + end + end + + defp compile!(source) do + {:ok, machine} = Statifier.compile(source, function_providers: @providers) + machine + end + + defp ok(_effect, _context), do: :ok + + defp no_dispatch(_type, _params, _context), do: {:error, :no_dispatch} + + defp same_reason?({:invalid_provider, module, message}, {:invalid_provider, module, :_}), + do: is_binary(message) + + defp same_reason?(reason, reason), do: true + defp same_reason?(_refused, _expected), do: false + + defp leaves(machine_state) do + machine_state + |> MachineState.active_leaf_states() + |> Enum.map(&Machine.id(machine_state.machine, &1)) + |> Enum.sort() + end +end diff --git a/test/support/guard_functions.ex b/test/support/guard_functions.ex new file mode 100644 index 0000000..0fb8fb3 --- /dev/null +++ b/test/support/guard_functions.ex @@ -0,0 +1,21 @@ +defmodule StatifierPersistence.Test.GuardFunctions do + @moduledoc """ + A host's `Predicator.FunctionProvider`: the one guard function the + `function_providers:` tests register, `Approved/1`, true for `"yes"` + and false for anything else. + + A chart that calls it compiles only with `function_providers:` naming + this module (statifier checks the names a chart calls at compile), and + evaluates it only on a position the list is stamped onto: without the + stamp the `cond` that calls it is an `error.execution`, read as false. + """ + + @behaviour Predicator.FunctionProvider + + @impl Predicator.FunctionProvider + def functions, do: %{"Approved" => {1, :approved}} + + @doc "`Approved(answer)`: whether `answer` is `\"yes\"`." + @spec approved([term()], term()) :: {:ok, boolean()} + def approved([answer], _context), do: {:ok, answer == "yes"} +end