This tool runs multi-step write sequences against an upstream API that doesn't support distributed transactions. The pattern is compensating writes: each step has a forward function (the write) and an undo function (the compensating write that reverses it). If a later step fails, the runner walks the completed steps in reverse and calls their undo functions to roll back the partial sequence.

The runner was written cleanly. Each step is an object with name, write, undo, and optional verify. The forward pass iterates and tracks what completed. The rollback pass iterates in reverse and calls each completed step's undo.

The bug is in how the rollback pass found which undo to call.

// Forward pass — execute each step, record completion
for (const step of steps) {
  const result = await step.write();
  completed.push({ name: step.name, result, hasUndo: typeof step.undo === 'function' });
}

// Rollback pass — find each completed step's undo by name
for (let i = completed.length - 1; i >= 0; i--) {
  const c = completed[i];
  const original = steps.find(s => s.name === c.name);
  if (!original || typeof original.undo !== 'function') {
    irreversible.push({ name: c.name, result: c.result });
    continue;
  }
  await original.undo(c.result);
}

The forward pass stored only the step's name and result on the completed record. The rollback pass then looked up the original step by name via Array.find. The assumption: names are unique within a spec.

Names are not necessarily unique within a spec. Nobody validates them. A spec author who writes two steps with the same name — by template instantiation, by copy-paste, by ordinary human variation — produces a spec the runner accepts and the rollback misroutes.

What goes wrong

Suppose a spec has three steps:

  1. name: "create-record", write produces {id: A1}, undo deletes the record
  2. name: "create-record", write produces {id: B2}, undo deletes a different record
  3. name: "fails", write throws

Forward pass completes step 1 (id A1), completes step 2 (id B2), fails at step 3. Rollback walks completed in reverse.

Step 2 first. steps.find(s => s.name === 'create-record') returns the FIRST step matching that name — step 1. The rollback calls step 1's undo function with step 2's result (B2). Step 1's undo is the wrong recipe for B2. In a Merus context, that's a delete fired at B2 using step 1's delete arguments, which might be the wrong endpoint, wrong record type, or just produce a malformed call.

Step 1 next. find returns step 1 again (it's still the first match). The rollback calls step 1's undo with step 1's result (A1). This one happens to be correct — step 1's undo against step 1's result is what you wanted — but only because the rollback iteration index 1 happens to receive step 1's result.

Step 2's undo function is never called. The actual recipe for B2 is silently abandoned.

For a sequence that creates two activities and then fails, the rollback can end up deleting the wrong activity (the second one with the first one's delete args) and leaving the first one in place. Or, if the misrouted call fails for some reason, the audit log would show one undo error, leaving the attorney to manually figure out what actually happened.

The fix

Capture each step's undo and verify callbacks directly on the completed entry at forward time. The rollback then doesn't need to look anything up — each completed entry is self-contained.

// Forward pass
for (const step of steps) {
  const result = await step.write();
  completed.push({
    name: step.name,
    result,
    undo: typeof step.undo === 'function' ? step.undo : null,
    verify: typeof step.verify === 'function' ? step.verify : null,
  });
}

// Rollback pass
for (let i = completed.length - 1; i >= 0; i--) {
  const c = completed[i];
  if (typeof c.undo !== 'function') {
    irreversible.push({ name: c.name, result: c.result });
    continue;
  }
  await c.undo(c.result);
}

The change is small in lines and large in semantics. The reference to undo is now tied to the specific step that produced the result, not to whichever step happens to have the same name. After the fix, the duplicate-name probe correctly calls step 2's undo with B2 and step 1's undo with A1. Step 2's undo no longer disappears.

Why this pattern is worth recognizing

Whenever you have an array of items and you want to associate per-item state with them, there are two ways to do it.

By reference: store the item directly (or store enough of the item's state) on the per-item record. The association is unique by construction.

By name: store a key, look up the original later via search. The association depends on the key being unique. If it isn't, you get the wrong original.

The by-name approach is appealing because it keeps the per-item record small (just a name, not a closure reference). It's also appealing in serializable contexts — closures can't be serialized; names can. For a JavaScript runtime where everything is in memory, neither of those advantages matters, and the uniqueness assumption is a hidden footgun.

The lesson: when associating per-item state with items, prefer references unless serialization forces names. If you must use names, validate uniqueness at construction time, not at lookup time. find() is a footgun the moment names stop being unique.

Why this slipped through

Spec validation rejects empty steps, missing args, and wrong types. It doesn't check for duplicate names. The forward pass works correctly regardless of whether names are unique — each iteration uses the step's own undo via the for-of loop. Only the rollback path looks up by name. And the test cases for rollback don't use duplicate names because nobody thought to write them that way.

The probe found this in about thirty seconds: write two steps with the same name, give them distinct undo functions, force a failure, watch which undo runs. The fix took ten minutes. The bug had been there since the rollback engine was first written.

The version on the install URL is 4.5.108.