A durable subchart child's parent linkage: the reserved, package-owned
namespace inside a run's metadata (ADR-0008 decision 2).
ADR-0006 decision 1 says this package never reads a metadata key to make
a decision. ADR-0008 decision 2 narrows that, and narrows it exactly
this far: linkage lives under one reserved top-level key -
"statifier_persistence", this module's reserved_key/0 - this package
reads only that key, and everything outside it stays as opaque as it
was: never read, never validated beyond shape, never merged into a blob.
ADR-0006 decision 2 is untouched: every value here is an identity (a run
id, an invocation id, a content hash) and never personal data.
The stored shape, under the reserved key:
%{
"parent_run_id" => "run_42",
"invoke_id" => "call",
"child_index" => 0,
"content_hash" => "sha256:...",
# fan-out only, both absent otherwise
"child_count" => 3,
"policy" => "all"
}content_hash is the mandatory pin ADR-0008 decision 2 hardens into
contract: the same hash Statifier.Machine.identity/1 produces for the
child's own chart, recorded a second time where the parent-child
relationship can see it. A child is resumed by whatever node picks it up,
and this is what stands between "resumed the workflow you started" and
"resumed a different workflow that happens to share an id" - without the
pin, a reused or collided run id would let a node silently guard a
child's own load against the wrong chart's identity while still
believing it is answering the right parent.
child_index is the child's position in the list its invocation fanned
out over: 0 for an ordinary durable subchart, 0..child_count - 1 for
a fan-out. It is durably on the child rather than held by whatever
started it, which is what lets a completion arriving on a node that has
never seen the parent live be placed at its index (ADR-0008's sp-3n2
amendment, point 2).
The two fan-out values
child_count and policy are the amendment's ordered set expressed
per-child: N, and how the invocation aggregates its N answers - :all
waits for every child, :first_error cancels the rest as soon as one
fails. Both are absent from the stored map for an ordinary durable
subchart, and absence is the discriminator fan_out?/1 reads: a child
with no child_count answers its parent's door directly, exactly as
every child created before this feature does, and its stored metadata is
byte-identical to what that path has always written.
child_count: 1 is therefore not "not a fan-out". It is the N=1 fan-out
the amendment's point 3 names - one shape read at N=1 or at N=1000 -
and it settles through the same path a larger one does.
Both values are identities in ADR-0006 decision 2's sense: a count and a constant, never personal data.
Summary
Types
How an invocation aggregates its children's answers (statifier_blocks
ADR-0009 decision 6): :all waits for every child, :first_error
cancels the remaining ones as soon as one fails.
The reserved top-level metadata key this module owns.
Functions
Derives a child run id from its parent and invocation - the single definition site of the id shape (see the plan's "Implementation Approach")
Whether this linkage's child is one of a fan-out's N.
Reads a linkage back out of a stored run's metadata.
The same containment map, narrowed to one invocation - what a cancel of a single invocation walks, rather than every child a parent has ever started.
Builds a linkage struct from its four values. Does not derive a child run
id and does not touch storage - child_run_id/3 and to_metadata/1 do
that separately, so a caller that only needs the id shape never has to
fabricate a content_hash it does not yet have.
new/4's fan-out form: the same four values plus the invocation's
child_count and its aggregation policy.
The containment map a cascade queries Storage.list_runs_by_metadata/2
with to find every child of parent_run_id, whatever invocation started
it and whatever its child_index.
The one definition site for the reserved metadata namespace.
The reserved-namespace metadata map for linkage, string keys and
JSON-representable values only, so the Ecto adapter's jsonb check
passes and the map is safe to merge into a metadata: option unchanged.
Types
@type policy() :: :all | :first_error
How an invocation aggregates its children's answers (statifier_blocks
ADR-0009 decision 6): :all waits for every child, :first_error
cancels the remaining ones as soon as one fails.
@type reserved_key() :: String.t()
The reserved top-level metadata key this module owns.
@type t() :: %StatifierPersistence.Run.Linkage{ child_count: pos_integer() | nil, child_index: non_neg_integer(), content_hash: StatifierPersistence.Storage.Adapter.content_hash(), invoke_id: String.t(), parent_run_id: StatifierPersistence.Storage.Adapter.run_id(), policy: policy() | nil }
Functions
@spec child_run_id( parent_run_id :: StatifierPersistence.Storage.Adapter.run_id(), invoke_id :: String.t(), child_index :: non_neg_integer() ) :: StatifierPersistence.Storage.Adapter.run_id()
Derives a child run id from its parent and invocation - the single definition site of the id shape (see the plan's "Implementation Approach"):
parent_run_id <> "/" <> invoke_id <> "/" <> Integer.to_string(child_index)Deterministic in every input, which buys idempotency across ADR-0004
decision 3's at-least-once re-drive: the same crash-and-retry recomputes
the same id, so the adapter's atomic :run_exists refusal is what
answers the re-drive rather than a second child being created. The
result strictly extends parent_run_id as a string, which is the
acyclicity property the cascade in Phase 5 rests on: no run can be its
own descendant, because every descendant's id is strictly longer than
its ancestor's.
Whether this linkage's child is one of a fan-out's N.
The discriminator is the presence of child_count, not its value:
child_count: 1 is the N=1 fan-out (ADR-0008's sp-3n2 amendment, point
- and answers
true, while an ordinary durable subchart carries no count at all and answersfalse.
@spec from_metadata(StatifierPersistence.Storage.Adapter.metadata()) :: {:ok, t()} | :no_linkage
Reads a linkage back out of a stored run's metadata.
:no_linkage for a run whose metadata carries none - a %{} metadata
map, a host map with unrelated keys, or an adapter that does not store
metadata at all. Not an {:error, _}: having no parent is an ordinary
property of a run, not a failure, and every run this package has ever
created before this feature has none.
The two fan-out values are read when present. A reserved map carrying
one of them without the other, a child_count that is not a positive
integer, a child_index outside 0..child_count - 1, or a policy
that is neither spelling is malformed rather than partially readable,
and answers :no_linkage - the same arm every other malformed reserved
map takes.
@spec invocation_match( parent_run_id :: StatifierPersistence.Storage.Adapter.run_id(), invoke_id :: String.t() ) :: StatifierPersistence.Storage.Adapter.metadata()
The same containment map, narrowed to one invocation - what a cancel of a single invocation walks, rather than every child a parent has ever started.
@spec new( parent_run_id :: StatifierPersistence.Storage.Adapter.run_id(), invoke_id :: String.t(), child_index :: non_neg_integer(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash() ) :: t()
Builds a linkage struct from its four values. Does not derive a child run
id and does not touch storage - child_run_id/3 and to_metadata/1 do
that separately, so a caller that only needs the id shape never has to
fabricate a content_hash it does not yet have.
@spec new( parent_run_id :: StatifierPersistence.Storage.Adapter.run_id(), invoke_id :: String.t(), child_index :: non_neg_integer(), content_hash :: StatifierPersistence.Storage.Adapter.content_hash(), child_count :: pos_integer(), policy :: policy() ) :: t()
new/4's fan-out form: the same four values plus the invocation's
child_count and its aggregation policy.
child_index must be inside 0..child_count - 1; anything else is a
caller bug rather than a storage event, so it raises ArgumentError the
way a malformed writer option does elsewhere in this package.
@spec parent_match(parent_run_id :: StatifierPersistence.Storage.Adapter.run_id()) :: StatifierPersistence.Storage.Adapter.metadata()
The containment map a cascade queries Storage.list_runs_by_metadata/2
with to find every child of parent_run_id, whatever invocation started
it and whatever its child_index.
@spec reserved_key() :: reserved_key()
The one definition site for the reserved metadata namespace.
@spec to_metadata(t()) :: StatifierPersistence.Storage.Adapter.metadata()
The reserved-namespace metadata map for linkage, string keys and
JSON-representable values only, so the Ecto adapter's jsonb check
passes and the map is safe to merge into a metadata: option unchanged.
child_count and policy appear only for a fan-out child, so a
non-fan-out linkage produces exactly the four-key map this function has
always produced.