Lesson 5’s coordinator returned early and cleanly whenever a step failed, is_wp_error(),
return the error, done. That’s the easy failure to handle: nothing happened yet, so nothing
needs undoing. The hard case is the one that’s actually common in a four- or five-step chain:
step 3 fails after steps 1 and 2 already changed something real. The refund already went out.
The restock call is what just failed. Now what does the coordinator do, and what does it tell
whoever’s waiting on the result?
The four-step order-ops/coordinate-return chain from Lesson 5, and the idempotent
annotation from Course 1 and Course 2’s ability schema and MCP-hint lessons.
Step 1: Why WordPress ability chains have no rollback
A database transaction can roll back cleanly because every write happens inside one
connection, one commit boundary. A chain of abilities is nothing like that: refund-order
might call an external payment gateway, restock-returned-item writes to your own inventory
table, and neither knows the other exists. There is no ROLLBACK that undoes a completed
external refund. Once step 2 has genuinely refunded money, the only way to “undo” a step 3
failure is a deliberate, explicit compensating action, more code you write, not a mechanism
WordPress or PHP gives you for free.
Step 2: Classify every step by what failure there actually means
| Step | If it fails | What’s already true |
|---|---|---|
| 1. validate-return-eligibility | Nothing happened, safe to just report the error. | No side effects yet. |
| 2. refund-order | Payment gateway didn’t confirm, safe to retry the whole chain from here. | Depends on the gateway; check its own idempotency guarantee before retrying blindly. |
| 3. restock-returned-item | The refund already succeeded. Retrying step 2 would double-refund. | Refund is committed and irreversible from here. |
| 4. notify-customer-refunded | The refund and restock already succeeded, only the notification is missing. | Nothing more needs undoing, only the notification needs a retry. |
This table is the actual design work. Once you know, per step, whether a failure there means “nothing happened” or “something irreversible already happened,” you know whether the recovery path is retry the step, retry the whole chain, or run a distinct compensating action.
Step 3: The coordinator, extended to track what already succeeded
// File: order-ops/class-coordinate-return.php (extended)
function order_ops_coordinate_return( $input ) {
$order_id = $input['order_id'];
$completed_steps = array();
$eligibility = wp_get_ability( 'order-ops/validate-return-eligibility' )->execute( array( 'order_id' => $order_id ) );
if ( is_wp_error( $eligibility ) || empty( $eligibility['eligible'] ) ) {
return new WP_Error( 'not_eligible', 'Order is not eligible for return.', array( 'completed_steps' => $completed_steps ) );
}
$refund = wp_get_ability( 'order-ops/refund-order' )->execute( array(
'order_id' => $order_id,
'amount' => $eligibility['refund_amount'],
) );
if ( is_wp_error( $refund ) ) {
// Nothing committed yet. Safe to retry the whole chain later.
return new WP_Error( 'refund_failed', $refund->get_error_message(), array( 'completed_steps' => $completed_steps ) );
}
$completed_steps[] = 'refund';
$restock = wp_get_ability( 'order-ops/restock-returned-item' )->execute( array( 'order_id' => $order_id ) );
if ( is_wp_error( $restock ) ) {
// The refund already happened. Do not retry refund-order, record the gap instead.
update_post_meta( $order_id, '_order_ops_chain_state', array(
'completed_steps' => $completed_steps,
'failed_step' => 'restock',
'refund_amount' => $eligibility['refund_amount'],
) );
return new WP_Error(
'restock_failed',
'Refund succeeded but restock failed. Retry order-ops/retry-restock-only for this order.',
array( 'completed_steps' => $completed_steps, 'order_id' => $order_id )
);
}
$completed_steps[] = 'restock';
$notify = wp_get_ability( 'order-ops/notify-customer-refunded' )->execute( array(
'order_id' => $order_id,
'amount' => $eligibility['refund_amount'],
) );
$completed_steps[] = is_wp_error( $notify ) ? null : 'notify';
delete_post_meta( $order_id, '_order_ops_chain_state' ); // fully resolved, clear any prior failure record
return array( 'order_id' => $order_id, 'completed_steps' => array_filter( $completed_steps ) );
}
The _order_ops_chain_state meta written on a step-3 failure is the compensating record, it
tells a later recovery attempt exactly what’s already true, so it never re-runs the refund by
mistake.
Step 4: A narrow compensating ability for the specific gap
Rather than re-running the whole coordinator (which would call refund-order a second time),
register a narrow ability that only does the missing part:
// File: order-ops/class-retry-restock.php
wp_register_ability( 'order-ops/retry-restock-only', array(
'label' => 'Retry only the restock step for an already-refunded order',
'description' => 'For an order where order-ops/coordinate-return already refunded '
. 'successfully but restocking failed, retries only the restock step. Does not '
. 'issue a second refund.',
'input_schema' => array(
'type' => 'object', 'properties' => array( 'order_id' => array( 'type' => 'integer' ) ),
'required' => array( 'order_id' ),
),
'execute_callback' => 'order_ops_retry_restock_only',
'permission_callback' => 'order_ops_can_resolve',
'meta' => array( 'mcp' => array( 'public' => true ), 'annotations' => array( 'idempotent' => true ) ),
) );
function order_ops_retry_restock_only( $input ) {
$state = get_post_meta( $input['order_id'], '_order_ops_chain_state', true );
if ( empty( $state ) || 'restock' !== ( $state['failed_step'] ?? null ) ) {
return new WP_Error( 'no_pending_restock', 'No pending restock failure recorded for this order.' );
}
$restock = wp_get_ability( 'order-ops/restock-returned-item' )->execute( array( 'order_id' => $input['order_id'] ) );
if ( is_wp_error( $restock ) ) {
return $restock;
}
delete_post_meta( $input['order_id'], '_order_ops_chain_state' );
return array( 'order_id' => $input['order_id'], 'restocked' => true );
}
Marking this ability idempotent: true is deliberate and matches the annotation concept from
Course 1 and Course 2: calling it twice for the same order after the gap is already resolved
correctly returns the no_pending_restock error rather than doing anything twice, restocking
the same item’s inventory count more than once would be a real, silent bug.
Test it
# File: terminal
# Force restock to fail deliberately (e.g. temporarily break order_ops_restock_returned_item),
# then run the chain and confirm the failure response and the meta record:
wp-env run cli wp eval '
$result = wp_get_ability( "order-ops/coordinate-return" )->execute( array( "order_id" => 4821 ) );
var_dump( $result ); // should be a WP_Error mentioning restock_failed
var_dump( get_post_meta( 4821, "_order_ops_chain_state", true ) ); // should show completed_steps: [refund]
'
# Fix the restock bug, then run the compensating ability:
wp-env run cli wp eval '
var_dump( wp_get_ability( "order-ops/retry-restock-only" )->execute( array( "order_id" => 4821 ) ) );
'
Confirm the payment gateway shows exactly one refund for the order, never two, even after the retry, that’s the detail that actually proves the compensating-action design worked.
Wrapping the whole coordinator in one broad try/catch and returning "something went wrong"
on any failure discards exactly the information Step 3 needs: which steps already committed.
A model, or a human, reading that generic message has no way to know a refund already went
out, and the natural next action, “just try again,” is precisely how you end up double-
refunding an order. Always return which steps completed, not just that something failed.
Recap
WordPress ability chains have no transactional rollback, so a failure past the first
irreversible step needs a deliberate compensating design: know which steps are safe to retry
outright, record what already succeeded when a step fails after something irreversible, and
expose a narrow, idempotent-annotated ability that repairs only the specific gap rather than
re-running the whole chain. The annotation isn’t decorative here, it’s what stops a
recovery attempt from causing the exact double-write it’s supposed to fix.
Resources & further reading
- Abilities API reference, developer.wordpress.org
- wp_register_ability() function reference, developer.wordpress.org
- Metadata API reference, developer.wordpress.org