# Audit Remediation Program — "Mockup vs. Live audit" → fixes

**Source of truth for item text:** `Mockup vs. Live audit/BACKLOG_SHOP.md` + `BACKLOG_ADMIN.md`
(compiled 2026-08-06, baseline mockup `cb48c8d` v0.28.0). This file is the **execution tracker**:
wave order, standing decisions, and per-item status. Update the Status column as items land;
one commit per item/group, suite green before every commit.

**Owner directive (2026-08-11):** work through every point, one after the other.

---

## Standing decisions (recorded so no wave re-litigates them)

1. **The `charge` ledger is authoritative.** It is the port of the demo's `finance.ts` and the
   baseline formulas live there. The legacy `Earnings`/`shop_earning` columns keep being written
   during the transition, but displayed figures and (by the end of the payout wave) the payout
   read the ledger. This is the "one decision" S1 Group B (shop) demands. Matches
   `DEMO_SYNC_V14_V20_MASTER_PLAN.md` P0 direction.
2. **Baseline = the mockup.** Where the audit records portal-vs-baseline divergence, we match the
   mockup unless the item is explicitly tagged a product decision. The 4 product decisions in
   `REPORT.md` §7 (marketing-fee basis OI-FIN-11, entitlement default OI-SUB-05, package fee basis
   OI-CAT-01, role vocabulary OI-OTH-06) are **flagged to the owner, not decided here** — waves
   that depend on them implement the mockup's literal behaviour and note the open ruling.
3. **Ordering constraints are law:** F-FIN-01 (Group A) ships **before or with** F-FIN-10; never
   F-FIN-10 alone. PKG-01+02 = two sites, both fixed. SE-02+SE-03 = one extraction, not two
   patches. F-FIT-01+05 = both directions of one link.
4. **Verification preconditions:** local DB gets P-RATES (non-zero commercial rates) before any
   money verification; fresh rows only (old rows carry stamped zero rates). Other P-codes applied
   per item.
5. **api/ tier is out of audit scope but in-contract with mobile** — fixes here target
   `common/` + portals; where the correct implementation already exists in api/ (packages group),
   reuse the same helpers, never fork them.

---

## Wave plan (S1 first — 18 findings, 8 root-cause groups)

| Wave | Items | One-line fix shape | Status |
|---|---|---|---|
| **W1** Walk-in payment split | F-FIN-01 S1 · CF-CC-04 S1 · CF-CC-08 S2 (shop A) | `WalkInBookingService` assigns `payment_mode` + `amount_collected=0`; fee engine skips processing row on basis ≤ 0 | ✅ `0ab69ef` |
| **W2** Package-redemption guard | PKG-01+02 · PKG-03 · PKG-05 · CF-CC-02(shop) all S1 (+reduces PKG-04/07/08) | Guard-first at derive/revenue/outstanding/processing + completion no-mint + portal cancel REJECTED (baseline default-off). Redemption-time stamp left 🅿OI-CAT-01 | ✅ `bb55ba5` |
| **W3** Completion raises ledger rows | FIN-LEDGER-01 S1 · FIN-LEDGER-02 S2 (admin C) + F-FIN-02+03 side one | `BookingCompletionService::ensureEarnings()` invokes `deriveBookingCharges()` w/ promote-pending-to-unpaid + marketing exists-guard + never-reverse-twice | ✅ `9a58401` |
| **W4** Marketing-fee legacy rail gate | F-FIN-02+03 side two (shop B) | `common\models\Earnings` override delegates gate to ledger resolvers (shop_owned⇒0, grace⇒0, floored; fail-open w/o booking) | ✅ `99c40c5` |
| **W5** Admin unread-config resolvers | CF-CC-01 S1 + F-FIT-04 S2 · NOTIF-02 S1 (+SN-01) · CF-CC-02(admin) S1 (+F-FIN-06) | `effectiveCarryThreshold`→commercial_config; plan-aware `freeLimitFor` (3 sites unified); `offerRateDiscountPct` scales both stampers (dual-vocab processing match) | ✅ `b17da46` |
| **W6** Subscription billing extraction | SE-02 S1 · SE-03 S1 (+SE-04 companion) (admin B) | `SubscriptionBillingService` (netPriceFor + cardRailCharge) used by both tiers; no-card ⇒ dunning; dev-PAID gated non-prod; past_due retry + 30d expiry pass | ✅ `f8748e9` |
| **W7** Settlement↔fee bidirectional link | F-FIT-01+05 S1 (admin F) + resolve OQ-FIT-A | linkEarnings tags marketing+processing (numeric id — OQ-FIT-A: col is INT NULL); eligibleEarnings excludes consumedBookingIds | ✅ `a03975b` |
| **W8** Pay-now silent failure | CF-CC-05 S1 (shop D) | Server `complete=0` collect-only mode (mockup parity, non-terminal statuses, no side effects); client sends it + reads the response, surfaces failures | ✅ `13e3e2e` |
| **W9** Booking-engine writers | BE-F01 S1 · BE-F03 S1 (shop E) | Walk-in creation runs full `checkPlacement`; new `OvernightPersist` (guard-hook-compliant) rolls dates at moveBooking + both slots endpoints | ✅ `d3a4090` |
| **W10** Payout formula | F-FIN-10 S1 (needs W1 shipped first — hard constraint) | Payout nets UNPAID processing rows (+VAT) + non-booking notif fees (tagged after save); constraint honoured (W1 = `0ab69ef` first) | ✅ `c858e6e` |

## S2 waves (after S1) — enumerated, status per item

**Shop S2 (27):** F-FIN-05 ⬜ · F-FIN-06 ⬜(closes w/ W5 CF-CC-02) · F-FIN-07 ⬜ · F-FIN-08 ⬜ ·
F-FIN-09 ⬜ · F-FIN-16 ⬜ · PKG-04 ⬜ · PKG-06 ⬜(largest packages work, gates PKG-09/UI-17) ·
PKG-07 ⬜ · PKG-08 ⬜ · PKG-09 ⬜ · BE-F02 ⬜ · BE-F04 ⬜ · BE-F05 ⬜ · BE-F06 ⬜ · BE-F07 ⬜ ·
CF-CC-01(shop) ⬜ · CF-CC-03 ⬜ · CF-CC-06 ⬜ · CF-CC-08 →W1 · SN-01 →W5 · SN-02 ⬜ · SN-04 ⬜ ·
CLS-01 ⬜ · CLS-02 ⬜ · GRP-04 ⬜ · GRP-06 ⬜

**Admin S2 (38):** SE-01 ⬜ · SE-04 →W6 · SE-05 ⬜🅿(OI-SUB-05) · SE-06 ⬜ · SE-08 ⬜ · SE-09 ⬜ ·
SE-10 ⬜ · SE-11 ⬜ · CF-CC-05 ⬜ · CF-CC-06 ⬜ · CF-CC-07 ⬜ · F-FIT-02 ⬜ · F-FIT-03 ⬜ ·
F-FIT-04 →W5 · F-FIT-06 ⬜ · F-FIT-07 ⬜ · F-FIT-08 ⬜ · FIN-LEDGER-02 →W3 · FIN-LEDGER-03 ⬜ ·
FIN-LEDGER-04 →W1 · FIN-LEDGER-05 ⬜ · FIN-LEDGER-07 →W2 · NOTIF-01 ⬜ · NOTIF-03 ⬜ · NOTIF-04 ⬜ ·
F-RBAC-01 ✅ · F-RBAC-02 ✅ · F-RBAC-03 ✅ · F-RBAC-04 ⬜🅿(OI-OTH-06) · F-RBAC-05 ⬜🅿(OD#4) · F-CAT-01 ✅ ·
F-CAT-02 ✅ · F-CAT-03 ✅ · F-CAT-04 ✅ · F-CAT-05 ✅ · F-ADJ-01 ⬜(AuditLogService wiring — one task,
six findings) · F-ADJ-02 ⬜ · F-ADJ-04 ⬜

## S3 / S4 / UI — enumerated

**Shop S3 (17):** BE-F08 ⬜ · BE-F09 ⬜ · BE-F11 ⬜ · BE-F12 ⬜ · BE-F13 ⬜ · BE-F14 ⬜ · F-FIN-11 ⬜ ·
F-FIN-13 ⬜ · F-FIN-15 ⬜ · SN-03 ⬜🅿 · SN-05 ⬜ · SN-06 ⬜ · SN-08 ⬜ · CLS-03 ⬜ · CLS-04 ⬜ ·
GRP-02 ⬜ · CF-CC-07 ⬜
**Shop S4 (5):** BE-F15 ⬜ · F-FIN-14 ⬜ · CLS-05 ⬜ · GRP-01 ⬜ · SN-09 ⬜
**Shop UI (32):** F-SHP-UI-01 ⬜(first — dead control) · UI-02/03/04 ⬜(same flow as W8) · UI-17 S2 ⬜
(gated by PKG-06) · UI-05…UI-16, UI-18…UI-32 ⬜ (see BACKLOG_SHOP UI section; UI-18/19 need ar+en)

**Admin S3 (32):** SE-07/12/13/14/15/16 ⬜ · FIN-LEDGER-08/09 ⬜ · F-FIT-09/10/11/12/13/14/15 ⬜ ·
CF-CC-03/04/08/09 ⬜ · F-CAT-06 ✅ · F-CAT-07(mobile)/08(audit-cluster) ⬜ · F-CAT-09 ✅ · F-RBAC-06 ⬜🅿(OD#4 — design set w/ 04/05) · F-RBAC-07 ⬜ · F-RBAC-08 ✅ · F-RBAC-09/11 ⬜ · NOTIF-05/06 ⬜ · F-ADJ-03/05/06 ⬜
**Admin S4 (9):** SE-17 ⬜ · FIN-LEDGER-06/10 ⬜ · F-FIT-16/17 ⬜ · F-CAT-10 ⬜ · F-RBAC-10/12 ⬜ · F-ADJ-07 ⬜
**Admin "outside scorecard":** H1/H2 UI probes (4) ⬜ · H6 ⬜ · H7/OQ-NOTIF-B ⬜🅿 · OQ-FIT-A ⬜(resolve
in W7) · OQ-CAT-C ⬜
**Admin UI (18):** F-ADM-UI-01…18 ⬜

🅿 = blocked on a product ruling (REPORT.md §7) — implement mockup-literal where possible, flag otherwise.

## Verification environment (local)

- P-RATES: set non-zero `commercial_config` marketing/processing rates + sms/wa sell prices before
  money probes; always create FRESH rows (old rows carry stamped zeros).
- P-PKG / P-OFFER / P-OVERNIGHT / P-WALKIN / P-NOSUB seeded per wave as needed.
- Suite: `docker exec projects-webserver bash -c "cd /var/www/html/Navagoo/common && ../vendor/bin/codecept run unit"` — green before every commit (451/451 at program start).

## Log

- 2026-08-11 · Program started. Tracker created; W1 begun.
- 2026-08-11 · **W1 shipped** (`0ab69ef`): walk-in create persists payment_mode +
  explicit amount_collected=0 (+deposit_amount in deposit mode); server-computed
  pay_due_now consumed by booking-new.js; legacy NULL→total fallback pinned by test.
  +4 tests (18 assertions); suite 455/455 green. Closes F-FIN-01, CF-CC-04, CF-CC-08;
  unblocks W10.
- 2026-08-11 · **W2 shipped** (`bb55ba5`): redemption guard-first in
  deriveBookingCharges/buildProcessingFee/bookingRevenue/outstandingBalance +
  ensureEarnings no-mint + portal cancel rejected (bilingual). Redemption-time stamp
  in buildPackageRedemptionCharges deliberately left pending 🅿OI-CAT-01. +6 tests;
  suite 461/461. Closes PKG-01+02(site two), PKG-03, PKG-05, CF-CC-02(shop); also
  admin FIN-LEDGER-07.
- 2026-08-11 · **W3 shipped** (`9a58401`): ensureEarnings → full deriveBookingCharges
  (completion raises the marketing row — FIN-LEDGER-01 + F-FIN-02+03 side one);
  marketing exists-guard + PENDING→unpaid promote + never-reverse-twice
  (FIN-LEDGER-02). +2 DB tests (rolled back); suite 463/463.
- 2026-08-11 · **W4 shipped** (`99c40c5`): legacy rail gated via common\models\Earnings
  override delegating to ledger resolvers — shop_owned⇒0, grace⇒0, min-floored;
  fail-open without booking context. F-FIN-02+03 now fully closed (both sides).
  +4 tests; suite 467/467.
- 2026-08-11 · **W5 shipped** (`b17da46`): carry-threshold → commercial_config
  (CF-CC-01+F-FIT-04); plan-aware freeLimitFor unified across billing/tile/view
  (NOTIF-02+SN-01); enrolled-offer discount wired into marketing+processing rate
  stampers (CF-CC-02 admin + F-FIN-06 shop). +3 tests; suite 470/470.
  **S1 scoreboard: 11 of 18 closed (W1-W5).**
- 2026-08-11 · **W6 shipped** (`f8748e9`): SubscriptionBillingService extraction —
  netPriceFor (offer discount every term) + cardRailCharge (no-card ⇒ dunning,
  dev-PAID gated non-prod); past_due retry on the charge pass + 30d expiry lapse
  pass (SE-04, PAST_DUE_EXPIRY_DAYS=30 assumption). +4 tests; 474/474.
- 2026-08-11 · **W7 shipped** (`a03975b`): linkEarnings tags marketing+processing
  (numeric id — OQ-FIT-A resolved: column INT NULL, old string write coerced to 0);
  eligibleEarnings excludes consumedBookingIds. +2 tests; 476/476.
- 2026-08-11 · **W8 shipped** (`13e3e2e`): actionCollect complete=0 collect-only
  mode (mockup parity, non-terminal, no side effects); booking-new.js sends it +
  reads the response, surfaces failures. +1 i18n key; 476/476.
- 2026-08-11 · **W9 shipped** (`d3a4090`): walk-in creation runs full checkPlacement
  (time-off/shift/can-perform); OvernightPersist rolls post-midnight dates at
  moveBooking + both slots endpoints (guard-shared hook respected — helper class,
  not the protected service; hook blocked the direct edit by design). +3 tests
  (mockup date-roll pin incl. 1440 boundary); 479/479.
- 2026-08-11 · **W10 shipped** (`c858e6e`): payout nets UNPAID processing rows
  (+VAT) + non-booking notif fees, tagged after save; F-FIN-01→F-FIN-10 ordering
  constraint honoured. +1 full-formula test; 480/480.

## S2 sweep log (2026-08-11, session 2)

- `28d10c4` BE-F04 + CF-CC-01(shop) + BE-F11(S3): update-status endpoint FSM-gated
  (allowedNext + no-show grace + outstanding hard-gate + completed_at).
- `465fa3a` CF-CC-03 + BE-F05 + BE-F12(S3): collect amount server-derived on
  complete; shop/calendar moves uncapped (customer-only counter); read-time
  service-sum footprint.
- `ae7dec6` BE-F06 + BE-F13(S3) + BE-F07(creation): promo re-validated vs the
  CUSTOMER + attributed + counted; per-line snapshots via api's own helper;
  Payment starts ON_HOLD, promoted at completion.
- `15c035f` F-FIN-07 + F-FIT-06 + BE-F07(terminal): ensureRetainedEarnings reusing
  createCancellationEarnings re-based on the REAL collection; wired at all 3 portal
  terminals; hold anchors on terminal ts, rounded whole days.
- `4166e72` F-FIN-08 + F-FIN-15(S3) + F-FIN-09: signed runningBalance +
  carriedBalance identity; ONE minimum resolver (shop→config→default) across all
  surfaces; admin collectable sums amountCollected+in-store (ledger helper public).
- `a317453` NOTIF-01 + NOTIF-03 + SN-05(S3): channelApproved(channel) single gate
  (in_app never approval-gated); dispatch+settings unified; admin writes keep
  per-channel columns live (edit resets ONLY the edited channel).
- `5a692b2` GRP-04 + GRP-06: party actions through the FSM (disallowed children
  left alone; api-shared behavioural note); outstanding derived at read time.

- `6ff6825` PKG-07 + FIN-LEDGER-08(S3) + F-FIN-13(S3) + F-FIN-05 + CLS-02: grace
  waives package-sale marketing; costsToDate = all rails not-paid/not-pending;
  no-show re-derives (idempotent); classification at api CREATION.
- `1d7267a` PKG-08 + CLS-03(S3): redemption valued from per_session_price snapshot
  (api-impact noted); classificationFor/isChargeable mobile fallback + 3 call sites.
- `9d0b5f4` FIN-LEDGER-03 + FIN-LEDGER-05 + F-FIT-08 + CF-CC-07: refund anchor
  carries slot time (3 date shapes pinned); all 3 subscription stamps →
  charge_to_card; payout notif netting UNPAID-only; ONE hold resolver
  (shop→config→7) incl. AgentsWallet locals.
- `a97012a` SE-10 + F-FIT-03: card invoice payment mirrors verify (charges flip
  paid, past_due subscription reactivates) in one tx; OI-FIN-01 (no gateway call)
  stays flagged.

**S2 sweep scoreboard: 24 S2 + 8 S3 closed in 12 code commits. Suite 488/488.**

**Remaining S2 (verified list):** shop — F-FIN-16, PKG-04, PKG-06(+PKG-09/UI-17,
schema+hub feature), BE-F02 ⛔guard-hook (BookingScheduleService::canPerform —
needs owner bypass), CF-CC-06(shop)+SN-04 (payment entitlement gating + locked UI),
SN-02 ⛔partly guard-hook (minuteLabel), CLS-01 🅿(needs api signup invite-token
persist), CLS-02 residual (none — closed). admin — SE-01 (term_features schema+stamp),
SE-05 🅿, SE-06 (gating resolver), SE-08 (plan-cap lock schema), SE-09 (bank upgrade
deferral), SE-11 (admin stamps term), CF-CC-05 (creation-time rate copy 🅿-adjacent:
changes retroactivity posture), CF-CC-06 (effective max-deposit → commercial config),
F-FIT-02 (settle flips charges — wire actionSettle effects into WithdrawalController),
F-FIT-07 (docs required to settle), NOTIF-04 (event-basis enum), F-RBAC-01/02/03/05,
F-CAT-01..05, F-ADJ-01(audit wiring→6 findings)/02/04. Then S3 (rest), S4, UI.

## Item-by-item log (2026-08-20 — owner directive: one item at a time, report, wait for "next")

- **F-ADJ-01 ✅ (UNCOMMITTED — owner commits themselves)** — AuditLogService wired into every
  admin mutation (now ~29 call sites, 10 controllers) + changes()/emitChange() diff helpers.
  Also closes: CF-CC-08 ✅, F-RBAC-09 ✅, F-CAT-08 ✅, F-ADJ-07 ✅, SE-15 (event half —
  notification half open). NO commits without an explicit owner ask.
  **4-agent review pass applied** (php-reviewer / security-reviewer / silent-failure-hunter /
  code-reviewer; ~24 deduped confirmed findings, all fixed):
  emit() hardened (never throws — audit can no longer roll back/500/false-fail a mutation, incl.
  the settle/verify/classification post-commit windows and the activation-email starvation);
  per-value clip (64KB TEXT cap — unbounded notification templates); OFFER CRUD wired
  (offer.created/updated/status_changed/deleted — was the missing 11th family);
  user toggle records the REAL prior status (4-state enum, was fabricated binary);
  every toggle/delete emit now gated on the write's return (no phantom events: triggers ×5,
  city toggle/delete, category delete, plan delete 0-row guard + status in condition,
  RBAC addChild/removeChild); pre-mutation before-capture everywhere;
  shared AuditLogService::auditable() (updated_by no longer leaks into diffs);
  naming unified (settlement.executed on both rails; trigger.activated/deactivated vs
  trigger.optional_changed). +2 more tests (actor resolution, oversize clip); suite 541/541.

- **F-ADJ-02 ✅ (UNCOMMITTED)** — AddToTimelineCommand::handle is append-only (the
  (event,user_id) find-and-reuse upsert removed); User::notifyDeletion now carries user_id
  (deletions no longer collapse into one mis-dated row); User::notifySignup dedupes at its
  own layer (exists-guard) because AFTER_INSERT hook + 5 explicit api calls can double-fire —
  the old overwrite was hiding that. Readers verified list/count-only (no uniqueness
  assumption). +3 tests; suite 544/544.

- **F-ADJ-04 ✅ (UNCOMMITTED)** — the admin marketing-rate surface now IS the billed rate:
  shop form's "Marketing Fee Rate %" input rebound to `platform_commission` (the column
  `FinanceLedgerService::marketingRatePct` reads; blank ⇒ global), the dead
  `Shop[marketing_fee_rate_pct]` input REMOVED, directory Marketing column shows the
  EFFECTIVE rate (muted+tooltip when inherited — new bilingual string), literal-5 seeding
  + both POST-assignment paths of the retired column removed (column left in DB untouched —
  no schema change, no mobile impact). +1 regression test (retired column has zero influence);
  suite 545/545. Browser-verified: form has ONE marketing field (value 5.00 from
  platform_commission), directory renders effective rates.
  **→ Batch 1 (audit-events) COMPLETE: 3/3 items + 5 dependent findings.**
- **4-agent review pass #2 (items 2+3) — 4/4 APPROVE, 0 CRITICAL / 0 HIGH.** Blank-field
  semantics PROVEN live ('' → NULL via dbTypecast → inherits global; explicit 0 survives);
  getId()-after-delete, emitter call-graph, i18n, XSS, mass-assignment all cleared.
  3 agreed fixes applied: shop-index Marketing cell resolves through
  financeLedger->marketingRatePct (one source of truth) + inherited-global display gated
  to `administrator` (was leaking an admin-only value to shop-scoped managers);
  `m260821_100000` adds timeline_event filter indexes (user_id + category,event —
  append-only growth); blank→NULL pinned by test. Noted, not fixed (LOW/pre-existing):
  form-vs-activate label wording, notifySignup TOCTOU (harmless), empty commercial_config
  dev seed. Suite 546/546; browser re-verified. UNCOMMITTED.

- **SE-01 ✅ (UNCOMMITTED)** — feature access snapshotted at the term boundary:
  `m260821_120000` adds `shop_subscription.term_features` JSON (applied; api tier has ZERO
  shop_subscription refs — additive, no mobile impact); `ShopSubscription::stampTermFeatures()`
  (fresh findOne, JsonExpression) called from stampTerm() (subscribe + both renewal passes,
  which apply pending_plan_id first) + trial activation + trial upgrade/downgrade switches +
  mid-term upgrade; `EntitlementService::shopFeatures()` reads the stamp first, NULL legacy
  rows keep live-plan behavior until their next boundary. +3 tests incl. full DB round-trip
  (stamp → mid-term plan edit doesn't leak → renewal re-stamp picks it up). Suite 549/549.
  Note: admin subscription actions don't stamp — that is SE-11's own item.

- **SE-06 ✅ (UNCOMMITTED — portal half; api half deferred to the mobile phase)** —
  payment-timing availability = toggle AND plan feature (online→mada_apple_pay,
  deposit/on_visit→deposits_pos): `ShopPaymentSettings::FEATURE_BY_MODE` +
  `planAllowsModeFor()` (static — gates the no-row default too; branch rows check the
  parent) + `getEntitledModes()`/`isModeEntitled()`. Wired portal-side:
  WalkInOptionsService::timings (picker) + WalkInBookingService POST guard (bilingual
  error). `getEnabledModes()` untouched → api/mobile behavior unchanged; the 4 api call
  sites (Booking×2, GroupBooking, Shops) reuse the SAME resolver in the mobile phase —
  flagged, not forked. Rides SE-01 term stamps + SE-05 no-sub default-allow. +3 tests;
  suite 552/552.

- **Review pass #3 (SE-01+SE-06) — security agent returned first: 1 HIGH + 3 LOW, all fixed.**
  HIGH: the party/group-create portal path accepted payment_timing with NO plan guard (full
  SE-06 bypass — could even record a party as fully "paid online" on a plan lacking the
  feature). Fixed at the portal boundary in AgentsBookingsController::actionCreateGroup
  (normalise-then-gate, mirrors the walk-in guard; api hardcodes on_visit — service-level
  guard lands in the mobile phase). LOWs fixed: term_features 'safe' rule REMOVED
  (entitlement column must never be mass-assignable — persists fine as a dirty attribute);
  actionUpdateBooking base\Booking choice pinned with a comment (payment_mode not
  mass-assignable there — deliberate); planAllowsModeFor now per-request memoized
  (+flushPlanFeatureCache, tests reset it). +1 frontend bilingual key. Suite 552/552.
  php/silent-failure/code-review agents still pending on the same scope.

- **Review pass #3 COMPLETE (all 4 agents) — every confirmed finding fixed. Suite 557/557 ×2.**
  Consensus majors, all closed: (1) STARTER walk-in outage (0/20 shops have settings rows;
  on_visit rides deposits_pos per the audit's own mapping) → empty timing list now renders a
  bilingual LOCKED/upsell notice + create disabled in BOTH walk-in and group modals (JS
  isValid gates; group buttons render entitled-only, default = first entitled) — mapping kept
  baseline-faithful; "should STARTER really have zero cash walk-ins?" flagged as an
  OI-SUB-05-adjacent product question. (2) Branch bypass → entitlementShopId() resolves
  inherit_parent branches to the subscribing PARENT inside planAllowsModeFor. (3) Guards now
  check toggle AND plan via ONE shared resolver entitledModesForShop() (walk-in service +
  group controller + timings all consume it). (4) SE-01 tier-edit bypass → stamps store the
  RESOLVED set (canonical-or-tier-default frozen at stamp time) via shared
  EntitlementService::resolvePlanFeatures(); read-time live-tier fallback now applies to
  UNSTAMPED rows only. (5) JsonExpression landmine → stamp assigns a plain array
  (dbTypecast wraps) + defensive unwrap in shopFeatures; same-request read test added.
  (6) stampTermFeatures no-ops now Yii::warning. (7) cache flush at the stamp choke point +
  test _before/_after flushes (a static-memo cross-test flake was caught and fixed).
  +6 tests (branch, no-row lock, create-guard rejection, tier-edit pin, label-data pin,
  same-request read). build:css rebuilt (amber notice classes). UNCOMMITTED.

- **SE-08 ✅ implemented (UNCOMMITTED — 4-agent review IN FLIGHT)** — the plan-cap lock:
  `m260821_130000` adds user.plan_cap_locked + user.wage_frozen_at (additive, applied; not
  exposed by api serialization — verify in review). EntitlementService::enforceSpecialistCap
  (NEWEST surplus actives → NOT_ACTIVE + lock + freeze stamp) wired into BOTH renewal rails
  inside the tx (uses the PENDING plan's cap at a boundary downgrade);
  releaseSpecialistCap (longest-frozen first, up to room; NULL max = all; manual
  deactivations untouched) wired into trial + active upgrade paths only — per baseline
  "clears only on an upgrade with room". Note: locking deactivates the user row, which
  mobile mirrors (specialists vanish from booking) — that IS the audited baseline behavior,
  console-enforced; no api code touched. +2 tests (raw-SQL probes — User AR hooks read web
  state); suite 559/559.

- SE-08 review — security agent returned: 1 HIGH (release fires BEFORE the proration
  outcome; bank rail never fails → free headcount growth — the same unpaid-upgrade window
  SE-09 tracks) → **fix folded into SE-09** (next item, same flow restructure) together with
  its LOW (wrap release+save in one tx). 1 MEDIUM = product question: the cap-lock uses
  STATUS_NOT_ACTIVE, which also revokes the specialist's OWN app/API access (identity
  resolver ->active()), not just bookability — **owner ruling needed: is full account
  lockout the intended consequence?** Cross-tenant/mass-assignment/api-exposure all ruled
  out (resources whitelist fields; columns invisible to mobile).

- **SE-08 review COMPLETE (4/4 agents) — every actionable finding fixed. Suite 559/559.**
  Rework highlights: enforcement moved POST-COMMIT on both rails + never-throws (kills the
  double-charge window after an irreversible Paymob charge) + also runs on the DECLINED
  branch (it still persists the plan switch — pre-existing posture); release = demo parity
  (ALL locks clear on upgrade; boundary re-locks excess); column renamed cap_locked_at and
  the wage freeze now mirrors into user_profile.wage_frozen_at (THE anchor
  WagePayrollService clamps on — 573 profileless agents covered by the user-table lock);
  toggle guards in BOTH portals (demo updateSpecialist guard — the lock was bypassable);
  walk-in POST now requires ACTIVE agents; trial-downgrade enforces immediately; zero-cap
  refused (guard + savePlan normalizes 0→NULL); status logs + audit events per flip;
  updated_at stamped; missing `use Yii` (would have fataled at runtime) caught; banner
  count = active-only. **Parked (guard-hook bundle with BE-F02/SN-02):** inactive-specialist
  gating on the REMAINING portal booking surfaces (checkPlacement/calendarColumns/drag-
  reassign live in the protected BookingScheduleService). +bilingual toggle-error strings.

- **SE-09 ✅ implemented (UNCOMMITTED — 4-agent review IN FLIGHT)** — the bank-rail upgrade
  now DEFERS: `m260821_140000` stages pending_upgrade_{plan_id,price,invoice_id} on
  shop_subscription (rule-less — never mass-assignable; deliberately separate from
  pending_plan_id so an unpaid upgrade can never auto-apply at renewal).
  actionUpgrade restructured: zero-proration ⇒ immediate; CARD ⇒ charge FIRST (a decline
  no longer switches the plan NOR releases SE-08 locks — closes the SE-08 HIGH);
  BANK ⇒ issueSubscriptionInvoice (generalized: slip-less ⇒ UNPAID/DOC_NONE, returns
  the Invoice) + stage; the switch+grandfather+SE-01-stamp applies atomically in a tx,
  SE-08 release post-commit. Completion seam = SubscriptionBillingService::
  applyPendingUpgrade(), wired into BOTH AdminInvoiceController::actionVerify and
  EarningsController card-pay (the SE-10 mirror) — verified OR paid completes it, cap
  release after each commit. Console renewal's unresolved-skip excludes the pending
  invoice (a stale unpaid upgrade never freezes renewals). One-pending-at-a-time guard.
  +3 tests (staging leaves plan+features untouched; apply switches/grandfathers/
  re-stamps/clears + idempotent; unrelated invoices ignored); 2 bilingual keys.
  Suite 562/562.

- **SE-09 review — silent-failure + security agents returned (2 CRITICAL / 2 HIGH between
  them), ALL FIXED. Suite 565/565.** (a) Card retry double-charge → pre-charge idempotency
  (JSON_EXTRACT on the proration note within the current term: a retry completes the switch,
  never charges twice). (b) Stale-staging blind apply (the security CRITICAL: pay an old tiny
  proration whenever → top tier on a cheap renewed term) → applyPendingUpgrade now refuses
  non-live status AND a rolled term (issued_at vs current_term_start), clearing the staging
  with a warning. (c) Orphan invoice → invoice+staging are one atomic unit (nested-tx
  savepoint). (d) voidPendingUpgrade(): cancel + re-subscribe + guard-replace all void an
  abandoned UNPAID slip-less staging (invoice+unpaid charges deleted); slip-in-flight keeps
  the block. +3 tests (cancelled refusal, rolled-term refusal, void semantics) = 6 SE-09
  tests total. php-reviewer + code-reviewer still pending on the same scope.

- SE-09 review — code-review agent returned (its HIGH = the renewal-staleness case, ALREADY
  fixed by the term-rolled guard + pinned by test). New actionable fixed: persistent
  pending-upgrade indicator on the plans hero (sibling of the downgrade line, reuses the
  toast key — zero new i18n) + admin invoice queue now renders Verify for the slip-less
  UNPAID subscription invoice (off-band payment path). Noted, not fixed: settlement-hook
  duplication (helper-worthy, LOW), controller-level test coverage (unit harness limit).
  build:css rebuilt. Suite 565/565. php-reviewer still pending.

- **SE-09 review COMPLETE (4/4) — all actionable findings fixed. Suite 565/565.** The php
  agent empirically re-verified both original CRITICALs closed (nested-savepoint atomicity
  probed live; idempotent card retry confirmed non-false-positive). Final round fixed:
  actionDowngrade now carries the same pending-upgrade guard (a staged upgrade no longer
  silently eats a later downgrade — void-if-abandoned/block-if-slip-in-flight);
  voidPendingUpgrade is atomic (one tx) AND leaves an audit_log trail
  ('subscription.upgrade_voided' — answers the no-VOID-status concern); admin invoice queue
  Verify now renders for EVERY not-paid invoice (un-strands the pre-existing
  UNPAID+doc-uploaded rows too); FQCN imports cleaned. Noted, not fixed: charged-but-
  never-applied persistent-failure needs an admin reconciliation queue (residual, logged);
  controller-level integration tests (unit-harness limit).

- **SE-11 ✅ implemented + LIVE-verified (UNCOMMITTED — 4-agent review IN FLIGHT)** — admin
  actionSubscription rewritten raw-SQL → AR: assign honors trial-once (trial_consumed +
  free_period_ends_at + SE-01 feature stamp; consumed-trial ⇒ ACTIVE + stampTerm at
  netPriceFor), activate stamps a term when none is current (resumes mid-term otherwise),
  cancel/flag via AR; SE-09 hygiene (voidPendingUpgrade on re-assign + cancel).
  LIVE test through the real endpoint (qa_admin session, probe shop 1, then cleaned up):
  assign → free_period + trial_consumed=1 + canonical features stamped; activate → term
  2026-08-21→09-21 @ 99.00; cancel → accessState **grace** to term end (was: instant
  locked — the audited bug, inverted). Suite 565/565.

- SE-11 review — security agent returned (1 CRITICAL + 1 HIGH), both FIXED. Suite 565/565.
  CRITICAL: admin assign/activate stamped a PAID-LOOKING term (real netPriceFor price, zero
  Charge/Invoice) — fabricated MRR (SubscriptionMetricsService sums current_term_price) and
  "Change plan" could CLOBBER a paying subscriber's live term → now: a live paid term is
  never overwritten (admin change-plan schedules pending_plan_id at the boundary, like the
  shop's own downgrade — new bilingual message), and admin grants stamp an honest COMP at
  0.00 (the renewal cron bills the plan's REAL price at the boundary via the
  `current_term_price ?: plan price` fallback). HIGH: the action emitted no audit events →
  now emits subscription.assigned/activated/cancelled/flagged/plan_change_scheduled with
  before/after (closing the gap F-ADJ-01 had deferred to SE-11). 3 reviewers still pending.

- SE-11 review — silent-failure agent returned (1 CRITICAL + 1 HIGH + 3 MEDIUM), all FIXED.
  Suite 565/565. CRITICAL: void→save was non-atomic with zero try/catch → assign AND the
  op-switch now run in ONE tx each (voidPendingUpgrade nests as a savepoint), throw on
  save-false with getErrors context, Yii::error + clean flash on failure. HIGH: the trial
  branch left past_due_since stale (the `?: $now` dunning re-anchor truncated the next
  grace window → silent early expiry cascade) → re-assign resets past_due_since +
  failed_attempts + first_failed_at in BOTH branches (also kills the false "Failed (Nx)"
  badge — M3); activate resets them too. M4: the plan-orphan activate fallback now
  Yii::warning()s (schema's ON DELETE SET NULL anticipated). php + code reviewers pending.

- SE-11 review — code-review agent returned (conformance CONFIRMED incl. the live recipe;
  demo has NO admin-subscription actions at all, and our eager trial_consumed reading is
  stricter than the demo's lazy flip — deliberate, matches SE-11's own complaint). Fixed
  from it: activate now REFUSES an orphaned plan_id (rollback + error, was a
  warning+degrade — the original bug wearing a success flash) + the stale docblock
  rewritten to the real contract. Noted, not fixed: 115-line method (extract later),
  SE-08 cap on admin assign (baseline scopes to renewal — follow-up ticket note),
  Reflection-based controller test (precedent exists — future). Suite 565/565.
  php-reviewer still pending on SE-11.

- **SE-11 review COMPLETE (4/4) — BATCH 2 (subscriptions) FULLY CLOSED. Suite 565/565.**
  Final php-agent round fixed its 2 HIGHs: the schedule-at-boundary branch now carries the
  actionDowngrade guard (an in-flight self-service upgrade can no longer silently erase an
  admin-scheduled change), and voidPendingUpgrade's refusal now BLOCKS the assign (a
  slip-in-flight staging could otherwise corrupt a fresh trial whose null term_start
  disabled the staleness guard) — pre-tx check + bilingual message. Noted product question
  for the owner: admin assign on a MID-TRIAL shop ends the trial and starts a comp ACTIVE
  term (portal's own mid-trial switch preserves the trial) — deliberate supersede or not?
  **Batch 2 tally: SE-01/06/08/09/11 all implemented + 4-agent-reviewed + live-tested;
  suite 445→565; ~20 review-found defects fixed incl. 5 CRITICAL-class.**

- **CF-CC-05 IMPLEMENTED + unit-tested + live-verified (B4 item 1/7) — 4-agent review IN FLIGHT.**
  The global commercial card is now COPIED onto the shop at creation (baseline C-ADM-034):
  `Shop::seedCommercialDefaultsOnCreate()` stamps platform_commission ← marketing_fee_pct,
  payment_processing_fee_rate/fixed ← processing_fee_pct/fixed_fee_sar, minimum_withdrawal_amount
  ← min_withdrawal_sar (beats the scaffold 50), minimum_elapsed_period_days ← settlement_hold_days
  (beats the scaffold 7 — advances CF-CC-07's seed half). Wired beforeValidate (pre-default-rules)
  + beforeSave insert (PPF pair post-validation so a sub-floor global can't fail validatePpfFloor
  and block signup). Branches copy the PARENT's card verbatim (negotiated rate extends; live-fallback
  parent → live-fallback branch). Explicit input wins; empty-source stays NULL → legacy accessors
  keep covering old shops (F-ADJ-04 inherit display untouched). carry_forward_threshold +
  max_deposit_pct deliberately NOT copied (baseline: true live overrides). Bonus: base
  Shop::beforeSave pathInfo read console-guarded (the old "console shop saves fatal" landmine).
  Note: shop.min_marketing_fee column doesn't exist (accessor's isset() masks it) — excluded.
  Tests: CommercialSeedTest ×6; suite 445→**571/571**. Live probe (_cfcc05_live.php): stamp ✓,
  global edit not retroactive ✓, legacy NULL shop still live-follows ✓, self-cleaned ✓.

- **CF-CC-05 CLOSED (4/4 reviews, all confirmed findings fixed). Suite 576/576; live probe ALL PASS.**
  Review round was unusually productive — 1 CRITICAL each from php+security reviewers:
  (1) php-CRITICAL (reproduced): commission seeded pre-validation while config
  marketing_fee_pct has no max → a fat-fingered global (250) would block EVERY validated
  shop creation. Fixed: two buckets — pre-validation seeds only the two scaffold-default
  columns (min_withdrawal, elapsed_days); commission + PPF seed post-validation in
  beforeSave. (2) security-CRITICAL (verified live): BranchController imported
  common\models\base\Shop (pre-existing) → subclass seeding never ran for branches AND
  applySystemFields' explicit nulls persisted → 0%-commission branches. Fixed: real Shop
  import + applySystemFields now nulls the WHOLE privileged set the subclass exposes
  (elapsed_days [R-H007 settlement-hold bypass], PPF×5, max_deposit_override,
  max_group_size, freeze_list_cap/locked_at, first_portal_login_at). (3) hunter: dangling
  parent_shop_id (no FK) was silently reclassified as root+global-stamped → now left
  unstamped + warned; all-or-nothing stamping (resolve sources first); sub-floor global
  PPF skipped not stamped (would trip validatePpfFloor on later unrelated admin saves);
  catch log carries shop identity; ''→null normalization. (4) SignInController::
  UpdateUserRelatedTbls discarded save(false) → now returns real result + caller flashes
  error instead of false "saved". (5) code-reviewer: real save(false) round-trip test
  added (the production-governing beforeSave path — 4/5 creation sites use link()/
  save(false)); CommercialConfig::getInstance(); docblocks name the real resolvers.
  Tests 6→11 (dangling parent, sub-floor skip, explicit-0, out-of-range-global,
  save-path); test suite now transaction-rolled-back (no shared-config wipe risk).
  FOLLOW-UPS (not blocking, logged): config marketing_fee_pct deserves a max bound;
  FinanceLedgerService::marketingRatePct's bare 0.0 empty-config fallback vs siblings'
  CEO defaults; branch-create could switch to SCENARIO_SHOP_OWNER for structural
  stripping (kept null-list to avoid create-form regression); link()/save(false) paths
  never run ANY validators (pre-existing platform-wide trait). Config form posts all 5
  card fields every save → the "DB placeholder stamped invisibly" scenario requires no
  partial-save path that exists today.

- **CF-CC-06(admin) IMPLEMENTED + unit-tested + live-verified (B4 item 2/7) — 4-agent review IN FLIGHT**
  (first battery was killed mid-run by the session usage limit; relaunched after reset).
  commercial_config.max_deposit_pct was rendered/saved but never enforced (the audit noted
  FinanceLedgerService::maxDepositPct() had no production call site). Fix: the enforced
  chain in ShopPaymentSettings::getEffectiveMaxDeposit() is now per-shop override →
  **commercial-config cap (min-clamped to the 100 hard ceiling)** → legacy
  settings.max_deposit_percent → hard cap. Baseline keeps it a LIVE fallback (C-ADM-034's
  one of two live-override fields) — deliberately NOT stamped at creation, unlike CF-CC-05.
  api tier: zero usages (mobile untouched). Tests: CommercialDepositCapTest ×5
  (override-wins, config-cap-binds, legacy-fallback, save-time rejection, >100 clamp);
  suite **581/581**. Live probe (_cfcc06_live.php) on real shop 1: cap empty→100, config
  15 → effective 15, deposit 20% REJECTED ("must not exceed 15%"), 15% accepted,
  config restored — the audit Verify scenario exactly reversed.

- **CF-CC-06(admin) CLOSED (4/4 reviews — code-reviewer APPROVE; all confirmed findings fixed).
  Suite 583/583; live probe re-run ALL PASS.** Review-driven fixes on top of the chain change:
  (1) security-HIGH (pre-existing, neutralized the whole cap): shop owner could POST
  Shop[max_deposit_percent_override]=100 through the General-settings save — the override
  (which wins over every cap tier) was NOT in PRIVILEGED_FIELDS. Added it + all
  admin-negotiated NVG-BEA siblings (PPF×5, max_group_size, freeze_list_cap/locked_at,
  first_portal_login_at) to the const — scenario stripping + the settings snapshot both
  read it, so both portals are covered; regression test added. (2) hunter+php HIGH:
  CommercialConfig.max_deposit_pct allowed 0 (silently read as "no cap" by the >0 guard)
  and had NO upper bound (form+150 would save while min() silently enforced 100 — the
  audit's own failure mode re-created). Rule now min=>1 max=>100 (mirrors legacy
  Settings + shop override convention); tests for 0/150/15. (3) hunter-HIGH:
  FinanceLedgerService::commercialConfig() swallowed read failures with NO log —
  indistinguishable from "not configured" for every rate consumer; now warns.
  (4) php-MED: missing-financeLedger branch now warns (mirrors the legacy-Settings
  warning); memoization got an invalidation hook (resetCommercialConfig() called from
  CommercialConfig::afterSave). Tests 5→7. FOLLOW-UPS logged, not coded: charge-time
  reads (WalkInBookingService + 4 api controllers) use raw deposit_percentage — an
  admin lowering the cap grandfathers existing over-cap rows until the shop re-saves
  (shop-facing banner already shows the bind; admin has no over-cap listing). The api
  half of any clamp belongs to the MOBILE phase. Also note: the config-cap tier sits
  BEFORE the legacy settings.max_deposit_percent — the first-ever Commercial Config
  save activates it at whatever max_deposit_pct holds (DB default 100).

- **F-FIT-02 IMPLEMENTED + unit-tested (B4 item 3/7) — 4-agent review IN FLIGHT.**
  The live settle path (WithdrawalController::actionUpdate settlement form) now completes
  the baseline C-ADM-025 accounting via a NEW shared
  `WithdrawalBundlingService::applySettlementEffects(Withdrawal)`: (a) every charge tagged
  with the withdrawal (charge.transfer_request_id — stamped at bundling by the earlier
  F-FIN-10/F-FIT-01+05 waves) flips to PAID; (b) ONE paid transfer_settlement invoice
  documents the uninvoiced non-reversed fees (ex-VAT amount_due + vat_amount,
  issued_at/paid_at stamped) and those charges link to it; (c) pre-linked invoices flip
  PAID. Own transaction, idempotent; controller wraps in try/catch (a ledger hiccup logs +
  flashes a warning but never un-settles) and the settlement.executed audit payload now
  carries settlement_invoice_id/charges_flipped/invoices_marked_paid. Deliberate
  divergences from the audit's unreachable twin (AdminFinanceController::actionSettle):
  reversed rows excluded from the invoice sum; paid_at stamped. This closes the
  costsToDate() permanent overstatement (paid charges leave it). Bilingual warning key
  added. Tests: SettlementEffectsTest ×3 (full a-c matrix incl. reversed exclusion +
  idempotency + empty withdrawal); suite **586/586**. Live browser settle test planned
  COMBINED with F-FIT-07 (same modal; doc-gating changes the same flow — one settled
  staging record instead of two).

- **F-FIT-02 CLOSED (4/4 reviews, all confirmed findings fixed). Suite 586/586.** The review
  round substantially hardened the first cut: (1) hunter-CRITICAL (systemic): the aurora
  tailwind ADMIN LAYOUT rendered NO session flashes at all — every settle/save outcome was
  invisible; added a generic flash→ngToast bridge in backend/views/layouts/tailwind.php
  (manual per-view bridges consume first, so no double-fire). (2) hunter-CRITICAL: a
  one-time effects failure was permanently stranded (transition-only gate) — effects now
  run on EVERY settled save (idempotent ⇒ re-saving the form is the built-in retry), and
  php-M added a 'settlement.effects_retried' audit event for retry passes that did work or
  failed again. (3) code-HIGH (probe-verified): REVERSED rows are NEGATIVE correction rows
  that must NET (ledger convention: outstanding()/costsToDate()) — my first cut excluded
  them, overbilling the settlement invoice and orphaning the twin at -4.6 in costsToDate
  forever; now they net the sum and get linked without a status flip (test fixture reworked
  to a real negative twin: invoice 10−4=6.00 ✓). (4) security-M: invoice shop attribution
  now DERIVED from the charges (withdrawal.shop_id is mass-assignable on the settle form —
  a tampered value could re-attribute a paid invoice); mixed-shop tags refuse loudly.
  (5) settle-form documents threaded onto the invoice (no proof-less paid document);
  invoice amounts + effects_failed/effects_error in the audit payload; charge-save veto
  now throws; docblock savepoint claim corrected (top-level tx — saveAll committed first);
  pointer comment added on the dead twin AdminFinanceController::actionSettle.
  FOLLOW-UPS logged: WithdrawalController::beforeAction skips checkPermissions for any
  role not literally named "manager" (pre-existing, controller-wide — needs its own pass);
  post-bundling reversal twins self-correct via outstanding() on the next threshold
  invoice (verified — no gap). Live browser settle test: combined with F-FIT-07 (next).

- **F-FIT-07 IMPLEMENTED + LIVE-TESTED end-to-end (B4 item 4/7) — 4-agent review IN FLIGHT.**
  Both halves of the baseline gate: client — Execute Settlement renders DISABLED (+tooltip,
  + toast on blocked click) until both documents are present (stored path OR freshly chosen
  file, watched via change events); server — hard gate before the SETTLED flip (fresh
  upload or stored path; retry re-save never demands a re-upload), plus the pre-existing
  silent-drop fixed (upload addError()s were wiped by the later validate() — now collected
  + early-returned with an error flash). Two bilingual keys.
  **LIVE BROWSER TEST (combined F-FIT-02+07) uncovered and fixed a REAL pre-existing
  crash: every settlement with agent withdrawals 500'd** — "SECURITY Fix 2.6" in
  pushSpecialistSettlementTransactions matched/stamped `transactions.withdrawal_id`, a
  column that never existed. Migration m260821_150000 adds it (nullable + index; additive —
  api tier never reads it; APPLIED to dev). Full live matrix: button disabled bare →
  enabled after attaching both files (DataTransfer) → settle executed clean → withdrawal 22
  SETTLED + specialist transaction #287 created WITH withdrawal_id + audit
  settlement.executed carrying the full effects payload (0 tagged charges → no settlement
  invoice, correct for a pre-tagging legacy transfer) → no-docs POST on withdrawal 23
  REFUSED (gate message rendered, status untouched) → stored-docs retry path enables the
  button. Staging mutations logged in `Mockup vs. Live audit/test-data.md` (new file).
  Suite 586/586.

- **F-FIT-07 CLOSED (4/4 reviews, all confirmed findings fixed). Suite 586/586; bypass
  live-retested.** Review-driven hardening on top of the gate: (1) security-HIGH
  (live-verified rejected): `Withdrawal[settlement_status]=2` POSTed WITHOUT the
  settlement_form flag skipped the whole gate AND every settle side effect — added a
  MODEL-level beforeSave invariant on common\models\Withdrawal (transition-into-SETTLED
  requires both docs; legacy settled rows stay editable); browser bypass attempt now
  refused with the message and wd23 stayed REQUESTED. (2) code-HIGH + hunter-CRITICAL:
  mixed valid/invalid upload orphaned the good file with a false "attached" re-render, and
  saveAs() was unchecked with paths pre-stamped (phantom docs could pass the gate) — now
  saveAs runs FIRST, its result is checked, and each successful document persists
  IMMEDIATELY via updateAttributes (truthful re-render, genuine stored-docs retry).
  (3) hunter-CRITICAL: pushSpecialistSettlementTransactions re-runs on every settled save
  while pre-migration transactions carry withdrawal_id=NULL (SQL equality never matches) —
  a re-save of an old settled row could double-post a specialist's ledger; dedupe now
  ADOPTS a matching legacy NULL row (same type/agent/shop/amount) by stamping the id
  instead of inserting a duplicate. (4) php-HIGH: prod schema-cache note added to the
  migration (flush cache/restart FPM after migrating or workers 500 on the new column) +
  idempotency guard + down() outage warning; Transaction docblock gained withdrawal_id;
  toast-safe plain-text error separator; Html imported properly.
  FOLLOW-UPS logged: storage vhost serves directory listings (Options Indexes — every
  uploaded document enumerable unauthenticated; pre-existing infra, needs its own fix +
  apache reload); transactions.withdrawal_id has no FK (matches table convention — note).

- **NOTIF-04 IMPLEMENTED + unit-tested + live-verified (B4 item 5/7) — 4-agent review IN
  FLIGHT.** "Fires" is a CLOSED CATALOGUE now: NotificationTrigger::DISPATCHABLE_EVENTS
  (7 dispatched keys → canonical event_basis) + the booking_reminder_* timed family;
  model-level inline validator refuses any other key, `unique` on event_key (dispatch
  resolves by ->one() — a duplicate would silently shadow), and beforeSave stamps
  event_basis from the key (the m260728_100300 column finally has live write/read paths —
  _row.php reads basisLabel() instead of the PHP map). Form: free-text input + auto-slug
  JS replaced by the catalogue select (legacy timed keys preserved on edit). 3 bilingual
  keys. Tests: NotificationTriggerEventCatalogueTest ×4; suite **590/590**. LIVE: create
  screen renders the 7-option closed select; a direct POST with a fabricated key
  ('weekly_marketing_digest') is refused with the validator message and zero rows persist.
  Noted in the const docblock + as follow-up: customer_invitation is seeded/allowlisted
  but currently has NO dispatch call site (pre-existing).

- **NOTIF-04 CLOSED (4/4 reviews — security clean, code APPROVE, php Warning→all fixed).
  Suite 590/590; screen re-verified live post-fixes** (placeholder-first select, 6 real
  events, one-axis suffixes, no customer_invitation). Review-driven fixes: dead
  fillEventKey() slug helper removed from the controller (security); customer_invitation
  EXCLUDED from the authoring select — it has NO dispatch call site, offering it would
  author the exact "active but never fires" trigger this item kills (kept in the
  validation map so a legacy row still validates/renders via basisLabel) (hunter-HIGH);
  forced "Choose an event…" placeholder (browser was silently defaulting the first key);
  translated unique message (label was untranslated English inside Arabic UI); legacy
  pre-catalogue keys got an unchanged-key validator carve-out (editing an old row's name
  no longer bricks on its key) + honest labels via basisLabelFor(); label source
  CENTRALIZED on the model (form builds options from DISPATCHABLE_EVENTS +
  basisLabelFor — no hand-duplicated strings); beforeSave timed check aligned to the same
  isTimedKey() regex (+preg_quote); persistence test now proves the DB write (bogus_basis
  → reload → stamped); stale _row docblock + orphaned i18n key pruned (both languages).
  FOLLOW-UPS: customer_invitation needs a real dispatch call site (then re-add its
  option); pre-prod check for out-of-catalogue legacy event_key rows (query in review
  transcript).

- **F-FIN-16 IMPLEMENTED + live-verified (B4 item 6/7) — 4-agent review IN FLIGHT.**
  buildEarningsRows now sources the four per-row figures from the FOUR reconciling ledger
  methods (bookingRevenue / bookingFeesIncurred / bookingNetEarnings /
  bookingNavagooPayout — demo §5.5) whenever the earning row has its booking; the legacy
  navagoo_marketing_fees+vat_navagoo math survives only as the orphan-row fallback.
  bookingRevenue already carried the package-redemption→0 branch (S1 Group C). Suite
  590/590. Live reflection probe on real rows (values 120/900): table row ==
  the four methods exactly, ALL PASS. Dev carries no stamped booking fee charges
  (P-RATES) so the fees-nonzero equality rests on FinanceLedgerServiceTest's existing
  coverage of the methods themselves.

- **F-FIN-16 CLOSED (4/4 reviews, all confirmed findings fixed). Suite 593/593; probe
  ALL PASS on final semantics.** The review round substantially reshaped the first cut:
  (1) php-CRITICAL: SATELLITE ShopEarning rows (a post-completion tip mints a second row
  for the SAME booking with earning_id NULL via the mobile tipping endpoint) would have
  displayed the whole booking's ledger figures — looked paid twice; rows now split:
  PRIMARY row (earning_id set) = ledger figures, satellite/orphan rows = their own
  stamped columns; the "incl. tips" sub-line now reads the same source its netEarnings
  baked in (booking.tipping_amount on primary rows). (2) code-HIGH + security-LOW (N+1):
  the naive per-row methods re-ran the charge query up to 3×/row (≤150 queries/page);
  now ONE page-level prefetch feeds the ledger's *FromRows companions (their first real
  callers) + 3 new unit tests covering the three previously-untested methods.
  (3) hunter-MEDIUM (demo-verified): the table's BOOKING VALUE column is the RAW quoted
  value in the demo (Earnings.tsx table) — bookingRevenue belongs to the detail drawer;
  reverted value to raw, ledger stays for fees/net/payout (the audit's actual targets).
  (4) security belt-and-suspenders: settlementFeeRows() now also scopes by shop
  (matches actionView's precedent — booking_id is de-facto shop-exclusive but no DB
  constraint enforces it). REJECTED with reasoning: hunter's "payout disagrees with the
  transfer" fix (the bundling rail DOES net processing fees since F-FIN-10, so the
  ledger figure matches the real payout better than the stamped column) and an
  eligibility-pane precedence flip (would over-promise on pre-charge-era bookings —
  that is the S1 Group B "one rail decision"; stamped-first kept + documented).
  FOLLOW-UPS logged: WithdrawalBundlingService's hand-rolled netting duplicates ledger
  math (drift risk — point it at the FromRows companions or document why not);
  fees/processingFee row keys are computed but unrendered (scaffolding); Earnings
  reachable with no activeShop → ShopEarningSearch unscoped (pre-existing, needs triage).

- **SN-04 (+CF-CC-06 shop half, already closed by SE-06) IMPLEMENTED + LIVE-TESTED
  (B4 item 7/7 — LAST item of the batch) — 4-agent review IN FLIGHT.** Settings ▸ Payments
  now carries the baseline's entitled-vs-locked state per mode: entitled rows keep the
  pill toggles; locked rows render the lock icon + "Included in a higher plan" upsell
  link to /navagoo-plans with NO input at all (a POST never carries the attribute — a
  stale stored toggle can never be re-enabled from this form). Server guard:
  ShopPaymentSettings::validateEntitledModes refuses FLIPPING an unentitled mode on
  (transition-aware via isAttributeChanged so stale toggles never brick unrelated
  saves) — reuses SE-06's bilingual message. One new bilingual key. Tailwind rebuilt.
  Tests: +1 in PaymentModeEntitlementTest (stale tolerated / flip refused); suite
  **594/594**. LIVE browser matrix on the real portal (shop 15, logged in as the dev
  test owner): entitled (Growth) → 6 toggles, 0 locks; temporarily repointed its sub to
  a staged Starter-like probe plan → 2 locked upsell rows (Arabic label rendered) +
  online-only toggles; crafted POST enabling a locked mode persisted NOTHING; probe
  plan deleted and sub restored to plan 2 (verified). CF-CC-06(shop)'s walk-in surface
  was already closed by SE-06's shared resolver — no further change needed.

- **SN-04 CLOSED (4/4 reviews, all confirmed findings fixed) — BATCH B4 COMPLETE (7/7).
  Suite 596/596.** The review round was the heaviest of the batch: (1) hunter-CRITICAL
  (live-reproduced): `pay_online_enabled=''` in a crafted POST made Yii skip BOTH anchored
  validators (skipOnEmpty default) — bypassing the entitlement gate AND at-least-one
  together (zero-payment-methods save possible); fixed with skipOnEmpty=>false + a
  regression test through the REAL load()/validate() pipeline. (2) php-HIGH (reproduced):
  the controller's hardcoded new-record default pay_online_enabled=1 is a phantom "enable"
  on first save (isAttributeChanged degrades to isset on new records) and would brick the
  first save of any future plan lacking mada_apple_pay — default now derives from
  planAllowsModeFor; + new-record entitled-only test. (3) code-M (demo-verified): the
  locked row now matches the demo's own LockedPayRow — whole row is the link, lock badge,
  and a DYNAMIC CTA naming the unlocking plans via new
  ShopPaymentSettings::plansUnlockingMode() ("Subscribe to Growth · Pro to enable this
  feature." — live-verified resolver output); "Included in a higher plan" demoted to its
  demo role as the empty fallback. (4) code-M: validator re-anchored on ALL THREE
  attributes (per-attribute body) so a scenarios() edit can never silently disable the
  gate. (5) security-M/LOW: legacy /payment-settings page now REDIRECTS to the canonical
  gated tab (dead code deleted); setup-wizard payment step gated by the same resolver +
  all-locked upsell notice. (6) hunter-HIGH: the PRG no longer swallows refusal reasons —
  the flash carries getFirstErrors + a Yii::warning trail. FOLLOW-UPS logged: api tier
  still offers raw toggles (documented mobile-phase boundary); a branch inheriting parent
  settings re-parents the PARENT's row on its first save (pre-existing, needs its own
  look); walkin_* toggles currently have no downstream reader.

- **HANDOVER: Batch B4 (config/finance leftovers) COMPLETE — 7/7 items closed with full
  4-agent reviews + live tests: CF-CC-05, CF-CC-06(admin), F-FIT-02, F-FIT-07, NOTIF-04,
  F-FIN-16, SN-04(+CF-CC-06 shop via SE-06). Suite 565→596. Highlights: commercial card
  copied at shop creation; deposit cap finally enforced; settlements complete their
  accounting (and the settle rail's pre-existing 500 fixed by migration m260821_150000);
  both-documents gate client+server+model; notification triggers are a closed catalogue;
  Earnings table on the four ledger methods; payments settings carry the plan
  entitled/locked state. A systemic flash→toast bridge landed for the whole tailwind
  admin. All uncommitted.**

- **BATCH B5 (RBAC + Catalogue) STARTED.** DEFERRED to the owner-decision tail:
  **F-RBAC-05** (default role→permission grid) is blocked on owner-question #4 (final role
  labels — the baseline's 7-role/2-scope model incl. finance/support/front_desk personas
  needs product sign-off before a default grant grid can be seeded); parked with the mobile
  phase, not implemented now.
- **F-RBAC-01 CLOSED (4/4 reviews, every confirmed finding fixed). Suite 600/600; expanded
  live probe ALL 8 PASS.** This authorization change drew the heaviest review of the whole
  program — multiple CRITICAL/HIGH caught and fixed: (a) security+php CRITICAL: the app gate
  FAILED OPEN for a user with no RBAC role (mid-reassignment) AND for a MULTI-ROLE user
  (reset() picked one arbitrary role — a shopOwner+custom holder could bypass via the
  built-in name); now denies no-role and bypasses ONLY when EVERY held role is built-in;
  checkMenuPermissions made multi-role-safe the same way. (b) hunter CRITICAL: the landing
  page (site/index) still gated on the three literal built-in names → silently logged out
  every custom role on login (the real reproduction); now redirects to the role's first
  granted route (or a no-access view). (c) php HIGH: sign-in controller wasn't whitelisted →
  custom roles couldn't log out (403); added. (d) php HIGH: authManager had no cache → the
  gate + ~40 nav checks re-queried the graph every request; wired 'cache' (DbManager
  auto-invalidates on grant edits). (e) hunter MEDIUM + security: actionAssignRole's
  revoke+assign non-transactional and never synced the manager CSV (promote-to-manager →
  empty nav); now atomic + seeds/clears the CSV. (f) RoleForm fails LOUD if loginToBackend
  is missing; debug/gii whitelisted by MODULE id; built-in list centralized as
  User::BUILTIN_BACKEND_ROLES. Tests: RbacCustomRoleTest 2→4 (+no-role deny, +administrator
  short-circuit). FOLLOW-UPS logged: the ~40 per-controller `beforeAction` overrides that
  `else return true` should eventually delegate to the app gate (the app gate already covers
  them, but the local code reads misleadingly); section headers in Menu.php stay admin-only
  by design (managers get a flat list too — extending grouped headers to custom roles is
  nav polish); a generic rbac-module role-create path doesn't auto-grant loginToBackend.

- **F-RBAC-01 (superseded log line below) IMPLEMENTED + live-verified (B5 item 1).** A custom
  backend role could authenticate but every page 403'd + the sidebar showed Home only.
  Two root causes fixed: (a) backend/config/web.php catch-all AccessControl allowed only
  manager/administrator/shopOwner → added 'loginToBackend' (the permission RoleForm grants
  every custom role) so a custom role clears the gate; (b) User::checkMenuPermissions
  returned true only for the literal 'manager' role → now administrator=all,
  manager=per-user CSV (unchanged), any other role=authManager->checkAccess(key) from the
  registry. PLUS a new application-level `'on beforeAction'` gate: the per-controller
  pattern only constrains the literal 'manager' and `else return true` (custom roles were
  UNCONSTRAINED at the controller layer) — the app gate now requires
  checkAccess(controller_action) for custom-role holders (whitelisting site/debug/error so
  they always have a landing page). The audit's 3rd leg (actionAssignRole revoke →
  manager-to-custom lockout) is resolved by making the destination role functional. Tests:
  RbacCustomRoleTest ×2; suite 596/596. Live probe (_frbac01_live.php): custom role granted
  loginToBackend + shop-category_index reaches that page, refused withdrawal/index, nav
  resolves correctly, all auth rows cleaned up. ALL PASS.

- **F-RBAC-02 CLOSED (4/4 reviews — all APPROVE). Suite 604/604; live probe ALL PASS.**
  Review-driven fixes: (a) security+php+hunter converged: the manager branch was a
  FALLTHROUGH — a manager whose CSV lacked a key still got checked against the registry
  (a latent widening once F-RBAC-05 seeds a built-in grid). Made TERMINAL (CSV is the sole
  source for managers, fail-closed by construction) + a dedicated regression test.
  (b) hunter CRITICAL (in MY F-RBAC-01 actionAssignRole): it synced the CSV but not the
  singular user.role column → a later Managers-editor save silently re-derived the OLD
  RBAC role and reverted the reassignment; now syncs both, and ManagerForm only
  revokes/reassigns RBAC when the role ACTUALLY changed (was unconditional every save).
  (c) (string) cast on the now-nullable CSV read. Tests 6→8 (+terminal-fail-closed,
  +empty-CSV-manager, +both-gates on terminal). NOTE (security "23+ controllers else
  return true for non-manager roles" adjacent-CRITICAL): that is ALREADY covered by
  F-RBAC-01's application-level 'on beforeAction' gate (checkAccess(controller_action) for
  every custom-role request; proven live — withdrawal_index refused for a scoped role);
  the per-controller `else return true` is backstopped, only the LOCAL code reads
  misleadingly (F-RBAC-01 follow-up already logged). Perf: +≤1 cached query/request in
  prod (dev DummyCache overstates it) — informational.

- **F-RBAC-02 (superseded log line) IMPLEMENTED + live-verified (B5 item 2).** The
  per-controller gate (User::checkPermissions, called by 33 backend controllers'
  beforeAction for the 'manager' role) read ONLY the frozen per-user CSV, while nav
  (checkMenuPermissions, post-F-RBAC-01) read the live registry — a divergence, and neither
  re-synced when a role's grants changed. Both now delegate to ONE private
  resolveBackendPermission: administrator→all; built-in 'manager' holder→per-user CSV (the
  editable subset; unifying built-ins onto a default grid is F-RBAC-05, DEFERRED); any other
  (custom) role→authManager->checkAccess (live registry). So editing a custom role's
  permissions immediately changes every holder's reach across BOTH gates. Multi-role safe.
  Tests: RbacCustomRoleTest 4→6 (+live-resync-both-gates, +both-gates-agree); suite 602/602.
  Live probe (_frbac02_live.php): remove a permission from a custom role → an existing
  holder's controller gate AND nav both revoke it live, gates agree. ALL PASS. Known residue
  (for review to weigh): ManagerForm still copies the picked role's keys into the CSV at
  creation — dead data for custom-role managers now (their access is registry-driven); the
  Managers-editor cleanup is a follow-up tied to the deferred role-model decision.

- **F-RBAC-03 CLOSED (4/4 reviews — code APPROVE; all confirmed findings fixed). Suite
  604/604; live probe ALL PASS.** Review-driven fixes: (a) php-HIGH/security: the
  `cancellations_and_refunds_index` key used underscores but the controller id is
  hyphenated (`cancellations-and-refunds`) — granting it showed the nav but 403'd the page;
  renamed to `cancellations-and-refunds_index` in ManagerForm + Menu.php (Menu's own gate
  had the same typo). (b) security: `city_index`/`district_index` were inert — CityController
  /DistrictController::beforeAction hard-denied ALL non-manager/admin roles ("Geography is
  admin-only" hardening) → re-blocked what the app gate allowed; added a branch permitting
  a CUSTOM role that holds the registry grant (built-in shopOwner/user still denied);
  live-verified. (c) hunter-MEDIUM+ (caused by this diff): the 16 new keys weren't seeded as
  RBAC items, so granting one to an EXISTING custom role via the matrix failed with a
  misleading "Unknown role." until an unrelated role-create seeded them — extracted
  ManagerForm::ensureCatalogueRbacItems() (idempotent), called from BOTH RoleForm::create
  and actionTogglePermission (seed-on-demand) + split the error messages (verified: 13
  missing items now seeded). (d) code: push-notification_index moved to its own
  Notifications section (was mislabeled under Marketing) + icon fixes.
  FOLLOW-UPS logged (pre-existing, NOT introduced by this diff, out of the 16-key scope):
  (1) **SECURITY — UserController / EarningsController / CancellationsAndRefundsController
  have NO per-controller beforeAction gate**, so the built-in MANAGER role (which the
  F-RBAC-01 app gate bypasses as built-in) reaches them ungated regardless of CSV —
  notably UserController::actionLogin mints an impersonate-any-user token; deserves its own
  urgent pass (custom roles ARE gated by the app gate; managers are not). (2) granting an
  `_index` key gives the read page but its interactive controls (verify/settle/send/create)
  use uncatalogued sub-action keys and 403 — least-privilege-correct but the dead buttons
  should be hidden for non-admin grantees, or sub-actions catalogued. (3) live-chat_index
  stays an intentional exclusion (demo stub). (4) shopOwner can't authenticate into the
  backend (LoginForm rejects the user_type), so its catch-all presence is inert.

- **F-RBAC-03 (superseded log line) IMPLEMENTED + live-verified (B5 item 3).** The 16
  nav-gating permission keys the admin menu checks but were ABSENT from
  ManagerForm::permissionCatalogue() (so those entries were un-grantable = effectively
  administrator-only) are now in the catalogue: settings_workflow/colors/policies added to
  the Settings section, faq_support to FAQs, + 5 new sections People (user_people), Finance
  (admin-finance_shop-balances, admin-finance_transfer-requests, admin-invoice_index,
  admin-charge_index, earnings_index, cancellations_and_refunds_index), Marketing
  (marketing_index, push-notification_index), Geography (city_index, district_index), Demo
  Requests (demo-request_index). 3 new bilingual keys (Workflow/Colors/Earnings). The 6
  entries with NO delegable key (Dashboard/P&L/Costs/Invitations/Events/Users&Roles, pure
  can('administrator')) are correctly left out. Suite 604/604. Live probe: all 16 in the
  catalogue; a custom role granted admin-invoice_index+earnings_index reaches those pages +
  shows their nav, ungranted keys stay hidden. ALL PASS. NOTE for review: ~11 other
  menu-gated keys (category/education-level/language/medical-condition/page/request-log/
  skill/technical-support/user-status-logs/customer-request/finance _index) are also absent
  from the catalogue but weren't in the audit's enumerated 16 — flagged for the reviewers
  to weigh whether full "every nav entry grantable" needs them too.

- **F-CAT-01 + F-CAT-02 CLOSED (B5 items 4+5, shared root) — 4-agent review complete.**
  ShopCategoryController::parentOptions() collected the ids that already have a child and
  excluded them from the "Parent group" select — but children are already excluded by the
  `parent_id IS NULL` clause, so that filter removed exactly the valid groups: once a shop
  category had one child it vanished from the select, and applyParentId() coerced the
  unlisted pick to NULL, silently saving a 2nd "child" as a new top-level row. Fixed:
  parentOptions() now lists every top-level row except self (demo Catalogue.tsx parity), and
  a category that itself HAS children is offered NO parent (it must stay top-level — nesting
  it would orphan its own children as grandchildren). One correction closes both items.
  - **4-agent review resolved.** silent-failure-hunter found a **HIGH**: the has-children
    guard still let the form present a parent `<select>` and then *silently* discarded a
    legitimately-picked parent — the exact silent-coercion F-CAT-01 targets, reopened.
    **Fixed:** parentOptions() returns `[]` for a group-with-children (form offers no parent),
    and applyParentId() now routes every refused pick through `warnParentDiscarded()` → a
    warning ngToast + `Yii::warning` log instead of dropping it silently. php-reviewer:
    stale docblock (fixed) + phpcs on the new `if` (fixed) + "no test coverage" (fixed, see
    below). security-reviewer: **clean** — no mass-assignment/injection/tenant path (flagged,
    out of scope, a pre-existing codebase-wide CSRF-skip pattern in 19/61 backend controllers
    that don't call `parent::beforeAction` → noted in Systemic flags). code-reviewer: **APPROVE**.
  - **Accepted residual (documented, not fixed):** php + code reviewers both confirmed a narrow
    TOCTOU — two admins editing the same subtree in the same instant can defeat the one-level
    cap (no DB FK backs it). Admin-only, self-healing (next edit re-runs the guard); the one
    durable trace, an orphan after a later delete, is owned by **F-CAT-04** (delete-dependency
    hardening). Both reviewers offered "deliberately document the accepted risk" for this
    low-traffic taxonomy table; a full fix needs two-sided row-locking (disproportionate).
    Noted in the applyParentId() docblock.
  - **Test coverage added:** `common/tests/unit/controllers/ShopCategoryControllerTest.php`
    (10 tests, reflection + rolled-back tx) replaces the scratch probe — pins F-CAT-01 (a 2nd
    child nests under an existing group), F-CAT-02 (a child's unchanged parent survives
    resubmit), the group-with-children→no-parent invariant, and the anti-silent-discard signal.
  - **Suite 614/614 green** (was 604 — +10). i18n keys bilingual (ar+en backend). phpcs clean
    in range. All work staged, no commit.

- **F-CAT-03 CLOSED (B5 item 6) — 4-agent review complete + live-verified.** The Catalogue
  tree derives each category's Women/Men branch at read time from the gender of shops
  offering its services (`$catGenders`, service→shop_service→shop.gender). A just-created,
  serviceless category has no `$catGenders` entry, so it matched NEITHER branch and was
  invisible in the tree under Female AND Male. **Portal-only fix:** an un-pinned (empty
  `$catGenders`) category is treated as visible in whichever branch is viewed, so it shows
  immediately; a gendered service then pins it as before. Predicate extracted to a
  `protected categoryMatchesBranch()` for testability.
  - **Live-verified** on backend.navagoo.localhost (qa_admin): created a serviceless
    category ("FCAT03 probe", 0 services / 0 shops) → it appears in BOTH `?branch=women`
    and `?branch=men` (before: neither). Re-verified after the review fix below.
  - **4-agent review resolved.** php-reviewer + silent-failure-hunter BOTH independently
    found the same **HIGH**: `catalogue.php` renders each kept parent's `$children`
    UNFILTERED, so a parent kept for one branch dragged its oppositely-pinned children into
    the wrong tab — a pre-existing leak my guard amplified (an unpinned grouping parent now
    always survives). **Fixed:** actionCatalogue now also filters each kept parent's children
    by `categoryMatchesBranch()` (an unpinned/serviceless child still matches, so it stays
    visible in both). security-reviewer: **clean** (admin-gated, no tenant/data exposure,
    branch param whitelisted). code-reviewer: **APPROVE** — its MEDIUM (extract + unit-test
    the predicate) is done: +4 predicate tests (unpinned→both, women-pinned hidden from men,
    men-pinned hidden from women, GENDER_ALL→both).
  - **Deferred / adjacent (noted, not fixed here):**
    (a) **Full demo parity** — an AUTHORED `men|women` branch column on `shop_category` so
    the two trees are disjoint and a both-gender category isn't shown twice — needs a schema
    change to a table **shared with the mobile API** (`ShopCategoryResource`/`CategoryResource`).
    → **mobile phase / owner decision.**
    (b) silent-failure Finding 3 — a category whose services come from a mix of gender-NULL
    and gender-set shops is pinned on the partial signal (pre-existing, line 162 drops
    gender-NULL shops); separate root cause, tied to (a). → mobile phase.
    (c) silent-failure Finding 4 — `actionCreate` redirects to `/shop-category/index`, not
    back to `catalogue?branch=…`, so the audit's Verify needs one manual hop (pre-existing
    UX nit; the fix itself is correct once on the Catalogue screen). → minor follow-up.
  - **Suite 618/618 green** (was 614 — +4). phpcs clean in range. All work staged, no commit.

- **F-CAT-04 CLOSED (B5 item 7) — 4-agent review complete + live-verified.** The audit
  predicted an FK dead-end on the assignment tables; investigation found the real root cause
  is worse and had TWO parts:
  1. **Delete never worked at all.** `actionDelete` called `deleteWithRelated()` with NO
     argument, but that vendor method REQUIRES `$allowedRelations` → `ArgumentCountError` on
     EVERY category (even a 0-shop one). Confirmed by probe + reproduced live.
  2. **Catastrophic data-loss path.** `information_schema` showed `shop_category(id)` has FOUR
     inbound FKs, and TWO are ON DELETE CASCADE: `service.category_id` and **`shop.category_id`**
     — so a successful delete would have DELETED the shops pointing at the category (live: 1
     real shop on category 25). And `ShopCategory::relationNames()` is `['services','shops']`,
     so the naive "pass relationNames to deleteWithRelated" fix would have deleted the shops too.
  - **Fix:** new `detachCategoryReferences()` NULLs the two CASCADE columns (service/shop
     survive as uncategorised) and DELETEs the two RESTRICT join tables (`shop_category_assignment`
     membership + `service_category_assignment` image overrides — the demo baseline), then a new
     `deleteCategorySubtree()` deletes children + target as model INSTANCES (never `deleteAll()`),
     so AR delete events fire.
  - **4-agent review resolved (all confirmed findings fixed):**
    - **HIGH (all 4 reviewers):** child `deleteAll()` skipped AR events → orphaned
      `translations_with_text` rows (MultiLanguageBehavior) + leaked image files (UploadBehavior).
      Fixed via the per-child `$child->delete()` loop in `deleteCategorySubtree()`.
    - **CRITICAL (silent-failure) / MEDIUM (php):** `commit()` + success flash fired even when
      `$model->delete()` affected 0 rows (concurrent delete / future beforeDelete veto → half-
      cleaned DB + false "deleted"). Fixed: on 0 rows, roll back + neutral warning flash, no commit.
    - **CRITICAL (security):** `beforeAction()` never called `parent::beforeAction()`, so CSRF
      validation + the delete/reorder VerbFilter were dead for the whole controller — and this fix
      is what made `actionDelete` actually succeed, turning a dormant CSRF hole into a live
      destructive vector (forged GET could delete). Fixed by restoring `parent::beforeAction()`;
      verified every POST path already sends a token (ActiveForm, save-order `_csrf`, data-method=post).
    - **MEDIUM (silent-failure/security):** catch echoed the raw exception to the user → now logs
      the full exception + context server-side and shows a generic flash.
    - **MEDIUM (php):** the `applyParentId()` docblock overclaimed that F-CAT-04 sweeps a
      race-created grandchild → softened to state the deepest node is self-healing instead.
    - LOW: `(int)` cast in `findModel()`.
  - **Test coverage:** +5 unit tests (detach defuses cascades / clears join tables / empty no-op;
    subtree removes target+children / fires child events cleaning translations / returns false when
    target already gone). Suite **623/623** (was 618).
  - **Live-verified end-to-end** (backend.navagoo.localhost, qa_admin): legit create + legit
    tokened delete both succeed with clean flashes; a GET delete and a token-less POST delete are
    both REJECTED (category survives). The stuck FCAT03 probe (id 87) was cleaned up via the fixed
    delete. NOTE: rejected requests currently render a 500 because of a PRE-EXISTING null-deref in
    the shared backend error layout (`common/... common.php:210`, `getNewAvatar()` on a null
    userProfile) — the delete is still correctly blocked; flagged as a separate task.
  - **Deferred / spawned (out of scope):** (a) `NotificationHelper.php:402` copy-paste bug
    (`module_id = shop->category_id` vs `booking->id`) — my change makes shop.category_id NULLing
    reachable; (b) the backend error-layout `getNewAvatar` null-deref above. Both spawned as owner
    tasks. Mobile/API note: nulling `shop.category_id` + deleting assignment rows is correct
    non-breaking cleanup (columns already nullable; API resources already null-guard) — verified by
    reviewers, no contract change.
  - All work staged, no commit.

- **F-CAT-05 CLOSED (B5 item 8) — 4-agent review complete + live-verified.** `City::rules()` used
  `array_replace_recursive(parent::rules(), [...])`, which merges the parent's numerically-indexed
  rule ROWS positionally: the base `[['name','slug','meta_description'],'string']` row was folded
  with the child's `[['active'],'integer']` → `[['active','slug','meta_description'],'integer']`, so
  `slug`/`meta_description` became integer-validated (and `country_id`/`sort`/`name` were corrupted
  too). Every legacy city has a non-numeric slug ('أبها'), so the edit modal's save failed with
  "Slug must be an integer" for ALL of them. **Fix:** `array_merge` (appends the child rows, leaving
  the parent rules intact) — the idiomatic Yii pattern, matching 8 other in-repo models.
  - **4-agent review resolved.** security: **clean** (flat geography table, no sensitive columns,
    admin-gated). php: **Approve** — surfaced that the SAME bug had a quieter second half: with `name`
    un-ruled it fell out of `safeAttributes()`, so the classic /city/create + /city/update forms
    SILENTLY dropped English-name edits; the fix restores it. code: **Approve** (verified no cross-tier
    impact — the mobile API uses a *different* `backend\models\City` class). silent-failure: **clean**.
  - **Applied review fixes:** tightened `active` to `'boolean'` (was loose `'integer'`, matches the
    NotificationTrigger convention); tidied the leading-backslash import (phpcs now clean); +3 tests
    (`name` keeps its string rule, `name`/`country_id` are mass-assignment-safe, `active` rejects
    non-boolean). Total +9 unit tests in `CityRulesTest`. Suite **632/632** (was 629).
  - **Live-verified** on the real legacy row (city id 1, slug 'الخبر'): validates on edit with zero
    errors (before: "Slug must be an integer"). A full HTTP round-trip was deliberately NOT run — this
    legacy row has `name_ar = null` and null-Arabic districts, so actionSaveGeo's mandatory-name_ar +
    district-sync would have mutated/removed real data; the model-level validate() is the faithful
    non-destructive proof.
  - **Same-pattern bugs found in OTHER models (out of scope — noted/tasked, NOT fixed here):** the
    code review's repo-wide grep for `array_replace_recursive(parent::rules())` found 3 more live
    instances — **`Ads::rules()` CRITICAL** (folds an integer row into the parent image-FILE rule →
    `UnknownPropertyException` → admin Ads/Marketing feature crashes on every use), **`District::rules()`
    HIGH** (drops `city_id` validation → district edit silently ignores the city dropdown), and
    **`ShopCategory::rules()` HIGH = the audit's tracked F-CAT-06** (drops `name`'s `required` rule →
    categories saveable blank). Ads + District spawned as an owner task; **F-CAT-06 (ShopCategory) is
    confirmed = this exact anti-pattern and will be fixed in its own audit batch (S3).**
  - Also spawned (silent-failure, out of scope): the systemic `deleteWithRelated()`-with-no-arg
    `ArgumentCountError` (CityController/DistrictController + ~15 backend controllers — same bug class
    as F-CAT-04; City's delete additionally hides the failure with a silent JS reload).
  - All work staged, no commit.

- **=== BATCH B5 (RBAC + Catalogue) COMPLETE ===** (2026-08-21) — F-RBAC-01/02/03 + F-CAT-01/02/03/04/05
  all CLOSED, each with the full protocol (implement → suite green → 4-agent review on the item diff →
  fix confirmed findings → re-run → live test). Suite grew 596 → **632** across the batch. RBAC was
  hardened as authorization code (multi-role fail-closed, registry re-sync, seed-on-demand, custom-role
  landing); Catalogue closed a silent 2nd-child save, a branch-visibility gap + child-leak, a
  never-worked delete that also masked a **shop-cascade data-loss** path + a live CSRF hole, and the
  legacy-city save. **F-RBAC-05 remains DEFERRED** on owner-decision #4 (final role labels/personas).
  Six owner tasks spawned for pre-existing/systemic issues discovered along the way (manager
  impersonation gating, NotificationHelper module_id, error-layout null-deref, systemic
  deleteWithRelated, Ads/District rules bug). Next: **B6** (Admin S3 sweep) — see the batch plan below;
  note F-CAT-06 (ShopCategory rules) belongs to the S3 array_replace_recursive family.

### B6 (Admin S3 sweep) — IN PROGRESS

- **F-CAT-06 CLOSED (B6 item 1) — 4-agent review complete + live-verified.** `ShopCategory::rules()`
  had the same `array_replace_recursive(parent::rules(), [...])` positional-merge bug as F-CAT-05:
  the child's `[['sort_order'],'integer']` folded into base row 0 `[['name'],'required']`, DROPPING
  `name`'s required rule → a shop category could be saved with a **blank name** (and `created_by`/
  `updated_by` lost their integer rules). **Fix:** `array_merge`, and (per review) dropped the now-
  redundant child `sort_order` integer rule (parent already covers it), keeping only the default.
  - **4-agent review:** php **APPROVE**, security **CLEAN** (created_by becoming mass-assignable is
    neutralised by BlameableBehavior; image file rule byte-identical; admin-gated), code **APPROVE**,
    silent-failure **CLEAN** (blank-name failure surfaces on the form; MultiLanguageBehavior interaction
    safe — `required` targets the ar/base `name` column; no other writer silently fails). All findings
    were LOW/advisory (redundant rule, leading-backslash import, test line length) — all applied.
  - **+5 unit tests** (`ShopCategoryRulesTest`: blank/null name rejected, present name passes,
    created_by keeps integer, sort_order defaults 0). Suite **637/637**. **Live-verified**: submitting
    the create form with a blank name is now rejected (form re-renders with the error, no row saved).
  - **Adjacent findings noted (out of scope, NOT fixed here):** security flagged a pre-existing admin
    self-XSS — `image_base_url` is independently mass-assignable and an array-POST skips the file
    validator's `when` guard, landing a `javascript:` string that `view.php:78` linkifies un-scheme-
    sanitised → **spawned as an owner task**. silent-failure flagged a **vendor** landmine:
    `RelationTrait::saveAll()` (vendor/marena/yii2-relation-trait) doesn't roll back its transaction on
    a validation failure — dormant for the two audited controller actions (no later writes; request-
    scoped connection tears down), but a silent-data-loss hazard for any future loop-based `saveAll()`
    bulk-importer/seeder; would need a composer patch, left for the owner (no live impact today).
  - All work staged, no commit.

- **F-CAT-09 CLOSED (B6 item 2) — 4-agent review complete + live-verified.** Deleting a city
  orphaned its districts: `CityController::actionDelete` called `deleteWithRelated()` with no arg
  (ArgumentCountError → delete never worked), and `district.city_id` has no FK. Verified: NO FK
  references city or district at all. **Fix:** new `detachCityDependents()` — in one transaction —
  NULLs shop references to the city, deletes its districts, then plain `$model->delete()`; failure
  rolls back + (AJAX) HTTP 422 {ok:false} / (non-AJAX) danger flash; audit `city.deleted` emits only
  on the committed success path.
  - **4-agent review resolved.** security **CLEAN** (admin-gated, ids server-derived + parameterized,
    mutations bounded to the city's own districts). code **APPROVE**. php **BLOCK → fixed**: the FK
    diag checked for a `city_id` column and MISSED that `shop` has a plain **`city`** column too —
    shops reference the city TWO ways (`shop.city` directly + `shop.district`→district→city). The
    first cut only nulled `shop.district`, so 16 shops on city 5 would keep an orphaned `shop.city`.
    **Fixed:** `detachCityDependents` now NULLs `shop.city` unconditionally as well (+ a test that
    would have caught it). Also applied: moved `auditLog->emit()` out of the `try` (so an emit throw
    can't misreport a committed delete), `(int)$id` cast in `findModel`. silent-failure **clean** on
    all 5 hunt questions.
  - **+3 unit tests** (`CityControllerTest`: districts deleted + shop.district & shop.city nulled +
    shop survives; direct shop.city nulled even with no districts; empty city no-ops). Suite **640/640**.
    **Live-verified end-to-end**: seeded city id=8 + 2 districts, deleted via the real endpoint →
    `{ok:true}`, city + both districts gone, zero orphans (before the fix: a 500 ArgumentCountError).
  - **Accepted residual (documented):** narrow TOCTOU — a concurrent `actionSaveGeo` inserting a
    district under the city between the SELECT and commit could orphan it (admin-only, rare, no DB FK
    to catch it — same accepted class as F-CAT-01/02). Not fixed (would need a locking read).
  - **Adjacent findings → owner tasks (out of scope):** (a) mobile-API `ShopsResource` `city`/`district`
    fields have no null-guard → newly reachable null-deref (suppressed to null on the api tier, but a
    silent field regression) — **tasked** (+ the delete-confirm shop count in city/index.php counts
    only shop.city, should count both). (b) login infers "brand-new shop, finish onboarding" from
    `city==null && district==null`; nulling those on a city delete can misroute a pending-verification
    owner to a profile page that can't set city/district — **tasked**. (c) LOW: BranchController
    inherit-from-parent silently copies a nulled parent city/district — noted in that task.
  - **Task reconciliation:** the systemic `deleteWithRelated()` task (task for ~15 controllers) — City
    is now handled here; **DistrictController::actionDelete is a LIVE 500 today** (same bug, and
    `District::relationNames()` returns a real `'city'` relation) → should be prioritised within that task.
  - All work staged, no commit.

- **F-RBAC-08 CLOSED (B6 item 3) — 4-agent review complete + live-verified.** A custom role
  couldn't be renamed (a label typo forced delete+recreate). **Fix:** new `actionRenameRole` +
  extracted `renameCustomRole()` seam updates only the RBAC item `description` (the human label);
  the machine name stays stable so every `auth_assignment`/`auth_item_child` row survives (php
  verified this via a live rolled-back DB probe). Built-in roles refused (`isCustomRole` `shop_`
  gate); label bounds 2–60; emits `role.renamed`. UI: a Rename button + a prefilled `ngPrompt`
  (via a small additive `opts.value` on the shared aurora helper) → CSRF-safe `ngPost`.
  - **4-agent review resolved (all confirmed findings fixed):**
    - **HIGH (code) / MEDIUM (php) / LOW (security):** no label-uniqueness check — two custom
      roles (or a role + a built-in) could end up with the same VISIBLE label, and the reassign
      dropdown shows only the label → an admin could mis-grant. **Fixed:** refuse a rename that
      collides (case-insensitive) with any other role's effective label.
    - **HIGH ×2 (silent-failure):** the rename JS silently swallowed an empty submit (the server's
      "2–60" message was unreachable) and had no `.catch` on the POST (a network reject showed
      nothing). **Fixed:** empty now goes to the server so its message toasts; added `.catch`.
    - **MEDIUM (silent-failure/code/php):** the `if (!$auth->update())` branch is dead
      (DbManager::updateItem always returns true) — a real DB failure throws uncaught with no
      domain log. **Fixed:** wrapped in try/catch + `Yii::error` (mirrors `assignRoleToUser`).
    - **php LOWs:** defensive `trim` inside the seam, explicit `mb_strlen(...,'UTF-8')`, PSR-12
      line wrap — all applied.
  - **7 unit tests** (rename keeps machine name + permission grants + user assignments; built-in
    refused; unknown; too-short/long; no-op; duplicate-label refused). Suite **647/647**.
    **Live-verified end-to-end**: rename works, `manager` refused, too-short rejected, duplicate
    ("Manager") refused, original label restored — all via the real CSRF-safe endpoint.
  - **Deferred/noted (out of scope):** bilingual role label → tied to the F-RBAC-06 role-taxonomy
    design decision (create is single-language too). security LOW (label allows bidi/control chars
    — non-exploitable since every render path HTML-encodes; pre-existing, shared with create) →
    noted. TOCTOU on concurrent delete-during-rename → accepted residual (admin-only, rare).
    F-RBAC-09's UsersRolesController half is largely already wired (this controller emits
    role.created/renamed/deleted/permissions_changed + user.role_assigned) — only UserController
    deactivate/reactivate remains.
  - All work staged, no commit.

## Batch plan for the remainder (added 2026-08-20 — owner asked for an execution split)

Ordered highest-severity-first; each batch = one session, one commit per item/group, suite green.

| # | Batch | Items | Est. |
|---|---|---|---|
| B1 | Packages block (biggest single piece) | PKG-06 + PKG-09 + UI-17 + PKG-04 (per-line schema → owned-package hub → nav). ⚠ api-impact: package tables shared w/ mobile — flag before schema | 2–3h |
| B2 | Audit emitter (highest leverage) | F-ADJ-01 + 02 + 04 — auto-closes F-RBAC-09, F-CAT-08, CF-CC-08, SE-15, F-ADJ-07 | 1.5–2h |
| B3 | Subscriptions S2 | SE-01 (term_features schema+stamp), SE-06, SE-08, SE-09, SE-11 (+SE-05 if ruling arrives) | 1.5–2h |
| B4 | Config/finance S2 leftovers | CF-CC-05, CF-CC-06(admin), F-FIT-02, F-FIT-07, NOTIF-04 + shop F-FIN-16, CF-CC-06(shop)+SN-04 | 1.5h |
| B5 | RBAC + Catalogue S2 | F-RBAC-01/02/03/05 + F-CAT-01..05 | 1.5–2h |
| B6 | Admin S3 sweep | SE-07/12/13/14/16, FIN-LEDGER-09, F-FIT-09..15, CF-CC-03/04/09, F-CAT-06/07/09, F-RBAC-06/07/08/11, NOTIF-05/06, F-ADJ-03/05/06 | 2–2.5h |
| B7 | Shop S3 + all S4 | BE-F08/09/14, F-FIN-11, SN-06/08, CLS-04, GRP-02 + the 14 S4 items | 1.5–2h |
| B8 | Shop UI (32) | F-SHP-UI-01 first (dead control), UI-02/03/04, UI-05..16, UI-18..32 — screenshot-verify vs mockup + build:css + ar/en | ~2h |
| B9 | Admin UI (18) + outside-scorecard | F-ADM-UI-01..18 + H1/H2 probes, H6, OQ-CAT-C | ~1.5h |

**Parked on owner decisions:** SE-05 (OI-SUB-05) · F-RBAC-04 (OI-OTH-06) · redemption stamp (OI-CAT-01) ·
marketing basis (OI-FIN-11) · BE-F02+SN-02 (guard-hook bypass approval) · CLS-01 (api signup change —
mobile contract) · SN-03 🅿 · H7/OQ-NOTIF-B 🅿. ≈ 1–2h more once unblocked.

**Total estimate: ~14–17h net execution.**

**Owner directive (2026-08-20): anything mobile-touching runs LAST.** Execution order is therefore
B2 → B3 → B4 → B5 → B6 → B7 → B8 (minus UI-17) → B9 → portal promo-editor fields
(trigger_type/min/max/windows/description_ar in `frontend/views/promo-code/_form.php`) →
**then the mobile-touching tail:** B1 packages block (shared tables — flag to mobile first, UI-17
rides with it) + CLS-01 (api signup invite-token) + follow-up on mobile's 3 pending promo answers.

## ✅ S1 PROGRAM COMPLETE — 18/18 findings closed, 10 commits (0ab69ef…c858e6e),
## suite 445→480 tests all green. Next: S2 sweeps per the enumerated lists above.
## Open product rulings still pending (🅿): OI-CAT-01 (redemption-time stamp),
## OI-SUB-05 (entitlement default), OI-OTH-06 (role vocabulary), OI-FIN-11
## (marketing-fee basis — W4 gates classification/grace/floor; the BASIS itself
## unchanged pending the ruling).
