# Admin portal — parity audit report

**Portal:** admin (`backend/` — https://stageadmin.navagoo.com)
**Baseline:** Navagoo 2.0 mockup, branch `dev`, commit `cb48c8d` (v0.28.0)
**Date:** 2026-08-06
**Status:** Phase A complete. Findings, evidence and the ticket-ready backlog are in
`03_FINDINGS/admin/`, `02_EVIDENCE/` and `BACKLOG_ADMIN.md`.

This is a technical comparison against a pinned baseline, written to the register in
`00_SPEC/PARITY_AUDIT_SPEC.md` §11: observed state versus baseline, never attribution.

---

## 1. What was compared

| Side | Ref | Verified |
|---|---|---|
| Mockup | `Navagoo 2.0`, branch `dev`, `cb48c8d` — v0.28.0 "aura login + shop first-run setup wizard" | 2026-08-06 · `git fetch` succeeded · 0 ahead / 0 behind `origin/dev` · working tree clean |
| Portal (local) | `NavagooBackend`, branch `parity-audit` off `tailwind-poc`, `5efbafc9` | 2026-08-05, pulled from `origin/tailwind-poc` |
| Portal (staging) | https://stageadmin.navagoo.com — sign-in verified 2026-08-06, reaches Dashboard | fingerprinted below |

### Staging build fingerprint

Neither deployment exposes a version string, so the build is fingerprinted by served-asset
metadata (`02_EVIDENCE/environment.md`):

| Asset | Last-Modified | Bytes | ETag |
|---|---|---|---|
| `stageadmin.navagoo.com/css/custom.css` | 2026-05-12 21:02:27 GMT | 2,195 | `893-651a52e434e70` |
| `stageadmin.navagoo.com/css/adminlte.css` | 2026-05-12 21:02:27 GMT | 590,996 | `90494-651a52e4346a0` |
| `stageshops.navagoo.com/css/tailwind.css` | 2026-08-03 17:41:58 GMT | 104,719 | `1990f-658280e0f37ce` |

### Divergence assessment (staging vs local HEAD)

The shop Tailwind bundle was built on staging 2026-08-03 17:41 GMT; local `HEAD` is dated
2026-08-05. Six commits sit between them. Five are `api/` tier work — the mobile surface,
explicitly out of scope per spec §2. The sixth (`e5741a6b`, an auth toast fix in
`frontend/`) is dated the same day as the staging build and may well be deployed.
**Staging is therefore a sound proxy for local `HEAD` for the portals this audit grades.**

Three limitations travel with that assessment and are not resolved:

1. Asset timestamps bound the build; they do not prove it. PHP on staging could be newer or
   older than the CSS bundle. Findings are reported against observed staging behaviour, with
   local code read as corroboration — never the reverse.
2. The admin portal's `custom.css` and `adminlte.css` date to 2026-05-12. The admin aurora
   work is delivered through a separate bundle path, so this is not evidence of a stale admin
   deployment — but it does mean the admin build cannot be dated from these two files.
3. There is no version endpoint on either deployment.

### Coverage

- **83 of 83 contracts evaluated.** The baseline registers 82 distinct contracts
  (`C-ADM-001` … `C-ADM-082`); `C-ADM-082` (Geography) sits in two audit areas, producing 83
  area-level evaluations.
- **101 baseline surfaces inventoried** (18 screens, 30 tabs, 38 modals, 1 drawer, 14 panels)
  in `01_BASELINE/admin/inventory.md`, read from mockup source before any implementation was
  observed.
- **Contract pass rate: 20.5%** (17 pass / 16 fail / 50 partial across the 83 evaluations),
  scored on Track L only. §4 has the per-area table and the reading.
- **Twelve areas compared for UI parity** — one primary landing screen per area, both sides at
  1440, plus an Arabic/RTL sample on one. 18 findings, none above S3. §5, and its coverage limit
  in §5.4.
- **30 contracts still need a live probe.** Each carries a written probe recipe and a stated
  confirm/refute condition in its area file. See §10.
- Findings were produced by a contract verification run followed by an adversarial refutation
  pass; every S1 was then independently re-verified by a hostile pass requiring quoted code,
  production reachability, a mockup baseline check and a worked example with numbers.
  Outcome: **7 upheld, 0 refuted outright, 2 downgraded to S2, 2 merged into 1.**

---

## 2. Verdict

**The admin portal is a broad implementation of the mockup's admin surface, not a partial
one.** All 83 pre-registered contracts had an implementing code path to evaluate against;
no examined screen was found missing; the navigation IA matches the baseline item-for-item;
and the entitlement engine is a faithful, unit-tested port of the mockup's own module.

**Breadth is not fidelity, and the pass rate sharpens that claim rather than replacing it.** Of the
83 contract evaluations, **17 match the baseline exactly — 20.5%** — and the dominant verdict is
`partial`, at 50 of 83 (§4). Set against the UI result, the shape of the audit is:
**coverage is broad, fidelity is lower than the coverage suggests, and the UI dimension is
materially stronger than the behavioural one.** Twelve areas compared at 1440 on both sides returned
**18 UI findings, none above S3 — 0 S2, 8 S3, 10 S4** — with design tokens identical on every value
sampled and RTL passing (§5); the behavioural comparison returned **7 S1 and 38 S2**. Neither number
carries the verdict alone — 20.5% understates how much is built, and the severity distribution
understates how many contracts diverge in some detail.

**The divergence is not spread thin — it is concentrated in the wiring between configuration
and the money engine.** Seven contracts produce, or will produce on an ordinary admin action,
an incorrect number. In five of the seven, a correct implementation already exists in the
codebase and is simply not reached from the path that needs it:

| Finding | The correct thing that exists | The path that does not reach it |
|---|---|---|
| CF-CC-02 | `OfferPricingService::rateDiscountPct()`, unit-tested | `FinanceLedgerService` — no reference to offers, enrolments or discounts anywhere |
| FIN-LEDGER-01 | the marketing-fee builder, matching the baseline formula | no completion or no-show transition invokes `deriveBookingCharges()` |
| CF-CC-01 | `commercial_config.carry_forward_threshold`, admin-editable and persisted | `effectiveCarryThreshold()` reads a column that does not exist on `settings`, then a hardcoded 200.0 |
| NOTIF-02 | `navagoo_subscription_plan.sms_included` / `wa_included`, admin-editable | `freeLimitFor(string $channel)` takes no shop or plan argument |
| SE-02 | `netFor()`, applied correctly on the subscribe path | the renewal cron charges `$plan->priceForPeriod($period)` |

The remaining two are different in shape: SE-03 is a development fallback that marks a
subscription charge `paid` when no card is on file, and F-FIT-01+05 is a missing
bidirectional link between the settlement rail and the fee rail.

**Two qualifications this verdict must carry.** First, two of the seven S1 findings are inert
against the data currently seeded and will read **0.00** on a naive staging probe (CF-CC-02,
NOTIF-02) — that is not disconfirmation, and §10 states what activates each. Second, the live
ledger probe established that **commercial rates are unset across staging**: both
processing-fee rows in the entire ledger read `0% + 0.00 of collected` and charge 0.00. Fee
arithmetic cannot be validated on staging as it stands, in either direction.

**Two further qualifications travel with the two new dimensions.** The stronger UI result is a
statement about the surfaces that were compared: UI parity is established for **primary landing
surfaces only** — 38 modals, 1 drawer, non-landing tab interiors, permission-denied forks and most
empty states were not compared (§5.4). And the pass rate is not a priority ordering: **severity
counts, not the pass rate, should drive remediation** — the three areas at 0.0% carry one S1 between
them, while `subscriptions-entitlements`, at 30.4%, carries two (§4.1).

---

## 3. Scorecard

Per area: confirmed findings by severity, and contracts evaluated. Sorted worst-first by
severity weight (S1×100 + S2×10 + S3×1).

| Area | S1 | S2 | S3 | S4 | Confirmed | Refuted | Contracts evaluated | Probes needed |
|---|---|---|---|---|---|---|---|---|
| [subscriptions-entitlements](03_FINDINGS/admin/subscriptions-entitlements.md) | **2** | 8 | 6 | 1 | 17 | 1 | 23 | 5 |
| [commercial-config](03_FINDINGS/admin/commercial-config.md) | **2** | 3 | 4 | 0 | 9 | 0 | 7 | 3 |
| [finance-invoices-transfers](03_FINDINGS/admin/finance-invoices-transfers.md) | **1** | 6 | 7 | 2 | 16 | 0 | 11 | 4 |
| [finance-ledger](03_FINDINGS/admin/finance-ledger.md) | **1** | 5 | 2 | 2 | 10 | 0 | 11 | 4 |
| [notifications](03_FINDINGS/admin/notifications.md) | **1** | 3 | 2 | 0 | 6 | 2 | 9 | 3 |
| [system-rbac](03_FINDINGS/admin/system-rbac.md) | 0 | 5 | 5 | 2 | 12 | 0 | 8 | 3 |
| [catalogue-geography](03_FINDINGS/admin/catalogue-geography.md) | 0 | 5 | 4 | 1 | 10 | 0 | 6 | 5 |
| [adjacent-admin](03_FINDINGS/admin/adjacent-admin.md) | 0 | 3 | 3 | 1 | 7 | 0 | 8 | 3 |
| **Total** | **7** | **38** | **33** | **9** | **87** | **3** | **83** | **30** |

**Worst:** `subscriptions-entitlements` and `commercial-config` — 2 S1 each. `commercial-config`
carries the highest defect density: 9 confirmed findings against 7 contracts.
**Best:** `adjacent-admin` — no S1, 3 S2, and its findings concentrate in audit-event emission
rather than in money or authorization.

**On pass rates.** Spec §4.3 asks for a pass rate per area alongside the counts. It is now stated,
in **§4**, on its own track. The two instruments are **not two views of the same number**: several
contracts carry more than one finding at different severities, and a contract's verdict describes
that contract's behaviour rather than counting the findings raised against it. When this report was
first written the contract register (`01_BASELINE/admin/contracts.md`) still carried
`Verdict: (unset until observed)` on all 82 contracts, which is why no rate was stated here; the
verdicts have since been written back from the verification run's journal, and §4 derives from
those.

**Findings outside the scorecard.** Four UI findings (F-ADM-H1-01, F-ADM-H2-01, F-ADM-H2-02,
F-ADM-H2-03) and three hypothesis outcomes (H5.2, H5.3, H7) live in
`03_FINDINGS/admin/_hypotheses*.md` and are not counted above, which indexes the eight contract
areas only. A further **18 UI parity findings** (`03_FINDINGS/admin/_ui-parity.md`, summarised in
§5) sit outside this table for the same reason — they are Track U, which spec §4.3 requires to be
scored separately and never blended into the contract counts. All 25 are carried into
`BACKLOG_ADMIN.md`.

---

## 4. Contract pass rates (Track L)

Spec §4.3 scores the two tracks separately, so what follows is the **behavioural** contract
dimension only — the 82 pre-registered contracts in `01_BASELINE/admin/contracts.md` across the
eight verification areas. UI parity is scored in §5 and the two are never averaged together.
Source: `03_FINDINGS/admin/_PASS_RATES.md`, which reads each area agent's `contractResults` array
from the verification run's workflow journal.

| Area | pass | fail | partial | needs-probe | unverifiable | Evaluated | Scored (denominator) | Pass rate |
|---|---|---|---|---|---|---|---|---|
| finance-invoices-transfers | 0 | 2 | 9 | 0 | 0 | 11 | 11 | **0.0%** |
| system-rbac | 0 | 4 | 4 | 0 | 0 | 8 | 8 | **0.0%** |
| catalogue-geography | 0 | 1 | 5 | 0 | 0 | 6 | 6 | **0.0%** |
| notifications | 1 | 2 | 6 | 0 | 0 | 9 | 9 | 11.1% |
| finance-ledger | 3 | 1 | 7 | 0 | 0 | 11 | 11 | 27.3% |
| commercial-config | 2 | 1 | 4 | 0 | 0 | 7 | 7 | 28.6% |
| subscriptions-entitlements | 7 | 5 | 11 | 0 | 0 | 23 | 23 | 30.4% |
| adjacent-admin | 4 | 0 | 4 | 0 | 0 | 8 | 8 | **50.0%** |
| **Overall (as run)** | **17** | **16** | **50** | **0** | **0** | **83** | **83** | **20.5%** |
| **Overall (distinct contracts)** | **17** | **16** | **49** | **0** | **0** | **82** | **82** | **20.7%** |

**Worst:** `finance-invoices-transfers`, `system-rbac` and `catalogue-geography`, all at **0.0%**.
**Best:** `adjacent-admin` at **50.0%**.

**Denominator rule.** `pass rate = pass ÷ (pass + fail + partial)`. `needs-probe` and `unverifiable`
are excluded from **both** the numerator and the denominator, so a blocked probe can never move the
score in either direction. In this run both excluded classes are **0**, so the denominator equals
the number of evaluations in every row.

**The two overall figures are one run counted two ways.** `C-ADM-082` (Geography) sits in two audit
areas and was evaluated twice; both evaluations returned `partial`. Deduplicating moves only the
`partial` count (50 → 49) and the totals (83 → 82). It changes no area's pass rate, and moves the
overall rate from 20.5% to 20.7%.

### 4.1 How to read 20.5%

This is the number in the report most likely to be misread, so the reading is stated explicitly.

- **The dominant verdict is `partial` — 50 of 83 evaluations.** Taken with the severity counts in
  §3, that describes **broad coverage with low exactness**: nearly every contract has an
  implementing code path to evaluate, and roughly one in five matches the baseline precisely.
  `fail` accounts for 16 of 83.
- **A 0.0% area is not a catastrophic area.** The three areas at 0.0% sit there because every
  contract in them diverges in some detail, not because anything in them is broken — one `partial`
  is enough to deny a contract its pass, so an area whose divergences are all detail-level scores
  the same 0.0% as one that is absent. Between the three of them they carry **one S1 finding**
  (F-FIT-01+05, in `finance-invoices-transfers`); `system-rbac` and `catalogue-geography` carry
  **no S1 at all**.
- **Severity counts, not the pass rate, should drive remediation priority.** The pass rate measures
  how many contracts match the baseline exactly. It does not weight a charge raised at the wrong
  rate against a mis-ordered filter list, and the two orderings genuinely differ:
  `subscriptions-entitlements` carries the most confirmed findings (17) and two of the seven S1s
  while scoring **above** the overall rate at 30.4%, and `catalogue-geography` scores 0.0% with no
  S1 at all. Priority follows §3 and §6.

### 4.2 Behavioural coverage — an inconsistency this report does not resolve

The verdicts record `needs-probe = 0` and `unverifiable = 0`, which at face value states that every
contract was settled from the evidence available and that behavioural coverage is complete. The same
run separately recommended **30 live probes** (§1, §10.2), each with a written confirm/refute
condition, and two findings — F-CAT-05 and F-CAT-06 — are explicitly recorded as predictions derived
by reading PHP rather than executing it.

**Those two outputs disagree.** The probe list is the more conservative reading and is the one this
report follows: **no claim of 100% behavioural coverage is made here.** The pass rates above are
reported as recorded, and the 30 outstanding probes are reported as outstanding. Reconciling the two
is a re-run task, not a reporting one.

---

## 5. UI parity (Track U)

Source: `03_FINDINGS/admin/_ui-parity.md`. Twelve areas — `home`, `dashboard`, `shops`, `bookings`,
`people`, `finance`, `analytics-costs`, `subscriptions`, `catalogue-geography`, `notifications`,
`growth`, `system` — one primary screen per area, captured on **both sides at 1440 CSS-px, LTR**,
plus one area (`shops`) sampled in Arabic/RTL on both sides. Baseline: the mockup at
`localhost:6100` under a super_admin session. Implementation: `stageadmin.navagoo.com` under an
admin session. Grading is at design-system fidelity — a different DOM reaching the same rendered
result passes, pixel offsets are not defects, and portal-only columns, filters, pagination and
actions are recorded as *richer data* rather than as findings.

**Result: 18 findings — 0 S2, 8 S3, 10 S4. No baseline screen was absent.** Every compared screen
carried the baseline's panels, table columns and controls, in the baseline's order, except where a
finding below records otherwise.

### 5.1 Design tokens match exactly

Sampled with `browser_evaluate` on both sides, on equivalent elements, and reported as the browser's
own computed values.

| Token | Mockup (baseline) | Portal (staging) | Verdict |
|---|---|---|---|
| Body font-family | `"Google Sans Text", Alexandria, Barlow, system-ui, -apple-system, "Segoe UI", Roboto, saudi_riyal, sans-serif` | identical string | match |
| Body font-size | `14px` | `14px` | match |
| Ink / body colour | `rgb(31, 22, 41)` | `rgb(31, 22, 41)` | match |
| Page background | `rgb(244, 247, 247)` | `rgb(244, 247, 247)` | match |
| `h1` | `rgb(31,22,41)` · `24px` · `800` | identical | match |
| `h3` (panel title) | `rgb(31,22,41)` · `15px` | identical | match |
| Table header cell | `rgb(111,118,130)` · `11px` · `700` · uppercase · `0.88px` tracking | identical on all five | match |
| Primary CTA fill | `rgb(89, 66, 121)` on `rgb(255,255,255)` text | identical | match |
| Primary CTA radius | fully rounded (pill) | `14px` | **diverges** (F-ADM-UI-10) |
| Sidebar gradient | `linear-gradient(rgb(22,15,32) 0px, rgb(22,15,32) 80px, rgb(36,26,54) 28%, rgb(36,66,87) 60%, rgb(26,147,164) 88%, rgb(21,188,203) 100%)` | same stops, first stop at `76px` | match (4px header-height offset) |
| Status · Scheduled / Completed / No Show / Cancelled | four badge pairs, text and background | **identical to six decimal places** on all four | match |
| Status · In Progress | no such row in mockup data | `color(srgb 0.806196 0.599765 0.112235)` on `color(srgb 0.99451 0.960471 0.874824)` | not-comparable (hue matches `--color-st-progress: #f5b71b`) |
| Status badge geometry | `12px` / `600` / pill | `12px` / `600` / pill | match |

**Every colour, font and type-scale value sampled is identical between the two codebases.** The only
token-level divergence found is the primary CTA corner radius (F-ADM-UI-10, S4).

**Where the tokens live differs, and that is a DOM difference only.** The mockup publishes its status
palette as inline custom properties on `<html>` (`--color-st-scheduled: #6a3ab8` and siblings, plus
`--c-invoice-*`, `--c-charge-*`, `--c-transfer-*`, `--c-settlement-*`, `--c-collection-*`,
`--c-classification-*`); the portal compiles the same values into its Tailwind bundle and exposes
only `--ng-dur-*` / `--ng-ease-*` motion tokens at `:root`. The rendered result is identical, so this
**passes the design-system bar** and is not recorded as a finding.

### 5.2 RTL passes, with two S4 deviations

Both sides were switched to Arabic on the `shops` screen. The shell mirrors correctly on both —
sidebar to the right, content to the left, headings and table cells right-aligned, the segment bar
and pagination mirrored, the deep-link external icon flipped; `document.documentElement.dir` is
`rtl` on both. The nav labels are **identical strings** on both sides, as are the table headers
(المتجر · المدينة · النوع · ضريبة القيمة المضافة · التسويق · الحالة · الرابط العميق) and the `h1`
(المتاجر) with its subtitle. Latin-script shop names embedded in RTL rows render correctly on both.

**Axis verdict: RTL — pass with two S4 deviations.** No layout mirroring defect was observed. The
two are numeral-system consistency (F-ADM-UI-13 — segment counts stay Latin while the pagination
footer converts, and the header clock mixes both inside one string) and two Arabic badge labels plus
the deep-link word (F-ADM-UI-14).

### 5.3 The substantive divergences are structural

The eight S3s are IA, component-vocabulary and responsive-integrity divergences. Four are
substantive enough to state in full:

- **P&L renders 1 of the 3 baseline charts.** The Trends panel below the P&L statement carries
  `Revenue, COGS & gross margin`; `GMV & bookings` and `Revenue by line` are absent from the DOM.
  Both do exist on the portal's own Dashboard, so this is specific to the P&L screen (F-ADM-UI-02).
- **The Events filter taxonomy is `backend` / `frontend` against a five-way baseline.** The portal
  offers `All`, `backend`, `frontend` — three options describing which application tier raised the
  event — where the baseline offers `All`, `admin`, `shop`, `customer`, `specialist`, `system`, six
  options describing which product surface raised it. Row values follow the same taxonomy, so this
  is a vocabulary change rather than a data gap (F-ADM-UI-04).
- **Shops carries six segments where the baseline scopes it to two.** The portal shares one bar —
  `Shops (20)`, `Requests (0)`, `Subscription Plans`, `Offers`, `Subscription Dashboard`,
  `Payment methods` — across the Shops and Subscription Plans screens, where the baseline gives
  Shops a two-segment bar and mounts the four subscription views only on their own screen. The
  baseline source records that these views were deliberately moved out of Shops (F-ADM-UI-01).
- **The finance transfers table needs horizontal scroll at 1440.** It measures `scrollWidth` 1546px
  inside a 1136px container and gains a horizontal scrollbar; the visible cut falls at `Fee VAT`, so
  **`Net Payout`, `Amount Paid`, `Status` and the row's primary `Review` / `View` action are all
  off-screen** until the table is scrolled sideways. The Events table behaves the same way, clipping
  `Message`. Part of this is data-driven: staging shop names are bilingual
  (`بيوتي سنتر | Beauty Center`) and consume more width than the baseline's single-script names
  (F-ADM-UI-07).

The full set, as recorded:

| Id | Sev | Area | Axis | Divergence |
|---|---|---|---|---|
| F-ADM-UI-01 | S3 | shops · subscriptions | IA | One shared six-segment bar; the baseline scopes Shops to two and mounts the subscription views on their own screen |
| F-ADM-UI-02 | S3 | analytics-costs | IA | The P&L Trends panel renders 1 of 3 baseline charts |
| F-ADM-UI-03 | S3 | analytics-costs | Component vocabulary | No `Refresh` control and no updated-at caption on P&L; the portal's Dashboard carries both |
| F-ADM-UI-04 | S3 | system | IA | Events surface filter is `All / backend / frontend` against a six-option product-surface taxonomy |
| F-ADM-UI-05 | S3 | system | IA | No shop combobox on Events; the log cannot be narrowed to one shop |
| F-ADM-UI-06 | S3 | home | IA | `Recent Activity` ends with no `View events log` link — the panel is a dead end |
| F-ADM-UI-07 | S3 | finance · system | Responsive integrity | Transfer and Events tables overflow at 1440; four columns and the row action are off-screen |
| F-ADM-UI-08 | S3 | shell (all areas) | Component vocabulary | Sidebar section headers are static labels; the collapsible-group CSS ships but no `<details>` element renders |
| F-ADM-UI-09 | S4 | analytics-costs | Design tokens | P&L renders the literal string `SAR` where the portal's other screens use the riyal glyph |
| F-ADM-UI-10 | S4 | all areas | Design tokens | Primary CTA `border-radius: 14px` against a pill |
| F-ADM-UI-11 | S4 | home | Label / copy | Title case and shortened KPI captions and subtitles against the baseline's sentence case |
| F-ADM-UI-12 | S4 | home | Responsive integrity | Quick actions lay out 2×2; the second label truncates to `Verify payme…` at 1440 |
| F-ADM-UI-13 | S4 | shops (RTL) | RTL / Arabic | Numeral systems mixed within one screen |
| F-ADM-UI-14 | S4 | shops (RTL) | Label / copy | Two Arabic shop-type badges and the deep-link word differ |
| F-ADM-UI-15 | S4 | system | Component vocabulary | The inline role select renders for platform-scope rows only and carries no `<optgroup>` grouping |
| F-ADM-UI-16 | S4 | notifications | IA | Channel chips ordered SMS → WhatsApp → in-app |
| F-ADM-UI-17 | S4 | analytics-costs | Label / copy | The Costs rows render without the `Cost register` card heading |
| F-ADM-UI-18 | S4 | growth | Label / copy | Promotion column shows the code over the scope rather than the label over a condition summary — **low confidence, may be data** |

One observation recorded so it is not later read as drift: the mockup's Overview chart title renders
the literal string `Revenue, COGS &amp; gross margin` — an escaping slip in the baseline. The portal
renders `Revenue, COGS & gross margin` and is correct here.

### 5.4 Coverage limit — this establishes UI parity for primary landing surfaces only

**State this wherever the 18 findings are quoted.** The pass covered one primary screen per area at
one width. What it does **not** cover:

- **All 38 modals, the 1 drawer and the 14 panels in the baseline inventory were not opened.** The
  shop form, activation form, settlement workspace, balance-breakdown drawer, the
  plan / offer / promotion / ad / trigger / FAQ / policy editors, the image croppers and every
  `*-confirm` dialog are **unassessed — no claim is made about them in either direction.**
- **Non-landing tabs were not compared rendered.** Dashboard tabs 2–5 were verified structurally
  (chart titles present in the DOM) only; People's Classifications and Specialists, Finance's
  Invoices / Charges / Balances / Config, Marketing's Ads and Push, Content's Policies and Contact,
  Subscriptions' Offers / Dashboard / Payment methods, and Settings' Documents and Colours were
  confirmed to exist but their contents were not diffed.
- **Permission-denied forks were not exercised** — only the super_admin / admin session was used on
  each side, so Home's `admin.finance` split, the disabled Save on Finance config and Costs, and the
  read-only role badge in Users were not reached.
- **Most empty states were not reachable.** Only two were (shops → Requests, and zero-count badge
  suppression on Finance); staging carries data almost everywhere. Loading states are deliberately
  not graded — the mockup reads a synchronous store and renders none, so there is no baseline.
- **Only 1440 was captured in this pass.** Portal-side breakpoint probing was done in earlier work
  (§8). Nothing beyond F-ADM-UI-07 and F-ADM-UI-12 was observed at 1440.
- **Five comparisons were limited by staging data rather than by the implementation** and are marked
  *not comparable* rather than counted as gaps: catalogue's second tree level (all categories report
  zero children), the shops branch badge (no branch rows on page 1), the subscriptions feature-matrix
  ordering (no features ticked), the notifications inline Approve action (no pending channels), and
  the promotion label column (F-ADM-UI-18).
- **Live Chat is on the exclusion list** and its presence or absence is not assessed anywhere.

---

## 6. The seven S1 findings

Each was independently re-verified against four requirements: quoted code from the cited files,
evidence the path is reachable in production, a check against the mockup baseline, and a
concrete failure scenario with numbers. All seven met all four. Amounts below are reproduced
exactly as recorded.

### S1-1 · CF-CC-01 — the admin-set credit limit has no reader
**Area:** commercial-config · **Contract:** C-ADM-034 · deviation · built-not-working

**Baseline.** `effectiveCarryThreshold(shop, config) = shop.carryForwardThreshold ??
config.carryForwardThreshold` — the global carry-forward threshold edited by admin is the live
fallback for every shop with no per-shop override, and it drives threshold auto-invoicing
(C-ADM-029). Mockup: `lib/finance.ts:695`; global seed 1000.

**Portal.** `AdminFinanceController::effectiveCarryThreshold()` (`:326-336`) checks the
per-shop column, then `backend\models\Settings->carry_forward_threshold`, then a hardcoded
`$defaultCarryThreshold = 200.0`. `common/migrations/db/sql/intialize.sql:556-596` creates
`settings` with no `carry_forward_threshold` column and no migration adds one, so Yii's
`__isset` returns false and the middle branch is dead against the schema. The column the admin
actually edits — `commercial_config.carry_forward_threshold` — has exactly two references
outside its own model: the config form and a label map. **No reader.**

**Failure scenario.** An admin sets the threshold to 1000. A shop has a NULL per-shop override,
300 SAR of issuable fees and 300 SAR of held earnings. Baseline: 300 < 1000, the balance carries
forward and the earnings stay withdrawable. Portal: 300 ≥ 200, an invoice is issued and 300 of
held earnings are consumed and removed from withdrawable. The screen also shows a credit limit
of 200.00 for every shop.

**Framing correction that must travel with this finding.** The wrong numbers are the credit
limit and the timing of invoicing. The **invoice amount itself is computed correctly.** This
must not be read as a mis-stated invoice total.

**Files:** `backend/controllers/AdminFinanceController.php`, `backend/models/Settings.php`,
`common/models/CommercialConfig.php`, `backend/views/commercial-config/index.php`

---

### S1-2 · CF-CC-02 — fee-discount offers are never applied to a charge
**Area:** commercial-config · **Contract:** C-ADM-036 · gap · built-not-configured

**Baseline.** An offer's `rateDiscountPct` reduces the stamped rate on the charge types it lists
(`marketing_fee` / `payment_processing_fee`) for any charge whose economic date falls inside
`[enrolledAt, lockedUntil]`. Mockup: `finance.ts:162-163`, gated by `lib/offers.ts:34-40`.

**Portal.** The arithmetic exists and is unit-tested — `OfferPricingService::rateDiscountPct()`
(`:95-108`) — but has no caller. A repo-wide search finds only its own definition,
`OfferPricingServiceTest`, and a comment in `EntitlementService`. A case-insensitive grep for
`offer|discount` across `FinanceLedgerService.php` returns zero hits: `buildMarketingFee()`
stamps `marketingRatePct($shop)` and `buildProcessingFee()` stamps `processingRatePct($shop)` /
`processingFixed($shop)` with no discount term.

**Failure scenario.** Basis 1000 SAR, marketing rate 10%, offer 25% on `marketing_fee`. Baseline
stamps an effective rate of 7.5% → **75.00**. Portal stamps 10% → **100.00**. A **25.00**
overcharge per booking for the lock-in duration.

**Dormancy caveat.** Both seeded offers carry `rate_discount_pct = 0`, so a staging probe run
today shows no discrepancy. The defect activates the moment an admin creates a fee-discount
offer — an ordinary admin action. The shop-facing UI already promises the behaviour
(`frontend/views/navagoo-plans/index.php:382`, "{pct}% off fees"). Confidence is high on the code
path; the S1 rating assumes such an offer is created.

**Files:** `common/components/FinanceLedgerService.php`, `common/components/OfferPricingService.php`,
`common/models/NavagooOffer.php`, `common/models/ShopOfferEnrollment.php`

---

### S1-3 · SE-02 — renewal charges the plan gross, ignoring an enrolled offer's discount
**Area:** subscriptions-entitlements · **Contract:** C-ADM-067 · deviation · built-not-working

**Baseline.** At renewal the charge is raised on the net price — the enrolled offer's
`subDiscount` applies while `now <= lockedUntil` (C-ADM-064) — and `currentTermPrice` is
re-stamped to that net. Mockup: `store.ts:2162` renews on `.net`; `entitlements.ts:298-307`
applies `subDiscount` inside the lock window.

**Portal.** `SubscriptionBillingController::actionCharge()` line 203 sets
`$amount = $plan->priceForPeriod($period)` and passes it to `chargeSubscription()` and
`stampTerm()`. The console tier contains **zero** references to `ShopOfferEnrollment` or
`netFor`, and `current_term_price` is not consulted. The subscribe path
(`NavagooPlansController.php:310-312`) *does* apply `netFor()`, so the discount is honoured on
charge one and lost from charge two onward. The cron `subscription-billing/run` is scheduled
daily on **both** qc and prod (`console/config/schedule.php:42,53`).

**Failure scenario.** 20%-off offer, 12-month lock, Growth plan at 500 SAR/month. Subscribe
charges 400 and stamps `current_term_price = 400`. The next cron run writes 500 and re-stamps
`current_term_price = 500`. Overcharge is **100 SAR/month for the 11 remaining locked months**.
The inflated stamp additionally shrinks any later upgrade proration, which reads
`current_term_price` (`NavagooPlansController.php:486`).

**Live reproduction case exists.** On staging, `CHG-0006` charges **1,696.50** = 1,885.00 × 0.90
— the Pro 6-month price with a 10% offer applied, i.e. the subscribe path calling `netFor()`
correctly. The next cron charge for that subscription is predicted to be the full **1,885.00**.

**Files:** `console/controllers/SubscriptionBillingController.php`,
`frontend/controllers/NavagooPlansController.php`, `common/models/NavagooOffer.php`

---

### S1-4 · SE-03 — a card-rail subscription with no card on file renews as paid
**Area:** subscriptions-entitlements · **Contract:** C-ADM-068 · deviation · built-not-working

**Baseline.** A term counts as paid only when `paymentMethod === 'card'` **and** a `cardLast4` is
on file; otherwise the charge is unpaid and the subscription goes `past_due` with `pastDueSince`
set. Mockup: `store.ts:2039`.

**Portal.** `chargeSubscription()` (`SubscriptionBillingController.php:467-492`) computes
`$useRealRail = $card && PaymobSubscriptionHelper::isConfigured() && token present`. When that is
false — including the case where the shop has **no saved card at all** — control falls through
the documented dev fallback to `writeSubscriptionCharge()` with `Charge::STATUS_PAID`, and
`actionCharge()` then stamps a new term, sets status active, clears `past_due_since` and emails a
renewal confirmation. The fallback is **not** guarded by any dev/prod flag: with Paymob fully
configured, a shop with no `user_card` row still falls through, because `$useRealRail` requires
`$card` to be non-null. Nothing upstream requires a card — `actionSubscribe` never rejects a
card-rail subscribe from a user with no card, and `recordSubscriptionCharge` carries the
identical fallback.

**Failure scenario.** Growth plan at 500 SAR, no card on file. Each cycle writes
`type = subscription, total_amount = 500, status = paid, settlement_method = charge_to_card`
with no `meta.transaction_id`, sets the subscription active, clears `past_due_since`, and emails
a renewal confirmation. **Zero is collected; ledger revenue is overstated by 500 per shop per
term**, and the shop is never taken `past_due`.

**Files:** `console/controllers/SubscriptionBillingController.php`

---

### S1-5 · FIN-LEDGER-01 — no completion transition raises a marketing fee
**Area:** finance-ledger · **Contract:** C-ADM-013 · gap · built-not-configured

**Baseline.** A navagoo-sourced booking raises a marketing fee when it reaches a terminal state;
a completed pay-on-visit booking raises it even though nothing was collected on the rail
(C-ADM-013, C-ADM-015). Mockup: `store.ts:1112` calls `rederiveCharges` on **every** status
transition.

**Portal.** The marketing-fee builder is complete and matches the baseline formula, but no
completion or no-show transition invokes it. `deriveBookingCharges()` is called from exactly six
sites: `GroupBookingService.php:390` (group creation), `:634` / `:717` / `:783` (group cancels),
`BookingController.php:780` (shop cancel), `UserController.php:283` (classification re-derive)
and `DemoController.php:228`. None is a completion path.
`BookingCompletionService::ensureEarnings()` — the shared chokepoint for solo completion,
group-child completion and collect-then-complete — calls `ensureProcessingFee()` only
(`:75-79`).

**Failure scenario.** 500.00 booking, marketing rate 5%, VAT 15%. Expected charge-ledger row:
basis **434.78**, fee **21.74**, VAT **3.26**, total **25.00**. Actual: no row is written.
`netPayout` therefore shows **483.04** instead of **458.04**, and `costsToDate()` is understated
by **25.00 per booking**.

**Corroborated live.** The staging charge ledger contains **six rows in total and no
`marketing_fee` row at all**. Two completed bookings (91.00 and 136.00, 3 Aug 2026) each produced
a processing-fee row and no marketing-fee row. Marketing reads 0.00 across all eight transfer
requests, including `NTR-QC-260200026` (earned 2,297.01) and `NTR-251200021` (earned 4,516.00).

**Caveat that must travel with this finding — do not point remediation at the payout rail.** A
parallel legacy rail exists: `Earnings::calculateFinancialFields()` writes
`navagoo_marketing_fees`, populated by `BookingHelper::createBookingEarnings()` at completion,
and that is the figure `WithdrawalBundlingService.php:236` / `:332` actually nets. Real cash
payout may therefore still deduct a marketing fee. What this finding establishes as wrong is the
**charge-ledger-driven figures presented to shops and admins**, not the payout rail. Severity
remains S1; remediation must target the charge ledger.

**Files:** `common/services/BookingCompletionService.php`, `common/components/FinanceLedgerService.php`

---

### S1-6 · F-FIT-01+05 — no bidirectional link between the settlement rail and the fee rail
**Area:** finance-invoices-transfers · **Contract:** C-ADM-024 · deviation · built-not-working

*This finding replaces the separately-reported F-FIT-01 and F-FIT-05. Both ids are retained for
traceability. Reported separately they double-count the same exposure.*

**Baseline.** On creation the transfer request writes `transferRequestId` onto **every** included
charge row so it cannot be re-batched, and `costsToDate` / invoice builders exclude anything
carrying a transfer id. Eligible bookings are those not already consumed by a prior transfer
**and** not already netted into an issued invoice's `appliedBookingIds`. Mockup:
`store.ts:1258-1268` tags every unpaid non-pending charge of every eligible booking;
`store.ts:1179-1197` excludes `consumedBookingIds`.

**Portal — direction A (transfer → invoice).** `WithdrawalBundlingService::linkEarnings()`
(`:375-386`) tags only `TYPE_SMS` / `TYPE_WHATSAPP` rows; marketing-fee and
payment-processing-fee rows are never tagged.

**Portal — direction B (invoice → transfer).** `eligibleEarnings()` filters on
`shop_earning.settlement_status` and `withdrawal_id IS NULL` only. `issueShopInvoice()` writes
`applied_booking_ids` (line 601) and touches nothing on `shop_earning`;
`actionRequestTransfer()` (`EarningsController.php:834-846`) gates only on the minimum withdrawal
amount and never checks `consumedBookingIds`.

**Failure scenario.** An admin opens Shop Balances, which auto-runs `reconcileBilling()`
(`AdminFinanceController.php:116-120`). A threshold invoice nets **1,000 SAR** of held earnings
against **98.90** of fees, marks the invoice and its charges paid, and records
`applied_booking_ids`. The shop then clicks Request transfer; the `shop_earning` row is
untouched, so it is bundled and paid out in full. **98.90 is recorded as recovered from money
that is simultaneously paid out.**

**Correction to the original F-FIT-01 text.** Its stated causal chain was partly wrong. A string
coerced to `0` is **not** NULL, so `notInTransfer()` correctly excludes those rows and
`consumedBookingIds()` does recognise a booking that carries an SMS/WhatsApp row. **The defect
bites bookings with no notification charge — which is the common case.** The int/string column
mismatch is a separate question, recorded as OQ-FIT-A (§10).

**Files:** `common/components/WithdrawalBundlingService.php`, `common/models/query/ChargeQuery.php`,
`common/migrations/db/m260623_211000_create_charge_ledger.php`,
`common/components/FinanceLedgerService.php`, `backend/controllers/AdminFinanceController.php`

---

### S1-7 · NOTIF-02 — a plan's bundled message allowance is stored but not read at billing time
**Area:** notifications · **Contract:** C-ADM-060 · deviation · built-not-working

**Baseline.** `notifFreeLimit(config, plan, channel) = plan.smsIncluded ?? config.smsFreeMonthly`
(and `plan.waIncluded ?? config.waFreeMonthly`) — the subscribed plan's bundled allowance wins,
the global default is only the fallback. The same resolver serves both send engines and the
shop-facing pre-send estimate. Mockup: `entitlements.ts:160-165`, used at billing time by
`lib/notifications.ts:143`.

**Portal.** `NotificationDispatchService::freeLimitFor(string $channel)` (`:467-472`) takes no
shop or plan argument; it resolves `CommercialConfig.sms_free_month` / `wa_free_month`, falling
back to the `ShopNotificationSetting` constants (**50 SMS / 20 WhatsApp**).
`navagoo_subscription_plan.sms_included` and `wa_included` exist, are admin-editable on the plan
form and are documented "NULL = global default", but nothing reads them at billing time. The
billing path is real and independent of any gateway: `billPaidSend` `:751-755` compares usage
against `freeLimitFor()` and `:759-783` constructs and **saves** a `Charge`, called from
`dispatchTrigger` `:132` inside the send transaction. The plan link is trivially resolvable —
`SettingsController.php:411` loads the plan from `ShopSubscription::plan_id` in the very method
that re-derives the allowance without it.

**Failure scenario.** Pro shop with `sms_included = 500`, `sms_sell_price = 0.25`, 200 SMS in a
month. Baseline: **0 charges**. Portal: **150 charge rows × 0.25 = 37.50 plus VAT**, billed
inside the allowance the shop already bought. The Settings tile also shows "50 free" to a shop on
a 500-SMS plan.

**Single-gate property does not hold.** The shop-facing allowance figure is re-derived in
`frontend/controllers/SettingsController.php:402-403` and again in
`frontend/views/settings/_notifications.php:44-45` rather than calling `freeLimitFor()`. The
three sites agree today only because all three ignore the plan.

**Dormancy caveat.** `commercial_config.sms_sell_price` defaults to `0.0000 NOT NULL` and
`configValue()` treats 0 as a value, so overage charges are written at **0.00 SAR** until an
admin sets a sell price. **The wrong allowance is stamped regardless of the price**, so the
defect is already present in the data; only the money appears later.

**Files:** `common/components/NotificationDispatchService.php`,
`common/models/NavagooSubscriptionPlan.php`, `frontend/controllers/SettingsController.php`,
`frontend/views/settings/_notifications.php`

---

## 7. Structural observations

These span findings and are more actionable than any single one.

### 7.1 A recurring shape: the value is configurable, editable and persisted — and unread

This is the most common pattern in the audit, and it accounts for three of the seven S1s. An
admin-facing field exists on a screen and saves to a column, and no consuming code path reads it.
The screen reports success; the engine uses a different value.

| Finding | Sev | Admin-editable field | What the engine actually uses |
|---|---|---|---|
| CF-CC-01 | S1 | `commercial_config.carry_forward_threshold` | hardcoded `200.0` |
| CF-CC-02 | S1 | offer `rate_discount_pct` | undiscounted `marketingRatePct` / `processingRatePct` |
| NOTIF-02 | S1 | plan `sms_included` / `wa_included` | `CommercialConfig` global, else the 50/20 constants |
| CF-CC-06 | S2 | `commercial_config.max_deposit_pct` | `ShopPaymentSettings::getEffectiveMaxDeposit()` |
| CF-CC-07 | S2 | `commercial_config.settlement_hold_days` | `shop.minimum_elapsed_period_days`, hardcoded fallback 7 |
| F-ADJ-04 | S2 | `shop.marketing_fee_rate_pct` (labelled "Marketing Fee Rate %") | `shop.platform_commission` |
| F-ADJ-05 | S3 | `commercial_config.grace_window_days` | `ShopController::GRACE_WINDOW_DAYS = 14` |
| CF-CC-09 | S3 | five `booking_transition_config` keys | no consumer (the model's own comment records this) |
| SE-12 | S3 | `EntitlementService::LIVE_FEATURES` | `EntitlementFilter::FEATURE_MAP` (one key overlaps) |

Nine findings, one shape. The operational consequence is uniform and worth stating on its own:
**the admin surface currently gives no signal when a saved value is inert.** A change is made,
persisted and displayed; the behaviour does not move. Two of these (F-ADJ-04, CF-CC-06) place two
fields for the same concept on the same form, one live and one inert.

### 7.2 CF-CC-01 and F-FIT-04 are the same code path, recorded twice

Both describe `AdminFinanceController::effectiveCarryThreshold()` falling back to a
`carry_forward_threshold` column that does not exist on the `settings` table, and therefore
resolving to the hardcoded 200.0. They were found independently by the `commercial-config` and
`finance-invoices-transfers` area runs and graded S1 and S2 respectively. **One fix closes both.**
The scorecard keeps them as recorded rather than silently deduplicating; the backlog states the
shared fix once.

### 7.3 SE-02 and SE-03 share one root: the console tier hand-mirrors the controller's billing methods

Both originate in the same structural decision — the console tier hand-mirrors
`NavagooPlansController`'s private billing methods rather than sharing them. The net-price lookup
was not carried across into the mirror; the dev simulation rail was. That is why the two rails can
disagree at all: the subscribe path applies `netFor()` and the renewal path does not, and the
renewal path carries a fallback the subscribe path's own checks would not survive.

**Extracting a shared billing service used by both the subscribe path and the renewal cron closes
both findings and removes the mechanism by which the two rails can diverge again.** Fixing each
symptom in place leaves the duplication that produced them.

### 7.4 F-FIT-01+05 is one missing link, not two bugs

The transfer→invoice direction (charges are not tagged, so the invoice rail re-nets them) and the
invoice→transfer direction (earnings are not marked consumed, so the transfer rail re-bundles
them) are the two halves of a single absent bidirectional link between `shop_earning`/`withdrawal`
and `charge`/`invoice`. Same money leak, same fix. The re-verification merged them precisely
because reporting them separately double-counts one exposure.

### 7.5 Two further clusters worth remediating together

- **Charges are never flipped to `paid` on two of three settlement rails.** F-FIT-02 (the live
  settle path never touches `Charge`) and F-FIT-03 (the card invoice-payment branch never touches
  `Charge`) reduce to the same secondary symptom: `costsToDate()` goes permanently stale. Both were
  **downgraded from S1 on re-verification** — in each case the money movement itself was shown to
  be guarded, and the residual is a wrong displayed number. The area file records that they may be
  consolidated into a single S2.
- **Two Yii `rules()` overrides collapse their parent's rules positionally.** F-CAT-05 (`City`) and
  F-CAT-06 (`ShopCategory`) both use `array_replace_recursive(parent::rules(), …)`, which merges
  rule rows element-by-element. In `City` this puts an integer validator on `slug`, making the edit
  modal unsaveable for any legacy city with a non-numeric slug; in `ShopCategory` it removes the
  `name` required rule. Same construct, two different failures. Both were derived by reading PHP,
  not executing it, and both carry a probe.

### 7.6 Audit-event emission is built and unwired

`AuditLogService::emit()` writes actor / scope / shop_id / action / entity / before_json /
after_json into the `audit_log` table, is registered as the `auditLog` component at
`common/config/base.php:218-219`, and has **zero call sites** in `backend/`, `common/`, `frontend/`,
`console/` or `api/`. The Events screen reads a different table (`timeline_event`), written by four
model hooks in signup/shop/request flows only. This one absence produces F-ADJ-01 (S2), F-RBAC-09
(S3), F-CAT-08 (S3), CF-CC-08 (S3), SE-15 (S3) and F-ADJ-07 (S4) — six findings across four areas.
It is one wiring task with unusually broad coverage.

---

## 8. What is working

Reported with the same rigour as the defects.

### Navigation IA is at full parity
19 nav items, same order, same section grouping as `ADMIN_NAV` (`src/portals/nav.ts:48-120`). One
label difference: the portal names the P&L hub **Analytics**. **Two qualifications from F-RBAC-11
must not be lost:** the four section headers are themselves gated on `can('administrator')`
(`Menu.php:33, :63, :135, :170`), so a `manager` session sees an ungrouped flat list rather than
the grouped sidebar; and Settings renders as the last item inside the System group rather than in
the separate sidebar footer slot the baseline pulls it into (`Sidebar.tsx:44-49`). Full grouping
parity therefore holds for an administrator session.

### The entitlement engine is a faithful port
`common/components/EntitlementService.php` is a port of the mockup's `src/lib/entitlements.ts`,
carrying the same three cumulative tiers (starter ⊂ growth ⊂ pro), the same feature keys,
`TIER_RANK`, `minTierFor`, `exceedsBand` as an advisory that never hard-blocks, and
`suggestedTierForCount`. It is covered by
`common/tests/unit/components/EntitlementServiceTest.php`, which pins it to the demo's own cases.
Enforcement is wired, not absent: `frontend/components/EntitlementFilter.php` is an `ActionFilter`
attached globally at `frontend/config/web.php:94`, calling `shopCanAccess()` and rendering
`frontend/views/site/_locked.php` in place of a gated action.

The reason subscribed shops appear to receive everything is a **deliberate, documented posture**,
not missing enforcement: `shopCanAccess()` takes `$defaultAllow = true` and returns it when the
shop has no subscription row, so shops predating billing are not locked out of features already in
use. That is recorded as SE-05 (S2, deviation, built-not-configured) — a one-line flip, not a
rebuild. It is reported as a deviation because the baseline force-subscribes; it is not reported as
an omission.

One near-miss recorded to prevent a false finding: `EntitlementService::hasRole()` returns `true`
for every feature, which reads like an unfinished RBAC axis. **The mockup does the same** —
`src/lib/entitlements.ts:186-195` looks up `FEATURE_PERMISSION`, which is empty by design. The
portal matches the baseline. This must not be reported as a gap.

### Page-level layout is sound, and the seeded layout hypothesis was largely wrong
H1 predicted that cards and text ignore container margins at some widths and that components
overlap. Measured across **32 screen × width probes** (8 admin screens at 1024 / 1280 / 1440 /
1920), by DOM measurement rather than visual impression:

- **Page-level horizontal overflow: zero in all 32 combinations.** `scrollWidth === clientWidth`
  on every screen at every width. No screen produces a horizontal page scrollbar.
- **Zero component-level overlaps anywhere.** Every intersecting sibling pair the probe reported
  was `<path>` / `<rect>` / `<circle>` / `<polyline>` geometry inside a single `<svg>` icon —
  intersection areas of 9–138 px². **No** intersecting pair of cards, panels or text blocks was
  found on any screen at any width. **The "components overlap" clause of H1 is not substantiated.**
- Wide tables on `/shop/index` and `/admin-finance/transfer-requests` sit inside
  `overflow-x: auto` wrappers — the standard responsive-table pattern, no content clipped, not a
  defect. (Density note only: 7 of 12 columns on transfer-requests need horizontal scrolling at
  1024.)
- The off-viewport elements on `/shop/update` are Google Maps internals (256 × 256 px tiles inside
  a clipped `div#map.overflow-hidden`); the clipped elements are `span.sr-only` utilities and
  decorative gradient layers. Not defects.

One genuine layout defect survived: **F-ADM-H1-01** (S3) — the read-only IBAN tile on
`/shop/update` renders a 34-character unbroken token that cannot wrap and is not ellipsised,
spilling 133 px at 1024 and 48 px at 1280 (clean at 1440 and 1920). At 1024 the final ~49 px is
past the viewport edge and, because the page has no horizontal scroll, unreachable.

### H2's presentation hypothesis was disconfirmed
Shop edit is a **full page in the main document** — not an iframe, not a modal. The single
`<iframe>` on the page is the Google Maps API's hidden resize-detection frame (empty `src`,
`aria-hidden`, `opacity: 0`, zero-length body). The two `.modal-panel` nodes measure 0 × 0 px at
`opacity: 0` — dormant layout dialog shells. Those shells carry the **same class signature as the
mockup's modal**, i.e. the portal already ships the baseline's modal component; this surface simply
does not use it.

The density half of H2 is confirmed: **45 visible controls vs the baseline's 15 (3.0×), in 12
sections vs 3**. Most of the surplus is data the demo does not model and the live system does —
compliance documents, operating hours, a scheduled fee-change workflow with an audit reason, map
picker, media. Those are listed plainly in the hypothesis file as **not defects**, for a human to
decide on. Helper-text density was measured, not assumed: portal 34 distinct hint strings vs
mockup 18 — no gap.

### The subscription-plans screen is operative
H8 predicted `/shop/plans` was not operative. **Refuted.** It renders a six-tab section, a New
plan action, three plan cards with per-period pricing, specialist bands and trial terms, and a
feature matrix with save-on-change. Observed catalogue: Starter 99.00 / 535.00 / 950.00, Growth
225.00 / 1,215.00 / 2,160.00, Pro 349.00 / 1,885.00 / 3,350.00, all 30-day trial. (The matrix
itself is empty — 0 of 57 checkboxes checked — recorded separately as H5.2.)

### Verification discipline held
Three candidate findings were **refuted and dropped rather than downgraded**: SE-18 (an internal
return-value label with no observable consumer), NOTIF-07 (the mockup control it graded against is
itself labelled "Mark failed (demo)" and sits on the exclusion list), and NOTIF-08 (the portal
faithfully transcribes what the mockup's own UI does; the stated baseline described the pure
helper's signature, not its caller). Two S1 candidates were downgraded to S2 and two were merged.
The refutation record is preserved in each area file.

---

## 9. Deviation / gap / drift split

Per spec §4.1, every finding carries a cause. Tallied across all 87 confirmed findings:

| Cause | Count | Share |
|---|---|---|
| **Deviation** — implemented, behaves differently from the baseline | 55 | 63% |
| **Gap** — present in the mockup before porting began, not present in the portal | 32 | 37% |
| **Drift** — added to the mockup after the corresponding port | **0** | 0% |

And by cause of *absence* (spec §4.2), which determines the kind of work each needs:

| Cause of absence | Count | Share |
|---|---|---|
| **Built, not working** — code exists and executes, produces the wrong result | 38 | 44% |
| **Not built** — no implementing code exists | 26 | 30% |
| **Built, not configured** — code exists but is inert in this environment | 16 | 18% |
| **n/a** — recorded divergence with no absence dimension (intentional or cosmetic) | 7 | 8% |

**Reading.** Nearly two-thirds of findings are deviations, and 44% are built-not-working: this is
predominantly a body of code that exists and runs, diverging at specific decision points, rather
than a body of unbuilt features. That is consistent with §7.1 — most of the money findings are a
value that is stored but not read, not a calculation that was never written. Only 30% is
never-built work, and it concentrates in RBAC (7 of 12 findings), the invoicing/transfer lifecycle
(5 of 16) and audit-event emission.

**On the zero.** No confirmed finding was classified drift. That is the recorded state, not a
proof: `releases.ts` dating was available as a method (spec §4.1) but no per-finding release date is
recorded in the findings files, so "0 drift" means *no finding was identified as post-port mockup
movement*, not *no post-port mockup movement exists*. If any backlog item is contested on the
grounds that the mockup moved after the port, `releases.ts` is where that is settled.

---

## 10. Coverage and limitations

An honest boundary is worth more than implied completeness. What follows is what this audit does
**not** establish.

### 10.1 What was not examined

- **Track U was scoped, not exhaustive.** The 101 baseline surfaces were inventoried as the
  *baseline*; they were not each graded against the portal. Track U grading now covers responsive
  integrity (axis 5) on 8 admin screens × 4 widths, an IA and field-level comparison of one surface
  (shop edit), and — from the UI parity pass in §5 — IA (axis 1), design tokens (axis 2), component
  vocabulary (axis 3) and RTL / Arabic (axis 6) on **one primary landing screen per area across
  twelve areas at 1440**, with RTL sampled on one of them. **That pass reaches primary landing
  surfaces only.** All 38 modals, the 1 drawer, the 14 panels and every non-landing tab interior
  remain ungraded, as do permission-denied variants and all but two empty states (axis 4); loading
  states are deliberately not graded because the mockup renders none. **No claim is made about the
  ungraded surfaces in either direction** — §5.4 lists them.
- **Mockup-side layout captures were not taken** for the eight layout screens, so no mockup-side
  layout comparison is claimed — H1's results describe the portal only.
- **No writes were performed on staging during the H1/H2 probe.** No form was submitted and no
  Save/Update button was clicked.
- **Shop edit was examined for one record** (`id=20`, "The Beauty of Nails") against one mockup
  shop ("Beauty Center"). Field *visibility* could differ for shops in other states. The field
  count carries ±1 uncertainty from the Kartik FileInput widget rendering a visible proxy beside a
  zero-size native input; the 3.0× ratio is unaffected.
- **The overlap detector deliberately excludes** `position: absolute/fixed/sticky` and floated
  children, so an overlap caused by an intentionally-positioned element would not have been
  reported.
- **Track D (documentation currency) has not been run.** It is a Phase C deliverable covering the
  repo as a whole.
- **`BOARD_ADMIN.html`** — the side-by-side visual evidence board named in spec §10 — is not part
  of this delivery. Screenshots are in `02_EVIDENCE/admin/` — the H1/H2 probe alone contributed
  40 (8 screens × 4 widths, plus shop-edit detail), and further captures were still being added
  at the time of writing, so treat the directory rather than a count as the reference.

### 10.2 The 30 unprobed contracts

Thirty contracts carry findings derived from source reading and still want runtime confirmation:
5 in subscriptions-entitlements, 5 in catalogue-geography, 4 each in finance-ledger and
finance-invoices-transfers, 3 each in commercial-config, system-rbac, notifications and
adjacent-admin. Each has a written probe recipe and a stated confirm/refute condition at the foot
of its area file; several state the condition that would *withdraw* the finding (F-ADJ-04: "if
`rate_snapshot` follows the 8, the finding is wrong and should be withdrawn"). Two findings —
F-CAT-05 and F-CAT-06 — are explicitly recorded as **predictions derived by reading PHP, not
executing it.**

These 30 probes coexist with a contract register that records `needs-probe = 0`. The two outputs
disagree; §4.2 states why this report follows the probe list rather than the verdict field, and does
not claim complete behavioural coverage.

One probe is blocked by tooling rather than by data: C-ADM-043 requires reading
`mootensai/yii2-relation-trait::deleteWithRelated()`, and `vendor/` is not installed in the audit
checkout. Whether deleting a shop category attempts to delete every `Shop` pointing at it — a
potential data-loss path — is **unresolved**, not cleared.

### 10.3 Preconditions that will make a naive probe read zero

This is the most important operational caveat in the report. From the live ledger probe:

- **Commercial rates are unset across staging.** Both processing-fee rows in the entire ledger read
  `0% + 0.00 of collected` and charge 0.00. The fee engine runs and creates the row; every rate
  resolves to zero. **Fee arithmetic cannot be validated on staging as it stands** — a probe of
  processing or marketing fees returns 0.00 regardless of whether the formula is right. Rates must
  be configured on Commercial Config before any fee-math probe is meaningful.
- **Both seeded offers carry `rate_discount_pct = 0`** (CF-CC-02 reads as 0.00 today).
- **`commercial_config.sms_sell_price` defaults to `0.0000 NOT NULL`** and `configValue()` treats 0
  as a value, so NOTIF-02's overage charges are written at 0.00 SAR. The *wrong allowance* is
  stamped regardless of the price.
- **The staging charge ledger holds six rows and contains no `marketing_fee`, `sms` or `whatsapp`
  row at all.**

Every backlog item carries its own precondition line for this reason.

### 10.4 Open question OQ-FIT-A — partially resolved, consequence still undetermined

`WithdrawalBundlingService` assigns a string (`'NTR-…'`) into the int column
`charge.transfer_request_id` (`m260623_211000_create_charge_ledger.php:43`). Under MySQL strict
mode this raises error 1366 inside `createWithdrawal()`'s transaction, rolling back the entire
request — meaning "Request transfer" would fail outright for any shop with notification charges.
That is a different and more severe failure than the leak described in F-FIT-01+05.

**Resolved as latent, not active.** The assignment touches only charges of type `sms` / `whatsapp`.
**Zero such rows exist on staging**, so the path has never executed. Eight transfer requests have
completed successfully, which is consistent with the path never being reached rather than with
strict mode being off.

**Still undetermined:** `@@sql_mode` on the deployed database has not been checked, so whether the
consequence is silent coercion to 0 or a transaction rollback is unknown. **The risk becomes
reachable the moment the first notification charge exists.** Do not assert either way without
checking `@@sql_mode` or attempting a live transfer request for a shop carrying a notification
charge.

### 10.5 A second open question, stated as a hypothesis and not a finding

H7 confirmed that the notification-trigger dispatch path does not reach a provider: the service's
own docblock states "The actual provider send (Msegat/Twilio) is still a separate integration — no
gateway is wired yet." `SMSHelper` and `WhatsAppHelper` exist and are called from other call sites,
so provider-calling code exists elsewhere — it is this path specifically that does not reach one.

The follow-on risk was **recorded as a hypothesis requiring its own probe, and that probe has not
been run**: if `dispatch()` records a notification and appends a billable `Charge` on a path where
no provider send occurs, a shop could be charged for a message that was never delivered — and the
refund mechanism (`markNotifFailed()`) is triggered by a *reported* delivery failure, which a
non-existent gateway would never report. If a charge is appended without a send, that is **S1**;
if the path short-circuits before billing, there is no money defect and it closes. **This is
unresolved.** It reads as dormant today only because `sms_sell_price` is 0.0000 and zero
notification charges exist.

### 10.6 Dormancy caveats that must survive into any status report

Two upheld S1 findings are inert against current seed data and **will show 0.00 on a naive staging
probe. That result does not disconfirm either finding.**

| Finding | Why it reads 0.00 today | What activates it |
|---|---|---|
| CF-CC-02 | Both seeded offers carry `rate_discount_pct = 0` | An admin creating any fee-discount offer — an ordinary admin action. The shop-facing UI already promises the behaviour. |
| NOTIF-02 | `sms_sell_price` defaults to 0.0000; overage charges are written at 0.00 SAR | An admin setting a sell price. The wrong allowance is stamped regardless, so the defect is already present in the data. |

---

## 11. Observed while writing, not verified

Recorded here rather than added as findings, per the no-invented-findings rule. None of these has
been through refutation.

- **The contract register's `Verdict` fields have since been written back.** When this report was
  first written, all 82 contracts in `01_BASELINE/admin/contracts.md` read
  `Verdict: (unset until observed)`, which is why §3 stated no pass rate. They were subsequently
  populated from the verification run's workflow journal — bookkeeping over existing evidence rather
  than new investigation — and §4 is computed from them. The write-back is the source of the
  `needs-probe = 0` figure that §4.2 records as inconsistent with the 30-probe list.
- **CF-CC-01 (S1) and F-FIT-04 (S2) appear to describe the same resolver.** Drawn out in §7.2. Both
  were independently confirmed; whether they should be merged the way F-FIT-01 and F-FIT-05 were is
  a grading decision this report does not make unilaterally — the counts in §3 keep them separate,
  as recorded.
- **Three incidental setup observations were logged and not yet assigned to an area** (from
  `02_EVIDENCE/environment.md`): the admin login page loads Lucide from `unpkg.com` at `@latest`
  (unpinned external CDN) where the mockup bundles its icon library locally; the admin login page
  has no language toggle where the shop login page has EN/العربية links and the mockup's pre-auth
  layout carries one; and the shop login field is labelled "Phone number" with placeholder
  `9665XXXXXXXX` but accepts an email address. The first two are admin-surface items with no area
  finding raised against them.

---

## 12. Where everything is

| Artifact | Path |
|---|---|
| Ticket-ready backlog | `BACKLOG_ADMIN.md` |
| Findings summary + S1/S2 index | `03_FINDINGS/admin/_SUMMARY.md` |
| Contract pass-rate scorecard (Track L), denominator rule, 83-vs-82 reconciliation | `03_FINDINGS/admin/_PASS_RATES.md` |
| UI parity (Track U) — token comparison, area-by-area, 18 findings, coverage note | `03_FINDINGS/admin/_ui-parity.md` |
| Per-area findings (8 files, verbatim baseline/observed text) | `03_FINDINGS/admin/*.md` |
| Seeded-hypothesis resolutions | `03_FINDINGS/admin/_hypotheses.md`, `_hypotheses_h1_h2.md` |
| Baseline surface inventory (101 surfaces) | `01_BASELINE/admin/inventory.md` |
| Behavioural contracts (82) | `01_BASELINE/admin/contracts.md` |
| Demo-artifact exclusion list — nothing on it may be reported as a gap | `01_BASELINE/demo-artifacts.md` |
| Environment, baselines, staging fingerprint, stated limits | `02_EVIDENCE/environment.md` |
| Live staging ledger and transfer-request observations | `02_EVIDENCE/admin/live-ledger-probe.md` |
| Screenshots (H1/H2 probe: 8 screens × 4 widths, plus shop-edit detail) | `02_EVIDENCE/admin/` |
| UI parity capture pairs (12 areas @1440, mockup + staging; `shops` also @1440 RTL) | `02_EVIDENCE/admin/<area>/` |
| Governing spec (classification, severity, writing standard) | `00_SPEC/PARITY_AUDIT_SPEC.md` |
