Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<invoke>` through your `dispatch:` fun inside that same
Expand Down
3 changes: 3 additions & 0 deletions changelog.d/sp-8ktk.md
Original file line number Diff line number Diff line change
@@ -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.
15 changes: 15 additions & 0 deletions docs/adr/0004-run-lifecycle-executor-seam-and-serialization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
35 changes: 32 additions & 3 deletions lib/statifier_persistence/driver.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -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
Expand Down
87 changes: 87 additions & 0 deletions test/statifier_persistence/function_providers_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading