From 764ee8c9b9c802da41247ddc57dcf2aeabf290db Mon Sep 17 00:00:00 2001 From: JohnnyT Date: Tue, 13 Oct 2026 08:26:53 -0600 Subject: [PATCH] Refuses unresolved providers at start_child_at Driver.start_child_at/6 now checks the driver's function_providers: list first, before the parent's record is read, any child is created or anything is written, and answers {:refused, {:function_providers, reason}} within its existing {:refused, reason} shape. Without it, a driver whose list does not resolve could start a fan-out child whose automatic answer the parent's door then refused, and that refusal is discarded on the automatic path, so the invocation never settled. The check is the one the driver's other doors use. The typespec names the reason, the docs and README say so, ADR-0004's proposed Amendment gains the door in its table by a dated edit, and a changelog fragment carries the host-facing line. Ruled by the operator, 2026-10-13. Refs: sp-8ktk --- README.md | 4 +- changelog.d/sp-8ktk.md | 3 + ...fecycle-executor-seam-and-serialization.md | 15 ++++ lib/statifier_persistence/driver.ex | 35 +++++++- .../function_providers_test.exs | 87 +++++++++++++++++++ 5 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 changelog.d/sp-8ktk.md diff --git a/README.md b/README.md index 4ada41f..823fb99 100644 --- a/README.md +++ b/README.md @@ -250,7 +250,9 @@ 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. +before any lock, load or write; `start_child_at/6`, the fan-out door, +refuses it in its own shape, `{:refused, {:function_providers, reason}}`, +before any child is created. One call is one durable step, every effect through your `effects:` executor, every `` through your `dispatch:` fun inside that same diff --git a/changelog.d/sp-8ktk.md b/changelog.d/sp-8ktk.md new file mode 100644 index 0000000..7ad258d --- /dev/null +++ b/changelog.d/sp-8ktk.md @@ -0,0 +1,3 @@ +### Added + +- When a driver's `function_providers:` list does not resolve, `StatifierPersistence.Driver.start_child_at/6` refuses with `{:refused, {:function_providers, reason}}`, its existing refusal shape, before any child is created or anything is written; a driver with a list that resolves, or with none, starts children as before. 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 b6b23ee..148d1c5 100644 --- a/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md +++ b/docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md @@ -1199,6 +1199,21 @@ check runs first. | `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 | +| `Driver.start_child_at/6` (added 2026-10-13, sp-8ktk) | the driver's | the read of the parent's record, the child's create and every write | + +Dated edit (2026-10-13, sp-8ktk): `Driver.start_child_at/6`, the door a +scheduler starts a fan-out child through, joins the table above. It +answers in its own existing shape, `{:refused, {:function_providers, +reason}}` with the same `reason`, not `{:error, _}`; no new return arm +is added (`start_child_at/6` in `StatifierPersistence.Driver`). Without +it, a driver whose list does not resolve could create and drive a +child whose automatic answer the parent's `answer_parent/3` then +refused, and that refusal is discarded on the automatic path, so the +invocation never settled. Ruled by the operator, 2026-10-13. The +refusal emits no `[:statifier_persistence, :child, :refused]`, as the +door's `:execution_not_found` emits none. Pinned in +`test/statifier_persistence/function_providers_test.exs`, "a list that +does not resolve is refused before any child is created or written". **Where the option reaches.** `step/5` takes it per call. `create/4` takes it inside `initialize:`, the only way a create reaches the core, diff --git a/lib/statifier_persistence/driver.ex b/lib/statifier_persistence/driver.ex index d42c5e6..314f7aa 100644 --- a/lib/statifier_persistence/driver.ex +++ b/lib/statifier_persistence/driver.ex @@ -406,7 +406,9 @@ defmodule StatifierPersistence.Driver do 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`). + (`t:StatifierPersistence.Executions.error/0`), and by + `start_child_at/6` with `{:refused, {:function_providers, reason}}`, + that door's own shape, before any child is created. - `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 @@ -882,6 +884,18 @@ defmodule StatifierPersistence.Driver do status projection refuses `:execution_states_unsupported`. All three are the same principle: a child whose invocation could never be settled is not started. A `parent_execution_id` naming no stored execution refuses `:execution_not_found`. + + A driver whose `function_providers:` list does not resolve refuses + `{:function_providers, reason}` (`reason` a + `t:StatifierPersistence.Executions.function_providers_reason/0`), the + same reason the driver's other doors answer inside `{:error, _}`. It + is checked first, before the parent's record is read, any child is + created or anything is written, so a child whose automatic answer the + parent's door would refuse is never started. Like + `:execution_not_found`, it emits no + `[:statifier_persistence, :child, :refused]`: that event's `reason` is + ADR-0008 decision 4's refusal set. The driver's list is the one read; + `opts` takes no `function_providers:`. """ @spec start_child_at( driver :: t(), @@ -890,7 +904,9 @@ defmodule StatifierPersistence.Driver do index :: non_neg_integer(), count :: pos_integer(), opts :: [policy: Linkage.policy()] - ) :: :ok | {:refused, term()} + ) :: + :ok + | {:refused, {:function_providers, Executions.function_providers_reason()} | term()} def start_child_at( %__MODULE__{} = driver, parent_execution_id, @@ -904,12 +920,25 @@ defmodule StatifierPersistence.Driver do resolved = resolved_invoke(effect) policy = Keyword.get(opts, :policy, :all) - with {:ok, context} <- start_context(driver, parent_execution_id, resolved) do + with :ok <- check_start_providers(driver), + {:ok, context} <- start_context(driver, parent_execution_id, resolved) do result = settleable(driver, context, resolved, {index, count, policy}) report_refusal(result, context) end end + # `check_function_providers/2` over the driver's own list, answered in + # this door's `{:refused, reason}` shape rather than `{:error, reason}`: + # the reason is the same term the other doors answer. + @spec check_start_providers(t()) :: + :ok | {:refused, {:function_providers, Executions.function_providers_reason()}} + defp check_start_providers(driver) do + case check_function_providers(driver, []) do + :ok -> :ok + {:error, reason} -> {:refused, reason} + end + end + @spec resolved_invoke(Invoke.t() | {:start_child, Invoke.t(), {:invoke, Invoke.t()}}) :: Invoke.t() defp resolved_invoke(%Invoke{} = resolved), do: resolved diff --git a/test/statifier_persistence/function_providers_test.exs b/test/statifier_persistence/function_providers_test.exs index e1e5af1..e269fc8 100644 --- a/test/statifier_persistence/function_providers_test.exs +++ b/test/statifier_persistence/function_providers_test.exs @@ -389,6 +389,93 @@ defmodule StatifierPersistence.FunctionProvidersTest do end end + describe "Driver.start_child_at/6" do + # sabotage: dropped `start_child_at/6`'s `check_start_providers/1` step + # -> red, the door answered `:ok` and created the child + test "a list that does not resolve is refused before any child is created or written", %{ + store: store + } do + good = fan_out_driver(store) + assert {: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]} + + assert {:refused, {:function_providers, {:invalid_provider, String, message}}} = + Driver.start_child_at(bad, "execution_1", fan_out_effect(), 0, 2) + + assert is_binary(message) + + # Checked ahead of the parent's read: a missing parent answers the + # list's refusal, not `:execution_not_found`. + assert {:refused, {:function_providers, {:not_a_list, :nope}}} = + Driver.start_child_at( + %{good | function_providers: :nope}, + "execution_missing", + fan_out_effect(), + 0, + 2 + ) + + refute_received {:with_execution, _execution_id} + assert {:ok, ^before} = Storage.fetch_execution(store, "execution_1") + + for index <- 0..1 do + assert {:error, :execution_not_found} = + Storage.fetch_execution( + store, + Linkage.child_execution_id("execution_1", "call", index) + ) + end + end + + # sabotage: `check_start_providers/1` refused every list, as if none + # resolved -> red, the resolvable list answered + # `{:refused, {:function_providers, _}}` + test "a list that resolves starts the child as before", %{store: store} do + driver = fan_out_driver(store) + assert {:ok, _execution, _ms} = Driver.create(driver, "execution_1") + + assert :ok = Driver.start_child_at(driver, "execution_1", fan_out_effect(), 0, 2) + + assert {:ok, %{status: :active}} = + Storage.fetch_execution( + store, + Linkage.child_execution_id("execution_1", "call", 0) + ) + end + end + + # A fan-out parent: the dispatch leaves the invocation pending, so the + # children are started only through `start_child_at/6`. + defp fan_out_driver(store) do + {:ok, machine} = Statifier.compile(@parent_source, function_providers: @providers) + + Driver.new(store, machine, + dispatch: fn "myapp:subchart", _params, _context -> :pending end, + invoke_types: InvokeTypes.new(types: ["myapp:subchart"]), + function_providers: @providers, + serialization: {SpyStrategy, self()} + ) + end + + defp fan_out_effect do + %Statifier.Effect.Invoke{ + invoke_id: "call", + type: "myapp:subchart", + src: nil, + params: %{}, + content: @child_source, + autoforward: nil, + state_index: 0, + invoke_index: 0, + macrostep: 0, + microstep: 0, + round: 0 + } + end + defp parent_driver(store, opts \\ []) do {:ok, machine} = Statifier.compile(@parent_source, function_providers: @providers)