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)