A contract-by-contract comparison of the admin portal running on staging against the
product mockup, followed by an adversarial refutation pass and an independent
re-verification of every top-severity finding. This board carries the counts, the
seven S1 findings, the resolution of the five pre-identified concerns, and the visual
evidence. The narrative write-up is in REPORT_ADMIN.md.
Baseline (mockup)
cb48c8d — v0.28.0 React demo, #/admin/*, audited as super_admin
Implementation
Staging admin portal stageadmin.navagoo.com, role admin
Local portal ref
5efbafc9 branch parity-audit off tailwind-poc
Observation dates
2026-08-05 → 2026-08-06
Coverage
83 of 83 contracts evaluated 101 surfaces inventoried
Still open
30 contracts need a live probe
Scorecard
87 confirmed findings across eight areas. Three candidate findings were refuted during
the adversarial pass and are recorded in the area files rather than reported here.
Severity reflects consequence, not effort: S1 is a wrong monetary or
access outcome on a reachable path; S2 is a wrong displayed figure or a
dead-ended flow; S3 is a behavioural deviation with bounded impact;
S4 is presentational.
7
S1
Reachable path, wrong money or access
38
S2
Wrong figure shown, or a flow that dead-ends
33
S3
Bounded behavioural deviation
9
S4
Presentational / label divergence
Area
S1
S2
S3
S4
Confirmed
Refuted
Probes needed
subscriptions-entitlements
2
8
6
1
17
1
5
finance-ledger
1
5
2
2
10
0
4
finance-invoices-transfers
1
6
7
2
16
0
4
commercial-config
2
3
4
0
9
0
3
catalogue-geography
0
5
4
1
10
0
5
system-rbac
0
5
5
2
12
0
3
notifications
1
3
2
0
6
2
3
adjacent-admin
0
3
3
1
7
0
3
Total
7
38
33
9
87
3
30
Counts reflect the re-verification pass: two finance-invoices-transfers findings moved
from S1 to S2 (F-FIT-02, F-FIT-03) and two were merged into one (F-FIT-01 + F-FIT-05),
which is why the confirmed total is 87 rather than 88.
The seven S1 findings
Each of these was re-checked by an independent hostile pass that required four things
before the rating could stand: quoted code from the cited files, evidence that the path
is reachable in production, a check against the mockup baseline, and a worked failure
scenario with numbers. Seven were upheld; two were downgraded and two merged.
S1SE-02
Renewal charges the gross plan price, ignoring an enrolled offer's discount
C-ADM-067deviationbuilt, not working
Baseline
At renewal the charge is raised on the net price — the enrolled offer's sub-discount applies while now <= lockedUntil — and currentTermPrice is re-stamped to that net.
Observed
SubscriptionBillingController::actionCharge() line 203 sets $amount = $plan->priceForPeriod($period). No ShopOfferEnrollment lookup occurs anywhere in the console tier, and current_term_price is not consulted. The subscribe path does apply netFor(), so the discount holds on charge one and is lost from charge two onward.
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 charges 500 and re-stamps the term price to 500 — 100 SAR/month over for the 11 remaining locked months. The inflated stamp also shrinks any later upgrade proration, which reads current_term_price.
Reachability: the subscription-billing/run cron is scheduled daily on both qc and prod (console/config/schedule.php:42,53), so this is a live path. Live corroboration: CHG-0006 on staging charges 1,696.50 = 1,885.00 × 0.90, i.e. the subscribe path applying a 10% offer correctly — that shop is a standing reproduction case for the renewal divergence.
A card-rail subscription with no card on file renews as paid
C-ADM-068deviationbuilt, 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 moves to past_due with pastDueSince set.
Observed
chargeSubscription() (:467-492) computes $useRealRail = $card && PaymobSubscriptionHelper::isConfigured() && token. When that is false — including when the shop has no saved card at all — control falls through to writeSubscriptionCharge() with Charge::STATUS_PAID. actionCharge() then stamps a new term, sets status active, clears past_due_since and emails a renewal confirmation.
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, activates the subscription and clears past-due. Zero is collected; ledger revenue is overstated by 500 per shop per term, and the subscription renews indefinitely.
Not environment-gated: the fallback carries no 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 either — actionSubscribe never rejects a card-rail subscribe from a user without one.
No completion path raises a marketing fee into the charge ledger
C-ADM-013gapbuilt, 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. The mockup re-derives charges on every status transition (store.ts:1112).
Observed
The marketing-fee builder matches the baseline formula, but no completion or no-show transition invokes it. BookingCompletionService::ensureEarnings() — the shared chokepoint for solo completion, group-child completion and collect-then-complete — calls ensureProcessingFee() only. deriveBookingCharges() has exactly six call sites; none is a completion path.
Failure scenario
500.00 booking, marketing rate 5%, VAT 15%. Expected charge row: basis 434.78, fee 21.74, VAT 3.26, total 25.00. Actual: no row is written. netPayout shows 483.04 instead of 458.04, and costsToDate() is understated by 25.00 per booking.
Corroborated live, with a scoping caveat. The staging ledger holds six charge rows and no marketing_fee row at all; two completed bookings on 3 Aug (91.00 and 136.00) each produced a processing-fee row and no marketing-fee row, and all eight transfer requests show Marketing 0.00. Remediation must target the charge ledger, not the payout rail. A parallel legacy rail (navagoo_marketing_fees, written by Earnings::calculateFinancialFields()) is what the payout actually nets, so real cash payout may still deduct a marketing fee. What is established as wrong here are the charge-ledger-driven figures shown to shops and admins.
Files
common/services/BookingCompletionService.php
common/components/FinanceLedgerService.php
S1F-FIT-01+05
No bidirectional link between the settlement rail and the fee rail
C-ADM-024deviationbuilt, not workingmerged
Baseline
On creation, a transfer request writes transferRequestId onto every included charge row so it cannot be re-batched, and cost/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.
Observed
Transfer → invoice:linkEarnings() (:374-387) tags only SMS/WhatsApp charge rows; marketing-fee and processing-fee rows are never tagged. Invoice → transfer:eligibleEarnings() filters on settlement_status and withdrawal_id IS NULL only. Issuing an invoice records the netted bookings in invoice.applied_booking_ids but sets nothing on shop_earning.
Failure scenario
An admin opens Shop Balances, which auto-runs reconcileBilling(). 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. The 98.90 is recorded as recovered from money that is simultaneously paid out.
Correction carried from re-verification: the original F-FIT-01 causal chain was partly wrong. A string coerced to 0 is not NULL, so notInTransfer() and consumedBookingIds() behave correctly for bookings that carry a notification charge. The defect bites bookings with no notification charge — which is the common case. The int/string column mismatch is tracked separately as OQ-FIT-A.
The admin-edited carry-forward threshold has no reader; every shop resolves to a hardcoded 200.00
C-ADM-034deviationbuilt, not working
Baseline
effectiveCarryThreshold(shop, config) = shop.carryForwardThreshold ?? config.carryForwardThreshold. The global threshold the admin edits is the live fallback for every shop without a per-shop override, and it drives threshold auto-invoicing. Global seed: 1000.
Observed
AdminFinanceController::effectiveCarryThreshold() (:326-336) checks the per-shop column, then backend\models\Settings->carry_forward_threshold, then a hardcoded 200.0. The settings table has no such column and no migration adds one, so the middle branch is dead against the schema. The value edited on Commercial Config — commercial_config.carry_forward_threshold — has no reader anywhere in the codebase.
Failure scenario
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, so 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: 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
S1CF-CC-02
Offer fee-discounts are computed and tested but never applied to a charge
C-ADM-036gapbuilt, not configureddormant on seed data
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].
Observed
OfferPricingService::rateDiscountPct() (:95-108) is correct and unit-tested, but has no caller — a repo-wide search returns only its own definition, its test, and a comment. A case-insensitive grep for offer|discount across FinanceLedgerService.php returns zero hits; buildMarketingFee() and buildProcessingFee() stamp the undiscounted rate.
Failure scenario
Basis 1000 SAR, marketing rate 10%, offer 25% on marketing_fee. Baseline stamps an effective 7.5% → 75.00. Portal stamps 10% → 100.00. A 25.00 overcharge per booking for the full 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").
Files
common/components/FinanceLedgerService.php
common/components/OfferPricingService.php
common/models/NavagooOffer.php
common/models/ShopOfferEnrollment.php
S1NOTIF-02
The free-message allowance resolver takes no plan, so plan-bundled allowances are not honoured
C-ADM-060deviationbuilt, not workingdormant on seed data
Baseline
notifFreeLimit(config, plan, channel) = plan.smsIncluded ?? config.smsFreeMonthly — the subscribed plan's bundled allowance wins and the global default is only the fallback. The same resolver serves both send engines and the shop-facing pre-send estimate.
Observed
freeLimitFor(string $channel) (:467-472) takes no shop or plan argument. It resolves CommercialConfig.sms_free_month / wa_free_month, falling back to the 50 SMS / 20 WhatsApp constants. navagoo_subscription_plan.sms_included and wa_included exist and are admin-editable, but nothing reads them at billing time. The shop-facing figure is separately re-derived in two more places, so the contract's single-gate property does not hold.
Failure scenario
Pro shop with sms_included = 500, sms_sell_price = 0.25, 200 SMS sent in a month. Baseline: 0 charges. Portal: 150 charge rows × 0.25 = 37.50 plus VAT, billed inside an allowance the shop already bought. The Settings tile also shows "50 free" to a shop on a 500-SMS plan.
Dormancy caveat:commercial_config.sms_sell_price defaults to 0.0000 NOT NULL and configValue() treats 0 as a value, so overage rows 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.
Files
common/components/NotificationDispatchService.php
common/models/NavagooSubscriptionPlan.php
frontend/controllers/SettingsController.php
frontend/views/settings/_notifications.php
The five pre-identified concerns
Five concerns were raised before the audit began and verified first, because their
answers shape everything downstream. Two resolved differently from how they were
originally framed, and one was refuted outright. Every layout claim below is a measured
DOM number, not a visual impression.
Concern
Resolution
What the measurement showed
H1 Cards and text ignore container margins at some widths; components overlap
PARTLY CONFIRMED overlap clause disconfirmed
One genuine defect across 32 screen × width probes: on /shop/update, an unbreakable 34-character IBAN escapes its tile at 1024 (133 px spill) and 1280 (48 px), clean at 1440 and 1920 (F-ADM-H1-01, S3).
The overlap claim was disconfirmed by measurement — zero intersecting pairs of cards, panels or text blocks on any screen at any width. Every intersection the probe reported was <path>/<rect> geometry inside a single SVG icon (9–138 px²).
H2 Shop edit is an iframe-hosted legacy form inside a modal
CONFIRMED WITH A DIFFERENT MECHANISM disconfirmed on presentation, confirmed on density
The shop edit is a full page in the main document — not an iframe, not a modal. The page's one <iframe> is the Google Maps resize-detection frame (empty, opacity: 0, z-index: -1); the two .modal-panel nodes measure 0 × 0 at opacity 0 and are dormant layout shells.
Density does diverge: 45 visible controls vs 15 in the baseline modal (3.0×), in 12 sections vs 3.
H5 Plan entitlements are not enforced; a subscribed shop receives all features regardless of tier
CONFIRMED WITH A DIFFERENT MECHANISM
Enforcement is wired, not absent: EntitlementFilter is an ActionFilter attached globally and renders a locked view on gated actions. Access is unrestricted for a different reason — shopCanAccess() takes $defaultAllow = true and returns it when a shop has no subscription row, a documented live-rollout posture.
Compounding it: the admin feature matrix is 0 of 57 cells populated, and plans stamp display labels rather than canonical feature keys, so the engine falls back to tier defaults. The matrix does not currently influence entitlement at all.
H7 SMS and WhatsApp notification triggers do not dispatch to their providers
CONFIRMED
The metering and billing pipeline is built — allowance consumption, append-only Charge rows, refund-on-failure. The provider gateway on the trigger path is not: the service's own comment block records that the Msegat/Twilio send "is still a separate integration — no gateway is wired yet". Provider-calling code exists elsewhere (SMSHelper, WhatsAppHelper); it is the trigger dispatch path specifically that does not reach one.
H8 The admin subscription-plans management screen is not operative
REFUTED
The screen is present and functional: six tabs, a New plan action, three plan cards with per-period pricing, specialist bands and trial terms, and a feature matrix with save-on-change.
A separate observation on the same screen stands on its own: document.querySelectorAll('table input[type=checkbox]') returns 57 total, 0 checked — all 19 feature rows read - - - across Starter/Growth/Pro.
Visual evidence
This board embeds a curated subset. The audit captured 40 screenshots totalling
8.6 MB. Fourteen are embedded below, chosen for evidentiary value. The complete set lives in
parity-audit/02_EVIDENCE/admin/<area>/ and can be opened directly from disk;
every file not embedded is listed by path at the end of this section.
Mockup-side captures exist for the shop-edit surface only. H1 was specified as a
portal-side layout probe, so matching-width mockup screenshots were not captured for the eight
layout screens. Where no mockup counterpart exists, the staging capture is shown alone and
labelled as such — no mockup-side layout comparison is claimed for those surfaces.
Look at the third tile of the Shop Overview card — IBAN NUMBER.
The value is SA22000000000000000000000000000000: 34 characters, one unbroken token.
Computed style on the value element is word-break: normal; overflow-wrap: normal; text-overflow: clip; overflow-x: visible,
so it cannot wrap and is not ellipsised. The measured grid gives the tile a 209 px content box at 1024
and 294 px at 1280, against a 310 px text width.
Callout: at 1024 the string renders through the tile border, past the card's right edge, and
49 px off the right of the viewport. Because the page has no horizontal scroll
(scrollWidth === clientWidth on every screen at every width), that last segment is
unreachable — the IBAN cannot be read in full at 1024. At 1280 the spill is 48 px but stays on screen.
The same tile at 1440, where it resolves.
The grid moves to 350px 350px 350px, giving a 348 px content box against a 316 px text
width — spill 0. The overflow reported further up the ancestor chain
(div.tw-shop-form, form#shop-form, div.ng-page, all showing
scrollWidth − clientWidth of 96 px at 1024 and 11 px at 1280) resolves together with the
tile, which is why it is read as a consequence of the single tile rather than as independent defects.
Shop edit — page vs. modal, and the density difference
Left is the baseline; right is the implementation. Look at how each hosts the form.
The mockup opens a centred 672 × 868 px modal over #/admin/shops — the hash does not
change, and the list stays visible behind it. The portal navigates to /shop/update?id=20,
its own route with its own <title>, and renders the form as ten stacked cards plus
two read-only cards. Both are ordinary DOM in the main document; neither is an iframe.
Notably, the portal already ships the baseline's modal component — two dormant .modal-panel
shells carry the same class signature — the shop-edit surface simply does not use it.
Field density: 15 controls in 3 sections vs 45 controls in 12.
The baseline modal carries BASIC INFO · STORE DETAILS · SETTINGS & TRANSFER THRESHOLDS.
The portal adds Media, Location Information, Shop Settings, a VAT section, Additional
Information, Shop Documents and a nested "CURRENT RATE" payment-processing-fee panel —
roughly 32 extra controls across 9 groups, carrying data the demo does not model
(compliance documents, a scheduled rate-change workflow with an audit reason, operating
hours, a map picker).
One baseline field has no portal counterpart: the paired ENGLISH / العربية commercial name
with per-field translate actions (visible top-left of the modal). The portal probe returned
translateButtons: 0 and arabicNamedInputs: [] (F-ADM-H2-01, S3).
Also visible: the portal shows commercial name, bank and IBAN twice — read-only in Shop Overview
and again as editable inputs in Store Details.
Other surfaces — staging captures
These four are representative of the eight screens probed for layout. No mockup counterpart was
captured at matching widths, so they are shown as single-side observations. All four were clean
at page level: scrollWidth === clientWidth at every tested width.
Wide tables are contained by scroll wrappers, not clipped (OBS-1 — not a defect).
Both tables sit inside a div with computed overflow-x: auto, so nothing is
clipped and the page never scrolls horizontally. Recorded as a density observation only: the
transfer-requests table measures 1,546 px, so at 1024 7 of its 12 columns — including
NET PAYOUT, AMOUNT PAID and STATUS — require horizontal scrolling; 5 of 12 at 1280, 4 of 12
at 1440, and it fits at 1920. The shops list puts 2 of 8 columns past the fold at 1024.
This table is also where the live ledger probe was read: every one of the eight transfer requests
shows Marketing 0.00 and Notifications 0.00, including one with 2,297.01 earned.
Left: the screen H8 predicted would be inoperative.
It renders a six-tab section (Shops · Requests · Subscription Plans · Offers · Subscription
Dashboard · Payment methods), a New plan action, and three plan cards — Starter
(1–3 specialists, 99.00 / 535.00 / 950.00), Growth (4–10, 225.00 / 1,215.00 / 2,160.00) and
Pro (11+, 349.00 / 1,885.00 / 3,350.00), each with a 30-day trial. The feature matrix further
down the same screen is the 57-checkbox / 0-checked measurement. Both screens are clean at
every probed width.
Two of the remaining layout screens, both clean at all four widths.
The only overflow: hidden elements carrying overflowing content anywhere in the 32-probe
run were span.sr-only visually-hidden utilities and decorative gradient layers (OBS-4),
and the only off-viewport absolutely-positioned elements were Google Maps' 256 × 256 px internal
tiles inside a clipped div#map (OBS-2). Neither is a portal layout defect.
Not embedded — open these from disk
All paths are relative to parity-audit/02_EVIDENCE/admin/. Twenty-six further captures,
plus the live-probe text record:
analytics/analytics@1024-staging.png
analytics/analytics@1280-staging.png
analytics/analytics@1920-staging.png
dashboard/dashboard@1024-staging.png
dashboard/dashboard@1280-staging.png
dashboard/dashboard@1920-staging.png
finance/finance-transfers@1280-staging.png
finance/finance-transfers@1440-staging.png
finance/finance-transfers@1920-staging.png
people/people@1024-staging.png
people/people@1280-staging.png
people/people@1440-staging.png
people/people@1920-staging.png
plans/plans@1024-staging.png
plans/plans@1280-staging.png
plans/plans@1920-staging.png
settings/settings@1024-staging.png
settings/settings@1280-staging.png
settings/settings@1920-staging.png
shop-edit/shop-edit@1280-staging.png
shop-edit/shop-edit@1920-staging.png
shop-edit/shop-overview-card@1280-staging.png
shop-edit/shop-overview-card@1440-staging.png
shops/shops-list@1024-staging.png
shops/shops-list@1280-staging.png
shops/shops-list@1920-staging.png
live-ledger-probe.md (text — the live ledger observations)
What is working
Navigation information architecture is at full parity
The admin sidebar's sections and entries match the baseline's ADMIN_NAV
structure. No nav item was found missing, misplaced or extra when audited as
super_admin — the role that matters, since the baseline nav is permission-filtered and
auditing under a narrower role would manufacture false "missing item" findings.
The entitlement engine is a faithful, test-pinned port
common/components/EntitlementService.php tracks the mockup's
src/lib/entitlements.ts closely: the same three cumulative tiers
(starter ⊂ growth ⊂ pro), the same feature keys, TIER_RANK,
minTierFor, exceedsBand as an advisory rather than a hard block,
and suggestedTierForCount. It is covered by
common/tests/unit/components/EntitlementServiceTest.php. Enforcement is wired
too — EntitlementFilter is an ActionFilter attached globally at
frontend/config/web.php:94, rendering a locked view in place of a gated action.
One detail worth recording so it is not later reported as a gap:
EntitlementService::hasRole() returns true for every feature, which
reads like an unfinished RBAC axis. The mockup does the same —
entitlements.ts:186-195 defines hasRole against a
FEATURE_PERMISSION map that is empty by design. The portal matches the baseline here.
Zero page-level horizontal overflow across 32 probes
Eight screens × four widths (1024 / 1280 / 1440 / 1920):
document.documentElement.scrollWidth === clientWidth in all 32 combinations,
delta 0 every time. No screen produces a horizontal page scrollbar. Wide tables are
contained by overflow-x: auto wrappers rather than clipped, which is the
standard responsive pattern. And across all 32 combinations, zero
intersecting pairs of cards, panels or text blocks were found.
The notification metering and billing pipeline is built
Allowance consumption, append-only Charge rows carrying unit sell price plus
VAT and linked to the send via charge.meta.notification_id, and an
admin-configured per-message refund on reported delivery failure are all present and
reachable. What is missing on this path is the provider gateway, not the accounting
(see H7, and NOTIF-02 for the allowance resolution defect).
Limitations
30 contracts still need a live probe. Those findings rest on migration
history and source reading rather than on a runtime observation. Each area file lists
the specific probe and what result would confirm or downgrade the finding. Two examples:
CF-CC-01 rests on the settings table having no carry_forward_threshold
column, established from migrations — a SHOW COLUMNS against the live schema
settles it, and a non-empty result would downgrade the finding to a source-of-truth split.
Two upheld S1 findings are dormant on the current seed data and will probe as 0.00.
That result does not disconfirm either. CF-CC-02: both seeded offers carry
rate_discount_pct = 0, so the discount that is never applied is also never non-zero.
NOTIF-02: sms_sell_price defaults to 0.0000 NOT NULL, so
overage charges are written at 0.00 SAR — but the wrong allowance is stamped regardless of price.
OQ-FIT-A remains open.transfer_request_id is assigned a string
('NTR-…') into an int column. Under MySQL strict mode that would raise error 1366 inside
createWithdrawal()'s transaction and roll back the whole request — a different and more
severe failure than the leak described in F-FIT-01+05. The live probe narrowed it: that assignment
only touches charges of type sms/whatsapp, zero such rows exist,
and eight transfer requests have completed successfully — so the path has never executed. The risk is
real in code but currently unreachable, and becomes reachable the moment the first notification charge
exists. @@sql_mode on the deployed database is still unchecked, so the consequence
(silent coercion vs. transaction rollback) is undetermined.
Commercial rates are unset on staging, so fee arithmetic cannot be validated there as it stands.
Both live processing-fee rows read 0% + 0.00 of collected and charge 0.00. The fee engine
runs and creates the row; every rate resolves to zero — built, not configured. Any probe of
processing or marketing fee arithmetic will return 0.00 regardless of whether the formula is correct.
Rates must be configured on Commercial Config before a fee-math probe means anything.
No mockup-side layout comparison is claimed for the eight layout screens. H1 was
specified as a portal-only probe, so matching-width mockup captures were not taken. Mockup evidence
exists for the shop-edit surface only.
The overlap detector excludes positioned and floated children
(position: absolute/fixed/sticky, floats) per the brief, so an overlap caused by an
intentionally-positioned element would not be reported. Only the first <table> in
<main> was column-profiled per screen, though every screen was still covered by the
generic overflow probe.
Shop edit was examined for one record on each side (id=20
"The Beauty of Nails"; mockup "Beauty Center"). Field visibility could differ for shops in other
states — a shop with no pending fee change may not render the "CURRENT RATE" sub-panel — so the
45 / 15 counts describe these two records. The portal figure carries ±1 uncertainty because the
Kartik FileInput widget renders a visible proxy alongside a zero-size native input; the ≈3× ratio is
unaffected.
No writes were performed on staging during the layout and shop-edit probes — no form
was submitted and no Save/Update button was clicked.
The build is fingerprinted, not versioned. Neither deployment exposes a version
string, so asset Last-Modified timestamps bound when the front end was last built without
proving what PHP is deployed. Findings are reported against observed staging behaviour with local code
read as corroboration, never the reverse. Admin custom.css and adminlte.css
date to 2026-05-12; the admin aurora work ships through a separate bundle path, so the admin build
cannot be dated from those two files.