diff --git a/docs/review/170/README.md b/docs/review/170/README.md
index 866767d..2c8b9b7 100644
--- a/docs/review/170/README.md
+++ b/docs/review/170/README.md
@@ -205,3 +205,13 @@ Historical PNGs, result JSONs, captions and conflict observer assertions remain
byte-for-byte unchanged; refreshed hashes alone are not browser acceptance
evidence. Hosted Admin must rerun #170 in both browsers against the exact PR
head before acceptance.
+
+### #281 merchant outcome copy source refresh
+
+The Admin fingerprint now binds presentation-only merchant outcome lines in
+`class-free-admin.php` (item headline, persisted-count summary, one existing
+next step). No planner math, storage, nonce, route, or handler change.
+Historical PNGs, result JSONs, captions and the conflict observer sentence
+remain byte-for-byte unchanged; this refresh does not claim new screenshots.
+Hosted Admin must rerun #170 in both browsers against the exact PR head before
+acceptance.
diff --git a/docs/review/170/proof.json b/docs/review/170/proof.json
index 9946d7d..d5e5898 100644
--- a/docs/review/170/proof.json
+++ b/docs/review/170/proof.json
@@ -2,7 +2,7 @@
"schema": 1,
"base_main": "bd66a79",
"source_hashes": {
- "class-free-admin.php": "85ec78ba49b79727d113ffc6f7ee720163691e16ca7b12f605ab4d498cb78b8e",
+ "class-free-admin.php": "806698fb7788b1e89904a52d95912713b05d2561b4e7ff31075bf59200de396a",
"free-selection.js": "19f60a7914b3b845e5adb9585f4b347e93e61d1ccd44d1e04b7601501e32e28e",
"free-selection.css": "77673d2620b0fea759ef4ffa168f175a8ff1ead605cf6cac96a76ac4f155b199"
},
diff --git a/wordpress/tests/admin/integration.php b/wordpress/tests/admin/integration.php
index 463081f..8faf704 100644
--- a/wordpress/tests/admin/integration.php
+++ b/wordpress/tests/admin/integration.php
@@ -738,7 +738,7 @@ function wl122_current_rows( string $html ): array {
finally { remove_filter( 'query', $trace_queries ); remove_filter( 'woocommerce_product_get_regular_price', $shopper_price ); }
$current_rows = wl122_current_rows( $current_html );
eq( array_slice( $current_rows[$current_id], 1, 3 ), array( '$18.00 USD', '$21.00 USD', '$14.40 USD' ), 'same conflict row preserves saved and current decimal strings' );
-ok( str_contains( $current_rows[$current_id][4], 'Not changed' ) && str_contains( $current_rows[$current_id][4], 'left the newer value unchanged' ), 'merchant conflict explanation in the affected row' );
+ ok( str_contains( $current_rows[$current_id][4], 'Newer price kept' ) && str_contains( $current_rows[$current_id][4], 'left the newer value unchanged' ), 'merchant conflict explanation in the affected row' );
eq( $current_rows[$current_other][2], '$19.20 USD', 'ordinary applied row also uses fresh Current' );
foreach ( $render_queries as $query ) {
ok( ! preg_match( '/^\s*(?:INSERT|UPDATE|DELETE|REPLACE|CREATE|ALTER|DROP|TRUNCATE|GRANT|REVOKE|START\s+TRANSACTION|BEGIN|LOCK)\b|\bFOR\s+UPDATE\b/i', $query ), 'Current render is SELECT-only, with no mutation/worker lock' );
diff --git a/wordpress/tests/admin/outcome-copy-unit.php b/wordpress/tests/admin/outcome-copy-unit.php
new file mode 100644
index 0000000..54f1e94
--- /dev/null
+++ b/wordpress/tests/admin/outcome-copy-unit.php
@@ -0,0 +1,67 @@
+getMethod( 'merchant_item_line' );
+$line->setAccessible( true );
+$summary = $outcome->getMethod( 'merchant_outcome_summary' );
+$summary->setAccessible( true );
+$next = $outcome->getMethod( 'merchant_next_step' );
+$next->setAccessible( true );
+$cell = $outcome->getMethod( 'render_item_outcome' );
+$cell->setAccessible( true );
+
+$expect = array(
+ 'APPLIED' => 'Changed',
+ 'UNCHANGED' => 'Not changed',
+ 'UNSUPPORTED' => 'Skipped',
+ 'CONFLICT' => 'Newer price kept',
+ 'FAILED' => 'Not changed; needs attention',
+ 'NEEDS_REVIEW' => 'Needs review; outcome uncertain',
+ 'UNDONE' => 'Restored',
+ 'UNDO_CONFLICT' => 'Not restored; newer price kept',
+ 'UNDO_FAILED' => 'Not restored; needs attention',
+ 'UNDO_NEEDS_REVIEW' => 'Needs review; restoration uncertain',
+);
+foreach ( $expect as $state => $copy ) {
+ check204( $line->invoke( null, $state, 'COMPLETED_WITH_ISSUES' ), $copy, 'merchant line ' . $state );
+ check204( preg_match( '/rolled back|full rollback|accessibility certified/i', $copy ), 0, 'merchant line stays bounded: ' . $state );
+}
+check204( WriteLeash\Free_Admin::item_label( 'FAILED', 'COMPLETED_WITH_ISSUES' ), 'Needs attention; not changed', 'item_label contract unchanged' );
+check204( WriteLeash\Free_Admin::item_label( 'UNSUPPORTED', 'PLANNED' ), 'Skipped at preview', 'item_label skip contract unchanged' );
+check204( WriteLeash\Free_Admin::item_label( 'CONFLICT', 'COMPLETED_WITH_ISSUES' ), 'Not changed', 'item_label conflict contract unchanged' );
+check204( str_contains( WriteLeash\Free_Admin::result_summary( array( 'planned' => 4, 'pending' => 0, 'applying' => 0, 'applied' => 1, 'unchanged' => 1, 'conflict' => 2, 'failed' => 0, 'needs_review' => 0, 'unsupported' => 1 ), 'COMPLETED_WITH_ISSUES', true ), '2 conflicts' ), true, 'result_summary contract unchanged' );
+
+$counts = array( 'planned' => 6, 'pending' => 1, 'applying' => 1, 'applied' => 1, 'unchanged' => 1, 'conflict' => 1, 'failed' => 1, 'needs_review' => 1, 'unsupported' => 1 );
+check204( $summary->invoke( null, $counts, 'RUNNING' ), 'Outcomes: 1 changed · 1 not changed · 1 skipped · 1 newer price kept · 1 not changed; needs attention · 1 needs review; outcome uncertain · 1 in progress; outcome not yet confirmed · 1 remaining.', 'partial summary uses persisted counts' );
+check204( $summary->invoke( null, $counts, 'COMPLETED_WITH_ISSUES' ), 'Outcomes: 1 changed · 1 not changed · 1 skipped · 1 newer price kept · 1 not changed; needs attention · 1 needs review; outcome uncertain · 1 in progress; outcome not yet confirmed · 1 not processed.', 'terminal pending is not processed' );
+check204( $summary->invoke( null, array( 'planned' => 2, 'pending' => 2, 'applied' => 0, 'unchanged' => 0, 'unsupported' => 0, 'conflict' => 0, 'failed' => 0, 'needs_review' => 0 ), 'PLANNED' ), '', 'planned pending is not invented as an outcome' );
+check204( str_contains( $summary->invoke( null, $counts, 'RUNNING' ), '%' ), false, 'merchant summary does not infer a percent' );
+
+check204( $next->invoke( null, true, true, true ), 'Next: use Resume remaining products. Completed changes are kept.', 'one next step prefers the existing Resume control' );
+check204( $next->invoke( null, false, true, true ), 'Next: use Re-preview conflicted products to build a fresh preview. Prices do not change until you approve that preview.', 'conflict next step is a fresh preview' );
+check204( $next->invoke( null, false, false, true ), 'Next: Undo restores eligible prices only. It does not reverse orders, sales, emails, webhooks, or other plugins.', 'Undo next step is not a full rollback' );
+check204( $next->invoke( null, false, false, false ), 'Next: review the product.', 'default next step is inspect the product' );
+
+ob_start();
+$cell->invoke( null, 'FAILED', null, 'COMPLETED_WITH_ISSUES', array( 'context' => '' ) );
+$failed_html = (string) ob_get_clean();
+check204( str_contains( $failed_html, 'Not changed; needs attention' ), true, 'failed cell headline' );
+check204( str_contains( $failed_html, 'Review the product. This outcome is not confirmed as unchanged.' ), true, 'failed cell one next step' );
+check204( str_contains( $failed_html, 'FAILED ·' ), true, 'typed failed state stays in Details' );
+check204( str_contains( $failed_html, 'rolled back' ), false, 'failed cell does not claim a rollback' );
+check204( str_contains( $failed_html, 'name="action"' ), false, 'outcome cell adds no form' );
+
+ob_start();
+$cell->invoke( null, 'CONFLICT', null, 'COMPLETED_WITH_ISSUES', array( 'context' => '' ) );
+$conflict_html = (string) ob_get_clean();
+check204( str_contains( $conflict_html, 'Newer price kept' ), true, 'conflict cell headline' );
+check204( str_contains( $conflict_html, 'Create a new preview' ), true, 'conflict cell keeps the existing preview link' );
+check204( substr_count( $conflict_html, ' 'FORBIDDEN', 'reason' => 'prog
// text, dropping the outcome emphasis and the conflict next-action links that
// the server-rendered cells carry. The payload must ship the same markup.
$html_rows = Admin::progress_snapshot( $post, 'POST' )['rows'][0];
- check204( str_contains( $html_rows['apply_html'], 'Not changed' ), true, 'polled Apply cell keeps the outcome emphasis' );
+ check204( str_contains( $html_rows['apply_html'], 'Newer price kept' ), true, 'polled Apply cell keeps the merchant outcome emphasis' );
check204( str_contains( $html_rows['apply_html'], $long_apply ), true, 'polled Apply cell keeps the long guidance' );
check204( str_contains( $html_rows['apply_html'], 'Create a new preview' ), true, 'polled Apply cell keeps the conflict next action' );
check204( str_contains( $html_rows['apply_html'], '