# `StatifierBlocks.Core.Send`
[🔗](https://github.com/riddler/statifier_blocks/blob/v0.44.1/lib/statifier_blocks/core/send.ex#L1)

`core.send`: a leaf step that names an event and says **when**.

ADR-0002 decision 10 carries the row, through its 2026-08-29 amendment
(section G2): `slots(config)` is `[]`, the config is `event` and an
optional `delay`, and the type has the single default outcome.

Two config fields: `event`, in the same grammar `core.raise` and
`core.on_event` speak, and `delay`, an **optional** duration.

## Why this is not `core.raise` with a `delay` key

`core.raise` names an event and hands control on, now: the event goes on
the *internal* queue and is processed before anything else the session is
holding, which is the property an enclosing group's interrupt rail
depends on. A delayed send goes on the *external* queue, outlives the
step that armed it, has to survive a restart, and is turned into a
durable timer by infrastructure outside the interpreter (statifier-ex
`docs/durable-timers.md`, ADR-0054/0059; the `sob-` lane). A `delay` key
that quietly turned a raise into a durable timer would make
`core.raise`'s whole note false half the time, so they are two types.

## The block finishes when the send is **armed**

This is what separates `core.send` from `core.wait`. A wait keeps the
chart live for the duration; a send arms an event and completes in the
same macrostep. So there is still one outcome and still no join to
refuse - `io/1` leaves `produces` absent rather than `:unknown`, the way
`core.raise` and `core.wait` do.

## Two things it deliberately does not declare

**No `target`.** What a target may name is not this repo's decision:
session identity is statifier-ex's (ADR-0052) and reaching the host is
the invoke seam st-ADR-0051 defines, which this vocabulary already spells
`core.invoke`. A config key that validates nothing is a proposal made by
accident. Absent a `target`, spec 6.2.2 puts the event on the running
session's own external queue, which is exactly what `statifier_oban`
turns into a durable timer.

**No cancel block, and there will not be one.** A cancel that names the
send it cancels is a cross-subtree reference between blocks, which is
the exact shape the operator's ruling of 2026-08-28 refuses - outcome paths are slots,
never ports, and connectors are rendered, never authored - as ADR-0001's
tree invariant and ADR-0005's amendment 10a state at record level. The
alternative that keeps the tree invariant is scope-shaped rather than
reference-shaped, and that is the one the 2026-08-29 delayed-send
lifetime ruling picked. So there is no `core.cancel` type, the palette
gains no entry, and cancellation is the compiler's to emit rather than
an author's to draw. The next section is what ships in its place.

## The send carries an id, and its scope cancels it

The `<send>` carries `id="<this block's state id>__send"`, minted
through the context under the role `"send"` the way ADR-0004 decision 3
requires of every derived id. Nothing authors it: no config field names
it, the editor gains no control, and a reader of the document never sees
it. It exists so the send can be named later, because a send with no id
is a send nothing can cancel.

What cancels it is a `<cancel sendid="..."/>` the **compiler** emits in
the `<onexit>` of the nearest enclosing scope state - the sequence or
group this block sits in, not this block's own state, and the nearest
one when scopes nest. A delayed send therefore lives exactly as long as
the scope that meant it to. `StatifierBlocks.Compiler.Cancels` is where
that emission lives and why it reads the id rather than the emitter.

Upstream owns what the pair means at runtime: a pending delayed send is
identified by `{session scope, send id}` and nothing else, and it lives
until it fires, is cancelled, or its run is found not live at fire time
(statifier-ex ADR-0054 decisions 3 and 4, ADR-0060 decision 3 for
resume).

## The delay's stored form

`delay` is the vocabulary's first optional duration, and "no delay" is
neither a zero duration nor an unfinished field - an absent or empty
`delay` emits no `delay` attribute at all, so the event goes out now.

A present `delay` is stored in one spelling: the duration string the
expression language reads (`30s`, `1h30m`, `2d`, `3d8h`). That is
ADR-0005 decision 9 as amended 2026-09-05 (clause 9a, one grammar), and
ADR-0002 decision 7's `:duration` field type is unchanged - it still
holds a string, and what changed is which strings.
`StatifierBlocks.Core.Duration` is where the compile lives and where the
grammar's one home - predicator's own lexer - is read rather than
mirrored.

# `config_schema`

The event first, then the delay.

The delay's default is `""` and not a duration: a send with no delay is
the ordinary case, and `core.wait`'s `1h` default would author a timer
nobody asked for.

# `emit`

A compound state whose entry sends the event and immediately goes final.

    <state id="s_blk_SND" initial="s_blk_SND__done">
      <onentry>
        <send delay="2h" event="signup.abandoned" id="s_blk_SND__send"/>
      </onentry>
      <final id="s_blk_SND__done"/>
    </state>

There is nothing to sequence - arming the send is instantaneous and the
block is done the moment it is entered - so `initial` points straight at
the `<final>` and no auxiliary state is minted. That is `core.raise`'s
shape with a different element inside the `<onentry>`, which is the
right way round: the difference between the two types is which queue the
event lands on, not how long the block lasts.

With no delay the `<send>` carries no `delay` attribute at all, so the
event goes out on the current macrostep. `Emission.element/3` drops a
`nil` attribute, which is how "no delay" and `delay=""` stay
indistinguishable in the emitted bytes.

## `event` is attributed and `delay` is not

ADR-0004 decision 9's annotation is for an attribute whose value is the
author's **verbatim**. `event` is: those bytes came out of the config
unchanged. The `delay` attribute's bytes did not - the stored string is
parsed and rendered from the normalised value, so a stored `3h2h` emits
as `5h` - and annotating it would point a runtime finding at a span the
author never typed. `core.wait` leaves its own `delay` unannotated for
the same reason.

## The `id`, and the half of the cancel that is not here

`id` is minted with `Context.role_id/2` under
`StatifierBlocks.Compiler.Cancels.armed_role/0`, so the one string the
convention turns on is written once and read back once. It is emitted
whether or not the send is delayed: ADR-0002's amendment states the
descriptor's id unconditionally, and an id costs nothing on a send that
fires now.

The matching `<cancel>` is deliberately absent from this function. It
belongs to the enclosing scope's state, which this block cannot reach -
ADR-0004 decision 4 gives a block type no way to write into its parent,
and it should not gain one for this. The compiler adds it on the
parent's own pass; see the moduledoc and
`StatifierBlocks.Compiler.Cancels`.

# `io`

`core.raise`'s `io/1` exactly, and for `core.raise`'s reason: one
outcome, so `produces` is absent rather than `:unknown`. A send reads
nothing through the type flow - its event name and its delay are its
own config - so there is no `consumes` either.

# `palette_entry`

The spike's descriptor declares a static `sends` badge here. ADR-0005
decision 10's `palette_entry/0` key set has no `badge`, so there is
nothing to declare it in yet and the icon carries the whole visual
signal - a Phase-B gap the spike's own note already records.

# `sentence`

This block as one line of prose (ADR-0002's 2026-09-07 amendment).

The event name, held to the same test `summary/1` holds it to: a name
that is not well formed is a finding on the card and does not belong in
a line that reads as though it were settled.

When the block carries a delay the line names it first, because when
the event goes out is half of what this block does: `In 7 days, send
loan.fines_notice`. The short duration form is put in words, one word
per unit and largest first (`1h30m` reads as `1 hour 30 minutes`). A
leading zero or a zero component is in that form too and is put in
words the same way, each number by its value: `07d` reads as `7 days`,
`1h0m` as `1 hour 0 minutes` and `0d` as `0 days`. Any other spelling
the field accepts, a fraction or a repeated unit, is shown exactly as
stored. An absent, empty or unreadable delay is no
delay, and the line is then `Send <event>`, unchanged.

    iex> StatifierBlocks.Core.Send.sentence(%{"event" => "order.paid"})
    "Send order.paid"

    iex> StatifierBlocks.Core.Send.sentence(%{})
    "Send an event"

    iex> StatifierBlocks.Core.Send.sentence(%{"event" => "order.paid", "delay" => ""})
    "Send order.paid"

    iex> StatifierBlocks.Core.Send.sentence(%{"event" => "loan.fines_notice", "delay" => "7d"})
    "In 7 days, send loan.fines_notice"

    iex> StatifierBlocks.Core.Send.sentence(%{"event" => "order.paid", "delay" => "1h30m"})
    "In 1 hour 30 minutes, send order.paid"

# `summary`

The event name, or `nil` (ADR-0002 amendment H6).

Only the event. The delay is the other half of what this block does and
it is deliberately not on the chip: `core.wait` already owns the timer
chip, and a chip reading `signup.abandoned after 2h` is a sentence
rather than a summary and would not fit under the presentation cap in
any case. The delay is named where sentences belong, in `sentence/1`.

A value that is not an event name is no chip rather than a chip nobody
can read - the same reading `emit/2` gives it.

    iex> StatifierBlocks.Core.Send.summary(%{"event" => "signup.abandoned"})
    "signup.abandoned"

    iex> StatifierBlocks.Core.Send.summary(%{"event" => ""})
    nil

---

*Consult [api-reference.md](api-reference.md) for complete listing*
