StatifierRouter.Contracts (StatifierRouter v0.9.2)

Copy Markdown View Source

The receiver contract at publish: whether the event a sender or a binding names is one the receiving document accepts (ADR-0008).

Two things in this package name an event another document's chart is expected to take. A binding names one in its event, and a <send> to the reserved execution target names one in its event and the receiving document in its document param (ADR-0006, section 1). When either name is one the receiving chart never listens for, nothing at run time refuses it: the event is delivered and recorded as delivered, and the receiver's step selects no transition (ADR-0008, decision 5). These functions find it before a chart or a configuration ships.

This package has no publish step and gains none here. A host calls these functions from its own - when it saves a revision, when it compiles a document in CI, when an author presses publish - and an editor calls them at edit time. The host decides whether a finding blocks the publish or only warns (ADR-0008, decision 6). They are pure over their arguments: a resolved StatifierRouter.Config.t/0, a compiled Statifier.Machine.t/0 and the host's lookup, which is the only call that reaches outside them. Nothing here adds a Statifier.Validator finding.

The lookup

The host supplies an arity-1 function from a receiving document id to one of three answers (ADR-0008, decision 2):

  • {:ok, names} - the receiver declares these event names. An event is accepted when it is equal, as a string, to one of them. [] is a declaration that accepts nothing.
  • {:ok, :undeclared, machine} - the receiver declares nothing, and machine is the chart a new execution of it would start on. Its contract is the engine's computed vocabulary: an event is accepted when Statifier.Chart.check_accepts(machine, [event]) answers unreachable: []. The package never matches descriptors itself.
  • {:error, :not_published} - the receiver has no published chart.

Any other answer is the host's fault, and the function raises ArgumentError. The lookup takes no scope: the host builds it over the scope it is publishing into (ADR-0008, decision 2).

The reasons

A finding carries reason, which says which contract refused it (ADR-0008, decisions 2 and 4, and its 2026-09-23 Amendment):

  • :undeclared - the receiver declares names and this is not one.
  • :undeclared_by_computed_set - the receiver declares nothing, and no reachable transition of its chart matches this name.
  • :not_published - the lookup answered {:error, :not_published}.
  • :delay - the <send> writes delay or delayexpr. A delayed send to the execution target is never delivered, so the lookup is not asked. A binding has no delay and never carries this reason.

The route findings

check/3 also carries every <send> of the configuration's type that will never be handed to the route its literal target names, under :unregistered_routes, each entry %{route, location, reason} (ADR-0008's 2026-09-24 Amendment):

  • :unregistered - the target names no registered route, or the send writes no target: StatifierRouter.Routes.unregistered/2's finding.
  • :no_timer_queue - the target names a registered route, the send writes a literal delay, and the configuration has no :timer_queue, so StatifierRouter.SendHandler never queues it and refuses it, as {:no_timer_queue, send_id} once the route resolves. A send to the reserved execution target is never this finding; its delay is a :delay finding above.

The finding reasons are closed

reason/0, and so the three of them a binding_finding/0 carries, and route_reason/0 are closed sets (ADR-0008's 2026-09-24 Amendment). A host may match on them exhaustively. A new reason arrives only with a record that decides it, in a minor release whose changelog names it as breaking.

What cannot be checked

Only a literal can be judged. A send undeclared_events/3 selects but cannot judge is never a finding and never passed silently: it is reported under :unchecked as %{reason: reason, location: location}, the shape StatifierRouter.Routes.unregistered/2's own unchecked entries have (ADR-0008, decision 3). The reasons are :eventexpr, :no_event, :document_expr and :no_document; see unchecked/0. A delayed send that cannot be judged keeps its unchecked entry and is not a :delay finding. A send whose type or target is an expression is not selected here at all: StatifierRouter.Routes.unregistered/2 reports it, and check/3 carries that list once.

A configuration that gives a :bindings_resolver has no bindings check/3 can read, and check/3 never calls the resolver. Its report says so: the first :unchecked entry is %{reason: :bindings_resolver, location: nil}, the one entry with no location (see bindings_unchecked/0), so the report is never the one a clean pass answers (ADR-0008's 2026-09-26 Amendment).

An example

A parcel is scanned from depot to doorstep. The parcel document declares the two events its chart takes, and a third binding routes the depot's missing-parcel report as an event the parcel never declared:

iex> lookup = fn
...>   "parcel" -> {:ok, ["parcel.scanned", "parcel.delivered"]}
...>   _other -> {:error, :not_published}
...> end
iex> {:ok, lost} =
...>   StatifierRouter.Binding.new(
...>     id: "depot_lost",
...>     source: "depot_feed",
...>     match: "event.kind == 'lost'",
...>     key: "event.parcel_id",
...>     document: "parcel",
...>     event: "parcel.lost"
...>   )
iex> StatifierRouter.Contracts.undeclared_binding_events([lost], lookup)
[%{event: "parcel.lost", document: "parcel", binding_id: "depot_lost", reason: :undeclared}]

Summary

Types

One binding whose event its document does not accept: the event, the document, the binding's id and the reason.

The :unchecked entry check/3 puts first when the configuration gives a :bindings_resolver: the bindings were not checked, because check/3 takes no scope and never calls the resolver. It is the one unchecked entry with no location; a host checks each scope's bindings with undeclared_binding_events/2.

What check/3 answers; see its @doc.

One execution-target <send> whose literal event its receiver does not accept, or which is delayed: the event, the receiving document, the <send> element's location and the reason.

The host's lookup: a receiving document id to its declared names, to {:ok, :undeclared, machine} when it declares none, or to {:error, :not_published}.

Which contract refused a name; see the moduledoc. :delay is a <send>'s only; a binding finding never carries it. A closed set.

What undeclared_events/3 answers, each list in c_index order.

One entry under check/3's :unregistered_routes: the route name, the <send> element's location and the reason. route is nil only for an :unregistered send that writes no target. It is StatifierRouter.Routes.finding/0 with reason added, so a pattern on route and location alone matches every entry.

Why a <send> of the configuration's type is not handed to the route its literal target names; see the moduledoc. A closed set.

One execution-target <send> this pass could not judge, and why

Functions

Every publish-time check this package ships, over one configuration, one compiled machine and the host's lookup, in one report under five keys (ADR-0008, decision 6)

Every binding in bindings whose event its document does not accept, in the order of bindings.

Every execution-target <send> in machine whose literal event its receiving document does not accept, with the sends this pass could not judge beside them. Both lists are in c_index order, which is document order.

Types

binding_finding()

@type binding_finding() :: %{
  event: String.t(),
  document: String.t(),
  binding_id: String.t(),
  reason: :undeclared | :undeclared_by_computed_set | :not_published
}

One binding whose event its document does not accept: the event, the document, the binding's id and the reason.

bindings_unchecked()

@type bindings_unchecked() :: %{reason: :bindings_resolver, location: nil}

The :unchecked entry check/3 puts first when the configuration gives a :bindings_resolver: the bindings were not checked, because check/3 takes no scope and never calls the resolver. It is the one unchecked entry with no location; a host checks each scope's bindings with undeclared_binding_events/2.

check_report()

@type check_report() :: %{
  unsupported_types: [Statifier.Send.Types.unsupported_send()],
  unregistered_routes: [route_finding()],
  unchecked: [
    bindings_unchecked() | StatifierRouter.Routes.unchecked() | unchecked()
  ],
  undeclared_events: [finding()],
  undeclared_binding_events: [binding_finding()]
}

What check/3 answers; see its @doc.

finding()

@type finding() :: %{
  event: String.t(),
  document: String.t(),
  location: Statifier.Parser.Location.t(),
  reason: reason()
}

One execution-target <send> whose literal event its receiver does not accept, or which is delayed: the event, the receiving document, the <send> element's location and the reason.

lookup()

@type lookup() :: (document :: String.t() ->
               {:ok, [String.t()]}
               | {:ok, :undeclared, Statifier.Machine.t()}
               | {:error, :not_published})

The host's lookup: a receiving document id to its declared names, to {:ok, :undeclared, machine} when it declares none, or to {:error, :not_published}.

reason()

@type reason() :: :undeclared | :undeclared_by_computed_set | :not_published | :delay

Which contract refused a name; see the moduledoc. :delay is a <send>'s only; a binding finding never carries it. A closed set.

report()

@type report() :: %{undeclared: [finding()], unchecked: [unchecked()]}

What undeclared_events/3 answers, each list in c_index order.

route_finding()

@type route_finding() :: %{
  route: String.t() | nil,
  location: Statifier.Parser.Location.t(),
  reason: route_reason()
}

One entry under check/3's :unregistered_routes: the route name, the <send> element's location and the reason. route is nil only for an :unregistered send that writes no target. It is StatifierRouter.Routes.finding/0 with reason added, so a pattern on route and location alone matches every entry.

route_reason()

@type route_reason() :: :unregistered | :no_timer_queue

Why a <send> of the configuration's type is not handed to the route its literal target names; see the moduledoc. A closed set.

unchecked()

@type unchecked() :: %{
  reason: :eventexpr | :no_event | :document_expr | :no_document,
  location: Statifier.Parser.Location.t()
}

One execution-target <send> this pass could not judge, and why:

  • :eventexpr - the event is an expression (eventexpr).
  • :no_event - the send writes neither event nor eventexpr.
  • :document_expr - a document is given and is not a literal: an expression other than exactly one lit of a non-empty string, a location, a namelist entry, or more than one param of that name.
  • :no_document - no document param and no namelist entry of that name.

When both the event and the document are uncheckable, the event's reason is the one reported.

Functions

check(config, machine, lookup)

Every publish-time check this package ships, over one configuration, one compiled machine and the host's lookup, in one report under five keys (ADR-0008, decision 6):

  • :unsupported_types - StatifierRouter.Routes.unsupported_types/2.
  • :unregistered_routes - every <send> of the configuration's type that is never handed to the route its literal target names, as %{route, location, reason} in document order: each entry of the :unregistered list of StatifierRouter.Routes.unregistered/2 with reason :unregistered, and each send that writes a literal delay to a registered route while the configuration has no :timer_queue, with reason :no_timer_queue (see route_finding/0).
  • :unchecked - the unchecked entries of StatifierRouter.Routes.unregistered/2 (:typeexpr, :targetexpr) and of undeclared_events/3 together, in document order, ordered by each <send> element's source offset. When the configuration gives a :bindings_resolver, %{reason: :bindings_resolver, location: nil} comes first (see bindings_unchecked/0); without one it is absent.
  • :undeclared_events - the findings of undeclared_events/3.
  • :undeclared_binding_events - undeclared_binding_events/2 over the configuration's :bindings. A configuration that gives a :bindings_resolver keeps bindings: [], so this key is always empty for it: check/3 takes no scope and never calls the resolver, and the host checks each scope's answer with undeclared_binding_events/2 itself (ADR-0001, the Amendment of 2026-09-25). The :bindings_resolver entry under :unchecked is what tells that empty list from a clean pass (ADR-0008's 2026-09-26 Amendment).

StatifierRouter.Routes.unsupported_types/2 is composed unchanged, and so are the :unchecked entries of StatifierRouter.Routes.unregistered/2; its :unregistered entries each gain reason: :unregistered, and nothing else about them changes. A finding under either :undeclared_events or :undeclared_binding_events carries one of the three reasons, :undeclared, :undeclared_by_computed_set (the receiver declares nothing and Statifier.Chart.check_accepts/2 finds no reachable transition for the name) or :not_published; a finding under :undeclared_events may instead carry :delay, for a judged <send> that writes delay or delayexpr. Every :unchecked entry is %{reason, location}, its reason one of :typeexpr, :targetexpr, :eventexpr, :no_event, :document_expr or :no_document, with a <send> element's location, except the one :bindings_resolver entry, whose location is nil. Which finding blocks a publish is the host's decision.

undeclared_binding_events(bindings, lookup)

@spec undeclared_binding_events([StatifierRouter.Binding.t()], lookup()) :: [
  binding_finding()
]

Every binding in bindings whose event its document does not accept, in the order of bindings.

A binding's document and event are always literal strings, so every binding is judged and none is unchecked (ADR-0008, decision 1). The lookup decides exactly as it does for undeclared_events/3, with the same three reasons: :undeclared under a declaration, :undeclared_by_computed_set under the computed vocabulary (Statifier.Chart.check_accepts/2), and :not_published. A finding is %{event, document, binding_id, reason}. Raises ArgumentError when the lookup answers anything else.

A host whose declarations differ by scope calls this once per scope, with one lookup each (ADR-0008, decision 2).

undeclared_events(config, machine, lookup)

@spec undeclared_events(StatifierRouter.Config.t(), Statifier.Machine.t(), lookup()) ::
  report()

Every execution-target <send> in machine whose literal event its receiving document does not accept, with the sends this pass could not judge beside them. Both lists are in c_index order, which is document order.

A send is selected when its type is the literal configuration's :send_type and its target is the literal StatifierRouter.SendHandler.execution_target/0 (ADR-0008, decision 1). A configuration with no :send_type selects nothing.

A selected send is judged when its event is literal and its document param is literal: exactly one <param> named document, no namelist entry of that name, written with expr, compiling to exactly the one instruction ["lit", value] with value a non-empty string. value is the receiving document, and lookup.(value) decides:

  • {:ok, names} - a finding with reason :undeclared unless the event equals one of names.
  • {:ok, :undeclared, machine} - a finding with reason :undeclared_by_computed_set unless Statifier.Chart.check_accepts(machine, [event]) answers unreachable: []. This is the fallback for a receiver that declares nothing, and it is the engine's relation, not a copy of it.
  • {:error, :not_published} - a finding with reason :not_published.

A judged send that writes delay or delayexpr is a finding with reason :delay instead, and the lookup is not called for it: a delayed send to the execution target is refused at run time whatever its event (ADR-0008's 2026-09-23 Amendment). A delayed send that cannot be judged is an unchecked entry like any other.

A finding is %{event, document, location, reason}. A selected send that cannot be judged is an unchecked entry %{reason, location} with reason :eventexpr, :no_event, :document_expr or :no_document (see unchecked/0). Raises ArgumentError when the lookup answers anything else.