External effect handler — invoked at drain time with resolved params and
a context. Returning outputs records them on the run's effectHistory
row, read downstream as $effects['<name>'].<output>. Returning ops
applies the state half of the effect in the completion commit (field.*
only, never status.set) — every returned op must explicitly set
target.scope, since completion ops have no authoring location to infer
one from. Throwing fails the attempt, with no ops. Without a
EffectRetry policy on the node, or once one has spent its attempts
or closed its window, that failure settles the effect as failed. A policy
with attempts left inside its window dispatches again instead, unless the
drain finds between two attempts that it no longer holds the claim: then it
stops without settling and reports the entry as lost. That check happens
between attempts only. The completion itself stays first-writer-wins, so a
dispatch that runs to its end still reports its outcome and settles the
entry if it gets there first.
Delivery is at-least-once: a handler MAY run more than once for the same
effect. Three causes. A dispatch can die after its side effect but before
commit. A lease can expire mid-dispatch and be taken over. And every
admitted attempt of a retry policy invokes the handler again. Completion
is first-writer-wins, and the losing run's completion is reported as lost.
Write handlers to tolerate repetition. Derive external identifiers from
ctx.effectKey so the receiving system can dedupe: the key is stable across
every repeat, including all attempts of one policy run. Checking
effectHistory[] for a row keyed by ctx.effectKey before irreversible
work catches a repeat of an already-completed run, but never a retry
attempt, because a policy writes one history row when the whole run ends and
nothing records the attempt that just failed.
The bivarianceHack indirection keeps this readable from the non-generic
Engine surface; only the typed drain invokes handlers.
External effect handler — invoked at drain time with resolved
paramsand a context. Returningoutputsrecords them on the run'seffectHistoryrow, read downstream as$effects['<name>'].<output>. Returningopsapplies the state half of the effect in the completion commit (field.*only, neverstatus.set) — every returned op must explicitly settarget.scope, since completion ops have no authoring location to infer one from. Throwing fails the attempt, with noops. Without a EffectRetry policy on the node, or once one has spent its attempts or closed its window, that failure settles the effect as failed. A policy with attempts left inside its window dispatches again instead, unless the drain finds between two attempts that it no longer holds the claim: then it stops without settling and reports the entry aslost. That check happens between attempts only. The completion itself stays first-writer-wins, so a dispatch that runs to its end still reports its outcome and settles the entry if it gets there first.Delivery is at-least-once: a handler MAY run more than once for the same effect. Three causes. A dispatch can die after its side effect but before commit. A lease can expire mid-dispatch and be taken over. And every admitted attempt of a
retrypolicy invokes the handler again. Completion is first-writer-wins, and the losing run's completion is reported aslost.Write handlers to tolerate repetition. Derive external identifiers from
ctx.effectKeyso the receiving system can dedupe: the key is stable across every repeat, including all attempts of one policy run. CheckingeffectHistory[]for a row keyed byctx.effectKeybefore irreversible work catches a repeat of an already-completed run, but never a retry attempt, because a policy writes one history row when the whole run ends and nothing records the attempt that just failed.The
bivarianceHackindirection keeps this readable from the non-genericEnginesurface; only the typed drain invokes handlers.