@@ -123,6 +123,15 @@ func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S]
123123 if res .Err != nil {
124124 i .rollback (txn )
125125 res .NewState = i .current
126+ // Clear any effects accumulated before the failure. The single-spine commit
127+ // path already returns Effects: nil on every failure, but the parallel path
128+ // (fireParallel accumulates earlier regions' effects) and the run-to-completion
129+ // loop (it appends a sub-step's effects before checking its error) can carry
130+ // real effects of earlier, successfully-committed sub-steps. Dropping them here
131+ // makes the no-effects-on-failure contract hold uniformly regardless of which
132+ // path produced the partial effects, so a host replaying a failed Fire cannot
133+ // double-apply them.
134+ res .Effects = nil
126135 }
127136 return res
128137}
@@ -131,7 +140,9 @@ func (i *Instance[S, E, C]) fireCore(ctx context.Context, event E) FireResult[S]
131140// Fire can restore them, making a failed macrostep a no-op on the instance's
132141// persisted internal state. It holds the configuration, current leaf, context,
133142// recorded history maps, and the internal-event queue — every field a commit
134- // writes before it can fail.
143+ // writes before it can fail. The configuration is aliased (the macrostep reassigns
144+ // i.config rather than mutating it in place); the history maps and queue are copied
145+ // only when non-empty (see beginTxn).
135146type fireTxn [S comparable , E comparable , C any ] struct {
136147 current S
137148 config []S
@@ -141,15 +152,27 @@ type fireTxn[S comparable, E comparable, C any] struct {
141152 raised []E
142153}
143154
144- // beginTxn snapshots the instance's macrostep-mutable state before a Fire runs. The
145- // configuration, history maps, and queue are copied (not aliased) so an in-place
146- // mutation during the macrostep cannot corrupt the saved snapshot; the context and
147- // current leaf are values. It allocates only the small per-macrostep snapshot and
148- // never touches a clock or IO, so Fire stays pure.
155+ // beginTxn snapshots the instance's macrostep-mutable state before a Fire runs. It
156+ // restores only the instance's PERSISTED state; the transient FireResult.Effects
157+ // accumulated by a failed macrostep are cleared separately in fireCore.
158+ //
159+ // The configuration slice is ALIASED, not copied: the macrostep only ever REASSIGNS
160+ // i.config to a freshly-built slice (descendToLeaves, a history restore, replaceRegionLeaf,
161+ // or a literal) and never writes an element of the live backing array in place, so the
162+ // saved header still points at the pre-Fire leaves after any commit and a rollback that
163+ // restores it is exact. Aliasing avoids a per-Fire slice allocation on every Fire,
164+ // including the successful ones that never roll back.
165+ //
166+ // The history maps and the raised queue are copied only when NON-EMPTY (copyMap /
167+ // copyLeafMap / the append return nil for an empty input, allocating nothing). recordHistory
168+ // writes the live history maps in place, so a non-empty map must be copied to keep the
169+ // snapshot intact; the steady-state machine carries empty history and an empty queue, so
170+ // this costs no allocation on the hot path. The context and current leaf are values.
171+ // beginTxn never touches a clock or IO, so Fire stays pure.
149172func (i * Instance [S , E , C ]) beginTxn () fireTxn [S , E , C ] {
150173 return fireTxn [S , E , C ]{
151174 current : i .current ,
152- config : append ([] S ( nil ), i .config ... ) ,
175+ config : i .config ,
153176 entity : i .entity ,
154177 historyShallow : copyMap (i .historyShallow ),
155178 historyDeep : copyLeafMap (i .historyDeep ),
@@ -158,9 +181,13 @@ func (i *Instance[S, E, C]) beginTxn() fireTxn[S, E, C] {
158181}
159182
160183// rollback restores the instance to the state captured by beginTxn, discarding every
161- // configuration, context, and history mutation a failed macrostep applied. Effects
162- // are already suppressed on the failure path (the NTE contract), so after rollback
163- // the instance is byte-for-byte identical to its pre-Fire state.
184+ // configuration, context, and history mutation a failed macrostep applied. It restores
185+ // only the instance's PERSISTED state; the transient FireResult.Effects accumulated
186+ // before the failure are cleared by fireCore right after this call (the single-spine
187+ // commit path returns no effects on failure, but the parallel and run-to-completion
188+ // paths can carry earlier sub-steps' effects, so that clear is what makes the
189+ // no-effects-on-failure contract global). After rollback the instance is byte-for-byte
190+ // identical to its pre-Fire state.
164191func (i * Instance [S , E , C ]) rollback (txn fireTxn [S , E , C ]) {
165192 i .current = txn .current
166193 i .config = txn .config
0 commit comments