# 1.2.0 Flow-Closure — Planner Rationale (Free side)

Formalizes the owner-approved `plan/1.2.0-flow-closure-plan.md` (DEC-5 included)
into the validated engine manifest `plan/1.2.0-plan-flowclosure.yaml`. This
doc carries the feasibility verdict, stability flags, wave-ordering reasoning,
and the contracts I was unsure about. HOW lives in the referenced skills, not
here.

## Feasibility verdict: GO — confidence 0.86

Every check in the planner rubric passes for the Free side:

1. **Structure valid** — mature plugin, recognizable layout, branch
   `autovap/wb-listora/integration` carries the merged 1.2.0 features.
2. **Work type supported** — `@wordpress/scripts` + `src/` present (CSS built
   via `bin/build-css.mjs`; IAPI store). REST + admin + template work all have
   existing homes.
3. **Scope sane** — 7 directions across 4 waves, each with a tight scope. No
   rewrite; every direction extends or wires EXISTING code.
4. **Dependencies present** — Action Scheduler 3.9.3 is vendored in Free (PR
   #77), so the background-import retry path the import journey depends on is
   real, not aspirational.
5. **DB-change safety** — additive minor, ZERO schema change. `listora_analytics`
   is read-only (view counts). Production rule 4 (no schema touch outside a
   version bump) is respected and there is no bump needed because nothing is
   altered.
6. **Build pipeline ready** — CSS source/compiled split is documented; the
   mobile-nav direction scopes `src/components/**` (source) not the compiled
   twin, per `docs/architecture/CSS-ARCHITECTURE.md`.
7. **Security posture** — the one new public endpoint (`/notifications/
   unsubscribe`) is intentionally `permission_callback __return_true` BUT
   HMAC-gated on a signed user+event token, so logged-out one-click works
   without an open-write hole. Flagged as the security-sensitive direction.

Confidence is 0.86 rather than higher because the unsubscribe token scheme and
the SSR reviews-panel deep-link are net-new behavior (not just wiring), and the
DEC-5 bot-helper extraction touches three call sites that must collapse to one
without changing the existing `wb_listora_analytics_is_bot` filter contract.

## What this plan deliberately does NOT do

- No Pro-side work (p10-* directions, the refund loop, monetization onboarding,
  analytics actions, migration polish). Free-only run.
- No gateway/payments e2e (blocker 10 is conditional on config verification and
  is a Pro concern).
- No competitor-migration backgrounding or receipt-token URLs — those DEC-5
  items are Pro-leaning and out of this Free run's scope. The Free DEC-5 slice
  here is exactly two items: the shared bot helper (Free-first) and documenting
  Background_Import as canonical.

## Wave ordering — why this sequence

Owner hard priority overrides the generic 9-phase order: **import fixes are
CORE and lead.** The data/rest/ui phase collapse still holds within that
constraint.

- **wave-1-import-core (f10-import-feedback)** — FIRST, nothing ahead of it.
  This is the rest-api + admin-ui handoff for import. It is mostly *wiring* the
  EXISTING `Background_Import::queue_file()` / `/import/progress/{run_id}`
  surface that the auditor proved exists but is unwired, plus failure feedback.
  Everything downstream can assume the background engine is reachable.
- **wave-2-email-optout (f10-email-optout)** — depends on wave-1 only for serial
  ordering per owner cluster priority (it shares the settings-page file with
  wave-1's advanced-tab edit, so it cannot run in the same parallel wave — a
  same-wave scope clash; sequencing it after wave-1 keeps `class-settings-page.php`
  edits non-concurrent). This is the rest-api + frontend handoff for emails.
- **wave-3-flow-closure** — three directions with **disjoint scopes** run in
  PARALLEL: views (admin column + dashboard tab), lifecycle (submission
  renderer + bulk actions + detail/reviews templates), mobile-nav (src/components
  + admin settings CSS/JS). The validator confirmed 0 scope-overlap warnings.
- **wave-4-architecture (DEC-5)** — LAST, because it consolidates code the
  earlier waves stabilize. Free-first per the CLAUDE.md upscale rule so Pro
  consumes the documented helper, never Free internals (INV-3). The two
  directions are sequenced (`f10-import-canonicalization depends_on
  f10-bot-helper-extraction`) because the canonicalization direction refreshes
  the manifest, which must reflect the bot-helper's new hook first.

## Same-wave scope-disjointness — the file-collision call

`class-settings-page.php` is touched by THREE pieces of work: the import
advanced-tab demo re-run (wave-1), the email admin toggle list + test-send
(wave-2), and (read-only-ish) the Privacy row. To keep every parallel wave's
scopes disjoint the validator can prove safe, the two directions that BOTH edit
`class-settings-page.php` (f10-import-feedback, f10-email-optout) live in
SEPARATE waves, never the same parallel batch. The Privacy row was assigned to
f10-lifecycle-polish's templates/bulk scope rather than re-touching
`class-settings-page.php` in wave-3, avoiding a third concurrent writer.
Result: 0 overlap warnings.

## Stability annotations (enterprise-scale flags)

- **Import (wave-1):** the >500-row timeout is exactly the failure the existing
  Background_Import async path fixes. The risk is the post-AS-retry re-poll: the
  widget must resume polling after a retry, not stay stuck. `wiring` gate +
  `browser-smoke` negative path cover it. Carries `action-audit`.
- **Email (wave-2):** HMAC token must be constant-time compared and salted; a
  replay/old-token must fail closed (negative journey asserts it). The footer
  link swap must not break the 4 events that legitimately offer unsubscribe
  (`draft_reminder, listing_expiring_soon, review_helpful, review_reminder` —
  the existing `should_send` list at `class-notifications.php:1103`).
- **Views (wave-3):** rendering 0 everywhere must not regress the `-` vs 0
  semantics — a missing analytics row (unknown) is distinct from a real zero.
  Negative journey enforces the distinction.
- **Bot helper (wave-4):** the richer `Analytics_Lite::is_bot()` becomes the
  canonical body; the existing `wb_listora_analytics_is_bot` filter must keep
  working for back-compat while the new `wb_listora_is_bot_request` filter is
  the going-forward override. Two filters coexist (production rule 2/3 —
  additive, no removal).

## Wave-7 wiring lesson — encoded

A self-boot class needs a `class_exists`/hook boot trigger, and a route that
exists only in source (never `register_rest_route`'d at runtime) is a FAIL.
This plan has two live proof points:

1. The EXISTING `Background_Import` route `/import/progress/{run_id}` is
   registered on `wb_listora_rest_api_init` — wave-1's job is to make the CSV UI
   actually *call* `queue_file()` and *poll* it. The `wiring` static gate +
   `browser-smoke` assert the runtime call, not just the source.
2. The NEW `/notifications/unsubscribe` controller must self-register on
   `wb_listora_rest_api_init` (class_exists boot) and the `contract` builtin
   gate diffs live routes vs `contracts.rest_routes` + `migrations` — a
   source-only route would breach.

## Reuse map (per direction — what NOT to re-implement)

The full per-direction reuse maps are inline in the manifest as `# REUSE` blocks
above each direction. Summary of the load-bearing existing surface every
direction MUST consume:

| Direction | Reuse (consume, never fork) | New surface (in migrations[]) |
|---|---|---|
| f10-import-feedback | `Background_Import::{queue_file,queue_demo,get_progress}` + `/import/progress/{run_id}` route + `wb_listora_bg_import_use_async` filter; `CSV_Importer`; `Settings_Page::render_advanced_tab()` (~1954); `assets/js/admin/admin-pages.js` | `wb_listora_demo_import_run` action |
| f10-email-optout | `Notifications::{send (~1106 footer),review_reminder (~801),send_test (~971),user_wants_notification,should_send}`; `_listora_notify_{event}` user meta; `Email_Templates_Page::get_event_map()` (~221, already has review_reminder + dashboard_url); `templates/emails/parts/footer.php`; `tab-profile.php`; `class-cli-commands.php` test-email list | `/notifications/unsubscribe` REST; `wb_listora_unsubscribe_token_secret` filter; `wb_listora_after_unsubscribe` action; review_reminder added to CLI event list |
| f10-views-everywhere | `Listing_Columns` `listora_views` case (~35, drop `>0` guard, add link); `Analytics_Lite::prepare_views()` (~141); `tab-listings.php` `$listing_views` (~233) | none (additive render only) |
| f10-lifecycle-polish | `submission-field-renderer.php` `business_hours` case (~348); `Listing_Bulk_Actions::{apply_action(returns WP_Error ~238),bulk_action_notices(~287)}`; `Privacy_{Exporter,Eraser}`; `listing-detail/tabs.php` + `listing-reviews` templates | none (additive UI only) |
| f10-mobile-nav | `user-dashboard/nav.php` (role=tablist already set 2026-05-24); `src/components/**` (dashboard tablist) + `assets/css/admin/settings.css`; `settings-nav.js` | none (CSS/affordance) |
| f10-bot-helper-extraction | `Analytics_Lite::is_bot()` (~331, richer impl) + `wb_listora_analytics_is_bot` filter; `Anti_Spam` UA read (~215) | `wb_listora_is_bot_request()` helper + filter |
| f10-import-canonicalization | `Background_Import` (canonical run_id engine); `audit/manifest.json` + `audit/architecture/**` | none (docs/manifest only) |

## Sanctioned new surface (the migrations[] allow-list)

Everything customer-facing this plan introduces is declared — undeclared surface
HARD-HALTS at the `contract` gate:

1. **REST** — `listora/v1 /notifications/unsubscribe` (HMAC token, logged-out).
2. **Hook (filter)** — `wb_listora_unsubscribe_token_secret` (rotate HMAC key).
3. **Hook (action)** — `wb_listora_after_unsubscribe` (Pro/integration mirror).
4. **Hook (filter)** — `wb_listora_is_bot_request` (DEC-5 shared helper override).
5. **Hook (action)** — `wb_listora_demo_import_run` (Re-run demo import observe).
6. **CLI** — `wp listora test-email` gains `review_reminder` in its event list.

## Contracts I was unsure about (flag for owner review)

- **`/import/progress/{run_id}` response shape** — I seeded the route into
  `contracts.rest_routes` from `class-background-import.php:662`, but I did NOT
  enumerate its JSON keys (run_id/processed/total/errors/messages/status). If
  wave-1 needs to ADD a key to the progress payload for the failure-messages
  rendering, that is technically a contract surface change — but it reads as an
  internal admin-only widget payload, not a documented public contract. Owner
  call: treat the progress payload as additive-free (extend at will) or declare
  any new key in migrations. I left it OUT of migrations on the assumption it is
  internal; flag if you disagree.
- **`review_reminder` email_output** — listed in `contracts.email_output`
  because the footer + dashboard_url change touches its rendered output. The
  template *identity* is stable; only the footer link target + an existing var
  change. No template file is removed (production rule 5 respected).
- **`listora` CLI command** — modeled the test-email event-list extension as a
  `cli_command` migration `from→to` because it changes accepted argument values,
  not the command surface itself. If the engine treats arg-value changes as
  non-contract, this migration entry is harmless but redundant.

## Risks

| Risk | Severity | Mitigation in plan |
|---|---|---|
| Unsubscribe endpoint is logged-out / open by design | High | HMAC-signed user+event token; negative journey asserts tampered token fails closed; `wb_listora_unsubscribe_token_secret` lets sites rotate |
| Post-AS-retry widget stays stuck (the original blocker 8) | High | `wiring` gate + browser-smoke negative path explicitly re-poll after retry |
| `class-settings-page.php` edited by import (w1) + email (w2) | Medium | Sequenced into separate waves so they never write concurrently; Privacy row kept out of settings-page to avoid a w3 third writer — 0 overlap warnings |
| Compiled CSS hand-edit drift | Medium | mobile-nav scopes `src/components/**`, not compiled twins; CI Rule 4 drift guard catches violations |
| Bot-helper extraction double-implements detection | Medium | DEC-5 direction explicitly collapses Analytics_Lite + Anti_Spam onto ONE helper; keeps the old filter, adds the new one (additive) |
| SSR reviews-panel deep-link to "oldest unanswered" mis-targets | Low | lifecycle journey negative path lands on #reviews and asserts SSR-visible + anchor resolution |
| Manifest staleness after new hooks/route | Low | wave-4 f10-import-canonicalization refreshes manifest after all new surface lands (depends_on bot-helper so its hook is included) |

## Validation

`node ~/autovap-agent/scripts/validate-plan.mjs plan/1.2.0-plan-flowclosure.yaml`
→ **PASS — 4 waves, 7 directions, 7 journeys, 0 warnings, 0 errors.**

STOP. Owner reviews + edits before any execution.
