Status: accepted (2026-08-24, sob-76d) - accepted by the operator's merge of PR #26 on 2026-08-23
Context
StatifierOban.Invoke.Worker deduplicates enqueues on the
{scope, invoke_id} pair, over every job state and an infinite window.
That key was chosen for the crash-replay case st-ADR-0051 decision 4
names: re-executing the same drive after a crash re-performs the same
start instruction, invoke_id rebuilds byte-identically, and the
duplicate insert conflicts with the stored job and becomes a no-op.
The first production embedder (a production CQRS/Oban host) found the
case that key cannot represent, during its end-to-end spike
(2026-08-23). Upstream, a document-authored invoke id is used verbatim -
st-ADR-0008 as amended, "document-authored IDs are always respected"
(Statifier.Machine.Invoke). A chart whose states re-enter - a retry
loop is the canonical shape - therefore produces <invoke id="resolve">
with a byte-identical invoke_id on every re-entry. Each re-entry is a
legitimately new invocation: the state exited (cancelling the old one)
and was entered again. Under the infinite-window {scope, invoke_id}
key, the second entry's insert conflicts with the first entry's stored
job - in whatever terminal state it reached - and the fresh invocation
silently never runs. Only generated ids (invoke_counter) escape,
because the counter is never reset and each re-entry mints a new id.
The host reconciled by packing its scope as run_id#macrostep (its own
scope wrapper), so crash replays still dedup while re-entries get fresh
jobs, and by matching cancels with a scope-prefix LIKE. It works, but
it overloads the meaning of scope, forces prefix matching into
cancellation, and every embedder would have to rediscover and reimplement
it.
Three facts bound the fix:
1. The effect already carries the discriminator. A
%Statifier.Effect.Invoke{} carries macrostep, microstep, and
round - "the counters as they stand when the invoke is produced" -
and they are pure fold state, so a crash replay rebuilds them
byte-identically (the same argument st-ADR-0059 makes for the timer
ordinal). StatifierOban.Invoke.JobArgs already writes macrostep at
the top level of the args, where Oban's uniqueness keys read.
2. Macrostep granularity is exactly re-entry granularity for
invocations. Invocations start only at the end of a macrostep, for
states still active then (spec 6.4's statesToInvoke discipline): a state
exited mid-macrostep never invokes, so a {state_index, invoke_index}
pair invokes at most once per macrostep. Within one macrostep,
{scope, invoke_id, macrostep} collides exactly when the insert is a
replay of the same scheduling decision; across macrosteps it never
collides. This is where invocations differ from timers: a <send>
inside a <foreach> executes many times per microstep, which is why
timers needed a minted ordinal (st-ADR-0059) - invocations do not.
3. Cancellation addresses the id, not the generation. Spec 6.4.3
cancels the invocation named by its id when the state exits; the base
handler matches stored jobs on {scope, invoke_id} via an args query,
not via the unique key. That match must keep spanning every generation -
exactly as timer cancellation matches every row under a send_id - so
the discriminator belongs in the insert key only.
Decision
Three options were on the table for sob-76d:
A. Bless the host's scope-packing recipe as documented practice.
Rejected: it overloads scope (one field carrying two identities),
forces prefix-LIKE cancel matching on every embedder, leaks into the
delivery seam (the packed scope must be unpacked before the run-liveness
check), and diverges invoke-job scoping from timer-job scoping inside
the same package.
B. Add macrostep to the worker's unique keys. Chosen. The unique
key becomes {scope, invoke_id, macrostep}, all three read off the args
at the top level, window and states unchanged. JobArgs already puts
the field on the wire, so no wire change, no upstream change, and stored
rows keep self-describing. Cancellation is untouched: it still matches
{scope, invoke_id} across every generation. The host recipe becomes
unnecessary: hosts pass their plain scope again.
C. Ask statifier-ex for a first-class per-execution ordinal on
%Effect.Invoke{} (st-ADR-0059's shape, a third counter or the shared
timer_counter). Not pursued: fact 2 means macrostep already gives
per-scheduling-decision uniqueness for invocations, so the ordinal would
add an effect-struct field upstream to solve a problem the effect's
existing fields solve. If upstream ever grows one for its own reasons,
this key can migrate by a superseding ADR.
Consequences
- A re-entered state's authored invoke id schedules a fresh job; a crash replay of the same drive still dedups. The two cases are separated by a field that is deterministic fold state, per the same replay argument the timer key rests on.
- Hosts that packed generation into the scope should stop: with this key the packed scope splits one run's jobs across scopes for no benefit, and plain scopes restore uniform cancel matching. (No released version carried the old key; 0.1.x shipped timers only.)
- Residual, recorded honestly: two
<invoke>elements authored with the same id, active in the same macrostep of the same session, still collide. That document is already ambiguous upstream -done.invoke. <invoke_id>delivery and 6.4.3 cancellation address the same shared id - so whether it is even valid is statifier-ex's call (invoke-id semantics are the interpreter contract's); this package does not paper over it with a wider key. The same boundary covers the stale- generation delivery question: a prior generation's completed job that escaped cancellation deliversdone.invoke.<id>that the session cannot tell from the new generation's - the delivery seam's liveness check knows runs, not generations. Both are raised upstream rather than decided here.