# Price-Breakdown Provenance Tooltips — Multi-Agent Execution Plan

**Goal:** In the `/map` "How was this price calculated?" panel, every operand
number becomes hoverable, showing a **full-path tooltip** stating where the
value comes from — e.g. hovering the `0.72` in `Route cost: 271 km × 0.72 × 0.92`
shows **"Pricing Data: Ireland → 5 m³ → short haul (under 1,000 km) → Cost per
km (ppk_cost)"**. Hover-only (no links). Display-only: the served price and the
panel's reconciliation guarantee must be untouched.

**Status:** Ready for hand-off. Give every agent §1 (Shared context) plus its
own workstream section. Workstreams A and B run in PARALLEL against the frozen
contract in §2; C runs after both.

---

## 1. Shared context (give to EVERY agent)

- App: MoPricer (Laravel 11 Blade + jQuery + Materialize 0.100.2), live at
  https://mopricer.digitaldecode.net. Work in the existing temp clone:
  `/private/tmp/claude-501/-Users-bhathiyapathirana-Dev-DigitalDecode/22f4c8ba-538b-4f04-b1dd-1c508c84ecda/scratchpad/mopricer-clone`
  (branch `feat/breakdown-provenance` off `master`). Do not run git/deploy —
  the orchestrator does. Read `UI_PATTERNS.md` and `docs/PRICING_GUIDE.md`.
- **How the panel works today** (read these first):
  - `app/Pricing/PricingExplainer.php` — display-only re-derivation of the
    quote. Returns `['engine'=>[...], 'sizes'=>[...]]`; returns null unless its
    totals reconcile with the served prices; `explain()` swallows all
    throwables. **This safety design is sacred.**
  - `resources/views/map.blade.php` — `renderBreakdown(response)` (~line 131)
    builds the panel rows client-side from `response.breakdown`.
  - Provenance inputs available inside `explainInput()`: `$in->ferries`
    (name/price per crossing), `$in->zones` (zone/price/fixed/distance),
    `$in->pricingData[$code]['name']` (country display name),
    `MuveOnePricingConstants` `$c` (markupBase, minCostDistanceKm,
    sizeMinPrices, ukVatRate, size30Multiplier, distanceTierBounds),
    `$pricingDataCountryCode`, `$in->distanceType`, and the per-size
    `$sizeData`/`$distancePricingData` field reads.
- **HARD RULES:**
  1. The served price must be bit-identical. Provenance is ADDITIVE — new keys
     only; never modify or reorder existing keys in the breakdown payload; the
     reconciliation check and the null-on-mismatch behaviour stay untouched.
  2. Everything stays inside the explainer/display path. No changes to
     kernels, dispatcher, controllers' calculation logic, routes, or field
     `name=` attributes.
  3. Tooltip copy that asserts pricing behaviour must be verified against
     `docs/PRICING_GUIDE.md` — never invented.
  4. Tests: `tests/**PricingExplainer*` must still pass, plus the golden
     master pricing tests. If provenance can't be added without breaking one,
     stop and report.

## 2. The payload contract (FROZEN — both agents build to this)

`PricingExplainer` adds one new top-level key `prov` (absent = frontend
renders exactly as today, so old/new server+client mix safely):

```jsonc
"prov": {
  // engine-level operands
  "distance_km":        "Computed: driven route distance from Google Directions, including forced waypoints",
  "distance_tier":      "Computed: 271 km falls in the short haul band (under 1,000 km) — bands set in Pricing Constants",
  "pricing_country":    "Selected rates: Ireland (IE) — destination country (GB → non-GB uses the destination's rate table)",
  "total_ferry_cost":   "Ferries: Holyhead, UK - Dublin, IE (153.00)",           // one line per crossing, joined with \n; note when dover_ferry zone rerouted into ferry
  "total_zone_cost":    "Zones: Dover Ferry (fixed 119.00)",                     // per zone: name + (fixed X | 0.90 × N km inside)
  "margin":             "Pricing Data: Ireland → EU country margin → Import margin (from GB) (import_margin)",
  "vat_rate":           "Pricing Constants → UK VAT rate (ukVatRate)",
  "min_cost_rule":      "Pricing Constants → applies when route is under 500 km (minCostDistanceKm)",
  "markup_base_const":  "Pricing Constants → Markup base (markupBase)",
  // exact numeric values of the two constants the JS currently hardcodes:
  "markup_base_value":  100,
  "min_cost_distance_km": 500,
  // per-size operands (same size keys as `sizes`)
  "sizes": {
    "5": {
      "ppk_rate":              "Pricing Data: Ireland → 5 m³ → short haul (under 1,000 km) → Cost per km (ppk_cost)",
      "ppk_percentage":        "Pricing Data: Ireland → 5 m³ → Distance rate multiplier (ppk_percentage)",
      "markup_base":           "Pricing Data: Ireland → Total markup (total_markup)",
      "markup_percentage":     "Pricing Data: Ireland → 5 m³ → short haul (under 1,000 km) → Markup multiplier (markup_percentage)",
      "ferry_cost_percentage": "Pricing Data: Ireland → 5 m³ → short haul (under 1,000 km) → Ferry cost share (ferry_cost_percentage)",
      "min_cost":              "Pricing Constants → Minimum charge for 5 m³ (sizeMinPrices)"
    }, ...
    "30":  { "derived": "30 m³ is priced as 2 × the 15 m³ quote (size30Multiplier, Pricing Constants)" }
  }
}
```

Rules baked into the contract:
- Human path segments use the SAME labels as the Pricing Data editor
  (`Cost per km (ppk_cost)` etc.) so hover text and edit screen match. Tier
  labels use the band ranges from `distanceTierBounds` (do not hardcode).
- Country is the display `name` with the code when helpful. The margin path
  points at the OTHER country's table (import → destination, export → origin)
  — this is exactly what the code does; copy verified vs PRICING_GUIDE §4.
- `ppk_percentage` sits at SIZE level (not distance band) — the path must
  reflect the real nesting, read from where the explainer reads it.
- Plain strings, `\n` for multi-line (ferry/zone lists). No HTML in prov
  strings (frontend inserts them as text — XSS-safe).

## 3. Workstreams

### A — Server: provenance builder (1 agent)
OWNED: `app/Pricing/PricingExplainer.php` (+ its test file, additions only).
1. Inside `explainInput()`, build `prov` per §2 alongside the existing
   arrays, reading names/labels from data already in scope. Append `'prov'`
   to the return array — change nothing else.
2. Sources of truth for wording: tier bounds from `$c->distanceTierBounds`;
   country names from `$in->pricingData[$code]['name']`; ferry/zone lines from
   `$in->ferries`/`$in->zones` (mirror the dover_ferry rerouting: if a
   `dover_ferry` zone was folded into ferry cost, say so in BOTH prov strings).
   Pricing-country selection sentence mirrors the actual if/elseif chain
   (EU-both → 'EU'; to GB → origin table; else destination table; missing →
   'ZZ' fallback, and prov must say "default (ZZ) — route not in the rate table"
   when `$fellBack`).
3. Extend the explainer test: assert `prov` exists, spot-check 3 paths
   (ppk_rate full path, margin direction, ZZ fallback), and assert the
   pre-existing keys are byte-identical to before (fixture comparison).
4. Run the pricing test suite (PHPUnit filtered to PricingExplainer + golden
   master) and report results in your return payload. If vendor/phpunit is
   unavailable in the clone, say so — orchestrator runs it on the server.

### B — Frontend: hover tooltips (1 agent)
OWNED: `resources/views/map.blade.php` only.
1. In `renderBreakdown()`, wrap each operand in a provenance span when its
   prov string exists — else render exactly as today (payload without `prov`
   must produce today's output character-for-character):
   `<span class="mo-prov" data-prov="...">0.72</span>` — set `data-prov` via
   text/attr escaping (build rows with jQuery or escape entities; prov strings
   are plain text, never inject as HTML).
2. Tooltip = pure CSS (no Materialize tooltip init — rows are re-rendered per
   quote): `.mo-prov` gets a dotted underline + help cursor;
   `.mo-prov:hover::after` shows `attr(data-prov)` in a dark rounded bubble,
   `white-space: pre-line` (multi-line ferry/zone lists), max-width ~320px,
   positioned above, `z-index` above the collection list. Keep styles in the
   page `<style>` block.
3. Wire every operand: distance_km, tier text in the Rates row, ppk_rate,
   ppk_percentage, markup_base, markup_percentage, total_ferry_cost,
   ferry_cost_percentage, min charge, zone cost, margin %, VAT %, and the
   30 m³ derived note.
4. Replace the two hardcoded display constants using the contract values when
   present: `+ 100` → `+ <markup_base_value>` (tooltip = markup_base_const),
   `route under 500 km` → `under <min_cost_distance_km> km` (tooltip =
   min_cost_rule). Fall back to current literals when `prov` is absent.
5. Amount column stays exactly as-is; only operands become hoverable.

### C — Verification (after A+B; orchestrator or 1 agent)
1. Blade compile (PHP 8.2, server vendor, temp dir) + `node --check` n/a
   (inline JS) → rely on browser run.
2. PHPUnit on the server working copy for explainer + golden master tests.
3. Deploy to live (branch → master → server pull, view:clear), then Playwright:
   - London→Paris quote still **1022**; breakdown panel still renders.
   - Programmatic hover assertions: `.mo-prov` count > 6; `data-prov` of the
     ppk-rate span matches `/^Pricing Data: /` and contains `ppk_cost`;
     ferry operand prov contains a ferry name; margin prov names the right
     country and direction. Screenshot with a tooltip forced visible
     (`:hover` simulated via mouse move) for the visual record.
   - Dublin→Liverpool (the user's example): `0.72` hover shows the Ireland path.
4. If reconciliation ever returns null (panel vanishes) → hard fail, revert.

## 4. Acceptance checklist
- [ ] Served prices bit-identical (1022 gate + golden master tests green)
- [ ] `prov` additive; old client × new server and vice versa both safe
- [ ] Every operand hoverable with FULL-path tooltip; hover-only, no links
- [ ] Paths use the Pricing Data editor's labels and real tier bounds
- [ ] Margin tooltip points at the correct country + direction
- [ ] ZZ fallback and dover_ferry rerouting truthfully described
- [ ] Hardcoded `+100` / `500 km` replaced by constant-driven display
- [ ] No HTML injection path from prov strings (rendered as text)
- [ ] PricingExplainer null-on-mismatch + swallow-all behaviour unchanged
