# WB Listora (Free) 1.2.0 Phase 2 — Plan Rationale

Companion to `plan/1.2.0-plan-phase2.yaml`. Written by the AutoVAP planner on
2026-06-07 against the `autovap/wb-listora/integration` branch (phase-1 merged).
Read-only scan; the planner wrote only the manifest + this file.

## Feasibility verdict: GO (confidence 0.86)

| Check | Result |
|---|---|
| Plugin structure valid | Yes — mature plugin, 56 REST, 11 tables, 233 fired hooks, manifest schema v2.1. |
| Work type supported | Yes — every item extends an existing subsystem (Notifications, search engine, Migration_Base, admin settings, list-table). No greenfield. |
| Scope sane | Yes — 8 cohesive directions, each on one subsystem; no rewrite, no 50-file blast radius. |
| Dependencies present | HivePress (item 6) is the only external — the migrator must be conditional on HivePress data being present (negative path in the journey). Schema audit already exists at `audit/architecture/competitor-schemas/hivepress.md`. |
| DB-change safety | No schema changes. `listora_hours` (item 3), `reviews/claims/favorites` (item 4) already exist. The de-dupe guard for item 2 is meta/AS-uniqueness, NOT a new table. One new NON-autoloaded option for item 5. All within additive-minor rules 7-9. |
| Build pipeline ready | Yes — `@wordpress/scripts` + `bin/build-css.mjs`; only items 3/5/9 touch assets and they reuse the existing token/primitive build. |
| Security posture | Items 4, 5, 8, 9 are the sensitive ones — all gated `wiring` (action-audit + REST/JS contract) and capability+nonce required in the journeys' negative paths. |

Confidence is 0.86 rather than higher because two items carry contract-collision
or external-dependency risk (items 7 and 6, below). Neither is a blocker; both
are de-risked in the manifest.

## Contract risks found (the load-bearing findings)

### RISK 1 — `wb_listora_search_results` name collision (item 7). RESOLVED in-plan.
The owner's scope named the new search action `wb_listora_search_results`. That
name is **already taken** — it is an `apply_filters( 'wb_listora_search_results',
$response_data, $args, $request )` at `includes/rest/class-search-controller.php:315`
(a REST-only response filter). Firing a NEW action under the same name would:
- shadow nothing at runtime (actions and filters are separate hook tables in WP),
  BUT
- guarantee developer confusion and a near-certain mis-wire where a Pro listener
  registered for the analytics action accidentally lands on the filter (or vice
  versa) with the wrong arg signature (`$response_data,$args,$request` vs the
  intended `$args,$total,$context`).

Resolution baked into the manifest: the new action is named
**`wb_listora_search_resolved( array $args, int $total, array $context )`** and
declared in `migrations[]`. It fires from `Search_Engine::search()` (not the REST
controller) so it covers BOTH the REST and the SSR/block search paths — the
existing filter only covers REST. This is genuinely new code: grep of
`hooks_fired` confirms NO post-search ACTION fires today, so the owner's
"if an equivalent hook already fires, this becomes documentation" clause does not
apply — there is no equivalent action, only a same-named filter.

### RISK 2 — HivePress migrator abstract-method contract (item 6). CORRECTED in-plan.
The owner's draft said the migrator implements "`extract_*` template methods".
The real `Migration_Base` (verified at `includes/import-export/class-migration-base.php`)
declares the abstracts **`detect()`, `get_source_count()`, `get_source_ids($offset,$limit)`,
`migrate_listing($source_id)`** plus the NON-abstract `detect_source_fields()`,
`get_default_mapping()`, `extract_for_preview()` (added later so existing
subclasses stay valid). The direction was rewritten to name the correct
abstracts. The `context: 'migration'` arg on `wb_listora_listing_submitted` is
non-negotiable (CLAUDE.md migrator contract) and has an existing sentinel at
`docs/qa/journeys/regression/migrator-context-arg.md`.

### RISK 3 — email filter prefix (item 5). CORRECTED in-plan.
The owner wrote the filters as `email_subject_{event}` / `email_content_{event}`.
Verified real names carry the plugin prefix: **`wb_listora_email_subject_{$event}`
($subject, $vars)** and **`wb_listora_email_content_{$event}` ($body, $vars)** at
`includes/workflow/class-notifications.php:1094/1109`. The editor reads back by
`add_filter` on those exact names. Preview uses the existing
`Notifications::send_test($event_key, $recipient, $context)` (line 916). The
event list comes from the Notifications event map (the `$templated_events` array
at line 1475 plus the dynamically-fired events).

## Stability annotations (100k-scale)

- **Item 2 reminder** — bounded batch sweep via `Cron_Scheduler` + Action
  Scheduler (long-running -> AS, per the standing rule). Idempotency via a
  per-user+listing de-dupe guard, NOT a table scan. Opt-out reuses the
  default-on `_listora_notify_{event}` gate so no behavior regresses for
  existing users.
- **Item 3 badge** — ONE indexed query against `listora_hours` (`idx_open`
  covers `day_of_week, open_time, close_time, is_closed`), folded into the
  existing detail render. No per-listing N+1; grid/card paths are out of scope
  (detail page only).
- **Item 4 privacy** — exporter + eraser are paginated per the WP privacy API
  (`page` param + `done` flag), so a user with thousands of reviews never loads
  unbounded. Review erasure ANONYMIZES (preserves rating aggregates) rather than
  deleting, avoiding an aggregate-recompute storm.
- **Item 5 email editor** — ONE non-autoloaded option (`update_option(..., false)`,
  the AUD-F6 pattern already used for `wb_listora_settings`). No autoloaded fat
  option; no per-event option proliferation.
- **Item 6 migrator** — reuses the phase-1 `Background_Import` AS pipeline for
  large runs; bounded `get_source_ids($offset,$limit)` batches.
- **Item 8 bulk-edit** — bounded to 100 rows/request (same cap as the
  `bulk-moderate` REST endpoint it borrows semantics from).

## Wave ordering rationale (9-phase, collapsed for parallelism)

The proven phase sequence is `data -> core-logic -> rest -> admin -> frontend ->
integration -> security -> testing -> build`. Phase 2 has no data-layer work
(no schema changes), so the waves collapse onto the remaining phases with
same-wave scopes kept disjoint:

| Wave | Phase(s) | Why here | Same-wave scope disjointness |
|---|---|---|---|
| wave-5-quick-wins | core-logic + frontend + docs | Independent quick wins; no shared subsystem. | `docs/website/feature-catalog.md` vs `includes/workflow/**`+`templates/emails/**` vs `blocks/listing-detail/**`+`templates/blocks/listing-detail/**`+`src/components/badge*` — disjoint. |
| wave-6-extensibility | rest + integration | The Free-first search hook and the HivePress migrator are both "extension surface" work; independent of w5 output. | `includes/search/**` vs `includes/import-export/**` — disjoint. |
| wave-7-privacy | security | Net-new privacy tools; runs after w5 so the live site is stable for the qa-suite privacy/db checks. | Single direction. |
| wave-8-email-editor | admin | Depends on w5 so the new `review_reminder` event shows up in the editor list. | Single direction; admin scope file-specific. |
| wave-9-bulk-edit | admin | `depends_on: wave-8` ONLY to serialize the `includes/admin/**` surface — both touch admin, so they must not run in parallel. Scopes are file-specific (`class-email-templates-page.php` + `class-settings-page.php` in w8; `class-listing-bulk-actions.php` in w9) so even if parallelized they would not overlap, but the dependency edge is the belt-and-braces guarantee. | Single direction. |

The one non-obvious edge is **wave-9 depends_on wave-8**. There is no functional
dependency between the email editor and bulk-edit — the edge exists purely to
keep the `includes/admin/` surface serialized. I scoped each admin direction to
specific filenames so they are technically parallel-safe, but the master runs
waves serially by `depends_on`, and chaining them is the cheapest way to
guarantee no two agents ever rebase the same admin file. If the owner wants
wave-9 to run in parallel with w6/w7 instead, change its `depends_on` to
`[wave-5-quick-wins]` — the scopes already permit it.

## Three-entry-point check (new data surfaces)

| Item | Frontend | Admin | REST/API |
|---|---|---|---|
| 2 reminder | reminder email + opt-out link (customer) | reminder pref row in dashboard tab-profile; editable subject/body via item 5 | reuses the existing dashboard notification-prefs REST (`_listora_notify_*`) |
| 3 badge | detail-page badge | (read-only feature; admin sees it on the listing) | reads `listora_hours` (already populated via the submission/REST hours field) |
| 4 privacy | n/a (admin-tool feature) | WP Tools -> Export/Erase Personal Data | uses WP core privacy request objects |
| 5 email editor | rendered emails | Settings -> Emails screen | reads/writes the `wb_listora_email_templates` option (admin-only) |

No item introduces a data store reachable from fewer than the surfaces its kind
requires. Item 3's badge is a read-only projection of `listora_hours`, whose
write paths (submission form + REST hours field) already exist — documented
exception to "needs all three", since the badge is a render of existing data,
not a new store.

## Reuse map (direction -> existing code it consumes; what it must NOT re-implement)

| Direction | Reuses (verified location) | Must NOT re-implement |
|---|---|---|
| f5-feature-catalog | `docs/website/feature-matrix.md` as the source of truth for the 9 entries; catalog's own heading style | new feature claims — mirror the matrix |
| f5-review-reminder | `Notifications::send()` + envelope; `Email_Body_Formatter`; `_listora_notify_{event}` gate (`class-notifications.php:865/893`); `Cron_Scheduler` + AS; dashboard `tab-profile.php` prefs | send/mail/opt-out logic; no new table for de-dupe; no autoloaded option |
| f5-open-now-badge | `listora_hours` table + `idx_open`; badge primitive in `src/components`; design tokens; existing detail render | a new table/option; a per-listing extra query (no N+1) |
| f6-search-resolved-hook | `Search_Engine::search()` (`includes/search/class-search-engine.php:51`) as the single fire site | the name `wb_listora_search_results` (existing filter); a duplicate REST-only fire |
| f6-hivepress-migrator | `Migration_Base` abstracts; `Term_Helper`; `Background_Import` AS pipeline; `wb_listora_get_migrators()` (`migration-helpers.php:21`); schema audit `hivepress.md` | `Term_Helper`; the AS batch loop; firing `wb_listora_listing_submitted` WITHOUT `context:migration` |
| f7-privacy-tools | core `wp_privacy_personal_data_exporters/_erasers` filters (none registered today); review/claim/favorite read paths; `listora_` table accessors; `class-plugin.php` as the single registration site | the rating-aggregate recompute (must go through it, not around) |
| f8-email-editor-mvp | `wb_listora_email_subject_{$event}`/`_content_{$event}` filters (`class-notifications.php:1094/1109`); `Notifications::send_test()` (line 916); Notifications event map (`$templated_events` line 1475); ux-foundation settings-card + auto-injected admin header; AUD-F6 non-autoload pattern | template-file edits; a rich builder; a per-event option; an autoloaded option |
| f9-bulk-edit | `Listings_Controller::bulk_moderate` semantics (`class-listings-controller.php:518`) + `wb_listora_after_bulk_moderate`; `Status_Manager::is_valid_transition()/get_statuses()`; `Featured::feature_listing/unfeature_listing` (`core/class-featured.php:125/198`); `Term_Helper`; existing Approve/Reject row-actions in `Listing_Columns` | duplicate transition logic; bypassing Status_Manager (would skip notifications/webhooks) or Featured (would skip Pro credit rules) |

## Scope conflicts checked

- **`includes/admin/**`** is touched by item 5 (email editor) and item 8
  (bulk-edit). Resolved by (a) file-specific globs so they never share a file,
  and (b) placing them in separate, dependency-chained waves (w8 -> w9). The
  validator confirms 0 same-wave overlaps.
- **`includes/class-plugin.php`** is touched only by f7-privacy-tools (single
  wave, single direction) — no contention.
- **`includes/workflow/**`** (f5-review-reminder) and the email-editor's
  `add_filter` consumer (f8) both relate to Notifications, but f8's scope is
  `includes/admin/**` + assets, not `includes/workflow/**` — it consumes the
  existing filters from the admin side, so no file overlap. w8 `depends_on` w5
  guarantees the `review_reminder` event exists before the editor enumerates it.

## Open question for the owner (does not block planning)

- Item 9 bulk-edit was previously listed as 1.3.0 backlog in
  `1.2.0-MASTER-PLAN.md` ("Out of scope - 1.3.0 backlog: ... bulk-edit listing
  actions"). This manifest pulls it into 1.2.0 per the owner's 2026-06-07 scope.
  If that was unintentional, drop wave-9 and the bulk-edit journey; nothing else
  depends on it. Likewise item 7 (search analytics hook) was on the same 1.3.0
  list — included here as the Free-first half only (Pro consumes it later).
