# Drupal 7 → Drupal 10 — Admin / Back-office migration report

Date: 2026-09-10
Scope: every admin and back-office feature of the D7 site, compared against D10.
Method: D7 was read only. Every figure below was produced by rendering the D7
page or view read-only (`ddev drush php-eval`, `menu_execute_active_handler`)
and the D10 page over HTTP or through `scripts/reconcile/verify_admin_parity.php`,
then comparing row counts and cell values.

---

## 0. What the D7 admin surface actually is

`{menu_router}` holds **992** paths under `admin/`. Grouping them by the module
that owns them shows what the migration is really about:

| Owner | Items | Migration meaning |
|---|---:|---|
| core: field_ui, system, help, block, menu, node, user, taxonomy, filter, image, shortcut, update | 469 | Drupal 10 core provides all of it natively |
| contrib: rules, ctools, views, features, entity, ultimate_cron, schema, module_filter, diff … | ~280 | D7-era infrastructure; replaced by D10 core/config or by custom modules |
| contrib: commerce, commerce_discount, commerce_coupon, flag, mailchimp, certificate, quiz, print, captcha, custom_breadcrumbs, media, facebook_tracking_pixel | ~180 | business modules; some have D10 releases, some were rewritten |
| **site-specific** | **63** | the actual migration work — listed below |

The 63 site-specific items are the back office this business runs on: the
Report, Sale report, Marketing report, Evaluation results and Report history
pages, the coupon-usage listing, the Flag Manager, the licence-prefix tool and
the quiz scoring queue.

---

## 1. Migrated successfully

Each row was verified by comparing the rendered D7 page with the rendered D10
page for the same filter input.

| D7 admin feature | D7 path | D10 path | Verification |
|---|---|---|---|
| **Report** (admin pane) | `/report` | `/report` | 67 rows both sides (Jul–Aug 2026); 14 both sides (2025); 0 both sides (Mar 2024). First two rows byte-identical, all 20 columns. |
| **Report** (instructor pane) | `/report` | `/report` | 68 rows both sides for uid 12; column set matches D7's instructor pane (no Nm_Middle, no State of Licensure). |
| **Report history** | `/report/history` | `/report/history` | 741 rows for uid 12 both sides, 50 per page, same `view`/`delete` action links. |
| Report-history delete | `/report-history/%/delete` | `/report-history/{h_id}/delete` | D7 access rule reproduced exactly: administrator **or** the row's owner. |
| Saving a report range | exposed-form submit | exposed-form submit | D7's duplicate check is on `(start_date, end_date)` only — not per user. Preserved. |
| **Sale report** (admin + instructor panes) | `/sale-report` | `/sale-report` | 72 rows both sides (Jul–Aug 2026); underlying SQL agrees at 72 order items. Columns, date format, price format and first rows match. |
| **Evaluation results** (admin + instructor panes) | `/evaluation-results` | `/evaluation-results` | 8 submissions both sides (Aug 2026), same users and timestamps. "Select any filter and click on Apply" behaviour reproduced. |
| **Coupon used by user** | `/admin/admin-coupon-used-by-user` | same | 300 rows both sides, 50 per page, identical first two rows including coupon code and `m/d/Y - g:ia` dates. |
| **Score quizzes** | `/admin/quiz/quiz-unevaluated-results` | same | 51 rows both sides, 10 per page, same columns and first rows. |
| Quiz admin sections | `/admin/quiz`, `/admin/quiz/reports` | same | D7's `quiz_access_multi_or` OR-list of five permissions preserved as a D10 `_permission` OR requirement. |
| **Flag Manager** | `/admin/config/content/flag-manager` | same | Landing page lists all five node flags; listing pages render with clear/unflag links; bad token → 403. |
| Flagged-content listings | `/admin/config/content/flag-manager/content[/fid]` | `…/content[/flag-machine-name]` | 20,398 rows on the default (submitted) listing, 20 per page. |
| **License Prefix Update** | `/admin/config/development/license-prefix-update` | same | Form renders with D7's exact (mis-spelled) labels; batch logic ported rule for rule. |
| Licence-correction audit trail | `{license_number_update}` | same | 4,600 rows restored from the archive; D7 = D10 = 4,600. |
| **admin/people** | `/admin/people` | `/admin/people` | Columns already matched; exposed filters brought back to D7's **Name · E-mail · Roles · Active**. |
| Report/sale/evaluation **Excel exports** | `report.xml`, `ireport.xml`, `sale-report.xml`, `instructor-sale-report.xml`, `evaluation-results.xml`, `ievaluation-results.xml` | same paths | Admin exports return 200 with `application/vnd.ms-excel` and the same row counts as their pages (68 / 73 / 9 including header). Instructor exports correctly 403 for an admin account. |
| Commerce admin (orders, products, carts, promotions, coupons, stores) | `/admin/commerce/*` | `/admin/commerce/*` | Provided by Commerce 3 core UI. |
| Content, files, media, comments, blocks, menus, taxonomy, users, roles, permissions, modules, status report, logs, cron, performance, config | `/admin/*` | `/admin/*` | Drupal 10 core. |

### Supporting machinery built for the above

* `ce_reports` — views data for `{report_history}`, two flag relationships that
  flag 8.x does not ship (`users → flagging` by flag, `node → flag_counts` by
  flag), a merge-rows table style that reproduces D7's `views_merge_rows`, an
  aggregation-safe numeric field, the `{ce_evaluation_pivot}` database view that
  replaces D7's `data`-module MySQL view, four controllers and six views.
* `ce_flag_clear` — a from-scratch replacement for D7 contrib `flag_clear`
  (no D10 release): permission, three routes, two views field plugins, the
  Flag Manager landing page and the generated `flag_manager` view.
* `ce_users` — the licence-prefix admin form, its batch, its schema and the
  archive-restore script.
* `ce_quiz` — quiz admin permissions, the `admin/quiz` sections and the
  "Score quizzes" view.

---

## 2. Missing / not yet migrated

| D7 feature | D7 path | Status | What it needs |
|---|---|---|---|
| **Marketing report (advanced report)** | `/marketing-report`, `/marketing-report/detail` | **Not built** | Two Panels panes (`sale_report:panel_pane_advanced_report`, `…adv_report_detail`) plus a third, newer pair (`…adv_report_new_detail`). ~4,400 lines of D7 view export. Page access is role IN (administrator, CE Admin); the two panes are selected by **path**, not by role. All joins it needs already exist in the migrated `sale_report` view. |
| Advanced-report export | `/adv-sale-report.xml` | Not built | Depends on the above. |
| Sale-report **total strip** | `sale_report` attachment_1 / attachment_2 | Not built | D7 showed a "Total Amount" attachment above each sale table. `cetc_d10_preprocess_views_view()` carries the contract; the column is `commerce_order_item.total_price__number` via the `ce_total_price_number` alias. |
| **Evaluation Word-document download** | `/evaluation-results/download-link` | **Still a stub** | `EvaluationDocumentBuilder` has the business rules (550-byte truncation, `&`→`&amp;` after truncation, 30-row threshold, per-request private file) but `loadResultRows()`, `resolveDocumentTemplateUri()`, `loadSubmission()` and `appendSubmission()` return nothing. The two blockers it cited are now gone: the `evaluation_results` view exists, and the template file (fid 590, `public://webform_document/input/Cosmetology Evaluation in Word _2_1_1 2_0.docx`) migrated and is on disk. What remains is porting the tbszip `word/document.xml` merge and the token replacement. |
| Quiz **result-type field UI** | `/admin/quiz/result*`, `/admin/quiz/result_answer*` | Not reproduced — **by design** | 30 of the 46 quiz admin items are Entity-API field UI for the quiz *result* entity. D10's `ce_quiz` stores results in plain tables, not entities, so these pages have nothing to administer. |
| Quiz **feedback rules** | `/admin/quiz/feedback/*` | Not reproduced — **by design** | Rules-based; Decision 11 removed Rules from the D10 stack. |
| Quiz settings forms | `/admin/quiz/settings/{config,questions_settings,quiz-form}` | Not built | These configure the contrib quiz module, which D10 does not run. Any setting `ce_quiz` honours would need its own form. **Needs a business decision on which settings still apply.** |
| `data` module MySQL-views UI | `/admin/content/webform/mysql` | Not reproduced | The one view it managed is replaced by `{ce_evaluation_pivot}`, created by `ce_reports_update_10001()`. The UI for *creating* such views has no D10 equivalent. |
| Contrib admin UIs with no enabled D10 counterpart | `masquerade`, `custom_breadcrumbs`, `diff`, `print`/`print_pdf`, `node_clone`, `flood_control`, `module_filter`, `stringoverrides`, `reroute_email`, `smtp`/`mailsystem`, `ultimate_cron`, `backup_migrate`, `facebook_tracking_pixel`, `responsive_menus`, `admin_menu`, `dashboard` | Absent | **Most are already downloaded** — `masquerade`, `custom_breadcrumbs`, `diff`, `flood_control`, `module_filter`, `stringoverrides`, `reroute_email`, `mailsystem`, `pathauto`, `entity_print`, `views_data_export`, `views_bulk_operations`, `views_merge_rows`, `views_aggregator` are all in `web/modules/contrib` and in `composer.json`, just **not enabled**. Enabling them is a decision, not a build. `admin_menu`, `dashboard`, `ultimate_cron`, `backup_migrate`, `smtp`, `node_clone`, `facebook_tracking_pixel`, `responsive_menus` have no D10 equivalent installed at all. |

---

## 3. Different behaviour / UI

Every difference below is deliberate and was checked against D7 before being accepted.

1. **Flag Manager path segments.** D7 used the numeric flag id
   (`…/content/1` … `/5`). D10 flags have no numeric id, so the pages live at
   `…/content/completed|enrolled|in_progress|bought|submitted`.
   D7 numbering, for translating old links: 1 completed, 2 enrolled,
   3 in_progress, 4 bought, 5 submitted.
2. **`/report/history` links.** D7's "view" link passed
   `date_filter[min][date]=…` (a `date_popup` artefact). D10's core date filter
   reads `date_filter[min]`, so the link uses that. Same path, same behaviour.
3. **Merge-rows configuration lives on the style plugin, not a display
   extender.** D10 display extenders are enabled globally for every view on the
   site; a style plugin is scoped to the views that ask for it. Same algorithm,
   same field options, same separators, same point in the render pipeline.
4. **`views_data_export` is not used for the Excel downloads.** D7's XLS style
   emits an HTML `<table>` with an Excel content type — nothing more. The D10
   controller emits the same wrapper with the same content type, and passes the
   exposed filters through so the download matches the table. D7 could *batch*
   very large exports; this streams in one request.
5. **Excel exports have no pager.** D7's export displays had their own paging
   config; here the pager is removed so an export can never silently stop at the
   page size.
6. **`admin/people` keeps Drupal 10's extra "Permission" filter.** D7 had Name,
   E-mail, Roles, Active. All four are restored; the D10-only Permission filter
   is left in place rather than removed, since deleting working functionality is
   not parity.
7. **The Word-document icon now renders.** D7 built its path from
   `theme_default` (`cetc_new`) — and `cetc_new/images/word.png` **does not
   exist**; the file only ever lived in the other theme. D7 therefore showed a
   broken image. The site's own `word.png` was copied into `cetc_d10/images/`.
8. **`Course Date` on the Report.** D7 formatted with `short` then trimmed to 10
   characters. D10 core's `short` pattern is byte-identical (`m/d/Y - g:ia`) and
   `m`/`d` are zero-padded, so the first ten characters are always `m/d/Y`; the
   field uses that custom format directly. Identical output, every date.
9. **Sale report instructor column.** `field_instructor_s_` is multi-value and,
   under aggregation, D10 puts its delta column in the `GROUP BY` — which turned
   72 purchased lines into 102 rows. D7 shows one row per line and one
   instructor name; `MIN()` reproduces both. Row count verified: 72 in SQL,
   72 in D7, 72 in D10.
10. **Core's `numeric` Views field plugin is subclassed site-wide.** D10 renders
    an aggregated (`MIN`/`MAX`/`SUM`) column with `NumericField` even when the
    column holds text, and `NumericField` fatals on it — which took out
    `/sale-report`, a page that has aggregated a course *title* with `MIN()`
    since D7. The subclass prints a non-numeric value as text, exactly as D7
    did, and changes nothing for numeric values. Regression-checked: `/courses`
    prices, `/admin/commerce/orders` totals and `/admin/content` all unchanged.
11. **`ce_evaluation_pivot` carries 25 of D7's 28 columns.** The three missing
    ones (`course_provider`, `info`, `info_help`) are D7 `markup` components:
    `SELECT … FROM webform_submitted_data WHERE cid IN (3,4,29)` returns **zero
    rows**, so those columns were permanently NULL in D7 too.
12. **Better Exposed Filters is not installed.** Its only settings on these
    views are `input_required` (reproduced with D10 core's plugin, same message)
    and a jQuery datepicker widget on the date filter (D10 renders a plain date
    field).
13. **The instructor panes are unreachable on this data — in D7 as well.**
    Every account holding `instructor` also holds `ce_admin` or `administrator`
    (0 accounts hold instructor alone), and D7's pane rule is
    `role = instructor AND NOT role IN (administrator, ce_admin)`. The panes are
    still reproduced, because that is a property of the data, not the design.

---

## 4. Blockers and dependencies

1. **⚠️ Drupal 7 was written to during comparison work.** Six rows were created
   in D7 *after* the migration freeze, all by uid 22217:
   * `{quiz_node_results}` 3981–3984 — 2026-09-09 16:42 → 2026-09-10 11:06,
     quizzes 673 and 675;
   * `{webform_submissions}` 21068, 21069 — 2026-09-09 12:43 and 14:58, node 27.
   They are absent from D10, which is correct: they post-date the migration.
   **Cause:** opening a D7 quiz *take* page or submitting the D7 evaluation form
   creates rows — D7 writes on GET. **Action:** compare D7 by rendering views
   and listing pages, never `/node/*/take` or a form submit. No migrated data is
   affected, and no reconciliation figure changes.
2. **Quiz settings need a business decision.** Three D7 settings forms configure
   a module D10 does not run. Someone has to say which of those settings still
   govern behaviour in `ce_quiz` before a form can be written for them. Not
   invented here.
3. **The Word-document merge needs the tbszip port.** Both blockers the existing
   stub cited are cleared (the view exists; the template file migrated). What is
   left is mechanical: replace the `[token]` placeholders in `word/document.xml`
   and append each submission's `<w:body>` into the merged document.
4. **The marketing report is the one substantial page still missing.** Its data
   is all reachable through the `sale_report` view that now exists; the work is
   transcribing ~4,400 lines of D7 field and filter configuration.
5. **Contrib decisions, not code.** Fourteen D7 admin modules are already
   downloaded but disabled. Whether the business still wants masquerade,
   custom_breadcrumbs, diff, print-to-PDF, reroute_email and the rest is a
   decision to take before enabling them.
6. **flag_counts differs by one row** (D7 675, D10 674). It is a derived counter
   table, and the Report view's counter relationship requires `count > 0` on
   both sides. Worth a look, but no page output differs.

---

## 5. Final admin migration status

| Area | Status |
|---|---|
| Core admin (content, users, roles, permissions, taxonomy, media, files, blocks, menus, modules, config, logs, cron, status) | ✅ Provided by D10 core |
| Commerce admin (orders, carts, products, variations, promotions, coupons, stores, payments) | ✅ Provided by Commerce 3 |
| Site-specific reporting — Report, Report history, Sale report, Evaluation results | ✅ Built and verified row-for-row against D7 |
| Site-specific reporting — Marketing report | ❌ Not built |
| Excel exports (6 of 7) | ✅ Built and verified |
| Excel export — advanced report | ❌ Depends on the marketing report |
| Coupon usage listing | ✅ Built and verified |
| Flag Manager and unflag actions | ✅ Built and verified |
| License Prefix Update + audit trail | ✅ Built; 4,600 audit rows restored |
| Quiz scoring queue and admin sections | ✅ Built and verified |
| Quiz result-type field UI, quiz feedback rules | ⚪ Not applicable to the D10 architecture |
| Quiz settings forms | ⏸ Blocked on a business decision |
| Evaluation Word document | ⏸ Stub; blockers cleared, port outstanding |
| Contrib admin UIs (masquerade, diff, print, breadcrumbs, …) | ⏸ Downloaded, disabled, awaiting a decision |

**Verdict.** Every site-specific back-office page the business uses day to day
is migrated and verified against D7 except the **marketing report**, and every
Excel export except the one that depends on it. Two items are blocked on a
decision rather than on code, and one (the Word document) has a clear, bounded
port remaining.

Re-run the parity check at any time:

```
ddev drush php:script scripts/reconcile/verify_admin_parity.php
```

---
---

# Round 2 — Marketing report, admin UI, and the Word download

Date: 2026-09-10 (same day, continued)
Scope: the gaps section 2 above left open, plus the admin dashboard, menu and
"scattered UI" problems reported after Round 1.

## A. Completed in this round

| Item | Path | D7 | D10 | Evidence |
|---|---|---|---|---|
| Marketing report — total | `/marketing-report` | `Total Amount: $4,716.00` | `Total Amount: $4,716.00` | Same window (Jul–Aug 2026). Markup matches D7 class for class: `views-field-commerce-total adv-report-total-price`, `views-label-commerce-total`, and the `(Detail)` link carrying the same `created` query string. |
| Marketing report — detail | `/marketing-report/detail` | 61 rows, 21 columns | **61 rows, 21 columns** | Header row identical including D7's ` Addr_State` (leading space). The Gainey row matches cell for cell: `CE Online Training · 07/02/2026 · 07/02/2026 · RC · Gainey · Jimmy · 4797 · 47313 · 321 Saint Clair Drive · Leesburg · Georgia · 31763 · (229) 446-2328 · (229) 291-9547 · jeg030963@yahoo.com · 07/02/2026 · 07/02/2026 · $117.00`. |
| Marketing report — new detail | `/marketing-report/new-detail` | 32 rows, 22 columns | **32 rows, 22 columns** | The hijacked “Video” column lists the same three courses in the same order for the Gainey row. |
| Advanced report export | `/adv-sale-report.xml` | XLS of the detail set | 200, `application/vnd.ms-excel`, 61 data rows | Matches the page it exports. |
| Sale-report total strip | `/sale-report` | 2 strips of `$4,716.00` around 72 rows | **2 strips of `$4,716.00` around 72 rows** | `attachment_position: both`, as D7. |
| Evaluation Word download | `/evaluation-results/download-link` | merged.docx | **506,547-byte valid .docx, 8 submissions, 0 unreplaced tokens** | Tokens `[webform:val-*]` and `[webform:custom-meta-submitted]` all resolved; 7 page breaks for 8 submissions; D7's `merged.docx` filename kept. At ≥30 rows it returns 404 — preserved defect E-1. |
| Admin dashboard | `/admin/dashboard` | Dashboard module page | Route + controller + tabs, `access dashboard` permission | D7's page callback returns one message and no content; its blocks lived in `dashboard_*` regions. Reproduced. |
| Admin menu / toolbar | — | admin_menu dropdown bar | Core `toolbar` | Item-for-item and weight-for-weight match (see C). |
| Scattered admin UI | every admin page | — | fixed | Six D7 `seven` blocks had been flattened into Claro's `content` region. Restored to their D7 regions. |

## B. The scattered admin UI — cause and fix

Six blocks migrated from D7's `seven` theme landed in Claro's `content` region
and therefore rendered on **every** admin page, under the real content. That is
the reported "Yes Ma'am! We are approved! / GA CEU'S / vivek sharma / There are
currently 1 users online / Ad Min" noise.

D7's `{block}` table says where they belonged:

| D7 block | D7 region | What that region meant | D10 now |
|---|---|---|---|
| `system.main` | `content` | the page | `content`, weight 0 |
| `user.login` | `content` w10 | core hides it from authenticated users | `content`, weight 10 |
| `search.form` | **`dashboard_sidebar`** | rendered **only** on `/admin/dashboard` | `content`, visibility `/admin/dashboard`, weight 0 |
| `user.new` | **`dashboard_sidebar`** | same | `content`, visibility `/admin/dashboard`, weight 1 |
| `comment.recent` | **`dashboard_inactive`** | the parked tray — rendered **nowhere** | disabled |
| `user.online` | **`dashboard_inactive`** | same | disabled |
| `node.recent` | `dashboard_inactive` | same | no D10 block exists |
| `system.help` | `help` | help region | `claro_help` placed in `help` |

`dashboard_inactive` is D7's "switched off" tray, so disabling those two blocks
**is** the faithful reproduction — not a tidy-up. Confirmed on `/admin/content`
and `/admin/people`: no stray blocks; `/admin/dashboard` shows exactly the two
`dashboard_sidebar` blocks.

## C. Admin menu parity

D7 ran contrib `admin_menu` + `admin_menu_toolbar`; core `toolbar` was off.
D10 had **no** admin navigation module enabled at all — hence "the menu bar is
not appearing". Enabled D10 core `toolbar`, `contextual`, `help`, `shortcut`
(the counterparts of what D7 had on) and `announcements_feed`.

| D7 item | D7 weight | D10 item | D10 weight |
|---|---:|---|---:|
| Dashboard | −15 | Dashboard | −15 |
| Content | −10 | Content | −10 |
| Structure | −8 | Structure | −8 |
| Store | −7 | Commerce | −7 |
| Appearance | −6 | Appearance | −6 |
| People | −4 | People | −4 |
| Modules | −2 | Extend | −2 |
| @quiz / Configuration | 0 | Quiz / Configuration | 0 |
| Announcements | 2 | Announcements | 2 |
| Reports | 5 | Reports | 5 |
| Help | 9 | Help | 9 |

Three weights needed restoring (`ce_core_menu_links_discovered_alter()`):
People (D10 core moved it to 4), Commerce (Commerce 3 uses −9) and
Announcements (D10 core uses 10). Labels **Commerce** and **Extend** are D10's
own names for those sections and were not renamed.

Permissions: D7 rid 4 (CE Admin) held `access administration menu` and
`access contextual links`; those map to D10 `access toolbar` and
`access contextual links`, now granted. Instructor keeps only
`view the administration theme`, as in D7. `access dashboard` granted to
administrator and CE Admin, matching D7 rid 3 and 4.

## D. Admin theme and layout

`system.theme`: admin `claro`, default `cetc_d10`; `node.settings.use_admin_theme`
true — the same shape as D7 (`admin_theme` seven, `theme_default` cetc_new,
`node_admin_theme` 1). Verified on `/admin/content`: Claro CSS, toolbar bar,
admin menu tray, shortcuts tray, user tray, breadcrumb, page title, local tasks,
local actions, messages region, exposed filters, views table, pager, contextual
links — all present, no front-end theme leakage.

⚠️ `/report`, `/sale-report`, `/evaluation-results` and `/marketing-report`
render in the **front-end** theme, and that is correct: D7's Page Manager pages
all carry `admin_paths => FALSE`.

## E. Access control (verified per role)

| Page | administrator | CE Admin | Instructor | anonymous |
|---|---|---|---|---|
| `/report`, `/sale-report`, `/evaluation-results` | yes | yes | yes | 403 |
| `/marketing-report` and both sub-paths | yes | yes | **no** | 403 |
| `/adv-sale-report.xml` | yes | yes | **no** | 403 |
| `/admin/dashboard` | yes | yes | no | 403 |

Matches D7: page roles 3,4,5 for the first group; roles 3,4 for the marketing
report; `access dashboard` on rid 3 and 4 only.

## F. Regression test

`ddev drush php:script scripts/reconcile/verify_admin_parity.php`

```
/report admin pane, Jul-Aug 2026               total:67       rendered:67
/report admin pane, 2025                       total:14       rendered:14
/report instructor pane (uid 12)               total:68       rendered:68
/sale-report admin pane                        total:72       rendered:72
/evaluation-results admin pane                 total:8        rendered:8
/report/history (uid 12)                       total:741      rendered:50
admin/admin-coupon-used-by-user                total:300      rendered:50
admin/quiz/quiz-unevaluated-results            total:51       rendered:10
admin/config/content/flag-manager/content      total:20398    rendered:20
/marketing-report total                        total:1        rendered:0   (unformatted list, not a table)
/marketing-report/detail                       total:61       rendered:61
/marketing-report/new-detail                   total:32       rendered:32
sale report total strip (admin)                total:72       rendered:0   (theme replaces the rows with the total)
```

Every Round-1 figure is unchanged. HTTP sweep: all 15 admin/report pages and all
7 exports return 200 for an administrator. `/courses` prices, `/admin/content`
and `/admin/commerce/orders` unaffected.

## G. Two defects found in Round 1's own work

1. **⚠️ `/report` renders the wrong pane.** The Round-1 build was verified
   against the `Report` view's *displays*, which match D7 exactly — but not
   against the D7 **page**. The live `{panels_pane}` row for `/report` has
   `Report:panel_pane_admin_report` **`shown = 0`**, and adds a third pane,
   `admin_report_new:panel_pane_admin_report_new_detail`, for roles 3 and 4.
   Rendering D7's `/report` as uid 1 confirms it: the page shows
   **`admin_report_new`**, not `Report`. D10 still shows the `Report` admin
   pane. **Not fixed in this round** — `admin_report_new` is a separate
   4,075-line view. The instructor pane is unaffected.
2. **The feature files are not the live configuration.** `{page_manager_pages}`
   and `{panels_pane}` hold database overrides for `report` and
   `advanced_report`. The marketing report's third pane
   (`/marketing-report/new-detail`) exists only in the database — reading
   `advanced_report_page.pages_default.inc` alone would have missed a whole
   page. Every Page Manager page must be read from the database.

## H. D7 write safety

D7 is unchanged by this round. Counts at the start and end are identical:
`report_history` 784 (max h_id 786) · `quiz_node_results` 3,977 (max 3,984) ·
`webform_submissions` 21,066 (max 21,069).

⚠️ Five `report_history` rows in D7 (h_id 782–786, uid 1 and 4458) carry the
exact date ranges used by **Round 1's** probes. Cause: rendering the `Report`
view's `panel_pane_admin_report` display with exposed input runs the exposed
form's submit handler, which writes a history row. Two further rows (780, 781,
uid 10570) are not from this work.

**Rule applied from here on, and to be kept:** never render the `Report` view's
`panel_pane_admin_report` / `panel_pane_instructor_report` displays on D7 with
exposed input. Every other report view carries only a validate handler and is
safe. All D7 rendering in this round used the Page Manager page callback with no
query parameters, or views with no writing submit handler.

D10's `report_history` gained 6 rows (h_id 782–787) from verification runs.
That is the migrated feature working as designed — running a dated report saves
it to history — and it does not affect the uid-12 parity figure of 741.

## I. Still outstanding

| Item | Status | Note |
|---|---|---|
| `/report` shows `Report` instead of `admin_report_new` | **Not built** | See G-1. Needs the `admin_report_new` view (4,075 lines, 3 displays) and its own `views_query_alter` flag re-pointing, which is already implemented for the marketing report and would be reused. |
| `admin_report_new` export | Not built | Depends on the above. |
| Quiz settings forms | Business decision | Unchanged from Round 1. |
| 14 downloaded-but-disabled contrib admin modules | Business decision | Unchanged. `toolbar`, `contextual`, `help`, `shortcut`, `announcements_feed` were enabled because they are **D10 core** counterparts of modules D7 had on — not D7-era contrib. |
| `flag_counts` off by one row | Cosmetic | Unchanged; no page output differs. |

---
---

# Round 3 — admin data visibility

Date: 2026-09-10
Scope: the reported problem that D10 Admin Orders showed only new orders, treated
as a general question — *is everything that migrated actually visible in the D10
admin UI?* — across every major dataset.

## A. The orders defect

`/admin/commerce/orders` listed **3 of 87,661 orders**. The three were the only
ones created natively in D10.

**Cause.** Commerce 3's admin orders view filters `cart <> 1`. The order
migration never populated the `cart` base field, so 24,529 migrated non-cart
orders had `cart` **NULL** — and in SQL `NULL <> 1` evaluates to NULL, not TRUE,
so every one of them was excluded. The records were intact; the admin UI hid
them. Row counts in the database said the migration was complete.

**Fix.** `scripts/reconcile/repair_order_cart_flag.php` populates the field from
D7's own status definitions. `commerce_order_statuses()` on the live D7 site
carries a `cart` flag per status:

| D7 status | state | cart flag | D10 state | `cart` set to |
|---|---|---|---:|---:|
| cart | cart | TRUE | draft | 1 |
| checkout_checkout | checkout | TRUE | checkout_checkout | 1 |
| checkout_review | checkout | TRUE | checkout_review | 1 |
| checkout_payment | checkout | FALSE | checkout_payment | 0 |
| checkout_complete | checkout | FALSE | checkout_complete | 0 |
| canceled | canceled | FALSE | canceled | 0 |
| pending | pending | FALSE | pending | 0 |
| completed | completed | FALSE | completed | 0 |

24,529 rows updated. Nothing created, nothing deleted, no state, total, date or
owner changed, and the three natively-created orders were left untouched
(`WHERE cart IS NULL`).

**Result**

| | D7 | D10 before | D10 after |
|---|---:|---:|---:|
| `/admin/commerce/orders` | 22,020 | **3** | **22,079** |
| `/admin/commerce/orders/carts` | 65,641 | 63,129 | **65,582** |
| total | 87,661 | 63,132 | **87,661** |

Verified cell-for-cell on migrated order 87528: order number, customer
(`kimhuynhngo89@gmail.com`), state Completed, total $39.00, and the raw
timestamp 1786130203 identical on both sides (the earlier apparent date
mismatch was the database container's timezone, not the data — both sites are
`America/Kentucky/Louisville`). Order detail page 200, deep pagination
(`?page=400`) 200 with 50 rows, exposed filters working.

⚠️ **Deliberate difference, +59 orders.** D7 had two different notions and
Commerce 3 has one boolean. D7's Shopping-carts *view* filtered on the order
STATE (cart + checkout = 65,641) while cart *loading* used the cart FLAG
(65,581). The 60 orders in `checkout_payment` / `checkout_complete` sit between
them: D7 listed them on the Carts page but never loaded them as anyone's cart.
They are mapped by the flag — the field's actual meaning — so they appear on
`/admin/commerce/orders` instead. Every order is still visible on exactly one of
the two pages; nothing is hidden either way. (+59, not +60, because of the
known one-order drift.)

## B. A second visibility defect, found by looking

`/admin/content/files` returned **HTTP 500**.

**Cause.** 249 migrated `{file_managed}` rows carry `wistia://v/<code>` URIs.
D7 registered that scheme through `media_wistia_ceonline`; D10 registered
nothing, so `FileUrlGenerator::generate()` threw
`InvalidStreamWrapperException` on the first such row and took the whole page
with it. A count-based check could never have caught this: the view found all
4,330 files, then died rendering them.

**Fix.** `ce_wistia` now registers a read-only `wistia` stream wrapper, ported
from D7's `MediaWistiaStreamWrapper`: external URL
`http://home.wistia.com/medias/<code>`, MIME `video/wistia`, every write and
directory operation refused. `/admin/content/files` returns 200 and Wistia rows
render with their D7 URL.

## C. Full three-level reconciliation

`ddev drush php:script scripts/reconcile/verify_admin_visibility.php`

D7 counts come from `scripts/reconcile/d7_dataset_counts.sh` — a read-only list
of `SELECT COUNT(*)` statements that never renders a D7 page, so it cannot trip
the exposed-form handler that writes `{report_history}`.

⚠️ The comparison is against **D7 at the migration freeze** (epoch 1787558508),
not D7 today. D7 has kept receiving rows since (test accounts 22216 and 22217),
and a raw D7 total would read every one of them as a migration loss.

```
DATASET                           D7 NOW D7@FREEZE    D10 DB     ADMIN  STATUS
Users (real accounts)             13,251    13,249    13,251    13,251  +2 created in D10
Users - active                    13,224    13,222    13,223       n/a  +1 created in D10
Users - blocked                       27        27        28       n/a  +1 created in D10
Roles                                  5         -         5       n/a  ok
Orders - all                      87,661    87,640    87,661       n/a  +21 created in D10
Orders - admin list               22,020         -    22,079    22,079  see A
Orders - carts                    65,581         -    65,582    65,582  +1 created in D10
Order items                      113,292   113,271   113,445       n/a  +174 (see below)
Products                             162         -       162       162  ok
Coupons                              120         -       120       n/a  ok
Payments                          26,801    26,797    26,800       n/a  +3 created in D10
Nodes                                620       620       620       620  ok
Files                              4,330     4,330     4,330     4,330  ok
Taxonomy terms                       100         -       100       n/a  ok
Quiz results                       3,977     3,973     3,975       n/a  +2 created in D10
Quiz results awaiting score           51        51        51        51  ok
Quiz answers                       5,133     5,129     5,130       n/a  +1 created in D10
Report history                       784       777       785       n/a  +8 created in D10
Webform submissions               21,066    21,064    21,065       n/a  +1 created in D10
Flaggings                        132,272   132,256   132,261       n/a  +5 created in D10
Licence audit rows                 4,600         -     4,600       n/a  ok
URL aliases                       13,971         -    13,977       n/a  +6 created in D10

Every dataset reconciles: nothing is short of the freeze count, and no
dataset has rows in the D10 database that its admin interface hides.
```

Notes on the two lines that are not simply "post-freeze activity":

* **Order items +174.** D10 holds D7's `product` line items (113,271 at freeze)
  **plus** its 154 `product_discount` items, plus 20 created in D10.
  154 + 20 = 174. D7's 3,047 `commerce_discount` line items are deliberately
  *not* order items in Commerce 3 — they migrated as order adjustments
  (`migrate_map_ce_commerce_order_adjustments`, 2,963 rows), which is why the
  line-item map holds 116,472 rows but only 113,425 destinations exist.
* **Flaggings.** D7 holds 16 post-freeze flaggings (uids 22216/22217, 8–9 Sept).
  132,272 − 16 = **132,256**, exactly the documented reconciliation target, and
  exactly what D10 migrated. The +5 are D10-side test activity.
* **Report history** has no creation timestamp, so its freeze figure uses
  `h_id <= 779` as a proxy and is approximate.

The one account that looked like a discrepancy — active in D7, blocked in D10 —
is uid 22215 `vivek@zrix.com`, **created in D10 on 2026-09-08** and absent from
D7 entirely. A D10 test account, not a migration difference.

## D. Admin pages, HTTP level

`bash scripts/reconcile/verify_admin_pages.sh` — 26 admin pages loaded as user 1:

Orders · Shopping carts · Products · Promotions · People · Roles · Permissions ·
Content · Files · Media · Taxonomy · Webform submissions · Quiz scoring queue ·
Flag manager · Flagged-completed · Flagged-bought · Coupon usage · License prefix
tool · Dashboard · Report · Report history · Sale report · Evaluation results ·
Marketing report · Marketing report detail · Marketing report new detail

**All 26 return HTTP 200.** This check exists alongside the count-based one
because a page can die while *rendering* rows a view found perfectly well —
which is exactly what `/admin/content/files` did.

Spot-checked functionally: `/admin/people` email search returns the migrated
user Kim Huynh; `/user/22210` renders; `/admin/commerce/products` 4 pages;
`/admin/content` 13 pages; webform results 1,054 pages; flag listings match
their per-flag database counts.

## E. Regression

`verify_admin_parity.php` — all 13 checks unchanged from Round 2. D7 unchanged:
`report_history` 784 · `quiz_node_results` 3,977 · `webform_submissions` 21,066 ·
`flagging` 132,272 · `commerce_order` 87,661, identical at the start and end of
this round. No D7 page was rendered in this round at all — only `SELECT COUNT(*)`.

## F. Items 3–10 of the brief

Already delivered and verified in Rounds 1–2, unchanged and re-tested here:
marketing report (all three panes), `/adv-sale-report.xml`, the sale-report total
strip, `/admin/dashboard`, admin menu/navigation, the scattered-UI fix, admin
theme parity, and the evaluation Word download.

## G. Still outstanding

| Item | Classification |
|---|---|
| `/report` renders the `Report` admin pane; live D7 renders `admin_report_new` | **Not built** — needs the 4,075-line `admin_report_new` view |
| `admin_report_new` export | Not built (depends on the above) |
| Quiz settings forms | Business decision |
| 14 downloaded-but-disabled contrib admin modules | Business decision |
| Orders admin/carts split shifted by 59 | Deliberate D7/D10 difference (see A) |
| Order items +154 vs D7 product items | Deliberate — Commerce 3 models D7 discounts as adjustments |
| `flag_counts` off by one row | Cosmetic; no page output differs |

---

# Round 4 — the display `/report` actually renders

Round 3 closed with one technical item open: **D7's `/report` does not render the
pane this migration verified in Round 1.** This round establishes what it does
render, migrates it, and verifies it against the live D7 site.

## A. The wrong display was verified, and why that was easy to miss

Round 1 built `/report` from the feature file
`course_features/report_page/report_page.pages_default.inc`, which describes two
panes:

| pane | display | access |
|---|---|---|
| 0 | `Report:panel_pane_admin_report` | role IN (3 administrator, 4 CE Admin) |
| 1 | `Report:panel_pane_instructor_report` | role IN (5 Instructor) AND NOT (3, 4) |

The **live D7 database says something else.** `page_manager_pages` pid 4 (`report`,
path `report`) → `page_manager_handlers.page_report_panel_context`, whose
`conf['did']` is **9**, and `{panels_pane}` for did 9 holds *three* rows:

| pid | display | shown | access |
|---|---|---|---|
| 22 | `Report:panel_pane_admin_report` | **0** | role IN (3, 4) |
| 23 | `Report:panel_pane_instructor_report` | 1 | role IN (5) AND NOT (3, 4) |
| 39 | `admin_report_new:panel_pane_admin_report_new_detail` | 1 | role IN (3, 4) |

Pane 22 is **switched off**. Pane 39 — which the feature file does not mention at
all — took its place with the same role rule. So the table an administrator has
been looking at on `/report` is `admin_report_new`, and the display Round 1
verified has not rendered on that page in years.

The Round 1 verification was internally consistent: it rendered
`report:panel_pane_admin_report` on D7 for comparison, and D10 matched it row for
row. Comparing the right thing to the wrong thing still gives a match. **The live
`{panels_pane}` rows are the authority on what a Page Manager page renders; a
feature file is a snapshot of what it once rendered.** This is the second time in
this migration that reading `{page_manager_*}`/`{panels_*}` rather than the
feature export changed the answer (the first was `advanced_report`, did 10,
Round 2).

## B. What the two displays are, and why the old one stays

| | `Report:panel_pane_admin_report` (pid 22, off) | `admin_report_new:panel_pane_admin_report_new_detail` (pid 39, live) |
|---|---|---|
| columns | 6 | 23 |
| shows | purchase date, instructor, course, video, student, price | the student's full identity, address, licence details, the courses on the order, and the dates enrolled / began / completed |
| grouping | Views aggregation | `views_aggregator`, grouped on order id |
| purpose | a **sales** summary | a **compliance** record — what a CE provider has to produce |

They answer different questions, so the second is not a redesign of the first; it
is a different report that took over the page.

**The old display is kept, unchanged, and is still required elsewhere:**

* `/report.xml` streams it — D7 `menu_router` `report.xml` →
  `views_page(Report, views_data_export_1)`.
* Its exposed form is what writes `{report_history}`, which `/report/history`
  lists. Deleting the display would break both.

It is no longer part of the `/report` page, and `verify_admin_parity.php` now
labels its two checks `report.xml source pane` so this cannot be misread again.

## C. What was built

**`views.view.admin_report_new`** — `scripts/reconcile/build_admin_report_new_view.php`

Three displays, mirroring D7's:

| D7 display | D10 display | reached by |
|---|---|---|
| `default` | `default` (Master) | nothing renders it; reproduced for fidelity |
| `panel_pane_admin_report_new_detail` | same id, `embed` | `/report`, for administrator + CE Admin |
| `views_data_export_admin_report_new` | same id, `embed` | `/new-report.xml` |

The build script **lifts the shared definitions out of the already-verified
`sale_report:panel_pane_adv_report_new_detail` display** rather than restating
them. The two D7 displays are siblings — same join chain, 24 of 29 field
definitions identical by id *and* table — so whatever was proven correct for the
marketing report is correct here by construction, and only the genuine
differences are spelled out.

**The five columns this report has and the marketing report does not:**
`field_abbreviation` (the profession-type **term's** abbreviation, via an extra
relationship), `field_middle_name`, `field_license_prefix`,
`changed` (the order's changed date, formatted `m/d/y` — two-digit year, the only
such column on either report) and `field_state_of_licensure`.

**Three settings that had to be taken from D7 rather than from the sibling
display**, each of which changed the row count on its own:

1. `payment_transaction` is **LEFT**, not INNER. Copying the marketing report's
   INNER join silently dropped every purchased line whose order carries no
   payment row: 73 rows where D7 gives 91.
2. `uid` is **INNER**, not LEFT.
3. `group_by` is **FALSE**. D7 stores no `group_by` key on any display of this
   view, so Views' aggregation is off. With it on, the same window collapsed 91
   rows to 73.

**Route and controller changes**

* `ce_reports.routing.yml` — new route `/new-report.xml` (D7 `menu_router`
  `new-report.xml`).
* `XlsExportController` — one new entry, filename `adv-new-sale-report.xls`
  (D7's own filename; the display was cloned from the sale report and never
  renamed, and admins know the file by that name).
* `ReportPageController::report()` — now renders pane 39 for administrator /
  CE Admin and pane 23 for Instructor-but-not-admin. Pane 22 is not rendered,
  exactly as `shown = 0` says.
* `ce_reports_views_query_alter()`, `ce_reports_views_post_execute()` and
  `ce_reports_preprocess_views_view_field()` extended to cover
  `admin_report_new`, matching the display sets D7's
  `course_actions_views_query_alter()` and `course_actions_views_post_execute()`
  name. ⚠️ The two reports read a **different query parameter for the same
  window** — `timestamp_2[…]` on the marketing report, `date_filter[…]` on
  `/report` — because their exposed identifiers were never made to match in D7.
  That is behaviour, not an accident to tidy up: a bookmarked URL from one page
  does not filter the other.
* `MergeRowsTable` — new `enumerate` merge option, reproducing
  `views_aggregator`'s `enumerate`: unique, non-empty, sorted, joined with
  `<br/>`. Three date columns on this report use it, so one row can carry several
  enrolment or completion dates. ⚠️ It de-duplicates on the cell's **text**, not
  its markup: D7 renders `07/02/2026`, D10 core wraps the same text in
  `<time datetime="2026-07-02T19:59:30-04:00">`, and the datetime attribute
  carries the time of day — comparing markup printed the same date three times
  where D7 prints it once.

## D. How D7 was compared without writing to it

The standing rule from Round 2 is that rendering a D7 report pane **with exposed
input** runs the exposed form's submit handler, and
`course_actions_add_to_history_submit()` inserts into `{report_history}`. That
must not happen again.

The method used here removes the possibility rather than relying on care:

1. **Every exposed filter is removed from the in-memory view** — from both
   `$view->display_handler->handlers['filter']` *and* `$view->filter`, since
   `_build()` iterates the second. That is also exactly what D7 applies when an
   admin submits the form with those boxes empty.
2. `uses_exposed()` then returns **FALSE**, so neither `build()` nor `render()`
   calls `render_exposed_form()`. No form is constructed, so no `#submit`
   handler can run. The script asserts this and aborts if it is not true.
3. The window under test is injected directly into the query.
4. Twelve mutable tables are counted before and after every run and the script
   prints `WRITE_CHECK: clean` or names what moved.

Independently: `course_actions_form_alter()` attaches that submit handler *only*
when `$form_state['display']->id` is `panel_pane_admin_report` or
`panel_pane_instructor_report`. `admin_report_new` has no write handler at all.

**⚠️ One trap worth recording, because it cost an hour and produced a fake
12-order discrepancy.** The obvious way to express the window —
`DATE_FORMAT(CONVERT_TZ(FROM_UNIXTIME(col), 'UTC', <site tz>), '%Y-%m-%d')`,
which is what D7's own date filter emits — is only correct when the **MySQL
session time zone is UTC**. Both DDEV containers run `time_zone = SYSTEM`, which
here is UTC+05:30, so `FROM_UNIXTIME` does not return UTC and the expression is
5½ hours out. It moved rows across the window boundary, differently on each side.
The probes now compute **epoch bounds in PHP from each site's own default
timezone** and compare `col BETWEEN :from AND :to`, which has no such dependency.

Scripts: `scripts/reconcile/probe_admin_report_new.php` (D10) and its D7 twin,
run through `ddev drush php-eval` so that **no file is written into the D7 tree**.

### The tab strip was missing, and the file name was why

D7 renders a `Report | History` tab strip above this table, from
`views-view--admin-report-new--panel-pane.tpl.php`. The D10 twig had been ported
under the same name in an earlier round — and never matched anything, because
D10's suggestion for this display is `views_view__admin_report_new__embed`
(Panels and Page Manager are not reinstalled; the pane is an `embed` display).
The template was dead, so `/report` rendered the table without the tabs.

Renamed to `views-view--admin-report-new--embed.html.twig`, not rewritten — the
same fix `views-view--report--embed.html.twig` already carries. `/report` now
renders `Report | History` with `Report` marked active, including D7's preserved
defect that the highlight never moves to History.

## E. Verification — cell for cell

Three windows, each rendered on both sites and diffed column by column.

| window | filter | D7 raw rows | D10 raw rows | D7 table rows | D10 table rows | cells compared | cells differing |
|---|---|---|---|---|---|---|---|
| Jul–Aug 2026 | Enrolled (`commerce_order.changed`) | 91 | 91 | 61 | 61 | 1,403 | **0** |
| Jul–Aug 2026 | Completed (`flagging` on the completed flag) | 83 | 83 | 58 | 58 | 1,334 | **0** |
| Jan–Mar 2025 | Enrolled | 3,872 | 3,877 | 3,030 | 3,033 | 69,690 | **1** |

The 23 column headers are identical on both sides, in the same order, including
D7's oddities: the leading space in `" Addr_State"`, `Cosmo Credential Number`
(the marketing report calls the same field `License Number`), and two pairs of
columns that share a label because the first of each pair is hidden
(`Video`, `Date Student Began Course`).

The export display was compared as a row multiset (its first column is a constant,
so it cannot be keyed): **88 rows on both sides, multiset identical**, for the
Enrolled-flag window.

`/report` over HTTP, as uid 1, with the Enrolled filter for Jul–Aug 2026:
HTTP 200, the `Report | History` tab strip, one table, **23 headers, 61 data
rows**, the `input_required` message
before any filter is applied, the XLS/CSV footer buttons, and all seven exposed
filters under D7's identifiers. `/new-report.xml`: HTTP 200,
`application/vnd.ms-excel`, 21 columns.

### Access, per account

| account | roles | `/report` | pane rendered |
|---|---|---|---|
| uid 1 | administrator | allowed | `admin_report_new` |
| uid 12 | administrator, ce_admin, **instructor** | allowed | `admin_report_new` **only** |
| uid 22 | ce_admin | allowed | `admin_report_new` |
| uid 6 | authenticated | **denied** (403) | — |
| anonymous | — | **denied** (403) | — |

uid 12 exercises D7's negation: an account holding both Instructor and CE Admin
sees the admin table only. Without it the instructor table — scoped to "courses I
authored" — would stack underneath and look like the admin report with rows
missing.

## F. Deliberate differences, with numbers

Three, all pre-existing and none introduced by this round's work.

1. **Orders whose customer was deleted (3 rows in the Jan–Mar 2025 window, 163
   site-wide).** D7 holds 163 orders whose `uid` points at a `{users}` row that
   no longer exists; the pane's INNER join to `{users}` hides them. The migration
   mapped those orders to uid 0, which does exist, so they now appear with blank
   customer columns. Orders 44341, 44342 and 46186 are the three in that window.
   D10 shows **more** data here, not less.
2. **The `product_discount` line-item type.** Commerce 1 modelled discounts as
   line items and D7's report filters to `type = product`, excluding them.
   Commerce 3 has no such type: the 154 `product_discount` lines were migrated as
   ordinary order items (`type: default`, unit price 0.00) and no column now
   distinguishes them, so this report and the marketing report both include them.
   They sit on **4 orders** out of 87,661. A `unit_price > 0` filter would hide
   them — and would also hide the one genuinely zero-priced order item on the
   site, so it was not added. The 3,047 `commerce_discount` lines were not
   migrated at all (their value lives on the order's adjustments).
   `d7_dataset_counts.sh` now exposes `order_items_migrated`
   (`product` + `product_discount` = 113,446) and the reconciliation compares
   against that, so the 154 no longer show up as phantom "created in D10" rows.
3. **One address label.** D7's `addressfield` renders subdivision `AE` as
   `Armed Forces (Europe, Canada, Middle East, Africa)`; D10's
   `commerceguys/addressing` renders `Armed Forces (AE)`. Same stored value,
   different label list, and it affects every address on the site rather than
   this report. One cell in 69,690.

Also recorded, because it looks like a difference and is not: **25 orders have a
different `changed` timestamp in D10**, all of them carts touched by testing in
the D10 environment after the migration (orders 35, 4662, 85040, 87819 and the
new 87820–87840). Any window that includes those dates will differ by a row or
two for that reason alone.

## G. Regression

`verify_admin_parity.php` — **16 checks, all matching D7** (13 from Rounds 1–3
unchanged, plus the three new ones):

```
report.xml source pane, Jul-Aug 2026           total:67       rendered:67
report.xml source pane, 2025                   total:14       rendered:14
/report instructor pane (uid 12)               total:68       rendered:68
/sale-report admin pane                        total:72       rendered:72
/evaluation-results admin pane                 total:8        rendered:8
/report/history (uid 12)                       total:741      rendered:50
admin/admin-coupon-used-by-user                total:300      rendered:50
admin/quiz/quiz-unevaluated-results            total:51       rendered:10
admin/config/content/flag-manager/content      total:20398    rendered:20
/marketing-report total                        total:1        rendered:0
/marketing-report/detail                       total:61       rendered:61
/marketing-report/new-detail                   total:32       rendered:32
sale report total strip (admin)                total:72       rendered:0
/report (Enrolled Jul-Aug 2026)                total:61       rendered:61
/report (Completed Jul-Aug 2026)               total:58       rendered:58
/new-report.xml (Enrolled Jul-Aug 2026)        total:88       rendered:88
```

`verify_admin_pages.sh` — **27 pages, every one HTTP 200** (26 from Round 3 plus
`/new-report.xml`).

`verify_admin_visibility.php` — all 22 datasets reconcile, D7 → D10 database →
D10 admin UI, with the order-item definition corrected as above.

## H. D7 freeze

Seventeen tables counted immediately before this round's first D7 read and again
after the last one:

| | before | after |
|---|---|---|
| `report_history` | 784 | **784** |
| `flagging` | 132,272 | **132,272** |
| `commerce_order` | 87,661 | **87,661** |
| `commerce_line_item` | 116,493 | **116,493** |
| `commerce_payment_transaction` | 26,801 | **26,801** |
| `node` | 620 | **620** |
| `users` | 13,252 | **13,252** |
| `users_roles` | 21 | **21** |
| `commerce_product` | 162 | **162** |
| `field_data_commerce_total` | 116,494 | **116,494** |
| `quiz_node_results` | 3,977 | **3,977** |
| `views_view` / `views_display` | 21 / 65 | **21 / 65** |
| `panels_pane` | 24 | **24** |
| `page_manager_pages` / `page_manager_handlers` | 4 / 6 | **4 / 6** |
| `watchdog` | 100,057 | 100,071 |

`MAX(flagging_id)` 166,918 and `MAX(order_id)` 87,840 are both unchanged.

**No business data was written.** The only table that moved is `watchdog`, by 14
rows: three `serial_launcher` "cron thread started" entries and two abandoned-cart
cron entries (D7's own scheduled jobs), four `security` "couldn't write .htaccess"
warnings and five PHP notices from the read-only drush bootstraps. A direct check
for recent writes confirms it: 0 orders created or changed, 0 line items changed,
0 flaggings, 0 nodes, 0 quiz results, 0 webform submissions, 0 payments in the
last 24 hours, and `{report_history}` still at 784.

One non-content column moved: `users.access` for uid 1, set to 18:46:33 by
`drush --user=1`. That column is a last-seen timestamp. (uid 1's `changed` and
`login` carry 12:48:05 / 12:47:51 the same day — **before** this round's first D7
read at 18:30 — so they are not from this work.)

## I. Still outstanding

**Technical:** nothing on `/report`. The live D7 display is migrated and verified.

**Business decisions — unchanged, not acted on:**

* Quiz settings forms.
* The 14 downloaded-but-disabled contrib admin modules.

**Deliberate differences:** the three in section F.

**Cosmetic, carried forward:** `flag_counts` off by one row; the benign core
notice `Undefined array key field_address_administrative_area`
(`FilterPluginBase:1609`) emitted while rendering the marketing report's exposed
form.

---

# Round 5 — Admin form parity

Started from the reported symptom: *the Report section forms do not match D7 —
fields missing, labels different, alignment different.* All three were real, and
one of them was hiding a missing filter.

## A. How the D7 forms were read, without writing to D7

D7 processes a GET form only when `$form_state['input']` is non-empty. Every D7
form in this round was therefore rendered **with no exposed input**, which
leaves `process_input` FALSE so no `#submit` handler can run — the same
mechanism that makes `course_actions_add_to_history_submit()` unreachable, and
therefore `{report_history}` unwritable. Every run counts five mutable tables
before and after and prints `WRITE_CHECK: clean`; every run in this round did.
Nothing was written into the D7 tree either — the PHP goes to `drush php-eval`
as a string.

Tools kept: `scripts/reconcile/d7/dump_d7_exposed_forms.sh` and
`scripts/reconcile/d7/extract_exposed_form.py`.

## B. Report forms — the six exposed filter forms

`scripts/reconcile/verify_form_parity.php` now compares, per widget and in
document order: presence, order, widget label, per-input labels, whether each
label is visible or screen-reader-only, single vs multiple, option counts and
placeholders. The D7 side is the recorded baseline `d7_form_baseline.json`.

**Result: 6 of 6 forms match, 0 differences.**

```
/report                          8 widgets - MATCH
/sale-report                     5 widgets - MATCH
/marketing-report                2 widgets - MATCH
/marketing-report/detail         21 widgets - MATCH
/marketing-report/new-detail     24 widgets - MATCH
/evaluation-results              3 widgets - MATCH
```

### What was wrong, and what fixed it

**1. A filter was missing outright — `field_address_administrative_area`,
"State".** `/marketing-report/detail` rendered 20 exposed filters against D7's
21, and `/marketing-report/new-detail` 23 against 24. D7 exposes a plain text
box; in D10 that column's views data belongs to the address module, which
resolves the filter to `Drupal\address\Plugin\views\filter\AdministrativeArea` —
a subdivision *select* that needs a country to populate itself. With no country
source it has no options, so Views rendered an empty wrapper and the widget
disappeared. The stray `Undefined array key "field_address_administrative_area"`
notice logged since Round 2 was the same defect showing its other face.

Fixed with a plain-string handle on the same column in
`ce_reports_views_data_alter()` — `ce_address_administrative_area` — which is
the box D7 shows. A static-country select was rejected: it could not match the
stored values, which include both `SC` and `South Carolina`.

**2. Field order was wrong on both marketing detail forms.** The address boxes
sat where D7 has Maiden Name / SSN / Licence Number, because the build script
appended them in its own order. Both lists are now ordered from D7's
`display_options['filters']`, key for key. Filters are ANDed, so this changed
the form only — every row count is unchanged.

**3. Every date range was boxed and mislabelled.** A `between` filter in D10
builds its inputs inside a `#type => 'fieldset'`, which this Bootstrap-derived
theme renders as a bordered `panel` with a `panel-title` — where D7 has a plain
label above two inline boxes. The exposed label was not even a `<label>`, so
`.views-exposed-widgets .views-widget label`, which the ported stylesheet uses to
size and space every other filter label, did not match it. D10 also titles the
two inputs "Min" and "Max"; D7 does not:

| D7 widget | min label | max label |
|---|---|---|
| `views_handler_filter_date` (created, timestamp, submitted, changed, …) | *(none)* | **And** |
| date module `date_popup` (date_filter, date_filter_2 on /report) | **Start date** | **End date** |

`ce_reports_form_views_exposed_form_alter()` turns the fieldset into a plain
container, re-emits the exposed label as a real `<label>` where D10 puts every
other filter label, applies D7's min/max titles, placeholders and sizes, and
marks the pair `container-inline` so the two boxes sit side by side. **No new
CSS** — every class used is one the ported D7 stylesheet already styles. The
problem was the markup.

⚠️ Two traps, both recorded in the code: the inputs sit at
`$form['<id>_wrapper']['<id>']['min']`, two levels down, not one; and the label
must be a child element, not `#prefix`, because `ce_core_form_views_exposed_form_alter()`
runs first and prepends D7's wrapper divs to `#prefix`, so a label added there
lands outside both wrappers.

**4. `Profession Type` on /report was the wrong widget.** D7 exposes a 7-row
multi-select with no "- Any -"; D10 offered a single-value dropdown, silently
removing the ability to report on several professions at once. D7's
`expose.multiple` is now carried through.

**5. Five labels on `/marketing-report/new-detail` were rewritten during the
Round-2 build.** All restored to D7's exact strings:

| filter | was | D7, now |
|---|---|---|
| `changed` | Date of purchase | **Order Update Date** |
| `field_last_name_value` | Last name | **Last Name** |
| `field_first_name_value` | First name | **First Name** |
| `created` | Registered | **User registered date** |
| `field_state_of_licensure_tid` | State of Licensure | **State of professional license** |

⚠️ The capitalisation genuinely differs between D7's two detail displays —
"Last Name" here, "Last name" on `panel_pane_adv_report_detail` — and both are
reproduced as typed.

### Deliberate difference

On `/report` the two date ranges are `input type="date"` in D10 and
`input type="text"` plus a jQuery UI popup in D7. D7 asked for a picker
(`form_type: date_popup`); D10's native date control is that picker, and D10
core has no jQuery UI datepicker to port. Labels, order, visibility and
placeholders all match, and the filter accepts both the D10 (`Y-m-d`) and D7
(`m/d/Y`) strings, so existing links keep working.

## C. Product forms

### The defect: the product form could not reach the product's own data

D7 has ONE form per product (`commerce_product_product_form`):

| # | field | label | widget | required |
|---|---|---|---|---|
| 1 | sku | **Product SKU** | textfield | ✔ |
| 2 | title | **Title** | textfield | ✔ |
| 3 | commerce_price | **Price** | commerce_price_full | ✔ |
| 4 | status | **Status** | radios **Active / Disabled** | ✔ |
| 5 | field_video_reference | **Video Reference** | entityreference autocomplete | ✗ |
| 6 | change_history | Change history → Creation log message | textarea | ✗ |

Commerce 3 splits that entity in two: the product keeps the title and stores,
the **variation** inherits the sku, the price and the status. The bridge is the
product form's `variations` component — and on this site it was **hidden**, with
no bundle-specific form displays at all. Opening any of the **159 video products**
showed Title, Stores, Author, Created, URL alias and Published, and offered no
way in to the sku, the price or the video reference. Migrated data the admin UI
could not reach or edit — the same class of defect as Round 3's invisible orders,
one layer further in.

### Fixed

`scripts/reconcile/configure_product_forms.php` creates the missing form
displays; `ce_commerce_entity_base_field_info_alter()` restores D7's wording on
the base fields it owns.

| D7 | D10 now | status |
|---|---|---|
| sku "Product SKU", required, w −10, with D7's description | same label, description, required, weight | **match** |
| title "Title", required, w −5 | on the product form, w −5 | **match** (different entity) |
| commerce_price "Price", required, w 0 | price "Price", required, w 0 | **match** |
| status "Status", required radios Active/Disabled, w 35, with D7's description | `options_buttons` radios, same labels, description, required, w 35 | **match** |
| field_video_reference "Video Reference", w 36 | same label and weight | **match** (label was "Video reference") |
| change_history fieldset | — | Commerce 3 keeps revision logging on the product |

Verified on a migrated product over HTTP: `/product/18/variations/1/edit`
returns 200 and carries "Product SKU", "Supply a unique identifier…", two Status
radios with "Disabled products cannot be added…", "Video Reference" and "Price".
`/product/18/edit` returns 200 and lists the variation.

### Deliberate difference, and one business decision

D7's single form is now two pages: the product form (title, stores, variations
table) and the variation form (sku, price, status, video reference). That is
Commerce 3's entity model, not a layout choice.

**Business decision, not taken:** every one of the 162 products has exactly one
variation. Setting the product types to single-variation would let Commerce's
`commerce_product_single_variation` widget inline the variation's fields onto the
product form, restoring D7's one-page form exactly — at the cost of removing the
ability to add a second variation later. That is a product-model decision for the
client, so the complex inline entity form is used instead, which reaches the same
data without changing the model.

## D. Regression

`verify_admin_parity.php` — **16 of 16 unchanged**, before and after the form
work. Reordering exposed filters and relabelling them cannot change a query, and
the counts confirm it.

## E. Still outstanding — the rest of the form surface

This round completed priorities 1 and 2 of the brief. **The remaining priorities
are audited only to the extent noted below and are NOT yet verified field for
field:**

| priority | area | state |
|---|---|---|
| 1 | Report forms | **done** — 6/6 match, verified by script |
| 2 | Product add/edit | **done** — matches D7 field for field on the variation, deliberate split documented |
| 3 | Other Commerce forms (orders, order items, coupons, promotions, stores, customers, payments) | **not audited** |
| 4 | Users add/edit | **not audited** in this round |
| 5 | Content / media add/edit | **not audited** |
| 6 | Taxonomy vocabulary / terms | **not audited** |
| 7 | Quiz admin forms | **not audited** (and quiz settings remain a business decision) |
| 8 | Remaining site-specific `ce_*` forms, Flag Manager, Licence Prefix Update | **not audited** |

`verify_form_parity.php` and `d7_form_baseline.json` are built to extend: adding
a form is adding an entry to the baseline and a row to the script's loop.

Also discovered and worth a decision: D7's `hands_on_color_class` product type
has no `admin/commerce/products/add/…` route — it is reachable only through its
type edit page. D10 carries it as a variation type with no product type. It looks
disused on both sides; confirming that is a business question.

---

# Round 6 — Dashboard shortcuts and every page they open

## A. Dashboard shortcut audit — 8 of 8 match

The dashboard is not `/admin/dashboard`; it is the **Dashboard tab on the user
page**, rendered by `user.html.twig` for accounts holding CE Admin or
administrator, exactly as D7 renders it from
`cetc_new/templates/user-profile.tpl.php`.

| # | icon | label | destination | target | destination query |
|---|---|---|---|---|---|
| 1 | dash_icon_1 | Marketing Report | /marketing-report | — | — |
| 2 | dash_icon_2 | New Marketing Report | /marketing-report/new-detail | — | — |
| 3 | dash_icon_1 | Sale report | /sale-report | _blank | destination |
| 4 | dash_icon_3 | Evaluation results | /evaluation-results | _blank | destination |
| 5 | dash_icon_4 | Report | /report | _blank | destination |
| 6 | dash_icon_5 | Create course | /node/add/course | _blank | destination |
| 7 | dash_icon_6 | Export Accounts | /accounts-export | _blank | — |
| 8 | dash_icon_7 | My Orders | /user/{uid}/orders | — | — |

Everything above is D7's, verified line by line against the D7 template:
the order, the icons **including D7's reuse of `dash_icon_1` for two different
cards**, D7's inconsistent capitalisation ("Sale report" and "Create course"
lower-case against "Marketing Report" and "Export Accounts"), which five cards
open in a new tab, which five carry a `destination` parameter, the
`stretched-link text-primary` classes, the role gate, and the `#dadhboard`
typo in the tab anchor. The sibling tab also switches between "Student profile"
and "Instructor profile" by role, as D7 does.

**No changes were needed here.** The reported problem was entirely in the
destinations.

## B. Destination parity matrix

| Dashboard item | Destination | Page | Fields | Sections | Layout | Data | Status |
|---|---|---|---|---|---|---|---|
| Marketing Report | /marketing-report | ✓ | ✓ | ✓ | ✓ | ✓ | match (Round 5) |
| New Marketing Report | /marketing-report/new-detail | ✓ | ✓ | ✓ | ✓ | ✓ | match (Round 5) |
| Sale Report | /sale-report | ✓ | ✓ | ✓ | ✓ | ✓ | match (Round 5) |
| Evaluation Results | /evaluation-results | ✓ | ✓ | ✓ | ✓ | ✓ | match (Round 5) |
| Report | /report | ✓ | ✓ | ✓ | ✓ | ✓ | match (Rounds 4–5) |
| Create Course | /node/add/course + /node/N/edit | ✓ | **fixed** | **fixed** | **fixed** | ✓ | match after this round |
| Export Accounts | /accounts-export | **was 404** | **built** | **built** | **built** | ✓ | match after this round |
| My Orders | /user/{uid}/orders | ✓ | ✓ | ✓ | ✓ | ✓ | match |
| User / Admin profile | /users/{name}, /user/N/edit, /admin/people/create | ✓ | ✓ | ✓ | ✓ | ✓ | match |

### Create Course — every field was hidden

`core.entity_form_display.node.course.default` listed nine components and put
**fifteen in `hidden`**: accrediting body, CE-or-non-CE, evaluation text, expiry
days, course image, course type, evaluation form, hide PDF, instructors, CEU
hours, quiz, state, teaser video, videos, weight. The fields existed with the
right types and labels and the migrated courses carried their values — but
`/node/add/course` could set none of them and `/node/N/edit` could change none.
The Add form was, in practice, a title and a body.

⚠️ `field_ce_or_non_ce` is REQUIRED. A required field hidden from its own form
cannot be filled in.

Restored by `scripts/reconcile/configure_course_form.php` at D7's own weights
and widgets, read from `{field_config_instance}`: body 1, course type 2, state 3,
accrediting body 4, instructors 5, videos 6, CEU/Hours 7, image 9, evaluation
text 10, evaluation form 11, teaser video 31, quiz 32, hide PDF 33, CE or Non CE
34, weight 35, expiry days 36. All eighteen D7 labels now render on both forms,
and a migrated course opens with its two course types, three states, video,
quiz, image and accrediting body intact.

Two more D7 details restored:

* **The title label.** D7's `{node_type}.title_label` is "Course Name" for
  course and "Name" for video; D10 had no base field overrides at all, so both
  read "Title". Fixed with `base_field_override` entities.
* **The Flags fieldset.** D7 sets `show_on_form = 1` on `completed`, `enrolled`
  and `submitted`, giving the course form a Flags fieldset at weight 1 with the
  labels *Flag this item Completed / Enroll / Flag this item Submitted*. D10's
  flags all had it off, so the fieldset was absent. Turned on for exactly those
  three (`configure_flag_node_form.php`), then two further corrections in
  `ce_course.module`: D10's flag module puts the group in the vertical-tabs
  sidebar (`#group => 'advanced'`) and labels the checkboxes with the flag's
  administrative label — "Enrolled" where D7 says "Enroll". Both undone.

⚠️ Two traps recorded in the code: `flag` is an **extra field**, so its position
comes from the form display component, not from `#weight` on the element; and
`ce_course_form_alter()` has to run **after** `flag_form_alter()`, which creates
the element — hence the `hook_module_implements_alter()`.

**D7 sections with no D10 counterpart:** Certificate settings, Print/PDF paper
size and orientation, Custom Breadcrumbs. All three are D7 contrib modules that
were not migrated. **D10-only:** "Promotion options" is core's rename of D7's
"Publishing options".

### Export Accounts — the page did not exist

`/accounts-export` returned **404**. The card was right; the destination was
never built. Three D7 routes had no D10 twin:

```
accounts-export              views_page(accounts_export, page)
accounts-export/xls-full     views_page(accounts_export, views_data_export_1)
accounts-export/xls-simple   views_page(accounts_export, views_data_export_2)
```

`build_accounts_export_view.php` reproduces the view from `{views_view}` vid 24:
table style, pager 25, `administer users`, sorted created DESC then realname,
filtered to `status = 1` (exposed, labelled "Active") and `uid > 0`, with the
page display's twelve visible columns and the two excluded phone columns D7
keeps for the exports. Rendered side by side, D7 and D10 give the **same twelve
column headings in the same order and 25 rows on page one**.

The two spreadsheet displays are routed through `XlsExportController` like every
other D7 `views_data_export`: 23 columns for Full, 5 for Simple.

⚠️ **The full export needed D7's batching.** Rendered in one pass, 13,251
accounts across 23 columns died with `Allowed memory size of 1073741824 bytes
exhausted` after 223 bytes. The controller now streams the table in chunks of
500 rows, clearing the entity memory cache between them — 62 MB and 13,223 rows,
no fatal. There is **no row cap**: truncating a compliance export silently is
worse than failing loudly. The previously verified exports are unaffected
(`/new-report.xml` still returns exactly 88 rows for its window).

### My Orders — already correct, and confirmed

D7's `commerce_user_orders:order_page` is a **line-item** list, not an order
list: three columns with **no headings** — the item title linked to its node,
the order's changed date as `m/d/y`, and the line total. Filter `state NOT IN
(cart, checkout)`, pager 25, empty text "You have not placed any orders with us
yet.", argument validated against the current user or role 3.

Rendered for customer 12 on both sites: **47 rows on each**, and the first rows
are identical cell for cell —
`February 28, 2021 Live! On Zoom! | 02/28/21 | $135.00`, linking to
`/videos/february-28-2021-live-zoom`. The migrated-order visibility requirement
holds at the user level as well as the admin level.

One cosmetic difference: D7 emits an empty `<th></th>` header row, D10 omits the
header row entirely. Same visible result.

### User / Admin profile

* **Profile page** — the tab strip, the Dashboard cards and the
  Student/Instructor profile switch all match (section A).
* **Edit User** — D10 renders D7's field set in D7's order, weight for weight:
  account −10, picture, first 4, middle 5, last 6, maiden 7, SSN 8, state of
  licensure 9, licence prefix 10, prefix zeros 11, credential number 12,
  licensee number 13, address 14, primary phone 15, secondary phone 16,
  profession type 17, referral link 20, actions 100.
  D7-only: Masquerade, Email (mimemail) and Rich text editor settings — contrib
  modules not migrated. D10-only: Google Analytics, Language, Contact settings.
* **Add User** — matches. ⚠️ Worth recording how nearly this was mis-called: a
  first pass reported nine missing fields, because the D7 form array *contains*
  middle name, maiden name, SSN, licensee number, address, phones, profession
  type, referral link, picture and signature. D7 marks every one of them
  `#access = FALSE` on the register form, from the per-field
  `settings.user_register_form` flag. Re-dumping with `#access` shown gives D7's
  real six — first name, last name, state of licensure, licence prefix, prefix
  zeros, credential number — which is exactly what D10's register display
  carries. **An element in a D7 form array is not a field on the D7 form.**

## C. Regression

* `verify_form_parity.php` — 6 of 6 report forms, 0 differences.
* `verify_admin_pages.sh` — 27 of 27 HTTP 200.
* `verify_admin_parity.php` — 16 of 16 unchanged.
* All 15 dashboard destinations, including both course forms, both account
  exports and two users' order pages: HTTP 200.
* Toolbar present when logged in, absent for anonymous.

## D. D7 safety

Every D7 form in this round was rendered read-only through `drush php-eval`,
with no exposed input and no form submission; each run printed
`WRITE_CHECK: clean`. **16 of 16 D7 business tables are identical to the Round-4
baseline.**

## E. Remaining differences, classified

| item | classification |
|---|---|
| Course form: Certificate settings, Print/PDF, Custom Breadcrumbs | obsolete D7 contrib, not migrated |
| Course form: "Promotion options" vs D7 "Publishing options" | D10 core rename |
| `field_weight` rendered as a number, not D7's weight selector | deliberate — D10 has no weight field type |
| Edit User: Masquerade, mimemail, CKEditor settings | obsolete D7 contrib, not migrated |
| Edit User / register: Google Analytics, Language, Contact settings | legitimate D10-only functionality, not removed |
| My Orders: no empty `<th>` row | cosmetic, D10 table style |
| `/report` date ranges use `input type=date` | deliberate (Round 5) |
| Product form split across product and variation | Commerce 3 entity model; single-variation widget remains a business decision |

---

# Round 7 — Commerce, Quiz, Taxonomy and Media forms

## A. Method: one sweep instead of nine audits

The same defect had now appeared three times — a field that exists, holds
migrated values, and is listed under `hidden` on its form display. So rather
than open nine forms one at a time, every D10 bundle was swept: configurable
fields against form-display components. That found **nineteen more fields in
one pass**, across eight bundles nobody had looked at yet.

D7's side is `{field_config_instance}.data` for every entity type, read off the
live site — 97 instances across 36 bundles, with each one's weight, widget,
label and required flag.

## B. What was hidden, and is now on the form

| bundle | fields that were not on the form |
|---|---|
| node/article | field_image, field_tags |
| node/blog | **field_category (REQUIRED)**, field_featured_image |
| node/page | **field_category (REQUIRED)**, field_featured_image |
| node/certificate | provider approval number, provider licence number, course number, course title, total hours |
| node/video | field_instructor_s_, field_price, field_video_from_wistia_video |
| node/webform | **webform** |
| taxonomy/profession_type | field_abbreviation, field_course_types |
| taxonomy/state | field_publish, field_pdf, field_license_prefixes |
| commerce_order | **coupons** |

Three of those are worse than the others:

* **`field_category` is REQUIRED on blog and page.** A required field that its
  own form does not show cannot be filled in, so neither content type could be
  created through the D10 UI at all.
* **`webform` on node/webform** is how a webform node points at its form.
  Without it a webform node renders nothing.
* **`field_abbreviation` on profession_type** is the column `/report` prints as
  "Prof_Abbr" — migrated, shown on the report, and uneditable.
* **`coupons` on the order** is the field the marketing report joins to print
  "Promo code". An admin could see a coupon on a report and not on the order it
  belongs to.

All 28 components are now placed at **D7's own weights**, with D7's widgets:
`scripts/reconcile/configure_remaining_form_displays.php` and
`configure_commerce_order_form.php`. Verified over HTTP — every D7 label renders
on every one of those Add forms, and the order form shows Coupons, Order items
and Billing information.

Two label corrections went with them: `field_video_from_wistia_video` read
"Video from wistia video" against D7's "Video from Wistia Video"; the D7 order
form's field order (line items −10, billing −5, coupons −3) was restored.

⚠️ Widget substitutions, each chosen rather than assumed: D7's
`taxonomy_autocomplete` becomes `entity_reference_autocomplete_tags`, because
D7's widget both selects and CREATES terms and Tags/Category are free-tagging;
`options_onoff` becomes `boolean_checkbox`; `media_generic` becomes
`entity_reference_autocomplete`, since `field_video_from_wistia_video`
references a FILE in D10 and core has no media browser for it.

## C. Commerce — form by form

| D7 | D10 | verdict |
|---|---|---|
| commerce_order/commerce_order | commerce_order/default | match after the coupon fix; `commerce_order_total` and `commerce_discounts` are model differences (below) |
| commerce_line_item/product | commerce_order_item/default | Product → Purchased entity, Unit price → Unit price, plus Quantity; `commerce_total` and `commerce_display_path` are model differences |
| commerce_customer_profile/billing | profile/customer | match — "Address", required, plus a D10-only revision log |
| commerce_discount/order_discount, product_discount | commerce_promotion/default | concept for concept, Commerce 3's vocabulary |
| commerce_coupon/discount_coupon | commerce_promotion_coupon/default | model difference (below) |
| — | commerce_store/online | D10-only: Commerce 1 has no store entity |
| — | commerce_payment_method/credit_card | D10-only, and its form is built by the gateway plugin, not the form display |

**Model differences, all Commerce 1 → Commerce 3, none of them lost data:**

* `commerce_order_total` — D7 let an admin type an order total that disagreed
  with the lines. Commerce 3 **computes** `total_price` from the order items, so
  it is displayed on the order and not editable on the form.
* `commerce_discounts` — Commerce 3 models discounts as **adjustments** on the
  order, which the form already exposes, rather than a reference to a discount
  entity.
* `commerce_display_path` — stored the path a product was displayed at. No
  Commerce 3 equivalent; obsolete.
* `commerce_discount_reference` on the coupon — in Commerce 3 a coupon belongs
  to its promotion by parent reference, created from the promotion's Coupons
  tab, so the reference is implicit rather than a field.
* `commerce_compatibility_selection` ("Selected discounts") — Commerce 3's
  compatibility is an enum with no per-discount list. **This one is a genuine
  loss of expressiveness**, not just a rename, and is recorded as such.

`/promotion/add` and `/admin/commerce/promotions` both render (the Commerce 3
paths; D7's `admin/commerce/discounts` no longer exists).

## D. Quiz — one technical gap, stated plainly

`node/quiz` and `node/multichoice` now carry their D7 fields at D7's weights,
and the multichoice body keeps D7's "Question" label.

**But D7's `alternatives` fieldset is not reproduced.** D7's multichoice form has
an "Answer" fieldset at weight −4 holding one row per alternative, an "Add
alternative" button, a per-question "Settings" fieldset and a "Question
feedback" element — all built by D7's quiz module, none of them fields.

D10 has the data: **128 multichoice nodes and 391 answers, matching D7 exactly**,
in `quiz_multichoice_answers` / `quiz_multichoice_properties`, and `ce_quiz`
renders them (it ports D7's `multichoice-alternative.tpl.php`). What is missing
is the **editing form**: the answers can be read and scored, and cannot be
changed through the D10 UI.

**Classification: technical work remaining, not a business decision.** It needs a
multi-value form element with AJAX add/remove, per-alternative score and
feedback fields, validation and save logic against those two tables. It is not a
configuration toggle, which is why it is reported rather than half-built.

## E. Taxonomy

`profession_type` and `state` term forms now match D7 weight for weight:
Course types 1 / Abbreviation 31, and Promoted to front page 1 / PDF 31 /
License Prefixes 32. The other three vocabularies (course_type, license_prefix,
accrediting_body) have no fields on either side.

## F. Media — not applicable, and checked rather than assumed

D7 has the media module installed but **`{media_type}` is empty** and no
`file_managed` bundle carries a single field instance, so there are no D7 media
entities and no fielded media or file forms to migrate. D7's files are plain
`file_managed` rows typed default / image / video (13 / 4,068 / 249).

D10 correspondingly has **no media bundles and zero media entities**, and its
file listing at `/admin/content/files` works — 8 columns, 50 rows a page. The
`wistia://` stream wrapper that page needs was built in Round 3.

## G. Regression

* `verify_admin_parity.php` — 16 of 16 unchanged.
* `verify_form_parity.php` — 6 of 6 report forms, 0 differences.
* `verify_admin_pages.sh` — 27 of 27 HTTP 200.
* Twelve Add/Edit forms checked over HTTP: every restored D7 label renders.
* **D7 freeze: 16 of 16 business tables identical to the Round-4 baseline.**
  Every D7 form was rendered read-only with no exposed input; each run printed
  `WRITE_CHECK: clean`.

## H. Remaining, classified

| item | classification |
|---|---|
| Multichoice "Answer" fieldset — alternatives cannot be edited | **technical work remaining** |
| `commerce_compatibility_selection` ("Selected discounts") | Commerce 3 has no equivalent — reduced expressiveness |
| `commerce_order_total`, `commerce_total`, `commerce_discounts`, `commerce_display_path`, coupon→discount reference | Commerce 1 → 3 model differences, no data lost |
| commerce_store, commerce_payment_method, Adjustments | legitimate D10-only functionality |
| Media entities | not applicable — none exist in D7 |
| Quiz settings forms; the 14 disabled contrib admin modules | business decisions, unchanged |

---

# Round 8 — the three reported pages, and the defect class behind them

The three pages reported were evidence of one structural defect the earlier
audits could not see, because they compared **markup and configuration** and the
differences lived in **attached behaviour** and in **classes the theme needs**.

## A. Sale Report — the calendar

**Reported:** D7's "Date of purchase" has a calendar; D10 does not.

**Cause.** D7 runs `better_exposed_filters`, and the per-filter setting
`bef_format: bef_datepicker` attaches a jQuery UI calendar - with month and year
dropdowns (`changeMonth: true, changeYear: true`) - to an otherwise ordinary
text field. Round 5 compared the two forms markup-for-markup and both sides read
"textfield, size 30": identical markup, and only one of them pops a calendar.
**A form comparison that stops at the DOM cannot see a behaviour attached in
JavaScript.**

**Scope.** Reading the BEF settings off every D7 report display shows this is not
one filter but sixteen, on six pages:

| page | D7 display | filters with a calendar |
|---|---|---|
| /sale-report | panel_pane_admin_sale_report | created |
| /marketing-report | panel_pane_advanced_report | created |
| /marketing-report/detail | panel_pane_adv_report_detail | created, timestamp, timestamp_1, timestamp_2 |
| /marketing-report/new-detail | panel_pane_adv_report_new_detail | changed, timestamp, timestamp_1, timestamp_2, created |
| /evaluation-results | evaluation_results:default | submitted |
| /report | admin_report_new pane | changed, timestamp, timestamp_1, timestamp_2, created |

**Fix.** `ce_reports_form_views_exposed_form_alter()` now converts **every**
between-date filter on those views to the native date control, deciding from the
filter's own handler (`getPluginId() === 'date'`) rather than from a list - so
`commerce_order_total_amount`, a numeric between filter, correctly stays a pair
of text boxes. D10 core ships no jQuery UI datepicker (removed in D10); the
native control is a calendar with month and year navigation, posts `Y-m-d` which
these filters already accept, and leaves existing links working.

Counted on the rendered pages, the date controls now match D7's picker list
exactly: 2, 2, 8, 10, 2 and 4.

## B. Evaluation Results — the layout

**Reported:** completely different design, not a missing field.

**Cause, and it is the important finding of this round.** D7's
`views-view.tpl.php` prints

```
<div class="view view-evaluation-results view-id-evaluation_results
            view-display-id-panel_pane_admin_results view-report ...">
```

Drupal 10 **removed every one of those classes** from `views-view.html.twig`,
keeping only the dom id. The ported templates were written as
`<div class="{{ attributes.class }}">`, and in D10 that variable holds only the
display's own `css_class`. The rendered wrapper was therefore:

```
<div class="view-report">
```

The theme's stylesheet - D7's, ported verbatim - keys off `.view-evaluation-results`,
`.view-id-evaluation_results` and their siblings hundreds of times. Almost none
of those rules could match. **That is why the page looked like a different
design rather than a different field**: same fields, same data, none of the
styling.

**Fix.** All five ported view templates now rebuild D7's class list in D7's
order, alongside D10's `js-view-dom-id`:

| page | wrapper now |
|---|---|
| /report | `view-report advanced-sale-report view view-admin-report-new view-id-admin_report_new view-display-id-panel_pane_admin_report_new_detail` |
| /report/history | `page-report … view view-report-history view-id-report_history view-display-id-page view-report` |
| /sale-report | `view-report view view-sale-report view-id-sale_report view-display-id-panel_pane_admin_sale_report` |
| /marketing-report/detail, /new-detail | `view-report advanced-sale-report view view-sale-report view-id-sale_report view-display-id-…` |
| /evaluation-results | `view-report view view-evaluation-results view-id-evaluation_results view-display-id-panel_pane_admin_results` |

**Structure verified separately.** `/evaluation-results` is a Page Manager page
defined in code, not in `{page_manager_pages}` - `onecol`, two panes in
`middle`, admin pane for roles 3/4 and instructor pane for role 5, page access
3/4/5, no body class. D10 matches. The result table is D7's five columns
(`#`, `Submitted`, `User`, and two unlabelled action columns) with Delete and
View links, 8 rows for the verified window.

⚠️ **The same class of defect could not have been found by comparing fields.**
Every previous round compared configuration and form structure; this one only
appears when the rendered wrapper is compared against D7's.

## C. Product "Change history" — investigated, and it is a business decision

**Traced to source.** `commerce_product_product_form()`
(commerce/modules/product/includes/commerce_product.forms.inc:70) builds a
collapsible fieldset at weight 350 containing:

* `revision` - checkbox "Create new revision on update", shown on EDIT only,
  default from the product type's `revision` setting, gated on
  `administer commerce_product entities`
* `log` - textarea, 4 rows, titled **"Creation log message"** on add and
  **"Update log message"** on edit

So it is the Commerce 1 **product revision** UI, not a custom field or a view.

**Why D10 has no home for it.** Neither `commerce_product` nor
`commerce_product_variation` is revisionable in this install, and neither
revision table exists. That is not an oversight: **Decision 12** archived D7's
Commerce revision tables (~570 MB) to CSV instead of migrating them.
`archive/commerce-revisions/commerce_product_revision.csv.gz` holds all 169
product revisions, so nothing is lost - it is simply not in the database.

D7's own settings are worth recording too: `revision` is ON for the `product`
and `hands_on_color_class` types and **OFF for `video_product`**, which is 159 of
the 162 products. For most of this catalogue D7's own Change history box was
collapsed and wrote nothing.

**Classification: business decision required.** Restoring the section means
making the Commerce entities revisionable - a schema change to migrated
entities - and importing the archived revisions, which reverses Decision 12. A
textarea that writes nowhere would be worse than the absence, and the brief
forbids faking a missing section. Presented as a costed choice rather than
implemented unilaterally.

## D. Admin toolbar rule

Implemented as stated, by route and not by CSS. `ce_core_page_top()` removes the
toolbar unless `router.admin_context` reports an admin route - the same service
Drupal asks when choosing the admin theme, so the toolbar now appears exactly
where the admin theme does.

| account | page | toolbar |
|---|---|---|
| admin | /admin/content, /admin/commerce/orders, /admin/dashboard, /admin/people | **visible** |
| admin | /courses, /blog, /users/ad-min, /report, /sale-report | **hidden** |
| plain authenticated | /courses, /user/6 | hidden (unchanged) |
| anonymous | /courses | hidden (unchanged) |

⚠️ **Two traps, both recorded in the code.** There is no `hook_page_top_alter()`
in Drupal 10 - written that way first, it did nothing and every page still had a
toolbar; the toolbar is added by `toolbar_page_top()`, so the removal has to be
another `hook_page_top()` implementation ordered last, via
`hook_module_implements_alter()`.

🛑 **Not CSS, and the difference matters.** `display: none` would still render
the toolbar, still ship its JavaScript, and still let `Drupal.displace` reserve
space at the top of the viewport - which is exactly what the Round-4 frontend
header overlap was made of. Removing the element means
`--drupal-displace-offset-top` stays 0 on the public site and the theme's own
header padding applies unchanged. The Round-4 displacement rules stay correct
for the pages that still have a toolbar.

⚠️ **One judgement call to confirm.** `/report` and `/sale-report` are
back-office reports rendered in the FRONT-END theme, so by the route rule they
now lose the toolbar. That follows the rule as written ("admin on /admin/* →
visible"), but if those pages should count as backend, say so and they can be
added to the admin route set.

## E. Regression

* `verify_admin_parity.php` — 16 of 16 unchanged.
* `verify_form_parity.php` — 6 of 6, 0 differences.
* `verify_admin_pages.sh` — 27 of 27 HTTP 200.
* All ten dashboard destinations 200 after the toolbar change.
* **D7 freeze: 16 of 16 business tables identical to the Round-4 baseline.**
  Every D7 render was read-only and printed `WRITE_CHECK: clean`.

## F. Comparison limitations, recorded as the brief requires

* D7's `evaluation_results` pane could not be rendered **with** exposed input in
  this session (the call aborted). Its data parity is covered by the standing
  8-row check in `verify_admin_parity.php`; the column set was compared from
  `{views_display}` instead.
* The `Report` view's admin pane is never rendered with exposed input on D7, by
  standing rule - its exposed form writes `{report_history}`.

## G. Remaining, classified

| item | classification |
|---|---|
| Product "Change history" | **business decision** — needs revisionable Commerce entities + re-import of 169 archived revisions (reverses Decision 12) |
| Multichoice "Answer" fieldset | **technical work remaining** (Round 7) |
| `commerce_compatibility_selection` | Commerce 3 has no equivalent |
| `/report`, `/sale-report` toolbar | **RESOLVED 2026-09-11 — see Round 26.** The toolbar is shown on front-end pages, as D7 does. |
| D7 print_pdf, custom_breadcrumbs, masquerade, mimemail, CKEditor settings | obsolete contrib, not migrated |
| Quiz settings forms; 14 disabled contrib admin modules | business decisions, unchanged |

---

# Round 9 — the multichoice answer editor, and two live bugs

## A. Multichoice answer editor — built

The gap Round 7 recorded as "technical work remaining" is closed. D7 builds the
editor from its question-type plugin, which has no D10 counterpart, so the D10
question form had a title and a body and no answers — while the data (128
questions, 391 alternatives) sat in the database being rendered and scored but
never editable.

`\Drupal\ce_quiz\MultichoiceAlternatives`, wired onto the multichoice node form,
reproduces D7's `MultichoiceQuestion::getCreationForm()` (line 478),
`validateNode()` (269), `saveNodeProperties()` (109) and `forgive()` (19):

* **"Answer"** section at D7's weight −4, opened, one row per alternative
* per row: **Correct**, the answer body (`text_format`, 3 rows, required on the
  first two), an **Advanced options** section holding **Feedback if chosen**,
  **Feedback if not chosen**, **Score if chosen** and **Score if not chosen**
  (size 4, with D7's descriptions verbatim), and a weight box
* **Add choice** (AJAX, no page reload), D7's default of two blank rows on a new
  question
* **Settings** at weight 30 — Multiple answers / Random order / Simple scoring,
  with D7's descriptions and its "Your settings will be remembered." note
* D7's two validation errors, word for word

**D7 behaviours preserved that would look like bugs if you did not know:**

* `correct` is **not stored**. D7 derives it from the scores
  (`score_if_chosen > score_if_not_chosen`) and uses the checkbox only to fill
  the scores in on save — reproduced in `forgive()`.
* `forgive()` differs by mode. With multiple answers, an alternative whose two
  scores are equal or non-numeric becomes 1/0 when ticked and −1/0 when not
  (D7's `multichoice_def_scoring` default). With a single answer,
  `score_if_not_chosen` is forced to 0 and a ticked alternative with no positive
  score gets 1.
* **Blanking an answer deletes it.** That is D7's only delete mechanism.
* ⚠️ **The alternatives belong to a REVISION, not to the node.** D7's
  `$is_new = $is_new || !empty($this->node->revision)` inserts a fresh set when
  a new revision is created. The D10 save reconciles against rows for the saved
  revision id, so an older revision keeps its own answers instead of being
  silently orphaned.

### Verified by round-trip, not by looking at it

`scripts/reconcile/verify_multichoice_form.php` feeds a question's stored
alternatives back through the save path and asserts nothing moved, then
exercises the paths a round-trip cannot reach:

```
question nid 258, revision 259: 4 alternatives, {"choice_boolean":"1","choice_multi":"0","choice_random":"0"}
1. round-trip unchanged .......... PASS
2. score edit kept the row id .... PASS (stored 7/0)
3. add alternative ............... PASS (4 -> 5)
4. blanking removes it ........... PASS (5 -> 4)
5. original state restored ....... PASS
```

⚠️ **The round-trip caught a real defect, and it had already done damage.** The
first version trimmed the submitted answer body. Every migrated answer ends in
`\r\n`, so an unchanged save silently rewrote all four rows of the question it
ran against — 78 bytes to 76. D7 stores exactly what the textarea submits and
only *tests* emptiness with a length check; the trim is gone, the emptiness test
uses a trimmed copy, and the four altered rows were **restored from D7 and
re-verified**.

`scripts/reconcile/verify_multichoice_data.php` now compares every migrated
answer against D7 and is part of the suite:

```
answers: D7 391, D10 391
391 answers checked, 0 difference(s) - D10 matches D7 exactly
```

Over HTTP: `/node/add/multichoice` renders two blank alternatives (D7's
default), `/node/265/edit` renders its four, both with Add choice and the
Settings section.

## B. `/sale-report` out-of-memory — fixed

Reported live:

```
/sale-report?created[min]=2026-01-01&created[max]=
Fatal error: Allowed memory size of 1073741824 bytes exhausted
in core/modules/views/src/Plugin/views/query/Sql.php on line 1652
```

**Three separate causes, all now addressed.**

**1. D10 drops a half-filled date range.** Core's
`Date::acceptExposedInput()` returns FALSE when *either* bound is empty, so the
date condition vanished and the report ran over every completed order on the
site. D7 drops a `between` filter only when **both** bounds are empty — it
applies `>= '2026-01-01' AND <= ''`, which matches nothing, so D7 answers that
URL with an empty report. **Neither is useful: D7 shows nothing, D10 crashed.**
`ce_reports_views_pre_view()` now fills the missing bound with an open sentinel,
so the report filters on the bound the admin actually gave. A deliberate
departure from D7, recorded as such.

⚠️ Recorded because it cost a cycle: the first attempt subclassed core's date
filter and rewrote the operator to `>=` inside `acceptExposedInput()`. The
operator changed and `$this->value` did not — core kept the original
`{min, max, type}` array, `opSimple()` found no `value`, and **no condition was
added at all**. Rewriting the exposed input before any handler sees it cannot be
undone by later core code.

**2. An empty form counted as a filter.** Core's `InputRequired` treats a
*present* identifier as input, so submitting every box blank skipped D7's
"Select any filter…" message and ran the view unfiltered.
`StrictInputRequired` requires a value that is neither empty nor `All` — which
is what D7's BEF does, and what stops the unfiltered query being run at all.

**3. The total strip loaded an entity per row.** The Total Amount attachment
carries pager `none` by design, and its grouping key was the order item id
through EntityField — so Views loaded an order item, order, payment, user and
node for every row in range. It now reads the same column through a plain
numeric alias (`ce_order_item_id`), which needs no entity. The id is grouped on,
never summed, and the total is unchanged: **72 rows, $4,716.00 twice** for the
standing window.

Verified:

| URL | before | after |
|---|---|---|
| `created[min]=2026-01-01&created[max]=` (reported) | fatal | 200, 200 rows |
| `created[min]=2026-01-01&created[max]=2026-02-11` (reported, with warnings) | warnings | 200, 200 rows, no warnings |
| all boxes empty | fatal | D7's "Select any filter…" message |
| `created[min]=2026-07-01&created[max]=2026-08-31` | 72 rows | 72 rows, $4,716.00 ×2 |

**Residual, stated plainly:** `created[max]=2026-01-31` with no start still
exhausts memory. That range covers **31,698 order items** — twelve years of
sales — and the total strip has no pager because the total must cover every row.
This is a genuinely unbounded report rather than a filter defect. The options
are to raise the memory limit for that endpoint or to bound the report; both are
decisions rather than fixes, so neither has been taken.

## C. `/admin/structure` missing Certificates — diagnosed, not built

D7's `/admin/structure` has thirteen children; D10 has twelve, and only one of
the differences is real functionality:

| D7 item | D10 | classification |
|---|---|---|
| **Certificates** (`admin/structure/certificates`, perm `administer certificates`) | **absent** | **not migrated — real gap** |
| Data tables, Schema | absent | obsolete D7 contrib (dev/reporting tools) |
| Features | absent | obsolete — D10 config management replaces it |
| Mini panels, Pages, Panels | absent | Panels/Page Manager deliberately not reinstalled (MODULE_MAPPING §55) |
| Blocks, Flags, Menus, Taxonomy, Content types, Views | present | match |
| — | Block types, Comment types, Contact forms, Display modes, Media types, Webforms | legitimate D10-only |

D7's Certificates section is not a single page. It is:

```
admin/structure/certificates              certificate_templates_list   (List)
admin/structure/certificates/add          -> node/add/certificate
admin/structure/certificates/templates    certificate_templates_list
admin/structure/certificates/templates/preview/%   certificate_preview
admin/structure/certificates/mapping      certificate_settings_form    (Global)
admin/structure/certificates/mapping/groups        field grouping form
admin/structure/certificates/mapping/groups/add    add field group
admin/structure/certificates/settings     certificate_admin_settings_form
admin/structure/certificates/clear        certificate_admin_clear_form
```

over 58 certificate nodes and three tables (`certificate_node`,
`certificate_node_settings`, `certificate_snapshots`). D10's `ce_certificate`
ports the *rendering* side only — `/node/{node}/certificate…` — and has no admin
section at all.

**Classification: not yet migrated.** It is a section of nine routes with three
configuration forms, not a link that can be added; building it is comparable in
size to the answer editor above. Reported here with the full inventory so it can
be scheduled rather than discovered again.

## D. Regression

* `verify_form_parity.php` — 6 of 6, 0 differences.
* `verify_admin_pages.sh` — 27 of 27 HTTP 200.
* `verify_multichoice_data.php` — 391 of 391 answers identical to D7.
* **D7 freeze: 16 of 16 business tables identical to the Round-4 baseline**;
  D7's `quiz_multichoice_answers` still holds its 391 rows.

---

# Round 10 — the Certificates admin section

Round 9 recorded this as "not yet migrated" with the route inventory. It is now
built, and the investigation changed what "it" turned out to be.

## A. What D7's section actually is

Reading the live D7 configuration rather than the module's feature set:

| D7 route | type | live data | built? |
|---|---|---|---|
| `admin/structure/certificates` | NORMAL_ITEM | 58 templates | **yes** |
| `.../templates` | DEFAULT_LOCAL_TASK "List" w −5 | same page | **yes** |
| `.../add` | LOCAL_ACTION "Create new certificate" | — | **yes** |
| `.../templates/preview/%` | callback | — | **yes** |
| `.../settings` | LOCAL_TASK "Settings" | snapshots off | **yes** |
| `.../clear` | LOCAL_TASK "Clear" w 99 | 0 snapshots | **yes** |
| `.../mapping` + `/list` | LOCAL_TASK "Mapping" / "Global" | **no rows** | no — see below |
| `.../mapping/groups` + `/add` | LOCAL_TASK "Field groups" | **never configured** | no — see below |

⚠️ **The most important part of D7's certificate configuration is not in this
section at all.** `{certificate_node}` holds **108 rows**, and every one uses the
`manual` mapper — meaning every one was set on the **course or quiz node form**,
in D7's "Certificate settings" fieldset. Round 6 saw that fieldset in the D7
course form and filed it as "a D7 section with no D10 counterpart"; it is in
fact the live mechanism that decides which certificate each course awards.
`ce_certificate` already **read** that table (`CertificateTemplateResolver`) but
nothing wrote to it, so an administrator could see the mapping and not change it.

## B. Built

**The admin section** — `CertificateAdminController`, two forms, and D7's menu
structure reproduced from `{menu_router}` including the weights:

* **List** — Title | Edit | Delete | Preview, ordered by title, with D7's link
  text (the preview link is labelled "PDF") and D7's empty message. Renders
  **58 rows**, matching D7's 58 certificate nodes.
* **Create new certificate** — D7's local *action*, redirecting to
  `node/add/certificate` with the destination back to the list.
* **Preview** — streams the template as a PDF. ⚠️ D7 previews against the
  **current user and no course**, so the course tokens render empty — that is
  what makes it a template preview rather than someone's certificate; the D10
  controller passes NULL for the node for the same reason. Verified: HTTP 200,
  `application/pdf`, real `%PDF-` bytes.
* **Settings** — the snapshots checkbox, with D7's exact title and description
  and D7's default of OFF.
* **Clear** — the snapshot-clearing table with D7's "Clear" button and its
  "No snapshots to clear." empty text. ⚠️ There are no snapshots on either side,
  because D7 runs with snapshotting off. It is built anyway because it is the
  counterpart to the Settings checkbox: the moment someone turns snapshots on,
  this is the only way to undo a frozen certificate. Shipping the switch without
  the reset would be worse than shipping neither.

**The per-node mapping** — "Certificate settings" → "Manual" on course and quiz
node forms, for the bundles D7 marks certifiable, gated on D7's own pair of
permissions (`administer certificates` OR `assign certificates`).

⚠️ **D7's two special options carry meaning and are easy to get backwards.**
D7 deletes the node's rows and re-inserts through `array_filter()`, so:

* `- no action -` is `''`, is filtered out, and therefore **removes** the mapping
* `- prevent certificate -` is `'-1'`, is truthy, and is **stored** as template −1

Storing the first or dropping the second would either leave stale mappings or
silently lose the suppression.

🛑 The save touches only the `manual` mapper, so it cannot delete a mapping type
the form does not display.

## C. Not built, with the evidence

**Mapping (Global)** and **Field groups** are driven by
`hook_certificate_map_options()`. On this site:

* `{certificate_node}` has **no `nid = 0` row** — that is where a global mapping
  is stored, so none has ever been saved here.
* the `rules` mapper needs the Rules module, which has no counterpart in D10
  (`ce_rules_behavior` ports the specific behaviours instead).
* the `profile` mapper appears only when the `certificate_field_groups` variable
  is set. D7's `{variable}` table holds exactly three certificate entries —
  `certificate_certifiable_course`, `_quiz`, `_certificate` — and that is not
  one of them, so Field groups has never been configured, and `profile2` is not
  installed either.

Building them would ship two forms that on this site can only ever be empty.
**Classification: not applicable**, with the queries above as the evidence.

> **⚠️ SUPERSEDED IN ROUND 11.** The client asked for both pages regardless, and
> building them turned up one thing this section got wrong: **Rules IS enabled
> in D7** (`SELECT status FROM system WHERE name = 'rules'` → 1), so D7 does
> render a Rules fieldset — an empty one. See Round 11 §B for the corrected
> evidence. The conclusion that neither page has any configuration to migrate
> still holds, and is now proven rather than inferred.

## D. A discrepancy checked rather than assumed

`{certificate_node_settings}` holds **64 rows in D7 and 61 in D10**. That is not
a migration loss: exactly **3 of D7's rows are orphans** whose `nid` no longer
exists in `{node}`, and D7's own admin joins `{node}` so it never showed them
either. 64 − 3 = 61. Recorded because the number looks like a defect and is not.

## E. Verification

`scripts/reconcile/verify_certificate_admin.php` — 8 checks, all passing, and it
restores what it changes:

```
certificate templates D7 = D10                       PASS  58 / 58
{certificate_node} rows D7 = D10                     PASS  108 / 108
{certificate_node} contents identical                PASS
saving the stored value changes nothing              PASS
changing the template stores it                      PASS  -> 235
"- prevent certificate -" stores -1                  PASS
"- no action -" removes the row                      PASS
original mapping restored                            PASS  template 268
```

Access: uid 1 reaches every page (the action link 302s to the node form as D7
does); a plain authenticated user gets **403**, matching D7's
`administer certificates` gate.

## F. Regression

* `verify_admin_parity.php` — 16 of 16 unchanged.
* `verify_form_parity.php` — 6 of 6, 0 differences.
* `verify_admin_pages.sh` — **30 of 30** HTTP 200 (the three new certificate
  pages added to the standing suite).
* `verify_multichoice_data.php` — 391 of 391 answers identical to D7.
* **D7 freeze: 16 of 16 business tables identical to the Round-4 baseline**;
  `{certificate_node}` still holds its 108 rows.

---

# Round 11 — Mapping (Global) and Field groups

> **Brief:** "now build the mapping and field groups pages too".
>
> Round 10 classified these two as *not applicable* and did not build them. The
> client asked for them anyway, so they are built. Doing so **found an error in
> Round 10's evidence** — corrected in §B below — and turned up a defect in the
> Round-10 node form that would have silently suppressed certificates.

## A. What these two pages actually are

D7's Mapping tab is not a settings page. It writes **`{certificate_node}` rows
with `nid = 0`**, and resolution reads

```sql
SELECT * FROM {certificate_node} WHERE nid = 0 OR nid = :nid ORDER BY nid ASC
```

so a `nid = 0` row is **the default certificate for every certifiable node**,
and any node's own row overrides it for the same mapper. `ORDER BY nid ASC` is
the entire precedence mechanism.

That makes the Mapping page and the per-node "Certificate settings" section the
same form pointed at different nids — which is literally true in D7: both
`certificate_alter_node_form()` and `certificate_settings_form()` call
`certificate_mapping_form()`. They are now one function here too
(`CertificateMappers::buildMappingForm()`), so they cannot drift apart.

Field groups is the second half. A **field group** is a named set of profile
field values that becomes an option of the `profile` mapper — "everyone whose
State is Georgia gets the Georgia certificate", set once instead of on 108
courses. Two separate stores, easy to confuse:

| D7 variable | Now | Holds | Written by |
|---|---|---|---|
| `certificate_field_groups` | `ce_certificate.settings:field_groups` | name → title. **Which groups exist.** | the "Add field group" action |
| `certificate_field_grouping` | `ce_certificate.settings:field_grouping` | name → field → accepted values. **What each group matches.** | the Field groups form |

## B. Round 10's evidence was wrong on one point — Rules

Round 10 wrote that "the `rules` mapper needs the Rules module, which has no
counterpart in D10". The second half is true; the implication that D7 therefore
shows no Rules fieldset is **not**. Measured read-only:

```
SELECT status FROM system WHERE name = 'rules';   ->  1     (ENABLED)
rules_get_components()                            ->  4 components
    19 coupon_type_discount_coupon_conditions   tag "Commerce Coupon"
    96 quiz_feedback_question                   tag "quiz"
    97 quiz_feedback_end                        tag "quiz"
    14 rules_grant_access_to_node_if_..._one     no tag
SELECT * FROM rules_tags WHERE tag LIKE '%ertificate%';  ->  0 rows
```

D7 lists only components **tagged `certificate`**, and there are none — so D7's
Rules fieldset renders with **zero options**. Rendering D7's own
`certificate_settings_form()` read-only confirms it: two fieldsets, Rules
carrying only its description, Manual carrying the select.

**And D7's "There are no mappings available for Rules." never appears.**
`certificate.admin.inc:165` sets `'#value'`, a Drupal 6 key that D7's renderer
ignores — it reads `'#markup'`. The message is dead code in D7. It is not
reproduced here, because reproducing it would *add* text D7 never shows.

**Decision: no Rules fieldset is emitted.** There is no Rules module in D10, so
the section could never gain an option — a permanently empty, unconfigurable
section is exactly the fake missing section the rules forbid. Nothing is lost:
zero options, zero `{certificate_node}` rows. This is the one visible difference
between the two Mapping pages, and it is recorded here rather than papered over.

## C. Built

**`/admin/structure/certificates/mapping`** — tab "Mapping" (D7 weight −4),
default sub-tab "Global". D7's `#tree` shape, D7's option list, D7's `Update`
button. The rendered select is byte-comparable to D7's:

```
name="certificate[map][manual][manual]"     60 options
  <option value="-1">- prevent certificate -</option>
  <option value="" selected>- no action -</option>
  <option value="235">2021-Florida 10 Hour CE Course Certificate</option>  …
```

**`/admin/structure/certificates/mapping/groups`** — sub-tab "Field groups",
plus **`/…/groups/add`** as a local action, with D7's "Title of field group",
machine-name field sourced from it, and `Create`.

🛑 **No delete page.** D7's `mapping/groups/delete/%` `hook_menu()` entry is
**commented out** (`certificate.module:113‑124`), so it never reaches
`{menu_router}` and its confirm form is unreachable. Adding one would be new
functionality, not migration.

**`CertificateMappers`** — the registry behind both, replacing D7's
`hook_certificate_map_options()` / `hook_certificate_map()`. It now also feeds
`CertificateTemplateResolver`, so **a mapper that can be configured is by
construction a mapper that can be resolved** — the `profile` mapper is not a
form that writes rows nothing reads.

The resolver now reproduces `certificate_single()` step for step, including the
pair of tests that make `- prevent certificate -` work:

```php
$keys = array_keys(array_filter($mapping[$map_type]));   // -1 is TRUTHY: kept
…
if ($template_nid > 0) { … }                             // -1 is not > 0: awards nothing
```

`array_filter()` **before** the mapper, `> 0` **after** it. Collapse them into
one test and either suppression stops working or −1 gets loaded as a node id.

## D. A defect this round found in Round 10's node form

Round 10's per-node select was built with `'#default_value' => $default` where
`$default` fell back to `''`. Correct — but the same code written the obvious
way, passing `NULL` as D7 does, is a live bug, and it is worth recording because
D7's own source invites it:

* D7 renders `(string) $element['#value'] === (string) $key`, and `(string) NULL`
  is `''` — so D7 selects **`- no action -`**.
* D10's `Select::setOptions()` skips an unset `#value` entirely, so nothing is
  selected and the browser falls back to the **first** option — which, because
  PHP casts the key `'-1'` to the integer `-1`, is **`- prevent certificate -`**.

A form that opens on "prevent" instead of "no action" suppresses a certificate
the moment anyone saves the node without touching the field. `buildMappingForm()`
now passes `''` explicitly, with the reason in the code.

Two smaller corrections to Round 10's node form, both toward D7:

* the outer "Certificate settings" wrapper is a plain **`fieldset`** — D7 sets no
  `#collapsible`, so it is always open. It had been a closed `details`, hiding a
  live setting behind a disclosure D7 does not have.
* **an unmapped node now opens on the global value.** D7 falls back to the
  `nid = 0` rows when a node has none of its own
  (`certificate.admin.inc:121‑131`). Without that fallback, saving an untouched
  course would silently override the site default with "no action".

## E. One deliberate departure, and it only ever preserves data

D7's `certificate_update_node_mappings()` deletes **every** row for the nid,
including rows belonging to a mapper the form did not render — so on a D7 site
where Rules was later disabled, saving a node destroyed its rules mappings.
`CertificateMappers::saveMapping()` scopes the delete to the mappers actually
present in the submission. For every mapper the form does render the behaviour
is byte-identical to D7. Verified: with both a `profile` and a `manual` row at
`nid = 0`, saving `manual` leaves the `profile` row standing.

## F. What these pages will show, on both sides

Both open **empty, and that is parity, not a missing migration.**

* `SELECT COUNT(*) FROM certificate_node WHERE nid = 0` → **0** in D7. No global
  mapping has ever been saved, so every select reads `- no action -` on both
  sides.
* `SELECT name FROM variable WHERE name LIKE 'certificate%'` → only the three
  `certificate_certifiable_*` keys. Neither grouping variable has ever been set,
  so D7 does not offer the `profile` mapper today either, and its Field groups
  form renders as **a bare Submit button** — confirmed by rendering D7's own
  form. D10's renders the same.

**⚠️ And a group added here will look empty, in D7 too.** D7 keeps a field only
when `field_info_field()['module'] == 'list'`:

| | fields | list fields |
|---|---|---|
| D7 `user` | 15 (text, number, addressfield, phone, taxonomy) | **0** |
| D7 `profile2` | not installed | — |
| D10 `user` | 17 configurable | **0** |
| D10 `profile` (Profile2's successor) | 1 bundle `customer`, 1 address field | **0** |

So a new group renders as a fieldset with no selects inside on both sides. That
is the site's field configuration, not a defect — and it is why no D10-only
"helpful" widget was added: inventing one would let an administrator configure a
match the resolver could never make.

## G. Verification

`scripts/reconcile/verify_certificate_mapping.php` — **43 checks, all passing**,
and it restores every row and config key it touches:

```
D7 special options come first, in D7's order               PASS  [-1,""]
template option count D7 = D10                             PASS  58 / 58
template options identical, in order                       PASS
mappers offered with no field group = [manual]             PASS  manual
Manual fieldset is collapsed, as D7 (#collapsed => TRUE)   PASS
with no global row the default is "- no action -"          PASS  ''
submit button reads D7's "Update"                          PASS
saving a template writes exactly one nid = 0 row           PASS
the global row becomes the default for an unmapped node    PASS  {"235":"Manual"}
an unmapped node form opens on the global value            PASS  '235'
"- prevent certificate -" stores template -1               PASS
a -1 global mapping awards no certificate                  PASS  []
"- no action -" removes the nid = 0 row                    PASS
field groups page renders with only a Submit button        PASS
a field group makes the `profile` mapper appear            PASS  profile,manual
the Profiles fieldset appears on the GLOBAL mapping page   PASS
the Profiles fieldset appears on the NODE form too         PASS
the fieldset is empty on BOTH sides (no list fields)       PASS  D7 list fields: 0
saving `manual` leaves the `profile` row alone             PASS
the live per-node rows are untouched                       PASS  108 rows
per-node row count still matches D7                        PASS  108 / 108
global row count matches D7                                PASS  D7 0 / D10 0
```

Rendered checks, as user 1: primary tabs `List · Mapping · Settings · Clear`,
secondary tabs `Global · Field groups`, the `Add field group` action link
present. Anonymous and a plain authenticated user get **403** on all three
routes.

**One test bug worth recording**, because it is the same trap as the form
default: `array_keys($specials) === ['-1', '']` fails — PHP casts the key
`'-1'` to the **integer** −1. That cast is exactly why `- prevent certificate -`
renders first, so the assertion now checks `[-1, '']`.

## H. Regression

* `verify_certificate_admin.php` — 9 of 9 (Round 10's 8 plus a new check that
  asserts the mapping element exists in the **real** built node form, so the
  script cannot keep passing against a path the form no longer has).
* `verify_form_parity.php` — 6 of 6, 0 differences.
* `verify_admin_pages.sh` — **33 of 33** HTTP 200 (the three new mapping pages
  added to the standing suite).
* `verify_multichoice_data.php` — 391 of 391 answers identical to D7.
* **D7 unchanged: 24 of 24 measured tables identical to the baseline**, including
  `{certificate_node}` at 108 rows and `{variable}` still holding exactly the
  three `certificate%` keys — proof that reading D7's forms created nothing.

---

# Round 12 — Audit of every remaining admin page

> **Brief:** "now audit the remaining admin pages against d7".
>
> Rounds 1–11 audited the pages someone thought to look at. This one walks D7's
> `{menu_router}` instead, so a page nobody remembered still gets counted. That
> is how `/admin/config/ce-referral` was found.

## A. Method, and why it is not a page-by-page walk

`scripts/reconcile/audit_admin_routes.php` — re-runnable, read-only against both
sides. It takes **every** `admin%` row in D7's `{menu_router}` and matches it
against D10's **route patterns**.

⚠️ **Matching on the pattern, not by loading the page, is the whole trick.**
D10 parameterises segments D7 spelled out — `{node_type}`, `{theme}`, `{flag}` —
so a literal string comparison reports hundreds of false gaps. Nor can the real
router be asked to match: `/admin/appearance/settings/bartik` fails to upcast
because that theme is gone, which says nothing about whether the *page* exists.
Both mistakes were made and corrected before the numbers below.

```
D7 {menu_router} admin entries      992
  wildcard (argument) paths skipped 379   (%node, %commerce_order … are arguments)
  admin/help/* skipped               81   (D10 ships its own per-module help)
  checked                           532
    matched by a D10 route          145   (exact 114, via parameter 31)
    no D10 route                    387
```

Every one of those 387 is now classified by an explicit rule in the script, and
it reports **"Every D7 admin path without a D10 route is accounted for."** The
large buckets: 76 D7 default local tasks (D10 renders the tab on the parent
route, with no path of its own), 105 Commerce 1 → Commerce 3 restructure, and
the rest obsolete or absent contrib.

## B. Five findings that need a decision

### 1. 🛑 `ce_referral` — two admin pages missing, and they move money

| D7 | What it does | D10 |
|---|---|---|
| `/admin/config/ce-referral` | per-state referral discount amounts + cookie lifetime | **no route** |
| `/admin/config/ce-referral/admin-credit-debit_referral` | credit or debit a user's referral balance | **no route** |

**The data migrated correctly and completely** — `ce_referral.settings` holds
D7's amounts value for value, including the one that is not uniform:

```
discount_referral_purchase: {6:10, 7:10, 8:15, 9:10, 26:10, 56:10, 62:10, 67:10, 121:10}
                                          ↑ South Carolina pays 15, every other state 10
{ce_referral_discount} rows       D7 361  =  D10 361
{ce_referral_admin_discount} rows D7  24  =  D10  24
```

So this is a **UI gap, not a data gap** — and the credit/debit form is not a
dormant feature: those 24 rows are 24 times an administrator actually used it.
Today the amounts can only be changed by editing config directly, and a user's
referral balance cannot be adjusted at all.

⚠️ **One orphan carried across.** `…_signup_62` and `…_referral_purchase_62`
point at taxonomy term **62, which does not exist in D7** (`SELECT tid FROM
taxonomy_term_data WHERE tid = 62` → no row). D7's form iterates the *state*
vocabulary, so D7 never showed these either. Same class as the certificate
orphans; recorded, not silently dropped.

### 2. `pathauto` is not enabled — new content gets no URL alias

D7 has **9 configured alias patterns**; D10 has the pathauto module downloaded
(`composer.json`: `drupal/pathauto ^1.15`) but **not enabled**, so
`pathauto_pattern` is not even an entity type.

```
pathauto_node_course_pattern    courses/[node:title]
pathauto_node_blog_pattern      blog/[node:title]
pathauto_node_video_pattern     videos/[node:title]
pathauto_node_pattern           content/[node:title]
pathauto_user_pattern           users/[user:name]
pathauto_taxonomy_term_pattern  [term:vocabulary]/[term:name]      (+3 more)
```

**The existing aliases are fine** — `{path_alias}` 13,977 against D7's
`{url_alias}` 13,971. The gap is forward-looking: create a course in D10 today
and it gets **no `/courses/<title>` alias**, where D7 generated one. Nothing is
broken now; it degrades from the first new node.

### 3. `commerce_abandoned_carts` — downloaded, not enabled, 12 live settings

D7 has it enabled with **3,915 rows** and real configuration — not defaults:

```
subject       'Did you have trouble?'          from_name  'CE Training Courses, Inc.'
from_email    jayson@ceonlinetraining.com      bcc_email  info@ceonlinetraining.com
timeout 10    batch_limit 10   history_limit 21600   testmode_active 0
```

A customer-facing reminder email that is switched off in D10. Enabling it is a
business decision — it would start sending mail — which is exactly why it is
reported rather than switched on.

### 4. `ce_wistia` settings — no admin page

`/admin/config/services/wistia` and `/admin/config/services/wistia_integration`
have no D10 counterpart. **The values did migrate**, exactly:

| D7 variable | value | `ce_wistia.settings` |
|---|---|---|
| `wistia_tracking_interval` | 5 | `tracking_interval: 5` |
| `wistia_query_interval` | 86400 | `query_interval: 86400` |
| `wistia_tail_length` | 2 | `tail_length: 2` |

Same shape as ce_referral, much lower stakes: three integers that govern video
progress tracking, changeable only by editing config.

### 5. Quiz settings — 16 live variables with no config object and no page

D7 holds 16 `quiz*` variables — `quiz_default_close` 30, `quiz_autotitle_length`
50, `quiz_max_result_options` 5, `quiz_use_passfail` 1,
`quiz_remove_invalid_quiz_record` 86400, the pager settings, the admin review
options. **`ce_quiz.settings` does not exist**, and `/admin/quiz/settings` has no
route; `ce_quiz` carries the D7 values as constants in code (e.g.
`QuizTakeController` names `quiz_pager_start` (100) in a comment).

That is a defensible choice for a frozen migration, but it should be a *chosen*
one: quiz behaviour now changes only by changing code. Flagged in Round 6 as a
business decision and re-confirmed here with the variable list attached.

### 6. The standing "downloaded but disabled" list, now quantified

**20 contrib modules are enabled in D7, present in `web/modules/contrib`, and
not enabled in D10.** Ordered by how much D7 configuration each carries:

| module | D7 variables | D7 admin paths |
|---|---|---|
| custom_breadcrumbs | 35 | 9 |
| **pathauto** | **18** | **4** |
| **commerce_abandoned_carts** | **12** | **1** |
| diff | 10 | 7 |
| mimemail | 10 | 1 |
| devel · mailsystem · masquerade | 3 each | 0–1 |
| reroute_email | 2 | 3 |
| ctools · module_filter | 1 each | 3–82 |
| commerce_tax, flood_control, stringoverrides, video_filter, views_aggregator, views_autocomplete_filters, views_bulk_operations, views_data_export, views_merge_rows | 0 | 0–2 |

The bottom nine carry no configuration and are safe to leave off. The top three
are the ones above. `reroute_email` should **stay off** in production — enabling
it diverts all outbound mail.

## C. Six things that looked like gaps and are not

Each was checked rather than assumed, and each check changed the answer.

**CAPTCHA — parity is correct.** The first pass reported "no captcha in D10" on
a guessed path; the module is installed, and the migration is right where it
counts. D7 has 15 captcha points but **13 of them have `module` and
`captcha_type` both NULL — no challenge at all**. Only two are live, both
MailChimp signup blocks, and both arrive in D10 as `captcha/Math`, `status = 1`,
matching D7's `captcha_default_challenge`. Login, registration and password reset
are unprotected on **both** sides; that is D7's configuration, not a regression.

**MailChimp — 2 of 2 signup forms migrated**, ids and titles identical
(`newsletter` "Join Our Newsletter!", `3_tips_to_increase_client_retent`).
⚠️ Worth a separate look: D7's `newsletter` **block is placed and enabled** in
both `cetc` (footer_bottom) and `cetc_new` (content). Whether that block is
placed in D10 is a front-end question, outside this audit's scope, but it is the
live half of a feature whose admin half is present.

**Actions — nothing to migrate.** D7's `{actions}` has 25 rows and
`WHERE parameters <> ''` returns **0**: every one is a default action registered
by its module, not a configured advanced action. D10's core `action` module is
not enabled, and there is no configuration it would carry.

**IP blocking — nothing to migrate.** `{blocked_ips}` is **0 rows**.

**Mail Logger — not a gap.** `admin/reports/mail-logger` has no D10 counterpart
because the module is **disabled in D7** (`system.status = 0`).

**Real name — migrated, with the token syntax correctly adapted.**
D7 `[user:field-first-name] [user:field-last-name]` →
D10 `[user:field_first_name] [user:field_last_name]`. Hyphens to underscores is
the D7→D10 token change; the page exists at `/admin/config/people/realname`.

**`/admin/structure` — no D7 item missing.** D7 lists 14, D10 lists 13. Every
D7 entry absent from D10 is classified obsolete contrib (Data tables, Features,
Panels, Mini panels, Pages, Schema, Custom breadcrumbs), and D10 adds six of its
own. Certificates is present — Round 9's reported gap stays closed.

## D. Pages confirmed working

Loaded as user 1, all HTTP 200: the 11 Commerce configuration collections
(product types, variation types, order types, order item types, checkout flows,
currencies, payment gateways, stores, number patterns, product attributes),
Commerce promotions/orders/products, the 12 `/admin/structure` sections, 15
`/admin/config` pages, and `/admin/reports/{status,dblog,fields,views-plugins}`,
`/admin/quiz`, `/admin/quiz/reports`.

One 403 checked and cleared: `/admin/config/services/google-tag/containers`
denies access **by design** — `google_tag.settings` has `use_collection: false`,
so the module routes to the single-container form, which returns 200.

## E. Regression

`verify_admin_pages.sh` — **33 of 33** HTTP 200, unchanged.
No code was changed in this round; the only new file is
`scripts/reconcile/audit_admin_routes.php`.

---

# Round 13 — the two `ce_referral` admin pages

> **Brief:** build `/admin/config/ce-referral` and
> `/admin/config/ce-referral/admin-credit-debit_referral` — the gap Round 12
> found — reproducing D7's implementation rather than the report's summary.
>
> Doing so uncovered a **migration data defect that every previous check had
> passed**: 48 values missing from the credit/debit audit trail. It is fixed.

## A. One correction to the brief's framing, before anything else

The brief describes "361 rows" as per-state discount records and "24 rows" as
credit/debit history. The second is right; the first is not, and the difference
matters because the two live in different places and are edited by different
pages.

| | what it is | where it lives | edited by |
|---|---|---|---|
| **18 values** (+1 orphan pair) | the per-state amounts | `ce_referral.settings` **config** | `/admin/config/ce-referral` |
| **361 rows** | the referral **ledger** — who referred whom, and every balance | `{ce_referral_discount}` | checkout, and the credit/debit form |
| **24 rows** | the **audit trail** of administrator adjustments | `{ce_referral_admin_discount}` | the credit/debit form |

The settings page reads and writes **only the config**. Wiring it to the 361-row
ledger would let a settings screen damage live balances. All three are verified
below, separately.

## B. What D7 actually is

Read from the live D7 code and database, not the feature exports:

```
ce_referral_menu()                       ce_referral.module:206
  admin/config/ce-referral               drupal_get_form('ce_referral_config_form')
                                         access: REFERRAL_PERM_ADMIN = 'administer referral'
  admin/config/ce-referral/admin-credit-debit_referral
                                         drupal_get_form('ce_referral_credit_debit_form')
                                         access: 'administer users'
{menu_links}  mlid 3456 (plid 8 = admin/config) -> mlid 3459
{menu_router} description on the parent: 'Configure referral discount amount.'
```

**🛑 The two pages carry different permissions, and that is D7's, not a slip
here.** D7's own comment on that line reads `// Adjust permissions as needed`.
Live consequence, confirmed on both sides: **CE Admin can adjust a user's
balance but cannot open the discount settings.** Reproduced verbatim — changing
it would either lock out an account that can adjust balances today or hand
balance adjustment to one that cannot. **Classification: business decision.**

## C. Built

**`/admin/config/ce-referral`** — `ReferralSettingsForm`. D7 builds this with
`system_settings_form()`, so there is no table, no column headings and no row
ordering to reproduce: one textfield per value, in one column. 18 elements, in
D7's order, with D7's labels and defaults:

```
cookie_lifetime                          1          (not required, as D7)
Georgia          signup 10  referrer 10  (required)
Illinois         signup 10  referrer 10
North Carolina   signup 10  referrer 10
Ohio             signup 10  referrer 10
South Carolina   signup 10  referrer 15   ⭐
Texas            signup 10  referrer 10
Washington DC    signup 10  referrer 10
Other            signup 10  referrer 10
                                          button: "Save configuration"
```

⚠️ **Order is weight then name, not alphabetical** — D7 calls
`taxonomy_get_tree()`, and the vocabulary is weighted, so **"Other" is last by
weight 7**, not filed under O. `loadByProperties()` returns terms in no
meaningful order, so the sort is required, not decorative.

🛑 **`referral_goto_path` is declared and hidden**, exactly as D7 (`#access =>
FALSE`). A hidden element still carries its default into the submitted values,
which is how `system_settings_form()` preserves the variable. Drop the element
and a save would be free to lose `goto_path`.

Four fields D7 has **commented out** (`ce_referral_discount_signup`,
`ce_referral_discount_referral_purchase`, `ce_discount_expiry_days`) are not
reproduced — D7 does not show them and the variables are not set. Adding them
would be inventing configuration.

**`/admin/config/ce-referral/admin-credit-debit_referral`** —
`ReferralCreditDebitForm`, D7's four inputs in D7's order with D7's exact
strings, and its `Submit` button. Rendered side by side, D7 and D10 agree
field for field, including the `- Select -` empty option and the HTML5
`required` attributes.

⚠️ **D7 has no reason field, no existing-balance display, no referral picker and
no confirmation step.** None is added. Their absence is worth stating: **an
administrator adjusting a balance here cannot see what the balance is.**
**Classification: business decision** (adding any would be a new workflow).

**`ReferralAdminLedger`** — D7's `update_latest_referral_entry()`, both branches.
**`ReferralUserAutocompleteController`** — D7's `user/autocomplete`, which the
Username field pointed at and which has no D10 counterpart (`entity_autocomplete`
rewrites the value to `name (uid)` and rejects unknown input during validation;
D7 does neither). Same query D7 used — `LIKE 'string%'`, 10 rows — and guarded by
the same permission as the form that calls it.

## D. 🛑 A preserved D7 defect: a debit can *credit*

When a user has no ledger row, D7's else-branch inserts one with
`referrer_remaining_balance = $amount` — **the raw amount, regardless of whether
the operation was a credit or a debit.** Debiting $12 from someone who has never
referred anyone leaves them holding a **$12 balance**. Verified live:

```
🛑 PRESERVED D7 DEFECT: a DEBIT on a user with no row CREDITS them 12.00   PASS
```

**It is reproduced, not fixed.** Silently changing it would change a money
outcome with nobody deciding to. The fix is one line — clamp that branch to 0 for
a debit. **Classification: business decision**, flagged here for it.

Two more D7 behaviours that look like mistakes and are load-bearing, so they are
kept: the new row's `uid` is the **administrator's**, not the target's (swapping
them would make the admin appear in the target's referral list, which
`ReferralLedger::referralsForQuery()` builds by joining on `uid`); and a debit on
the branch that *has* a row clamps at zero rather than going negative.

## E. 🛑 A migration data defect found here, and repaired

`verify_referral_admin.php` compares `{ce_referral_admin_discount}` **column for
column**, not by row count. That is what caught it:

```
migrations/ce_referral_admin_discount.yml — process block mapped 4 of 6 columns
  referrer_discount_amount   NOT MAPPED  ->  0.00 on all 24 rows
  created                    NOT MAPPED  ->  0    on all 24 rows
```

⚠️ **Nothing failed and no count was wrong.** All 24 rows arrived, so every
row-count check in every previous round passed. The audit trail recorded *that*
an administrator adjusted a balance and what the balance had been — but **not how
much, and not when**. The real values range from $5 to $100, with timestamps from
July 2025 to May 2026.

Two things were done, and both are needed:

1. **The migration YAML now maps all six columns**, with a comment saying why the
   omission was invisible, so a future run is correct.
2. **`scripts/reconcile/repair_referral_audit_columns.php`** repairs the 24 rows
   in place from D7. Re-running the migration was refused —
   `ce_referral_admin_discount` depends on `ce_user`, which has 2 deliberately
   unprocessed rows, and Migrate treats an incomplete dependency as an unmet
   requirement; re-running `ce_user` to satisfy a check would touch 13,250 user
   rows to fix 48 values elsewhere.

🛑 The repair writes **only those two columns, only where they differ, and only
where the D10 value is still the untouched default** — so it can never overwrite
an adjustment made through the new admin UI. It is idempotent: the second run
reports `repaired 0   already correct 24`.

## F. Two traps in the settings form

**The orphan must survive a save.** `ce_referral_discount_signup_62` and
`…_purchase_62` point at taxonomy term **62, which does not exist in D7 either**.
D7 saves one variable per element, so a key whose term is gone is simply never
written and survives. D10 stores the same values as one map per prefix — so
**assigning the submitted map would delete tid 62.** The submit handler merges
instead. Verified: `⭐ the orphan tid 62 SURVIVED the save (merge, not replace)`.

**One deliberate departure, and it is a bug fix.** D7's email validation calls
`form_error($element, t('Enter Correct Email'))` where `$element` does not exist
in that scope (`ce_referral.admin.inc:174`). PHP passes NULL, so D7 raises the
message as a **form-level** error and highlights no field. The message is
reproduced exactly; it is attached to `user_email`, the field it is about.
**Classification: legitimate D10 difference** (a strict-types environment cannot
reproduce an undefined-variable notice, and the message is unchanged).

## G. Verification

`scripts/reconcile/verify_referral_admin.php` — **51 checks, all passing.** It
snapshots both money tables and the config, and the final checks re-read them to
prove the restore worked.

```
--- data parity --------------------------------------------------------
signup amounts D7 = D10                                    PASS  9 / 9 keys
referrer amounts D7 = D10                                  PASS  9 / 9 keys
South Carolina referrer amount is 15, not 10               PASS  D7 '15' / D10 '15'
every other state referrer amount is 10                    PASS  10
{ce_referral_discount} rows D7 = D10                       PASS  361 / 361
{ce_referral_admin_discount} rows D7 = D10                 PASS  24 / 24
audit trail identical, column for column                   PASS
ledger money columns identical, all rows                   PASS  361 rows
--- settings form parity -----------------------------------------------
elements, and their ORDER, match D7 (weight then name)     PASS  18 elements
"Other" is last, by weight 7 - not sorted under O          PASS
referral_goto_path is present but #access FALSE, as D7     PASS
every label / required flag / default matches D7           PASS
--- settings round trip ------------------------------------------------
changing South Carolina persists                           PASS  '17.50'
⭐ the orphan tid 62 SURVIVED the save (merge, not replace) PASS  still '10'
South Carolina restored to 15                              PASS
negative and non-numeric rejected with D7's messages       PASS
--- credit / debit -----------------------------------------------------
credit adds to the balance                                 PASS  15 -> 20.00
credit updates the SAME row, not a new one                 PASS
⭐ the audit row records the balance BEFORE the change     PASS  15.00
an over-large debit clamps at zero, never negative         PASS  0.00
🛑 PRESERVED D7 DEFECT: a DEBIT with no row CREDITS 12.00  PASS
the new row's uid is the ADMIN, referrer_uid the target    PASS
all three validation messages match D7                     PASS
an unknown user writes NOTHING                             PASS
--- restore ------------------------------------------------------------
{ce_referral_discount} restored, row for row               PASS  361 rows
{ce_referral_admin_discount} restored, row for row         PASS  24 rows
row counts match D7 again                                  PASS  361 / 24
```

**One test bug worth recording**, because it produced five false failures:
`FormState::setErrorByName()` throws once validation is marked complete, and
`buildForm()` on a submitted state completes it — so calling `validateForm()` by
hand silently reports "no error" for a form that rejects the input perfectly
well. The harness now reads errors from a full `submitForm()` cycle. **The forms
were right; the test was wrong.**

### Access, per role — identical to D7

| role | D7 `administer referral` | D7 `administer users` | `/admin/config/ce-referral` | credit/debit |
|---|---|---|---|---|
| administrator | ✅ | ✅ | 200 | 200 |
| CE Admin (uid 22, role-isolated) | — | ✅ | **403** | **allow** |
| Instructor | — | — | 403 | 403 |
| authenticated, no role (uid 6) | — | — | 403 | 403 |
| anonymous | — | — | 403 | 403 |

The autocomplete endpoint carries the credit/debit permission, so it cannot
enumerate usernames for anyone who cannot open the page it belongs to.

### Navigation

Both appear in the admin menu with D7's hierarchy and D7's description:

```
CE Referral Discount Configuration  -> /admin/config/ce-referral
                                       desc="Configure referral discount amount."
  Credit/Debit User Referral Account -> /admin/config/ce-referral/admin-credit-debit_referral
```

matching D7 `{menu_links}` mlid 3456 (plid 8 = `admin/config`) → mlid 3459.
Breadcrumb: Home › Administration › Configuration › CE Referral Discount
Configuration. On `/admin/config` itself, D10 renders the parent as a **panel
heading** with the child beneath — the same shape D7's
`system_admin_config_page()` produces for any item with children.
**Classification: legitimate D10 difference** (identical structure, D10's theme).

## H. Remaining differences, every one classified

| difference | classification |
|---|---|
| Different permissions on parent and child (`administer referral` vs `administer users`) | **business decision** — D7's, reproduced; CE Admin can adjust balances but not see settings |
| A debit on a user with no ledger row credits them | **business decision** — preserved D7 defect; one-line fix, needs sign-off |
| No reason field, no balance display, no confirmation step | **business decision** — D7 has none; adding any is a new workflow |
| Email-validation error now bound to `user_email` instead of the form | **legitimate D10 difference** — D7's message reproduced; D7's undefined-variable notice is not |
| `user/autocomplete` replaced by a module-owned route | **legitimate D10 difference** — same query, same suggestions, permission-guarded |
| `host` / `http_referer` truncated to 255 chars on insert | **legitimate D10 difference** — D7 relied on non-strict MySQL truncating; D10 is strict and would otherwise throw away the whole adjustment |
| `/admin/config` renders the parent as a panel heading | **legitimate D10 difference** — same structure as D7 |
| Four commented-out D7 fields absent | **N/A** — not shown in D7, variables never set |
| tid 62 amounts kept with no matching term | **N/A** — orphan in D7 too; preserved, never rendered |
| Floating-point arithmetic on balances | **technical blocker** — D7's own arithmetic; changing to decimal maths would change results and needs the same sign-off as the defect above |

## I. Regression

* `verify_referral_admin.php` — **51 of 51**.
* `verify_admin_parity.php` — **16 of 16 views unchanged** (the suite the brief
  names): report panes, sale report, evaluation results, report history, coupon
  usage, quiz queue, flag manager, the three marketing reports and the two
  `/report` windows all return the same totals as before this round.
* `verify_certificate_admin.php` — 9 of 9 · `verify_certificate_mapping.php` — 43 of 43.
* `verify_form_parity.php` — 6 of 6, 0 differences.
* `verify_admin_pages.sh` — **35 of 35** HTTP 200 (the two new pages added).
* `verify_multichoice_data.php` — 391 of 391.
* **D7 unchanged**: 8 baseline business tables plus `{ce_referral_discount}` 361
  and `{ce_referral_admin_discount}` 24, all identical to baseline.
  ⚠️ A "drift" in the referral variable count was traced to the **check**, not to
  D7: an earlier `LIKE '%referral%' OR '%ce_discount%'` count of 22 included
  `commer**ce_discount**_line_item_types`. The correct `%referral%` count is 21,
  and it has not moved.

---

# Round 14 — pathauto enabled and configured

> **Brief:** enable pathauto and configure the patterns — the second finding
> from Round 12's audit.
>
> Three D7→D10 differences in this module would each have silently rewritten
> live URLs. One of them would have moved **13,247 of them**.

## A. What D7 has

18 `pathauto_*` variables, and **nine of them are empty strings**. That is not
"disabled": D7's `pathauto_pattern_load_by_entity()` walks
`pathauto_{entity}_{bundle}_pattern` → `pathauto_{entity}_pattern` and **breaks
on the first non-empty one**, so an empty bundle pattern *inherits* the
entity-type default. The live aliases prove it — article 5, certificate 58,
multichoice 128, quiz 59, page 5, webform 1 all sit under `content/…`, and the
five vocabularies with empty patterns all sit under `[vocabulary]/…`.

D10 has no inheritance, so the 18 variables collapse to **six patterns**:

| D10 pattern | from | weight |
|---|---|---|
| `/courses/[node:title]` | `pathauto_node_course_pattern` | −5 |
| `/videos/[node:title]` | `pathauto_node_video_pattern` | −5 |
| `/blog/[node:title]` | `pathauto_node_blog_pattern` | −5 |
| `/content/[node:title]` | `pathauto_node_pattern` | 0 |
| `/[term:vocabulary]/[term:name]` | `pathauto_taxonomy_term_pattern` | 0 |
| `/users/[user:display-name]` | `pathauto_user_pattern` | 0 |

⚠️ The weights are load-bearing: `getPatternByEntity()` takes the **first**
pattern whose criteria pass, so each bundle override must sort before the
catch-all.

🛑 **Two D7 variables are deliberately not reproduced.** `pathauto_blog_pattern`
(`blogs/[user:name]`) and `pathauto_forum_pattern` belong to the **blog** and
**forum** modules, both `status = 0` in D7. Neither generates anything there.
Note the first is *not* the blog content type — that is
`pathauto_node_blog_pattern`, which **is** reproduced.

## B. 🛑 Three differences that would each have broken live URLs

### 1. `[user:name]` means something different in D10 — 13,247 URLs

D7's `[user:name]` runs through `format_username()`, which the **realname**
module alters, so it resolves to the **real name**: `/users/melissa-snipes`.
Drupal 8 split that token in two. D10's `[user:name]` is now an alias of
`[user:account-name]` and returns the **login name**:

```
user 8082   getAccountName()   cosmogirl089_8082
            getDisplayName()   Melissa Snipes        <- what D7's token gave
            [user:name]        cosmogirl089_8082     <- D10
            [user:display-name] Melissa Snipes
            existing alias     /users/melissa-snipes
```

Copying D7's pattern string verbatim is exactly the wrong move. Measured with
the literal `[user:name]`: **13,247 of 13,251 user aliases would have been
regenerated**, each replacing a person's name with their login. With
`[user:display-name]` the count drops to **8** — and those 8 are a different
issue (§C). Copying D7's *behaviour* means display-name.

### 2. The leading slash costs a whole word — `max_length` is 101, not 100

D7 stores aliases without a leading slash and truncates `content/…` at 100.
D10 stores them with one and truncates `/content/…` at 100. Because the
truncation is **word-safe**, that single character does not shorten the alias by
one — it moves the cut past a word boundary and drops an entire word:

```
D7   /content/repeated-tardiness-…-and-increased-absenteeism   (100 + slash)
D10  /content/repeated-tardiness-…-and-increased               (89)   ← at 100
D10  /content/repeated-tardiness-…-and-increased-absenteeism   (101)  ← at 101
```

Three real nodes were affected. The cleaned token strings are byte-identical to
D7's — only the truncation differed. `max_length` is set to **D7's 100 plus one
for the slash**, and all three then regenerate byte-identically.

### 3. Enabling pathauto makes every entity "automatic" by default

```php
// PathautoState::getValue()
if ($this->value === NULL) {                      // nothing stored
  $pattern = …->getPatternByEntity($entity);
  $this->value = !empty($pattern) ? static::CREATE : static::SKIP;
}
```

So the moment patterns exist, **every entity with no stored state counts as
"generate automatically"** — and with `update_action = 2` (D7's value: delete
the old alias, create the new one) the next save of such an entity *replaces*
its URL. For an entity whose alias already matches, that is a no-op. For one
whose alias does not, it is a broken URL, silently, one save at a time.

`scripts/reconcile/set_pathauto_state.php` therefore compares every entity's
live alias against what the pattern would generate and stores **SKIP** wherever
they differ. That is also D7's rule: its per-entity checkbox defaults to checked
only when the existing alias is what the pattern would produce.

## C. The 14 entities that are now protected

```
node            reproduced exactly 615     MARKED SKIP 5
taxonomy_term   reproduced exactly  99     MARKED SKIP 1
user            reproduced exactly 13241   MARKED SKIP 8
```

| entity | keeps | the pattern would give |
|---|---|---|
| node 35 "Help" | `/faq` | `/content/help` |
| node 36 "About" | `/about` | `/content/about` |
| taxonomy term 53 | `/profession-type/instructor-esthetician-0` | `/profession-type/instructor-skin` |
| nodes 102, 138, 278 | `…-0` | the un-suffixed base |
| 8 users | `…-0` / `-1` / `-2` | a different dedup index |

The first three are **D7's own manual entries** — its `{pathauto_state}` records
exactly those three with `pathauto = 0`, and the comparison rediscovered all
three independently. That is the cross-check: had the measurement missed one,
the script fails loudly rather than leaving a URL unprotected. The other eleven
are deduplication suffixes whose base is now free, so a regeneration would drop
the suffix and move the URL.

🛑 **D7's 66 `quiz` rows in `{pathauto_state}` are NOT nodes and were not
mapped.** D7's quiz module declares an entity `quiz` whose base table is
`quiz_node_properties` and whose key is `qnp_id` — quiz *settings* records, a
separate id space with no alias of its own. Only 31 of the 66 ids collide with a
node id, and those nodes are articles, blogs, videos and pages. Mapping them
would have stamped pathauto state onto 31 unrelated pieces of content.

## D. Verification

`scripts/reconcile/verify_pathauto.php` — **27 checks, all passing.** Every
setting is compared against D7's live variable (or D7's own code default where
the variable is unset), every pattern against its D7 variable, and then:

```
--- re-saving entities must not move a single URL --------------------
  aliases before: 13977
  aliases after:  13977
alias COUNT unchanged after the saves                        PASS
every alias byte-identical after the saves                   PASS  13977 aliases
/node/35             still /faq                              PASS
/node/36             still /about                            PASS
/taxonomy/term/53    still /profession-type/instructor-…-0    PASS
/user/3209           still /users/rachel-mcgowan-0           PASS
--- a new node gets D7's pattern -------------------------------------
a new course   is aliased /courses/zzz-pathauto-probe-course  PASS
a new video    is aliased /videos/zzz-pathauto-probe-video    PASS
a new blog     is aliased /blog/zzz-pathauto-probe-blog       PASS
a new article  is aliased /content/zzz-pathauto-probe-article PASS
every probe node and its alias are gone                       PASS
alias count back to where it started                          PASS  13977 / 13977
```

The saves are deliberate re-saves with no field changes — a save is precisely
what triggers pathauto, so that is the test. Four scratch nodes are created,
checked and deleted; the closing alias count proves none survived.

### Every alias accounted for against D7

```
D7 {url_alias} 13971    D10 {path_alias} 13977
same path, DIFFERENT alias: 0
```

The +6 resolves exactly: **+8** are the D10 Webform module's own aliases
(`/form/contact`, `/form/course-evaluation` and their confirmation, submissions
and drafts sub-paths) — created by webform, not pathauto, with no D7 counterpart
since D7's webforms were nodes. **−2** are users 22216 and 22217, the two rows
absent from `migrate_map_ce_user` (13,250 of 13,252) and created **after the
freeze epoch** (1788863968 and 1788866703 vs 1787558508). 8 − 2 = 6.

## E. ⚠️ One warning for whoever runs this site

`/admin/config/search/path/update_bulk` offers **"Regenerate URL aliases for all
paths"**. The 14 SKIP records protect those entities from *entity saves*, and
bulk generate's default ("un-aliased paths only") is harmless — but the
regenerate-all option is explicitly designed to overwrite. Running it would
rewrite the 14 URLs above. Not a defect; a loaded gun on an admin page, recorded
so nobody fires it by accident.

## F. Remaining differences, classified

| difference | classification |
|---|---|
| `users/[user:name]` → `users/[user:display-name]` | **legitimate D10 difference** — D8 split the token; display-name reproduces D7's output exactly |
| `max_length` 100 → 101 | **legitimate D10 difference** — D10 counts the leading slash D7 did not store |
| No `blogs/[user:name]` or forum pattern | **obsolete** — both D7 modules are `status = 0` |
| Nine empty D7 bundle patterns not created | **N/A** — D7 inherits the default; one D10 catch-all reproduces all nine |
| 66 `quiz` pathauto_state rows ignored | **N/A** — `quiz_node_properties.qnp_id`, a different id space, never aliased |
| 11 dedup-suffix entities pinned as manual | **legitimate D10 difference** — their base alias is now free, so regeneration would move a live URL |
| Bulk "regenerate all" can still overwrite | **business decision** — the admin control exists in D7 too |

## G. Regression

* `verify_pathauto.php` — **27 of 27**.
* `verify_referral_admin.php` — 51 of 51 · `verify_form_parity.php` — 6 of 6.
* `verify_admin_pages.sh` — **37 of 37** HTTP 200 (the two pathauto admin pages
  added to the standing suite).
* D7 untouched — every query in this round was a `SELECT`.

---

# Round 15 — commerce_abandoned_carts enabled and configured

> **Brief:** enable `commerce_abandoned_carts` and configure the settings — the
> third finding from Round 12's audit.
>
> 🛑 **This module emails customers.** D7 runs it live and is still running it:
> the most recent row in its history table is dated **2026-09-09**. The order of
> work below was chosen so that nothing could be mailed before the thing that
> prevents duplicate mail was in place.

## A. What D7 is actually doing

`commerce_abandoned_carts_cron()` selects carts where:

```
o.mail <> ''                                   … a contactable address
LEFT JOIN commerce_abandoned_carts … a.status IS NULL   … not already mailed
o.created  <  now − timeout        (10 min)    … the cart has gone quiet
o.created >=  now − history_limit  (15 days)   … but is not ancient
o.status IN (pending, cart, checkout_checkout, checkout_review, checkout_payment)
… and has at least one line item
```

then sends **10 per cron run** (`batch_limit`), from
`jayson@ceonlinetraining.com` as "CE Training Courses, Inc.", subject
**"Did you have trouble?"**, with test mode **off** — i.e. to the real customer —
and writes a row into `{commerce_abandoned_carts}` so that cart is never mailed
again.

3,915 such rows exist, from **2020-12-08 to 2026-09-09**.

## B. 🛑 The history table is the only thing preventing duplicate mail

Both modules' cron carries the same clause:

```php
$select->leftJoin('commerce_abandoned_carts', 'a', 'o.order_id = a.order_id');
…->isNull('a.status')
```

A row there is the *only* record that a customer has already been contacted
about a cart. D10 installs the table **empty**. Enabling the module without
filling it lets the rolling 15-day window re-mail people D7 already wrote to.

That is not hypothetical. Measured before and after
`migrate_abandoned_cart_history.php`:

```
order 85040   j***@gmail.com                ALREADY MAILED by D7 on 2026-07-08  -> suppressed
order 87819   i***@ceonlinetraining.com     ALREADY MAILED by D7 on 2026-08-27  -> suppressed
order 88053   v***@mailinator.com           not in the history                  -> still queued
```

**A real customer would have been emailed a second time about a cart they
abandoned in July.** With the history migrated, the send queue drops from 3 to
1, and the one left is a post-freeze test cart on a `mailinator.com` address.

⚠️ **D7 keys that table by `order_number` while joining on `order_id`** — two
different columns (`commerce_abandoned_carts.module`). It gets away with it
because the two agree everywhere here: `SELECT COUNT(*) FROM commerce_order
WHERE order_number <> order_id` returns **0 of 87,661**. Measured, not assumed;
on a site where they diverged, D7's own history would already be wrong.

⚠️ **44 of the 3,915 rows point at orders that no longer exist.** They are copied
anyway — they are history, the join that reads them is a LEFT JOIN, and a row
with no matching order simply never matches. Dropping them would be discarding
source rows to make a count look tidy.

## C. Settings — all eleven, read live from D7

| D10 config key | D7 variable | value |
|---|---|---|
| `timeout` | `…_timeout` | 10 (minutes) |
| `history_limit` | `…_history_limit` | 21600 (minutes = 15 days) |
| `batch_limit` | `…_batch_limit` | 10 |
| `from_email` | `…_from_email` | jayson@ceonlinetraining.com |
| `from_name` | `…_from_name` | CE Training Courses, Inc. |
| `subject` | `…_subject` | Did you have trouble? |
| `customer_service_phone_number` | `…_customer_service_phone_number` | *(empty)* |
| `bcc_active` | `…_bcc_active` | FALSE |
| `bcc_email` | `…_bcc_email` | info@ceonlinetraining.com |
| **`testmode`** | **`…_testmode_active`** | **FALSE — live sending, as D7** |
| `testmode_email` | `…_testmode_email` | jayson@ceonlinetraining.com |

⚠️ The key names differ on one: D7's `testmode_active` is D10's plain `testmode`.

**Test mode is set OFF because that is D7's live value.** Leaving it on would
make the D10 site *look* configured while sending nothing — a silent behaviour
change dressed up as caution. On this machine that is safe, and it was checked
rather than assumed: `automated_cron` is **not enabled**, so nothing fires
unattended, and PHP's `sendmail_path` is **Mailpit**, so mail is captured rather
than delivered. **Neither is true in production** — see §E.

## D. Two behavioural differences in D10 2.0.1, measured

### (a) The status list has no D10 equivalent — 2,473 orders

D7 offers an **Abandoned Statuses** checkbox set with five ticked. D10 2.0.1 has
no such setting: its cron hardcodes `o.state = 'draft'`, which corresponds to
D7's `cart` alone. Orders in the other four states — `pending`,
`checkout_checkout`, `checkout_review`, `checkout_payment` — number **2,473**,
and D10 will not mail about any of them.

**This narrows the feature: fewer mails, never more.** Recorded rather than
patched; changing it means patching contrib.
**Classification: technical blocker** (contrib behaviour, no setting exposed).

### (b) D10 windows on `changed`, D7 on `created`

D7 asks when the cart was **created**; D10 2.0.1 asks when it was last
**changed**. Migration work updates `changed`, so a migrated cart can re-enter
the window purely because a script touched it — a customer could be mailed about
a cart they abandoned ten weeks ago.

Right now both windows contain the same single order, so the difference costs
nothing today. It is recorded because it is a standing hazard: any future bulk
operation that touches `commerce_order` rows can put cold carts back in the
queue. **Classification: technical blocker.**

## E. ⚠️ Before this goes to production

Three things are true here that will not be true there:

1. `automated_cron` is off, so mail only moves if someone runs `drush cron`.
2. Mail is captured by Mailpit.
3. `reroute_email` is downloaded but **not enabled** — the standard safety net.

Enabling the mailer on a live site is a **business decision**, not a migration
step. D7's own current queue is **0 orders**; D10's is 1 (the post-freeze test
cart). So resuming is low-volume today — but §D(b) means that can change without
anyone touching a setting.

## F. Verification

`scripts/reconcile/verify_abandoned_carts.php` — **17 checks, all passing.** It
reproduces the module's SELECT and reports; **it never sends mail** and never
writes to the history table.

```
--- settings, against D7's live variables ------------------------------
timeout / history_limit / batch_limit               PASS  10 / 21600 / 10
from_email / from_name / subject                    PASS
bcc_active / bcc_email / testmode / testmode_email  PASS
test mode is OFF, as it is in D7 (live sending)     PASS
--- the already-mailed history ----------------------------------------
history row count D7 = D10                          PASS  3915 / 3915
history identical, column for column                PASS
--- the two behavioural differences, measured -------------------------
  (a) orders in the 4 states D10 will NOT mail about: 2473
  (b) in the window by CHANGED (D10): 1   by CREATED (D7): 1
--- the send queue right now ------------------------------------------
    order 88053  v***@mailinator.com  created 2026-09-10  changed 2026-09-10
no order is queued whose cart D7 already mailed about  PASS
```

The admin page at `/admin/commerce/config/abandoned_carts` renders all eleven
settings with D7's values, both checkboxes correctly unchecked, `batch_limit`
selected at 10. ⚠️ D7's path contains a **typo** —
`admin/commerce/config/abandonded_carts` — which D10 spells correctly; the D7
path is not reproduced.

Label wording differs slightly throughout (D7 "Send Timeout" / "Subject Line" /
"From Email Name" vs D10 "Send timeout" / "Subject" / "From name").
**Classification: legitimate D10 difference** — contrib's own labels, and
overriding them would mean patching contrib to change capitalisation.

## G. Regression

* `verify_abandoned_carts.php` — **17 of 17**.
* `verify_pathauto.php` — 27 of 27 · `verify_referral_admin.php` — 51 of 51.
* `verify_certificate_mapping.php` — 43 of 43 · `verify_form_parity.php` — 6 of 6.
* `verify_admin_pages.sh` — **38 of 38** HTTP 200 (the settings page added).
* D7 untouched — every query in this round was a `SELECT`.

---

# Round 16 — the Wistia Integration Settings page

> **Brief:** enable the ce_wistia settings page — the fourth finding from
> Round 12's audit.
>
> D7 has **two** Wistia settings pages, not one. Only one of them has anything
> live behind it in D10; the other is documented with evidence rather than built.

## A. What D7 has

| D7 path | title | form | permission |
|---|---|---|---|
| `admin/config/services/wistia_integration` | Wistia Integration Settings | `wistia_global_settings()` | `administer wistia integration` |
| `admin/config/services/wistia` | Wistia Settings | `media_wistia_ceonline_global_settings()` | `administer wistia` |

## B. Built — Wistia Integration Settings

**`/admin/config/services/wistia_integration`**, D7's path verbatim, with D7's
three fields in D7's order and D7's labels:

```
Tracking interval in seconds    5       <- WistiaEmbed, resume-position saves
Query interval in seconds       86400   <- WistiaEmbed, "are you still there?"
Tracking tail length            2       <- WistiaTrackingController, rows kept
                                           per video per user
                                        button: Save configuration
```

⭐ **All three settings were already live — `ce_wistia` reads every one of them.**
The values were migrated correctly (5 / 86400 / 2, matching D7 exactly); what
was missing was only the form. This page changes no stored value.

⚠️ Note the third label has no "in seconds" — it counts **rows**, not time.
That is D7's wording and it is kept.

## C. 🛑 D7's fourth control is not reproduced, because it is dead in D7 too

D7's form carries a submit button, **"Create the store procedure if missing"**,
calling `create_stored_procedure()` — which is gated on
`check_for_stored_procedure()`. That function's entire body is **commented out**
and it ends:

```php
  // $count = $query->rowCount();
  $count = 1;
  return $count;
```

The gate is therefore always true, the else-branch always runs, and D7 answers
*"Stored procedure already exists."* every single time. **It has never created
anything.**

And D10 needs no procedure at all: `WistiaTrackingController` already
reimplements D7's `time_track()` MySQL procedure in PHP, documented in that
class. Reproducing the button would ship a control that does nothing, on top of
a mechanism that no longer exists. **Classification: obsolete.**

## D. 🛑 Not built — D7's other Wistia page, with the evidence

`admin/config/services/wistia` sets four values:
`media_wistia_ceonline_api_key` (a live 40-character credential),
`_api_url`, `_oembed_url`, `_getembed_url`.

They belong to the **D7 Media browser**. Read from D7's source, the consumers are:

```
includes/WistiaApi.php                       media_wistia_ceonline_api_url
includes/media_wistia_ceonline.browser.inc   media_wistia_ceonline_api_key
includes/MediaWistiaStreamWrapper.inc        media_wistia_ceonline_api_key
```

— i.e. the "browse Wistia videos" picker that listed projects and videos through
the Wistia API. **`ce_wistia` has no media browser and calls no Wistia API**: its
embed URLs are fixed in `WistiaEmbed` (`fast.wistia.net/embed/iframe/`), and a
search of every custom D10 module finds **no reference to a Wistia API key at
all**.

Building that page would ship four fields nothing reads — one of them a live
credential displayed on screen for no functional benefit. D7's Media browser was
already classified obsolete in Round 12. **Classification: obsolete** (the
feature), and the API key is left in D7 rather than copied into a D10 config
object nothing consumes. Its value was never printed by any script in this work.

## E. ⚠️ The permission D7 never declared

`wistia_menu()` guards the page with `administer wistia integration` — but
`wistia_permission()` declares **only** `access ajax wistia callback`. The
permission the route depends on is never defined by any module, so it appears on
no permissions page, no role can hold it, and D7 confirms none does:

```
SELECT COUNT(*) FROM role_permission WHERE permission = 'administer wistia integration';  -> 0
```

In D7 that means the page is reachable by **uid 1 alone**, since uid 1 bypasses
every permission check.

Declaring it in `ce_wistia.permissions.yml` is a deliberate, minimal departure:
a D10 route needs a real permission, and an undeclared one would make the route
permanently unreachable rather than uid-1-only. **Effective access is identical
to D7** — uid 1 in, everyone else 403, no role granted it. The only difference is
that the permission now appears on the permissions page, so it *can* be granted
deliberately, which D7 never allowed even by accident.
**Classification: legitimate D10 difference.**

## F. Verification

`scripts/reconcile/verify_wistia_settings.php` — **24 checks, all passing**,
restoring the values it changes.

```
tracking_interval  = D7 5 (wistia_tracking_interval)       PASS
query_interval     = D7 86400 (wistia_query_interval)      PASS
tail_length        = D7 2 (wistia_tail_length)             PASS
the three fields appear in D7's order                      PASS
  …each label is D7's, each default the stored value       PASS
button reads "Save configuration"                          PASS
D7's dead "Create the store procedure" button NOT reproduced PASS
saving stores the new values / as INTEGERS / round-trips   PASS
no D7 role holds `administer wistia integration`           PASS
no D10 role holds it either                                PASS
uid 1 can reach the page, as in D7                         PASS
anonymous / plain user / CE Admin cannot                   PASS
WistiaEmbed reads tracking_interval and query_interval     PASS
WistiaTrackingController reads tail_length                 PASS
```

The last two matter: they are what distinguishes a settings page from a
decorative one.

Rendered as uid 1: title **Wistia Integration Settings**, the three D7 labels,
values 5 / 86400 / 2, **Save configuration**, breadcrumb Home › Administration ›
Configuration › Web services, and listed under **Web services** with D7's
description *"Settings for the wistia integration module"*. Anonymous gets 403.

⚠️ **One storage difference:** D7 has **no validation on this form at all**
(`system_settings_form()` with no `#element_validate`), so D7 would store `"abc"`
as the tracking interval. `ce_wistia.schema.yml` types these as integers, so they
are cast on save. No input D7 accepted is rejected — the value is simply stored
as the type the schema declares. **Classification: legitimate D10 difference.**

## G. An observation, checked rather than left hanging

The playback tables differ by two rows — `{wistia_media}` D10 32,739 / D7 32,741
and `{wistia_media_track}` D10 62,141 / D7 62,143. Every differing row belongs to
**uid 22217**, one of the two post-freeze test accounts identified in Round 14
(created 2026-09-10, absent from `migrate_map_ce_user`). Not data loss; activity
recorded in D7 after the migration freeze.

## H. Regression

* `verify_wistia_settings.php` — **24 of 24**.
* `verify_abandoned_carts.php` — 17 of 17 · `verify_pathauto.php` — 27 of 27.
* `verify_referral_admin.php` — 51 of 51 · `verify_form_parity.php` — 6 of 6.
* `verify_admin_pages.sh` — **39 of 39** HTTP 200 (the settings page added).
* D7 untouched — every query in this round was a `SELECT`, and the Wistia API key
  was never read into D10 nor printed anywhere.

---

# Round 17 — quiz settings: the page not to build, and the gap behind it

> Round 12's fifth finding was "quiz settings — 16 live D7 variables, no D10
> config object, no page", parked as a business decision. Investigating it
> properly produced two answers, and neither was the expected one:
>
> * **`/admin/quiz/settings/config` should NOT be built** — its twelve globals
>   drive nothing in `ce_quiz`, so the page would be twelve dead controls.
> * **A real gap sat behind it**: 68 rows of per-quiz configuration — including
>   the pass mark that decides who earns a certificate — that `ce_quiz` reads and
>   nothing could edit. That is now built.

## A. Why D7's global settings page is not reproduced

D7's `quiz_admin_settings()` has twelve settings. Checking each against
`ce_quiz`'s source, **not one is read**:

| D7 global | D7 value | why nothing reads it in D10 |
|---|---|---|
| `quiz_auto_revisioning` | 0 (off) | D10 quiz nodes are non-revisionable (Decision 12); off in D7 anyway |
| `quiz_durod` | 0 (off) | delete-results-on-user-delete; not implemented, off in D7 |
| `quiz_index_questions` | 1 | D7 quiz's search hook, not reproduced |
| `quiz_default_close` | 30 | a default for *new* quizzes on D7's form |
| `quiz_use_passfail` | 1 | gated whether D7 showed `pass_rate`; the field is shown unconditionally now |
| `quiz_max_result_options` | 5 | `{quiz_node_result_options}` is **empty on all 68 quizzes** |
| `quiz_remove_partial_quiz_record` | 0 (disabled) | cron cleanup, not implemented |
| `quiz_remove_invalid_quiz_record` | 86400 | cron cleanup, not implemented |
| `quiz_autotitle_length` | 50 | auto question titles, an authoring feature |
| `quiz_pager_start` | 100 | unreachable: every quiz here has **one** question |
| `quiz_pager_siblings` | 5 | same |
| `quiz_time_limit_buffer` | 5 | `ce_quiz` does not enforce time limits at all — 0 mentions in the taking controller, answer form and attempt service |

A settings form for these would tell an administrator the site will do something
it will not. **Classification: N/A**, individually evidenced above.

### The one that could have been a live defect — checked, and it is not

D7's `quiz_can_review()` gives **administrators a different feedback set than
learners**:

```php
$admin = node_access('update', $quiz);
if ($admin) {   // global variables
  $review_options['end'] = variable_get('quiz_admin_review_options_end');
  …
}
else { … }      // the quiz's own review_options
```

`ce_quiz`'s `QuizResultReport` reads the **per-quiz** set for everyone, so if the
two differed, administrators would see the wrong columns. Measured:

```
D7 ADMIN   end:      quiz_question_view_full, attempt, choice, correct, score, solution
D7 PER-QUIZ end:     quiz_question_view_full, attempt, choice, correct, score, solution   (all 68)
```

**Identical.** D10's per-quiz read reproduces D7's rendered output for both
audiences. Recorded because the code paths differ even though the outcome does
not — a future change to either set would break the equivalence.

## B. ⭐ The gap behind it: 68 rows of per-quiz settings, read-only until now

`{quiz_node_properties}` holds 33 columns per quiz revision and drives `ce_quiz`
throughout. The values are **not uniform** — this is live configuration, not
defaults:

```
pass_rate             100 × 35 quizzes,  75 × 28,  0 × 5
max_score             1 × 53,  2 × 8,  0 × 2,  3 × 2
repeat_until_correct  0 × 63,  1 × 5        allow_skipping  1 × 63,  0 × 5
allow_resume          0 × 63,  1 × 5        quiz_open/close four distinct windows
review_options        two distinct sets (nid 521 differs from the other 67)
```

The D10 quiz node form carried **only core fields** — no pass mark, no attempt
limit, no availability window. An administrator could see how a quiz scored and
had no way to change it. Same shape as `{certificate_node}` in Round 10, and
higher stakes: `pass_rate` decides whether a student passes, which decides
whether they earn a CE certificate.

**Built:** D7's three fieldsets on the quiz node form, in D7's vertical tabs,
with D7's labels and descriptions — **Taking options**, **Availability options**,
**Pass/fail options** — carrying seventeen fields: the five taking checkboxes,
randomization, the review-options matrix, multiple-takes (attempts, display,
store results), time limit, always-available, open/close dates, pass rate and the
two summary texts.

Gated on **`administer quiz configuration`**, D7's own gate for every quiz
settings page. Without it, any user who can edit a quiz node could change its
pass mark.

## C. 🛑 Sixteen columns are deliberately not rendered, and never written

Nine are referenced **nowhere** in `ce_quiz` — `max_score_for_random`,
`summary_pass_format`, `summary_default_format`, `allow_change`,
`allow_change_blank`, `build_on_last`, `show_passed`, `mark_doubtful`,
`result_type`. D7 renders several of them; they are omitted rather than
shown-and-ignored, for the same reason the global page is not built.

Four more `ce_quiz` reads but no administrator should type: `max_score` and
`number_of_random_questions` are computed from the questions, `time_left` is
per-attempt runtime state, `uid` is ownership. The rest is the key.

The save is an **UPDATE of seventeen columns, never a delete-and-reinsert**, so
the other sixteen survive exactly as migrated. Verified: after a save that
changes all seventeen, **all sixteen are byte-identical**.

## D. ⚠️ Two traps in the storage, both checked

**The row is keyed by `(nid, vid)`.** 68 rows cover 59 quizzes — nine are
historical revision rows (nids 301, 302, 306, 431, 521 keep 2–3 each). An early
query flagged those as "vid mismatches"; they are not. **Every quiz's current
revision has its own row**, and D7 holds exactly the same 68. Writes go to the
current revision's row; the verifier deliberately targets a quiz that *has*
older rows and proves they do not move.

**The quiz content type has `new_revision = FALSE`**, so an ordinary save keeps
the vid and the row stays attached. If that is ever switched on, `QuizProperties`
is the class that has to carry the row forward — noted there.

**`review_options` is PHP-serialised**, with disabled keys stored as `0` rather
than dropped. The encoder rebuilds that exact shape: eleven options × two
feedback times, enabled ones as their own key. Dropping the zeros would still
read correctly today but would stop matching what D7 wrote.

## E. Verification

`scripts/reconcile/verify_quiz_properties.php` — **27 checks, all passing.** It
snapshots the whole table first and restores it; the closing checks re-read and
prove the restore.

```
{quiz_node_properties} row count D7 = D10                    PASS  68 / 68
every row identical, all 33 columns                          PASS
every quiz's CURRENT revision has a row                      PASS  59 of 59 quizzes
the extra rows are historical revisions, not orphans         PASS  9 historical row(s)
  pass_rate spread: 100 × 35  75 × 28  0 × 5
pass_rate is NOT uniform - this is real per-quiz configuration PASS
fieldset taking / quiz_availability / summaryoptions present PASS  (in the vertical tabs, as D7)
review options offer both feedback times, 11 options each    PASS
the columns ce_quiz never reads are NOT rendered as controls PASS
pass_rate saved                                              PASS  82
review_options round-trips as D7's serialised shape          PASS
  ...enabled stored as their own key, disabled as 0          PASS
⭐ the 16 columns the form does not own are UNCHANGED        PASS  16 columns
⭐ historical revision rows for this quiz are untouched      PASS  1 older row(s)
a user without the permission sees NO settings fieldsets     PASS
{quiz_node_properties} restored, and still identical to D7   PASS  68 rows
```

Rendered live on `/node/236/edit`: the vertical tabs read **Taking options**,
**Availability options**, **Pass/fail options** alongside core's; all seventeen
fields present, 22 review-option checkboxes (11 × 2), and none of the omitted
columns rendered.

**One test bug worth recording:** the first run crashed on
`array_walk_recursive()` over the built form — a form array holds
self-references and the walk dies with "Recursion detected". It died *before* any
write. A second was subtler: the first sample quiz had no historical rows, so
the "history untouched" check was **vacuous**; it now deliberately picks a quiz
that has them.

## F. Remaining, classified

| item | classification |
|---|---|
| `/admin/quiz/settings/config` (12 globals) | **N/A** — none is read by `ce_quiz`; per-setting evidence in §A |
| `quiz_admin_review_options_*` | **N/A** — identical to the per-quiz set on all 68 quizzes |
| `/admin/quiz/settings/quiz-form` | **N/A** — chose which fields D7's quiz form showed; `quiz_form_fields` is unset, so D7 showed all |
| `/admin/quiz/feedback`, `/result`, `/result_answer` | **business decision** — D7 entity-admin UIs for types with no D10 counterpart |
| 9 columns `ce_quiz` never reads | **N/A** — documented individually |
| 4 computed/runtime columns | **legitimate D10 difference** — not user-editable in D7 either |
| `summary_*_format` untouched | **legitimate D10 difference** — plain textareas, since the format columns are never read |

## G. Regression

* `verify_quiz_properties.php` — **27 of 27**.
* `verify_admin_parity.php` — **16 of 16 views unchanged**.
* `verify_wistia_settings.php` — 24 of 24 · `verify_certificate_admin.php` — 9 of 9.
* `verify_multichoice_data.php` — 391 of 391.
* `verify_admin_pages.sh` — **39 of 39** HTTP 200.
* D7 untouched — every query in this round was a `SELECT`.

---

# Round 18 — the 379 paths the audit had skipped

> **Brief:** continue with anything still missing on the admin side against D7.
>
> Round 12's audit covered 532 of D7's 992 admin paths and **deliberately
> skipped 379 wildcard ones**, on the grounds that the static parent covered
> them. It does not: the per-entity **operations** live only on the wildcard
> paths, and that is where order payment capture, void and refund live.
> `audit_admin_routes.php` now checks all 911 (help still excluded).

## A. Method

Every `%loader` segment is replaced by a placeholder and the result matched
against D10's route patterns. The placeholder contains no slash, so a path's
**shape** still has to agree — `admin/commerce/orders/%/payment/%/delete` can
only match a six-segment D10 route.

```
D7 {menu_router} admin entries      992
  admin/help/* skipped               81
  checked                           911      (was 532)
    matched by a D10 route          203      (exact 121, via parameter 82)
    no D10 route                    708
```

Every one of the 708 is classified by an explicit rule, and the script reports
**"Every D7 admin path without a D10 route is accounted for."**

## B. ⭐ The group that mattered: order payment operations

D7 has three Authorize.Net-specific admin operations on a payment transaction:

```
admin/commerce/orders/%/payment/%/authnet-aim-capture   Capture
admin/commerce/orders/%/payment/%/authnet-aim-credit    Credit
admin/commerce/orders/%/payment/%/authnet-aim-void      Void
```

Commerce 3 generalises all three into one route,
`/admin/commerce/orders/{order}/payments/{payment}/operation/{operation}`, and
**it works**. Verified live on a completed order:

```
Payment | State     | Payment gateway                                  | Operations
$49.00  | Completed | Example payment (TEST - approves without charging)| Refund  Delete
                                     operation link: /payments/123/operation/refund
```

⚠️ **Historical Authorize.Net payments correctly offer NO operations.** They
render with the gateway labelled *"Authorize.Net AIM (D7 historical -
disabled)"*, migrated as the `manual` plugin and disabled — so the row shows
only Delete. That is right, not a gap: a 2025 transaction cannot be voided
through a gateway that no longer talks to the processor, and Decision 17 forbids
using the Authorize.Net credentials at all. The live gateway (`authnet_test`,
`authorizenet_acceptjs`) does declare Capture, Void and Refund.

The other per-order paths all resolve too, and D10 adds several D7 never had:

| D7 | D10 | live |
|---|---|---|
| `orders/%/payment` | `/orders/{order}/payments` | 200, 20 rows |
| `orders/%/payment/%/delete` | `/payments/{payment}/delete` | ✓ |
| `orders/%/view` | `/orders/{order}` | 200 |
| `orders/add/%` | `/orders/add` | 200 |
| `orders/%/revisions` | — | Decision 12 (archived) |
| — | `manage-coupons`, `reassign`, `resend-receipt`, `unlock`, state transitions | 200 |

⚠️ Two returned **403 and both are correct**: `resend-receipt` on a *cart* order
(nothing to resend — it returns 200 on a completed one) and `unlock` on an order
that is not locked.

## C. A payment-count difference, resolved

`{commerce_payment_transaction}` is 26,801 in D7 against 26,800 in D10. Not a
loss:

* **4 only in D7** — ids 26880–26883, all `commerce_payment_example` test
  payments on orders 87837–87840 that **do not exist in D10**, created
  2026‑09‑08 and 09‑09, i.e. **after the 2026‑08‑24 freeze**.
* **3 only in D10** — ids 26877, 26879, 26886, D10-side test activity.

26,801 − 4 + 3 = 26,800.

## D. Where the other 708 went

No further gaps. The large groups, each with a stated reason:

| group | count | why there is no D10 path |
|---|---|---|
| Field UI sub-operations | ~90 | D7 splits a field into `/fields/%`, `/field-settings` and `/widget-type`; **D10 merges all three into one edit form** and moves widgets to the form display |
| Rules component management | 44 | Rules module — `workflow/rules/*`, `checkout/rules/*` and `quiz/feedback/manage/*` are all the same shape |
| Panels · Pages · mini-panels · Features | 35 | classified obsolete in earlier rounds |
| Commerce 1 → 3 restructure | ~105 | coupons under promotions, customer profiles to the profile entity, products to `/product/{product}` |
| Ultimate Cron job operations | 11 | contrib, absent |
| D7 Media file types | 31 | D7 Media browser, obsolete |
| core renames | ~60 | taxonomy `manage/{vocabulary}`, roles `manage/{role}`, dblog `event/{id}`, shortcut `manage/{set}`, block `manage/{block}` |
| D7 default local tasks | 76 | D10 renders the tab on the parent route |

Spot-checked live, all **200**: taxonomy overview and add-term, node type edit /
fields / form-display, flag manage, menu manage, account fields, shortcut set,
pathauto patterns, commerce product-type fields, views duplicate, single config
export, role edit, dblog.

⚠️ **Views export and revert have no D10 page** — D7's Views UI kept CTools
export/revert per view; D10 replaces both with config management (single
import/export, and config sync for revert), and renames clone to **duplicate**.
Verified: `/admin/structure/views/view/report/duplicate` and
`/admin/config/development/configuration/single/export` both 200.
**Classification: legitimate D10 difference.**

## E. Where the admin audit now stands

All of D7's admin surface has been walked, twice over:

* **911 paths checked** (992 minus help), **0 unaccounted for**.
* Round 12's five findings — ce_referral, pathauto, abandoned carts, Wistia,
  quiz settings — are **all closed** (Rounds 13–17).
* The remaining classifications are **business decisions** (whether to enable
  the abandoned-cart mailer, whether to revert Decision 12 for revisions,
  the quiz entity-admin UIs, the D7 permission quirks reproduced verbatim) or
  **obsolete D7 contrib** with no D10 counterpart.

Nothing in D7's admin is missing from D10 that has not been either built or
explained.

## F. Regression

* `verify_admin_pages.sh` — **39 of 39** HTTP 200.
* `verify_quiz_properties.php` — 27 of 27 · `verify_abandoned_carts.php` — 17 of 17.
* `verify_pathauto.php` — 27 of 27 · `verify_referral_admin.php` — 51 of 51.
* D7 untouched — every query in this round was a `SELECT`.

---

# FINAL ADMIN MIGRATION AUDIT

> Round 19. The brief: prove the whole chain — **menu → submenu → page → form →
> section → field → label → widget → layout → CSS → action → permission → data →
> behaviour** — not just that routes return 200.
>
> Two things changed in this round: the **menu-hierarchy audit** (new — every
> previous round compared routes, never menu links) and the **date pickers**
> (D7's jQuery UI calendar, reinstated). Everything else is verification.

## 1–6. The numbers

| | |
|---|---|
| **1. D7 admin menu items** | **804** (`{menu_links}`, `management`, excl. `admin/help/*`) |
| **2. D7 admin routes** | **992** — 911 audited, 81 `admin/help/*` excluded |
| **3. D10 mapped routes** | **683** admin routes · 117 menu links · 401 local tasks · 93 local actions |
| **4. Forms audited** | 6 report exposed forms + 7 custom admin forms + 2 node-form sections + the Commerce/core forms spot-checked below |
| **5. Fields audited** | 63 report filter widgets (`d7_form_baseline.json`) + 36 custom-form elements + 17 quiz columns + 1 certificate mapping |
| **6. Sections audited** | 3 quiz fieldsets, 2 certificate fieldsets, 1 referral flat form, report filter rows, product/course/commerce form-display groups |

## 2. Menu hierarchy — the new audit

`scripts/reconcile/admin_menu_parity.php` walks D7's `{menu_links}` tree and
asks, for each item, whether D10 can reach it.

⚠️ **The first run reported 119 false gaps**, because it compared D7 menu links
against D10 **menu links only**. D7 puts "Add user", "Permissions", "Uninstall",
"Add vocabulary" and the per-theme block pages in the *menu tree*; D10 renders
exactly those as **local tasks** (tabs) and **local actions** (buttons). A second
false batch — 123 more — came from D7's `DEFAULT_LOCAL_TASK` rows, which have
their own path pointing at their own parent. Both are now handled.

```
D7 admin menu links (management menu): 804
  in menu         69
  as a tab        81
  as an action    15
  reached from the page  3     (confirm steps, theme install)
  obsolete       513           (classified)
  D7 default tab 123           (D10 renders it on the parent route)
  UNEXPLAINED      0
```

**Every visible D7 admin menu item is in D10's menu, reachable as a tab or
action, or classified.** Label differences are all D10 core's own naming —
Store→Commerce, Modules→Extend, Blocks→Block layout, Regional and language→Region
and language — plus D7's untranslated `@quiz` token, which D7 itself renders as
"Quiz".

⭐ The classification table is now **one shared file**,
`d7_admin_classification.php` (129 rules), required by both the menu audit and
the route audit, so the two cannot give different reasons for the same path.

## 3. Routes

`audit_admin_routes.php`, unchanged since Round 18 except for the shared table:
**911 checked, 203 matched, 708 classified, 0 unaccounted for.**

## 7. Visual / CSS parity — what was done, and what was not

`scripts/reconcile/admin_ui_visual_parity.md` carries the per-page table.

🛑 **D7 and D10 do not share an admin theme, and this report does not claim
they do.** D7's admin renders in `seven`/`bootstrap`; D10's renders in **Claro**
— a different type scale, different control heights, different form-item
spacing. The brief's example ("if D7 has 8px spacing and D10 has 24px,
investigate and fix it") cannot be satisfied page-by-page without shipping a D7
theme clone as the D10 admin theme. That is a far larger change than anything
else here, it was not built, and no row claims otherwise.

What *was* matched is **structure** — the markup, classes, grouping and
inline/stacked shape the ported D7 stylesheet keys off. Every layout defect
found was fixed in the **Form API, render arrays, Views config or templates**,
never with new CSS:

| Defect | Fix |
|---|---|
| Date range stacking instead of inline | `container-inline` + `ce-report-date-range` on the wrapper |
| Widget label rendered outside its wrapper | label as a child `html_tag`, not `#prefix` |
| Views missing D7's `view-id-*` wrapper classes | five `views-view--*.html.twig` templates |
| Evaluation Results rendered as a modern D10 table | pivot view rebuilt to D7's shape |
| Flag fieldset ignoring its weight | it is an extra field; set on the form display |
| Hidden form-display components across 7 bundles | restored at D7's weights and widgets |

The single CSS file in the project, `toolbar-displacement.css`, exists because
core writes an inline `padding-top` that only an `!important` author rule can
outrank — documented at source.

## 8. Date pickers — changed this round

D7 runs **better_exposed_filters** with `bef_format = 'bef_datepicker'` — a
**jQuery UI calendar** — and `datepicker_options = "changeMonth: true\r\n
changeYear: true"` with **no `dateFormat` override**, so jQuery UI's default
`mm/dd/yy`.

D10 had `<input type="date">`. That *is* a calendar, but it differs where an
administrator notices: the browser's locale format instead of `mm/dd/yy`,
segmented keyboard entry instead of free text, and placeholders silently
ignored. `drupal/jquery_ui_datepicker` is now installed and bound to exactly the
filters D7 binds it to, with D7's two options and D7's format. The inputs render
as D7's do — `type=text size=30` with the calendar attached in JS.

Coverage, measured against the **rendered** D7 baseline rather than the stored
config, because D7 carries `bef_datepicker` settings for filters it never
exposes (**21 configured, 14 exposed**):

| Page | D7 exposed date filters | D10 picker inputs |
|---|---|---|
| `/report` | 2 | 4 ✓ |
| `/sale-report` | 1 | 2 ✓ |
| `/marketing-report` | 1 | 2 ✓ |
| `/marketing-report/detail` | 4 | 8 ✓ |
| `/marketing-report/new-detail` | 5 | 10 ✓ |
| `/evaluation-results` | 1 | 2 ✓ |

⚠️ `commerce_order_total_amount` is a numeric `between` filter and correctly gets
**no** picker — D7 renders it as a plain pair of text boxes. `/report/history`
gets none because D7 exposes no date filter there.

## 9–14. Reports, dashboard, Commerce, products, people, quiz

All re-verified this round, no regressions: report form parity **6 of 6, 0
differences**; `verify_admin_parity.php` **16 of 16 views** at identical totals,
including `/report` still served by `admin_report_new`; Commerce order
view/edit/payments/coupons/reassign/receipt and the
`payments/{payment}/operation/{op}` capture-void-refund route verified live in
Round 18; People filters Name·E-mail·Roles·Active intact; quiz per-quiz settings
**27 of 27**.

## 15–18. Taxonomy, content, media, webform, ce_* modules, actions

Spot-checked live, all 200: taxonomy overview and add-term, node type
edit/fields/form-display, flag manage, menu manage, account fields, shortcut set,
pathauto patterns, commerce product-type fields, views duplicate, single config
export, role edit, dblog. Every `ce_*` module's admin surface is covered by its
own verifier (below).

## 19. Breadcrumbs, tabs, local tasks

Verified per page as each was built — e.g. Certificates shows
`List · Mapping · Settings · Clear` with `Global · Field groups` beneath Mapping;
Wistia shows Home › Administration › Configuration › Web services.

## 21. Data visibility — D7 → D10 database → D10 admin UI

`verify_admin_visibility.php`, 22 datasets: **every one reconciles, and no
dataset has rows in the D10 database that its admin interface hides.** Deltas are
all post-freeze test activity created in D10 (+2 users, +21 orders, +5 products,
+6 URL aliases and so on), never a shortfall.

⚠️ That script needs `-d memory_limit=3G`; at the default it is OOM-killed
part-way (exit 137). Recorded so the next reader does not read a truncated run
as a failure.

## 23. Automated final audit

`scripts/reconcile/final_admin_parity.php` — one command, one verdict, covering
navigation, routes, forms/sections/fields, date pickers, data visibility, access
and the toolbar rule. **All checks passed.** It prints the list of focused
verifiers to run alongside it.

## 13. Regression — every suite, this round

| suite | result |
|---|---|
| `final_admin_parity.php` | **all checks passed** |
| `admin_menu_parity.php` | **0 unexplained of 804** |
| `audit_admin_routes.php` | **0 unaccounted for of 911** |
| `verify_admin_parity.php` | **16 of 16 views** |
| `verify_admin_visibility.php` | **22 datasets reconcile** |
| `verify_form_parity.php` | **6 of 6, 0 differences** |
| `verify_quiz_properties.php` | 27 of 27 |
| `verify_referral_admin.php` | 51 of 51 |
| `verify_wistia_settings.php` | 24 of 24 |
| `verify_pathauto.php` | 27 of 27 |
| `verify_abandoned_carts.php` | 17 of 17 |
| `verify_certificate_admin.php` | 9 of 9 |
| `verify_certificate_mapping.php` | 43 of 43 |
| `verify_multichoice_data.php` | 391 of 391 |
| `verify_admin_pages.sh` | **39 of 39** HTTP 200 |

## 14. D7 WRITE_CHECK

`scripts/reconcile/d7_write_check.php` — 29 business tables by row count, 6 of
them also by `CHECKSUM TABLE` so an in-place UPDATE cannot hide. Baseline taken
before this round's work, compared after:

```
WRITE_CHECK: CLEAN   (29 tables, 6 checksummed)
```

Every D7 statement in this round was a `SELECT`. The Report exposed-form write
handler was never triggered.

---

## REMAINING GAPS

**Not empty.** Six items remain, none of them an unexplained gap — five are
decisions that belong to the client, one is a technical limit.

| # | D7 feature | D10 gap | Reason | Severity | Required action |
|---|---|---|---|---|---|
| 1 | Product **Change history** (`diff` on products) | absent | Needs revisionable Commerce entities; reverses **Decision 12**, under which ~570 MB of revision tables were archived to CSV. 169 product revisions are preserved in `archive/commerce-revisions/`. | Medium | Client decides whether to re-enable revisions and re-import |
| 2 | Abandoned-cart mailer | configured, **not sending** in production | The module, settings and 3,915-row history are all in place and test mode is **off** as in D7. Cron is not automated here and mail is captured by Mailpit. Switching it on is a decision that emails customers. | High if switched on unintentionally | Client confirms before go-live; see Round 15 §E |
| 3 | Date picker built on **jQuery UI** | now matches D7 | jQuery UI is **end-of-life**; D10 core removed it deliberately, and Drupal's own maintainers deprecate integrations with it. Reinstated because the brief requires D7's picker UI. | Medium (maintenance/security) | Client chooses: keep parity, or accept the native `type=date` control |
| 4 | D7 admin **theme** pixel parity | Claro ≠ Seven | Different type scale, control heights and form-item spacing. Structural parity was delivered; pixel parity would require shipping a D7 theme clone. | Low (cosmetic) | Accept, or commission an admin theme |
| 5 | `commerce_compatibility_selection` | no equivalent | Commerce 3 has no counterpart for this Commerce 1 feature. | Low | Accept as obsolete |
| 6 | `/sale-report` with an **unbounded** date range | exhausts memory | A max-only range covers 31,698 order items. D7 had the same shape but different filter semantics. | Medium | Client decides: raise the memory limit for that endpoint, or bound the report |

Everything else in D7's admin panel is either **built and verified**, or
classified as **obsolete D7 contrib**, **N/A with per-item evidence**, or a
**D10 architectural equivalent** with the D7 → D10 → why-equivalent stated at the
point it is claimed.

**Status: the applicable D7 admin panel is migrated, with the six items above
outstanding as recorded decisions rather than unexplained gaps.**

---

# Round 20 — global form parity, and the six remaining gaps

> ⚠️ **No screenshot reached me** — the message describing it carried no image.
> I worked the way the brief itself calls authoritative ("DOM + computed CSS
> should be used to identify the cause") rather than guessing at pixels.

## 1. Root cause of the global spacing defect — found and fixed

The report pages render in the **ported D7 front-end theme**, not Claro, and
`style.css` carries all **91** of D7's `.views-exposed-widget` rules. So the CSS
was never missing. The markup had stopped matching its selectors.

D7 vs D10, same form, same field:

| | D7 | D10 (before) |
|---|---|---|
| wrapper id | `edit-created-wrapper` | `edit-created-wrapper-wrapper` |
| wrapper class | `views-widget-filter-created` | `views-widget-filter-created_wrapper` |
| widget label | direct child, **before** `.views-widget` | nested two levels deeper |
| boxes inside `.views-widget` | the two form-items | an extra `.form-inline` first |

**Two functions produced all of it.** `ce_core_form_views_exposed_form_alter()`
built the wrapper id and class from the raw element key — and D10 names a
BETWEEN filter's container `<identifier>_wrapper`, so the suffix doubled. Then
`ce_reports_form_views_exposed_form_alter()` added the widget label as a *child*,
which put it inside `.views-widget`, and wrapped the pair in `container-inline`.

The stylesheet addresses these ranges by name, so none of it applied:

```css
… > div.views-widget-filter-created .views-widget { display:flex; justify-content:space-between; }
… .views-widget > div:first-child { flex-basis: 45%; }
… .views-widget > div:last-child  { flex-basis: 55%; display:flex; align-items:center; }
… .views-widget > div:last-child label { margin: 0 0.5rem; }
```

`views-widget-filter-created_wrapper` matches none of them, so every date range
lost its flex row, its 45/55 split and the inline "And" spacing, and fell back to
stacked blocks with default form-item margins. **That is the spacing defect, and
it was structural, not stylistic.**

### The shared fix — two functions, zero new CSS

* `ce_core` strips the trailing `_wrapper` before deriving the id and class.
* `ce_core` emits the widget label **between** the two wrappers, and puts
  `views-widget` on the container itself so the two form-items are its
  `:first-child` and `:last-child` — exactly D7's shape.
* `ce_reports` stops adding the label child and drops `container-inline`.

Three traps worth recording:

1. 🛑 **`#prefix` is filtered by `Xss::filterAdmin()`, whose allow-list contains
   `div` but not `label`.** A plain string emitted the divs and silently ate the
   `<label>` tags, leaving the title as bare text. `Markup::create()` fixes it;
   every interpolated value is escaped first.
2. ⚠️ An earlier attempt passed a `#ce_widget_label` marker between the two
   modules and relied on `hook_module_implements_alter()` to reorder them. The
   reorder did not take, the marker was never read, and **every label vanished**.
   A marker nothing renders leaves no trace. `ce_core` now reads `#title`
   directly, so order does not matter.
3. ⚠️ `verify_form_parity.php` keyed the widget label on
   `class="views-widget-label"` — a class **D7 does not have** and only D10's
   first implementation added. The test encoded the old implementation and failed
   the moment the markup was corrected *toward* D7. It now identifies the label
   by position, which is D7's actual shape.

**Result:** all six report forms now match D7 element for element; form parity
**6 of 6, 0 differences**; `/courses` and every other exposed form unchanged;
zero stray `_wrapper` classes site-wide.

### ⚠️ What this fix does *not* cover

Entity forms — product, course, user, quiz, taxonomy, commerce — render in
**Claro**, not the ported theme (verified: those pages load `claro/css`; the
report pages do not). No D7 stylesheet applies to them, so their spacing is
Claro's. Matching D7 there would mean restyling Claro wholesale, which was not
done and is not claimed. **Classification: GENUINELY UNAVOIDABLE** without
replacing the admin theme — stated plainly rather than as "Claro is different".

## 2. ⭐ A defect found on the way that was not on the list

D10 had **all 118 promotions at `compatibility = any`**. D7 has **105 set to
`none`** — which D7 itself labels *"Not with any other discount"*
(`commerce_discount.module`).

`migrations/ce_commerce_discount.yml` hardcoded `default_value: any` under the
comment *"D7 has no equivalent"*. **That comment was wrong**:
`commerce_compatibility_strategy` is exactly that equivalent. So 105 discounts
that D7 refused to stack could combine on one order in D10 — a money defect no
row count could reveal, because all 118 promotions migrated.

Fixed: the YAML now maps the field through a `static_map` (with no entry for
`except`/`only`, so an unmapped value fails loudly rather than silently becoming
`any`), and `repair_promotion_compatibility.php` repaired the 105 rows in place —
**13 `any` / 105 `none`, exactly D7**, idempotent, writing only that column and
only where it was still the untouched default.

## 3. The six gaps — final disposition

| # | Item | Status | Evidence | Action required |
|---|---|---|---|---|
| 1 | Product Change history | **GENUINELY UNAVOIDABLE** | Commerce 3 ships `commerce_product` **non-revisionable upstream** — no `revision_table` in the entity annotation, so this is contrib's architecture, not Decision 12. Making it revisionable means patching contrib and re-patching on every update. Only **3 of 162** products have real history (4, 3 and 3 revisions), **every log message empty**. Archive verified: 169 rows, 162 products, all 3 present with the right counts, columns include `revision_uid`, `log`, `revision_timestamp`. | None, unless the client wants a custom history UI over the archive |
| 2 | Abandoned-cart mailer | **PRODUCTION OPERATIONAL REQUIREMENT** | Proven end to end by `verify_abandoned_cart_flow.php`: cron generated mail with D7's exact subject *"Did you have trouble?"* and from-address, recorded the send so it cannot repeat, and the 3,915-row history was restored exactly. The safety gate refuses to run if mail is not captured locally or if a real customer address is queued. | Schedule cron in production. Code and configuration are complete. |
| 3 | jQuery UI datepicker | **PARITY COMPLETE — MAINTENANCE RISK** | 14 exposed date filters across 6 pages, D7's `changeMonth`/`changeYear` and `mm/dd/yy`. Native `type=date` was measured and rejected: browser-locale format, segmented keyboard entry, placeholders ignored. No D10-native control reproduces D7's behaviour. | Client accepts the EOL dependency, or accepts the native control's differences |
| 4 | Admin form visual parity | **FIXED** for the migrated report forms; **GENUINELY UNAVOIDABLE** for Claro-themed entity forms | See §1. Root cause was markup, not CSS; fixed once in two shared functions. | None for reports. Entity forms need an admin theme decision. |
| 5 | `commerce_compatibility_selection` | **N/A — OBSOLETE** | The module is **absent from D7's `{system}`** (never installed), `field_data_commerce_compatibility_selection` has **0 rows**, and `{menu_router}` has **no** admin path for it. | None. But see §2 — its *sibling* field was a live defect. |
| 6 | `/sale-report` unbounded memory | **TECHNICAL RISK — root cause identified, not fixed** | Reproduced: a max-only range still exhausts 1 GB. The page pager is `full`/200, identical to D7, so the page is not the problem. **The total attachments are:** D7 has `group_by: true` on `attachment_1`/`attachment_2` (the query returns one SUM row); D10 has it **off**, so with `pager: none` Views buffers every matching row — up to 113,446 order items. The two are inverted. | Enable aggregation on the two attachments to match D7, then re-verify the rendered total ($4,716.00 for the Round-8 window) and `verify_admin_parity`'s total-strip row count |

## 4. 🛑 D7 write check — one write, disclosed

```
WRITE_CHECK: DIRTY - 1 table(s) moved
  DRIFT  variable    checksum changed (an in-place UPDATE)
```

**I caused a write to D7**, and the rule was that D7 stays read-only. What
happened: capturing D7's rendered exposed-form markup required building and
rendering a D7 form, and Drupal 7 records CSS/JS aggregate filenames in
`drupal_css_cache_files` the first time a page is built.

What is and is not established:

* The `{variable}` **row count did not move** (576 before and after).
* **No business table moved** — users, nodes, orders, payments, quiz results,
  `report_history`, certificates, referral, carts and the rest are all
  unchanged, by both count and checksum.
* Repeating the same D7 renders now produces **CLEAN**, consistent with a
  one-time first-render cache entry.
* ⚠️ **I could not name the exact key**, because the baseline stored only a
  table checksum. `d7_write_check.php` now stores a **hash per variable name**,
  so the next drift reports the key; re-baselined and re-verified **CLEAN**.

No configuration, content or business data was altered. The write was cache
bookkeeping — but it was a write, and it is recorded rather than excused.

## 5. Regression — every suite, after all changes

| suite | result |
|---|---|
| `verify_form_parity.php` | **6 of 6, 0 differences** |
| `final_admin_parity.php` | all checks passed |
| `admin_menu_parity.php` | **0 unexplained of 804** |
| `audit_admin_routes.php` | **0 unaccounted for of 911** |
| `verify_admin_pages.sh` | **39 of 39** HTTP 200 |
| `verify_quiz_properties.php` | 27 of 27 |
| `verify_referral_admin.php` | 51 of 51 |
| `verify_wistia_settings.php` | 24 of 24 |
| `verify_pathauto.php` | 27 of 27 |
| `verify_abandoned_carts.php` | 17 of 17 |
| `verify_abandoned_cart_flow.php` | **new — all checks passed** |
| `verify_certificate_admin.php` | 9 of 9 |
| `verify_certificate_mapping.php` | 43 of 43 |
| `verify_multichoice_data.php` | 391 of 391 |
| `repair_promotion_compatibility.php` | 105 repaired, then idempotent |

## 6. Final verdict

**NOT a full PASS.** Two items are outstanding and neither should be signed off
by a script:

* **Gap 6** — root cause now known precisely and the fix is one config change on
  two attachments, but it touches a display whose totals were verified in Round 8
  and it is **not applied**.
* **Gap 4 for Claro-themed entity forms** — unavoidable without an admin-theme
  decision.

Everything else in this round is either fixed and verified, or classified with
the evidence stated at the point it is claimed. The promotion-compatibility
defect in §2 was not on anyone's list and would not have been found by any
count-based check — worth weighing when deciding how much more of this kind of
column-level comparison the rest of the migration deserves.

---

# Round 21 — Gap 6 attempted: the cheap fix is wrong, and here is the proof

> **Task:** fix the `/sale-report` unbounded-memory defect and verify the totals
> are unchanged.
>
> **Outcome:** the fix Round 20 proposed was implemented, **changed the totals**,
> and was reverted. The totals are verified restored. The memory defect stands,
> now with the wrong answer eliminated and the right one specified.

## 1. Baseline — captured first, because "totals unchanged" is the whole test

Three windows, rendered over HTTP as an administrator sees them. The grand total
appears **twice** on each page (the attachment renders above and below the table):

| window | rows | total |
|---|---|---|
| 2026-07-01 … 2026-08-31 | 73 `<tr>` | **$4,716.00 ×2** |
| 2026-06-01 … 2026-06-30 | 56 | **$3,200.00 ×2** |
| 2025-01-01 … 2025-12-31 | 201 | **$349,606.35 ×2** |

## 2. The defect, measured per display

| display | bounded window | unbounded (max-only) |
|---|---|---|
| `panel_pane_admin_sale_report` | 72 rows, 18 MB | **200 rows, 6 MB** |
| `attachment_1` (the total strip) | 72 rows | **32,526 rows, 1,713 MB** |

The page was never the problem — its pager is `full` / 200, byte for byte D7's.
**The attachment fetches one result row per order item purely to print one
number, then sums them in PHP.** Unbounded that is 32,526 rows; across the whole
table it would be 113,446. That is the 1 GB.

## 3. 🛑 The Round-20 hypothesis was wrong, and testing it is what showed that

Round 20 proposed: D7 has `group_by: true` on the attachments and D10 does not,
so turn it on. Implemented exactly that — `group_by: TRUE`, `commerce_total`
aggregated as `sum`, `line_item_id` dropped to match D7's single-field
attachment — and re-measured:

| window | correct | after the change | drift |
|---|---|---|---|
| Jul–Aug 2026 | $4,716.00 | **$4,794.00** | +$78.00 |
| Jun 2026 | $3,200.00 | $3,200.00 | — |
| 2025 | $349,606.35 | **$356,388.00** | **+$6,781.65** |

**Why it is wrong.** The report's total is a **de-duplicated** sum: each order
item must contribute once. The view joins order items to flaggings and course
nodes, and those joins multiply rows — one order item can appear several times.
`line_item_id` is the de-duplication key, and grouping by it is what makes the
rendered result one row per item. Remove it and `SUM()` happily counts the
duplicates, which is exactly the inflation above. June was unaffected only
because no item in that window happened to be multiplied.

So the shape D7's stored config suggests (`group_by: true`, `commerce_total`
alone) does **not** reproduce D7's number in D10 — the join graph is not the
same, and Round 4 already had to rebuild these displays against Commerce 3's
tables.

**Reverted** from the pre-change backup. Totals re-verified on all three
windows: **$4,716.00 ×2 · $3,200.00 ×2 · $349,606.35 ×2** — identical to
baseline. `verify_form_parity` 6 of 6; `verify_admin_parity` shows
`/sale-report admin pane total:72 rendered:72` and the total strip unchanged.

⚠️ One process note: the first revert was hand-rolled and did **not** restore the
field order correctly. Restoring from the full `drush cget` backup did. A
config backup was taken before the change; hand-editing config back is not a
revert.

## 4. What the real fix has to be

The total needs a **de-duplicating aggregate** — conceptually

```sql
SELECT SUM(t) FROM (SELECT total_price__number AS t
                    FROM … GROUP BY order_item_id) x
```

Views cannot express a derived-table aggregate as configuration, so this is a
code change on those two attachment displays — a `hook_views_query_alter()` that
replaces the field list with a subquery aggregate, or a small custom area
handler that runs one scalar query instead of a row set. Either is a contained
change, but it is **not** the config edit Round 20 assumed, and it must be
verified against the three totals above before it is trusted.

## 5. Gap 6 — final status

**TECHNICAL RISK — root cause proven, cheap fix eliminated, real fix specified
and not implemented.**

| | |
|---|---|
| Root cause | The total strip fetches one row per order item (32,526 unbounded, 113,446 worst case) to print one number, and sums in PHP |
| Measured | 1,713 MB for the attachment vs 6 MB for the page, on the same query |
| Attempted | SQL `SUM` with `group_by` — **rejected**, inflates totals by up to $6,781.65 because the sum must be de-duplicated per order item |
| Current behaviour | Unchanged and correct for every bounded range; an unbounded range still exhausts 1 GB |
| Required action | Implement the de-duplicating aggregate above, then re-verify the three totals |

**Bounded ranges — every real use of this report — are unaffected and correct.**
The failure needs a range with no lower bound.

## 6. Regression after the revert

* `verify_form_parity.php` — 6 of 6, 0 differences
* `verify_admin_parity.php` — `/sale-report` 72/72, total strip unchanged
* Rendered totals — all three windows byte-identical to baseline
* D7 — untouched; every query this round was a `SELECT`

---

# Round 22 — Gap 6 fixed: the de-duplicating aggregate

> **Task:** implement the de-duplicating aggregate and verify the three totals.
>
> **Outcome:** implemented, totals byte-identical, and the range that used to
> exhaust 1 GB now renders in six seconds. Gap 6 is **FIXED**.

## 1. What was built

`ce_reports_views_pre_execute()` reuses the view's **own compiled query as a
derived table** and takes the sum over it:

```sql
SELECT SUM(x.<total>) FROM ( <the view's own query, verbatim> ) x
```

One scalar round trip instead of a row set. The de-duplication is whatever the
view already does — which is the whole point, and exactly what the rejected
Round-21 attempt got wrong. The attachment's own fetch is then limited to a
single row, and `ce_reports_views_post_execute()` writes the computed total into
it so the existing "Total Amount: $X" markup renders unchanged.

⭐ **Why this is correct where plain `SUM()` was not.** Round 21 dropped
`line_item_id` and let the database sum the joined rows — inflating the 2025
total by **$6,781.65**, because the joins to flaggings and course nodes multiply
rows and each order item must count once. Here the inner query keeps the view's
own grouping, so the multiplication is already collapsed before anything is
added up.

⚠️ Three details that matter:

* The subquery is a **clone** — modifying the view's `SelectInterface` would
  change the query Views is about to run.
* `$subquery->range()` clears any range first; a range on the inner query would
  silently cap the total.
* The column alias is read from the live field handler and checked against
  `/^[A-Za-z0-9_]+$/` before it is interpolated into the expression, and Views'
  `'unknown'` sentinel is rejected.

If the aggregate query fails or the column cannot be found, the hook logs and
returns, and the display falls back to the old row-sum behaviour rather than
printing a wrong number.

## 2. The three totals — unchanged

`scripts/reconcile/verify_sale_report_totals.sh`, rendering the real page as an
administrator. The total appears twice per page (the strip sits above and below
the table), so two occurrences is the expected result:

```
WINDOW                     ROWS     TOTAL            RESULT
2026-07-01..2026-08-31     73       $4,716.00        PASS (3s)
2026-06-01..2026-06-30     56       $3,200.00        PASS (3s)
2025-01-01..2025-12-31     201      $349,606.35      PASS (3s)

All three totals match.
```

## 3. The memory defect — gone

| range | before | after |
|---|---|---|
| `max=2026-09-01`, no min | **fatal, 1 GB exhausted** | **200 OK, 6s**, total $2,091,227.60 |
| `2020-01-01 … 2026-09-01` | — | 200 OK, 4s, $1,541,572.10 |
| `max=2026-12-31`, no min | — | 200 OK, 4s, $2,091,374.60 |

## 4. ⭐ The optimisation is arithmetic-neutral, and that is measured

`verify_sale_report_aggregate.php` computes the total **both ways** for the same
windows — the new aggregate, and the old fetch-every-row-and-sum-in-PHP — and
compares them:

```
WINDOW                                     AGGREGATE        ROW SUM          ROWS      RESULT
Jul-Aug 2026 (verified in Round 8)         4,716.00         4,716.00         72        MATCH
Jun 2026                                   3,200.00         3,200.00         55        MATCH
2025                                       349,606.35       349,606.35       3585      MATCH
max-only - the range that used to fatal    2,091,227.60     2,091,227.60     32526     MATCH

peak memory: 1587 MB
```

That 1,587 MB peak is the **row-sum side of the comparison** — the behaviour
being replaced — which is precisely why the page died against a 1 GB limit. The
aggregate agrees with it to the cent on all four windows, including the
32,526-row case.

## 5. Regression

| suite | result |
|---|---|
| `verify_sale_report_totals.sh` | **new — all three totals match** |
| `verify_sale_report_aggregate.php` | **new — aggregate = row sum, 4 of 4 windows** |
| `verify_form_parity.php` | 6 of 6, 0 differences |
| `final_admin_parity.php` | all checks passed |
| `admin_menu_parity.php` | 0 unexplained of 804 |
| `audit_admin_routes.php` | 0 unaccounted for of 911 |
| `verify_admin_pages.sh` | 39 of 39 HTTP 200 |
| `verify_quiz_properties` · `referral` · `wistia` · `pathauto` · `abandoned_carts` · `certificate_admin` · `certificate_mapping` | all passed |
| `verify_multichoice_data.php` | 391 of 391 |
| `d7_write_check.php` | **WRITE_CHECK: CLEAN** |

⚠️ **One measured number changed on purpose.** In `verify_admin_parity.php`,
`sale report total strip (admin)` reads `total:1` where it used to read
`total:72` — the strip now returns one aggregated row instead of 72. Every other
line in that script is identical. That script prints measurements and asserts
nothing, so this is an observation, not a failure; the assertion that the total
is *correct* lives in the two new scripts above.

## 6. Gap 6 — final status

**FIXED.**

| | |
|---|---|
| Root cause | The total strip fetched one row per order item (32,526 unbounded, 113,446 worst case) to print one number, and summed in PHP |
| Fix | Derived-table aggregate over the view's own query; display limited to one row |
| Totals | $4,716.00 · $3,200.00 · $349,606.35 — unchanged, verified on the rendered page |
| Equivalence | Aggregate = row sum to the cent on 4 windows, including 32,526 rows |
| Unbounded range | was a 1 GB fatal, now 200 OK in 6s |
| Guard | `verify_sale_report_totals.sh` — these three numbers are the contract for any future change to this report |

---

# Round 23 — Complete final audit

Everything below was re-run end to end in one pass, against the live D10 site,
after the Gap 6 fix landed. No result is quoted from an earlier round.

## 1. Coverage — is anything from D7 missing?

Two independent sweeps, one over the menu tree and one over the route table.

```
D7 admin menu links (management menu): 804
  in menu        69
  as a tab       81
  as an action   15
  route only     3
  obsolete       513
  D7 default tab 123
  D7 hidden      0
  UNEXPLAINED    0
```

```
992 D7 admin paths · 81 help pages skipped · 911 checked
203 matched a D10 route directly · 708 classified by rule
Every D7 admin path without a D10 route is accounted for.
```

**0 unexplained of 804 menu links. 0 unaccounted for of 911 routes.**

Every one of the 708 classified paths carries a named reason — `entity_view_mode
-> D10 core Display modes`, `flag ids are machine names in D10, not integers`,
`print_pdf`, `jQuery Update (D7 only)`, and so on — from the 146 shared rules in
`d7_admin_classification.php`. Nothing is dismissed as "probably fine".

## 2. Behaviour — does each page do what D7's did?

| verifier | result |
|---|---|
| `final_admin_parity.php` | all checks passed |
| `verify_form_parity.php` | 6 forms · 63 widgets · **0 differences** |
| `verify_quiz_properties.php` | all passed · 68 rows restored row-for-row |
| `verify_referral_admin.php` | all passed |
| `verify_certificate_admin.php` | all passed |
| `verify_certificate_mapping.php` | all passed |
| `verify_wistia_settings.php` | all passed |
| `verify_pathauto.php` | all passed · D7 13,971 → D10 13,977 aliases |
| `verify_abandoned_carts.php` | all passed |
| `verify_multichoice_data.php` | **391 of 391**, 0 differences |
| `verify_sale_report_totals.sh` | $4,716.00 · $3,200.00 · $349,606.35 — all match |
| `verify_sale_report_aggregate.php` | aggregate = row sum, **4 of 4** windows |
| `verify_admin_pages.sh` | **40 of 40 HTTP 200** |

The sale-report aggregate check is the one worth re-reading: it computes the
total the new way *and* the old fetch-every-row way and compares them, and the
old side peaked at **1,775 MB** on the 32,526-row window — the behaviour that
used to kill the page against a 1 GB limit. The two agree to the cent.

## 3. Visibility — is anything in the database that the UI hides?

`verify_admin_visibility.php` (3 GB) reconciles 22 datasets against the freeze
counts. Nothing is short, and no dataset has rows the admin UI cannot reach:

```
Quiz results           3,977 → 3,975   +2 created in D10
Quiz answers           5,133 → 5,130   +1 created in D10
Report history           784 →   785   +8 created in D10
Webform submissions   21,066 → 21,065  +1 created in D10
Flaggings            132,272 → 132,261 +5 created in D10
URL aliases           13,971 → 13,977  +6 created in D10
```

The `+n created in D10` rows are this project's own test activity, post-freeze
and individually accounted for — not source rows that went missing.

## 4. ⭐ D7 read-only — and a correction to Round 22

`d7_write_check.php` reported **DIRTY** in Round 22. I reported it as such, and
it needed running down rather than waving through. It was **not** a write by
this audit.

`{variable}`'s checksum had moved while its row count sat unchanged at 576 — an
in-place UPDATE. The per-key hashes named the keys, and they were D7's own
housekeeping. The decisive evidence is `cron_last`, read three times during this
audit while it issued nothing but SELECTs:

| read at | `cron_last` |
|---|---|
| baseline | 05:03:27 |
| after the first sweep | 05:11:01 |
| after this one | 05:17:13 |

**D7's cron is running on its own schedule.** That is what moved `{variable}`.

`d7_write_check.php` now buckets the changed keys instead of printing a bare
verdict, because "DIRTY" that fires on a live site ticking over is a guard that
will be ignored:

```
  {variable}: 16 key(s) changed of 576
     cron / cache bookkeeping           15   (D7's own cron, not this audit)
     CSS/JS aggregate bookkeeping       1   (written when any page is served)
     OTHER                              0

WRITE_CHECK: CLEAN   (29 tables, 6 checksummed, 576 variable keys)
No business table moved, and no variable changed that this audit could have written.
```

Only the `OTHER` bucket means the audit itself wrote something. It is empty.
Alongside it: **0 of 29 tables moved by row count**, and 5 of 6 checksummed
tables — including `{report_history}`, the table D7's Report exposed form
writes to — are byte-identical.

**WRITE_CHECK: CLEAN.**

## 5. Verdict

**Zero unexplained gaps.** Every D7 admin menu link and every D7 admin route is
either present in D10 or classified with a stated reason; every verifier passes;
all 40 admin pages return 200; D7 is untouched.

**That is not the same as "zero open items."** Four dispositions remain, all
classified, none of them unexplained:

| # | item | disposition | needs |
|---|---|---|---|
| 1 | Product Change History | **GENUINELY UNAVOIDABLE** — Commerce 3 ships `commerce_product` non-revisionable upstream, so there is no revision table to show a history from | client decision: accept, or fund a custom revision implementation |
| 2 | Abandoned-cart mailer | **PRODUCTION OPERATIONAL REQUIREMENT** — the code, settings and 3,915 rows of send history are migrated and the flow is proven end to end against a captured mailbox; it has never been allowed to send to a real address from this machine | production cron, on the production host |
| 3 | jQuery UI datepicker | **PARITY COMPLETE / MAINTENANCE RISK** — D7's exact `changeMonth`/`changeYear`/`mm/dd/yy` calendar is restored and working; jQuery UI is EOL and out of D10 core, so the library is carried locally | client decision: keep D7's calendar, or accept a different one later |
| 4 | Claro entity-form pixel parity | **GENUINELY UNAVOIDABLE** without an admin-theme decision — the exposed-filter spacing defect was fixed globally at its root in `ce_core_form_views_exposed_form_alter()`, but core entity forms are rendered by Claro, not Seven | client decision on the admin theme |

Items 2 and 4 are decisions, not defects. Items 1 and 3 are constraints of the
D10 platform, stated rather than hidden.

---

# Round 24 — Exposed filter forms: the spacing, found and fixed at the root

The screenshots showed D7 and D10 side by side on `/sale-report` and
`/marketing-report/detail`: in D10 the filter inputs ran edge to edge with no
gutter, the rows sat at the wrong rhythm, and the "Total Amount" bar had lost
its border. Three separate causes, all of the same kind — **D7 markup or CSS
that Drupal 8 removed, with the ported D7 theme still pointing at it.**

None of this was fixed with new CSS on individual pages.

## 1. How the causes were found

Reading the stylesheets was not enough to settle it — the cascade spans D7's
`views.css`, a 155 KB theme stylesheet, Bootstrap and a responsive layer, and
twice a plausible reading was wrong:

* the flex container on `/marketing-report/detail` comes from the
  `.page-marketing-report` rule, not the `-detail` one, which sets no `display`
  at all — the browser's own matched-rule list settled it;
* the row-height difference was **not** a CSS gap at all but a markup one.

So the geometry was measured in a headless browser on both sides, with D7's
side being **D7's own rendered markup under D7's own stylesheets** — the
fixtures under `scripts/reconcile/d7/fixtures`, captured with no exposed input,
each printing `WRITE_CHECK: clean`.

## 2. Cause A — D7's Views stylesheet, which D8 deleted

D7's `views.info` carried `stylesheets[all][] = css/views.css`, loading this on
every page:

```css
.views-exposed-form .views-exposed-widget { float: left; padding: .5em 1em 0 0; }
.views-exposed-form .form-item, .views-exposed-form .form-submit { margin: 0; }
.views-exposed-form label { font-weight: bold; }
.views-exposed-widgets { margin-bottom: .5em; }
```

D10 core's `views.module.css` has **no exposed-widget layout at all**, and the
theme — ported byte-for-byte, confirmed by diff against `cetc_new/css/style.css`
— supplies only the column track:

```css
.page-sale-report … .views-exposed-widgets > div { flex-basis: 33.3%; }
.page-sale-report … .views-exposed-widgets > div input { width: 100%; }
```

In D7 that 33.3% track (519px) also carried Views' `padding: … 1em 0 0`, so the
input filled ~503px and the remaining 16px **was the gutter**. Measured:

| | widget track | input | result |
|---|---|---|---|
| D10 before | 0..519, 519..1039 | 0..519, 519..1039 | borders touching |
| D7 | 500 | 482 | a real gutter |

Restored as `ce_core/views_exposed_d7`, attached in
`ce_core_form_views_exposed_form_alter()` — the one hook that runs for every
exposed form in the site.

## 3. Cause B — the label was one div too deep

D7 puts every filter's label **outside** `.views-widget`:

```html
<div class="views-exposed-widget views-widget-filter-X">
  <label for="edit-X">Last name</label>
  <div class="views-widget"><div class="form-item"><input …></div></div>
```

D10 left it inside the form item. The ported stylesheet carries two label rules
with different margins, and the inner one has a **top** margin:

```css
… .views-exposed-widgets label              { margin-bottom: 1rem; }
… .views-exposed-widgets .views-widget label { margin: 1rem 0; }
```

So every D10 row gained 10px that D7 never had. `ce_core_preprocess_views_exposed_form()`
moves the label, which must happen at **preprocess** time: the label does not
exist until `template_preprocess_views_exposed_form()` copies it from
`$form['#info']`, and the element `#id` is not assigned until after
`hook_form_alter()` has returned — and that id cannot be recomputed by hand
(`field_instructor_s__value` → `edit-field-instructor-s-value`, the doubled
separator collapsing).

## 4. Cause C — D7's attachment wrapper

D7's `views-view.tpl.php` wraps each attachment:

```html
<div class="attachment attachment-before"> … </div>
```

D8 prints attachments bare. The ported stylesheet addresses that wrapper:

```css
.page-sale-report .view-sale-report .attachment .view-sale-report
  .views-field.views-field-commerce-total
  { display:flex; justify-content:space-between; padding:1.5rem;
    border:1px solid rgba(0,0,0,.1); font-weight:700 }
```

Without `.attachment` in the chain the rule could not match, so the sale
report's "Total Amount" bar lost its border and its right-aligned figure and
fell back to two stacked lines. Restored in the theme's `views-view.html.twig`,
beside the `.view-filters` wrapper that was restored earlier for the same
reason.

## 5. ⭐ Verified by measurement, against D7

`verify_exposed_form_geometry.mjs` renders both sides and compares:

```
PAGE                       METRIC           D7             D10            RESULT
/marketing-report/detail   row pitch        86             86             MATCH
/marketing-report/detail   input/track      0.824          0.826          MATCH
/marketing-report/detail   label margin     10px/10px,0px/10px 10px/10px,0px/10px MATCH
/sale-report               row pitch        163            163            MATCH
/sale-report               input/track      0.964          0.965          MATCH
/sale-report               label margin     0px/10px       0px/10px       MATCH
```

`verify_exposed_label_binding.php` compares every label against D7's recorded
markup — text, order, nesting and the set of `for` values that name nothing:

```
/sale-report  /marketing-report  /marketing-report/detail
/marketing-report/new-detail  /report  /evaluation-results      all PASS
```

🛑 **Two of its assertions were wrong before they were right**, and both are
worth keeping in mind:

* it first demanded that no label dangle, and failed all six pages — **D7
  dangles too**, labelling a range `for="edit-created"` while the inputs are
  `edit-created-min`/`-max`. A tidier rule than D7's reports defects that do not
  exist.
* it then failed `/report` on nested labels. Those are D7's `date_popup`
  screen-reader-only labels (`class="element-invisible"`, pointing at
  `-datepicker-popup-0` inputs D10 has no counterpart for) — a pre-existing
  difference in D7's date widget, invisible on the page.

## 6. Scope — what deliberately did NOT change

| page | result |
|---|---|
| `/courses` | **pixel-identical** — every coordinate unchanged; only the computed `float` differs, which is inert inside the flex container, exactly as in D7 |
| `/admin/content`, `/admin/people` | **untouched** |

D7 did load `views.css` on admin pages — but D7's admin pages wore **Seven** and
D10's wear **Claro**. Pushing D7's stylesheet onto Claro moved the exposed
filter labels 37px off their controls, a change with no D7 to match it. Both the
stylesheet and the label move are therefore scoped to the front-end theme.
Admin-theme parity remains its own tracked item.

## 7. Regression

| suite | result |
|---|---|
| `verify_exposed_form_geometry.mjs` | **new — 6 of 6 metrics match D7** |
| `verify_exposed_label_binding.php` | **new — 6 of 6 pages match D7's markup** |
| `verify_form_parity.php` | 6 forms, **0 differences** |
| `verify_sale_report_totals.sh` | $4,716.00 · $3,200.00 · $349,606.35 unchanged |
| `verify_admin_pages.sh` | 40 of 40 HTTP 200 |
| `admin_menu_parity` · `audit_admin_routes` | 0 unexplained · 0 unaccounted for |
| `final_admin_parity` · quiz · referral · wistia · pathauto · abandoned carts · certificate ×2 | all passed |
| `verify_multichoice_data.php` | 391 of 391 |
| `d7_write_check.php` | **WRITE_CHECK: CLEAN** |

The eight D7 form captures each reported `WRITE_CHECK: clean`, and the global
check confirms it: 0 of 29 tables moved, and the only `{variable}` keys that
changed are D7's own cron bookkeeping.

---

# Round 25 — The menu the audit could not see, and the /report page

## 1. ⭐ "Modules is missing" — the audit had a blind spot, and it was real

`admin_menu_parity.php` asks *"is every D7 admin path reachable in D10?"* and
answers **by path**. It reported 0 unexplained of 804 and was right on its own
terms. But a menu is read, not diffed — and D10 core **renames** items:

| D7 label | D10 label | path |
|---|---|---|
| **Modules** | Extend | `/admin/modules` |
| **Store** | Commerce | `/admin/commerce` |
| **Blocks** | Block layout | `/admin/structure/block` |
| **Site information** | Basic site settings | `/admin/config/system/site-information` |
| **Regional and language** | Region and language | `/admin/config/regional` |
| **Date and time** | Date and time formats | `/admin/config/regional/date-time` |
| **CAPTCHA** | CAPTCHA settings | `/admin/config/people/captcha` |

The page was present and reachable every time. The **word** was not, so to
someone who knew D7 the menu was simply missing. A parity audit that never
compares the label cannot see that.

`admin_menu_tree_parity.php` now compares **label, parent and weight at every
depth**, and `ce_core_menu_links_discovered_alter()` restores D7's wording,
keyed by route rather than by menu-link plugin id.

⚠️ **This supersedes an earlier decision.** A previous comment argued the
Commerce label should stay, because "Commerce" is Commerce 3's own name for the
section. That reasoning describes the **module**; a menu label describes the
**destination** to the person reading it, and that person knows this site's D7
menu.

## 2. A structural gap in the Quiz submenu

```
D7    management › @quiz › Score quizzes                        depth 3
D10   Administration › Quiz › Quiz reports and scoring › …      depth 5
```

Verified against `{menu_links}`: `admin/quiz/quiz-unevaluated-results` carries
the same `plid` as `admin/quiz/reports` — they are **siblings** in D7, not
parent and child. Reparented to match.

The other four D7 children of Quiz remain classified, not missing:
`/admin/quiz/settings` (its twelve globals drive nothing in `ce_quiz` —
per-setting evidence in Round 17 §A) and `feedback` / `result` /
`result_answer` (Entity-API field UI for an entity D10 does not have).

## 3. Every depth, accounted for

The tree audit now applies the shared classification rules itself rather than
asserting coverage in a comment:

```
D7 visible admin menu links: 297   D10 admin menu links: 116   matched by path: 68

Every matched link carries D7's label.

Not present as a D10 menu LINK: 229
  covered by a classification rule, or reachable by route as a tab/action: 229
  UNACCOUNTED FOR AT ANY DEPTH: 0

MENU TREE PARITY: CLEAN
```

## 4. Toolbar icons for Dashboard and Quiz

The toolbar derives an item's icon class from its menu-link plugin id, and core
ships a rule for each of **its own** ids. The two links this project adds got
their classes the same way —

```
Dashboard  ->  .toolbar-icon-ce-core-dashboard
Quiz       ->  .toolbar-icon-ce-quiz-admin
```

— and core has no rule for them, so they rendered as bare text beside eleven
items that all carried a glyph. Both icons are now drawn to core's own spec (a
16×16 viewBox, one filled path, flat colour) and supplied in **both** of core's
states — `#787878` at rest and `#000000` for `:active`/`.is-active` — because an
icon given in one colour only sits visibly wrong in the open tray.

## 5. ⭐ `/report` — one missing wrapper, seven broken things

The page was reported as "totally different". It was, and it was a single
cause: in D7 `/report` is a **Panels** page, and Panels wraps every pane —

```html
<div class="panel-pane pane-views-panes
            pane-admin-report-new-panel-pane-admin-report-new-detail">
  <div class="pane-content"> … the view … </div>
</div>
```

D10's controller rendered a bare `#type: view`, which emits
`views-element-container` and nothing else. The ported stylesheet addresses that
pane class **57 times**. Everything hanging off it failed at once:

| ported rule | symptom without the wrapper |
|---|---|
| `ul.report-tabs { display:flex }` · `li { list-style:none }` · `li a { background:#090909; color:#fff; padding:2rem 7.5rem }` | Report / History rendered as a **bulleted list** |
| `.form-control { width:100%; margin-bottom:1rem }` | inputs at inconsistent widths |
| `select[multiple] { height:auto; width:100% }` | Profession Type shrank to a **scrolling stub** |
| `.views-submit-button .btn-info {…}` | Apply was the **default grey button** |
| `.view-content { overflow-x:scroll }` | wide result table could not scroll |

Fixed in `ReportPageController::renderPane()`, with the class derived the way
Panels derives it — view id and display id, lowercased, underscores to dashes —
so it stays correct if a display is renamed rather than being a literal that
quietly stops matching.

**Verified against D7.** D7's own `/report` markup, rendered under D7's own
stylesheets, shows the same two-column arrangement and the same asymmetry
between the two date ranges — *Enrolled* stacked full width, *Completed*
side-by-side with placeholder text. D10 now matches it. (The stray "Date"
labels visible in the D7 harness are D7's `date_popup`
`class="element-invisible"` labels; the harness omits the core stylesheet that
hides them.)

🛑 **Third instance of one pattern.** The missing `views.css`, the missing
`.attachment` wrapper and now the missing Panels pane class are all the same
defect: **D7 markup or CSS that Drupal 8 removed, with the faithfully-ported D7
theme still pointed at it.** Worth checking first whenever a page "looks wrong
but works".

## 6. Regression

| suite | result |
|---|---|
| `admin_menu_tree_parity.php` | **new — label, parent and depth: CLEAN; 0 unaccounted at any depth** |
| `admin_menu_parity.php` | 0 unexplained of 804 |
| `audit_admin_routes.php` | 0 unaccounted for of 911 |
| `verify_form_parity.php` | 6 forms, 0 differences |
| `verify_exposed_label_binding.php` | 6 of 6 pages match D7's markup |
| `verify_exposed_form_geometry.mjs` | 6 of 6 metrics match D7 |
| `verify_sale_report_totals.sh` | three totals unchanged |
| `verify_admin_pages.sh` | 40 of 40 HTTP 200 |
| `final_admin_parity.php` | all checks passed |
| `d7_write_check.php` | **WRITE_CHECK: CLEAN** |

---

# Round 26 — The admin toolbar on front-end pages

Reported: *"I am logged in as an admin but the admin menu bar is not visible on
the frontend — are you hiding this?"*

Yes. `ce_core_page_top()` unset the toolbar render element on every non-admin
route, and `ce_core_module_implements_alter()` existed solely to make that
removal run after `toolbar_page_top()` had added it.

## 1. It was a deviation from D7, not parity

The code carried the rule as **"THE RULE, AS THE CLIENT STATED IT"** — an admin
on a front-end page should not get a toolbar merely for holding an admin role.
Checked against the D7 source, read-only, that is not what D7 does:

| evidence | finding |
|---|---|
| `{system}` | `admin_menu` **status = 1**, `admin_menu_toolbar` **status = 1**, core `toolbar` **status = 0** |
| `{variable}` | **no `admin_menu%` row of any kind** — nothing restricts it to admin paths |
| `{role_permission}` | `administrator` and `CE Admin` both hold `access administration menu` |
| client screenshots | the black bar is present on `/sale-report` and `/marketing-report/detail` — both front-end-themed pages |

So on D7 an admin sees the menu everywhere.

🛑 **This sat in the report as "confirm intent" for five rounds and should have
been raised, not parked in a table.** An open question that only ever appears in
a list of open questions is not a question anyone has been asked. Confirmed with
the client on 2026-09-11: show it everywhere, as D7 does. Both functions are
removed.

## 2. The overlap it was avoiding was already solved — properly

The theme pins `.front-page-header` and `.inner_page-main header` to `top: 0`,
so a toolbar on a front-end page shares that strip.
`css/toolbar-displacement.css` already handled this through Drupal's own
displacement API and was written for exactly this case:

```css
top: var(--drupal-displace-offset-top, 0px);
body { padding-top: calc(15rem + var(--drupal-displace-offset-top, 0px)) !important; }
body.front { padding-top: var(--drupal-displace-offset-top, 0px) !important; }
```

The `0px` fallback is what makes it free for customers: with no toolbar the
custom property is never set and every rule resolves to the theme's own value.
Verified on the rendered pages — `/courses` and the front page both show the
site header immediately below the toolbar, with the full-bleed hero intact.

## 3. ⭐ The test that was protecting this asserted the wrong thing, twice

`final_admin_parity.php` §7 read:

```php
$check('ce_core still removes the toolbar on non-admin routes',
  function_exists('ce_core_page_top'));
```

Two faults in one line:

* it asserted the **deviation** as if it were the requirement, so the suite
  would have reported "all checks passed" for as long as the bug existed;
* it tested only that a function was **defined**. A check that passes on the
  existence of code cannot fail when the behaviour breaks.

Replaced with three checks that render real pages and read the markup:

```
an admin sees the toolbar on a FRONT-END page, as D7 does   PASS
an anonymous visitor still gets no toolbar markup at all    PASS
the theme has the displacement rules that keep its header clear  PASS
```

## 4. Regression

| suite | result |
|---|---|
| `final_admin_parity.php` | all checks passed (§7 now behavioural) |
| anonymous `/courses` | **0 toolbar markup**, HTTP 200 — unchanged for customers |
| `verify_admin_pages.sh` | 40 of 40 HTTP 200 |
| `verify_form_parity.php` | 6 forms, 0 differences |
| `verify_exposed_form_geometry.mjs` | 6 of 6 metrics match D7 |
| `admin_menu_tree_parity.php` | CLEAN at every depth |
| `verify_sale_report_totals.sh` | three totals unchanged |
| `d7_write_check.php` | **WRITE_CHECK: CLEAN** |

---

# Security update — Entity API 1.8.0

Drupal's status report raised *"There are security updates available for one
or more of your modules or themes"* on 2026-09-11. This section records the
update, what it changed, and the regression run that accepts it.

| | |
|---|---|
| package | `drupal/entity` (Entity API) |
| previous version | **1.7.0** (`8.x-1.7`) |
| new version | **1.8.0** (`8.x-1.8`) |
| advisory | [SA-CONTRIB-2026-113](https://www.drupal.org/sa-contrib-2026-113) · CVE-2026-81158 · Moderately critical (12/25) · Information disclosure |
| reason | Entity API < 1.8.0 does not correctly apply access control on JSON:API entity collection endpoints. The advisory is public; the module is active on every request because Commerce depends on it. |
| applied | 2026-09-11, `ddev composer update drupal/entity` (single package) |
| committed | **no** — working tree only, as instructed. `git status`: `composer.lock`, this report and the D7 guard's store `scripts/reconcile/.d7_write_check.json` modified; `backups/` untracked |

## 1. Baseline and rollback path

The project is a git repository, but contrib code is git-ignored and the
database never is, so a rollback path independent of git was built before
anything was touched, in `backups/entity-1.8.0-pre/` (172 MB, untracked):

* `composer.json`, `composer.lock`, `installed_packages.{txt,json}` (249 packages)
* `enabled_modules.{csv,json}` (101 enabled), `drush_status.json` (Drupal 10.6.15, PHP 8.3.31)
* `d10_db.sql.gz` — full D10 database export, **plus** ddev snapshot
  `entity-1-8-0-pre` (`ddev snapshot restore entity-1-8-0-pre`)
* `custom_code.tgz` — `web/modules/custom` and `web/themes/custom`
* `d10_tables_before.counts` — exact `COUNT(*)` of all 435 tables;
  `d10_tables_before.checksums` — `CHECKSUM TABLE` of 40 business tables
* `suite_before/` — every verifier's full output before the update
* `pages_before/` — normalised HTML of 60 admin pages/forms and 19 screenshots
* `scripts/reconcile/.d7_write_check.json` — the D7 guard, saved fresh

The full verification suite was run **before** the update and was green (§6).
D7 was never opened for writing: its connection is a SELECT-only account and
`d7_write_check.php` reports **CLEAN** before, after the suite, and at the end.

## 2. What Entity API is to this site

| question | finding |
|---|---|
| who requires it | root `composer.json` (`^1.7`), `drupal/commerce` 3.3.8 (`^1.0`), `drupal/profile` 1.14.0 (`^1.0`) |
| what it requires | `drupal/core ^10.1 \|\| ^11` — nothing else |
| modules declaring it | `commerce`, `profile` (and entity's own test modules) |
| custom `ce_*` modules | **none** list it in `info.yml`; **no** custom code references `Drupal\entity\`, its query-access handlers, permission providers or bundle plugins. The custom modules reach it only through Commerce. |
| upstream diff 1.7 → 1.8 | **two files**: `entity.module` (−4/+1 lines inside `entity_jsonapi_entity_filter_access()`) and a new `tests/src/Functional/Jsonapi/TermTest.php`. No `.install`, no schema, no service, no template, CSS or JS change. |

The changed function is an implementation of `hook_jsonapi_entity_filter_access`,
which only the core JSON:API module ever invokes.

## 3. JSON:API — verified, not assumed

| check | result |
|---|---|
| `jsonapi` in `core.extension` | absent |
| `jsonapi` / `rest` / `hal` / `graphql` modules | not installed (only `serialization`, a Commerce dependency) |
| routes containing `jsonapi` | 0 |
| `jsonapi.*` config objects | 0 |
| `GET /jsonapi`, `GET /jsonapi/node/course` | 404 |
| references in custom modules, theme, config export, `composer.json` | 0 |
| API-auth modules (`simple_oauth`, `basic_auth`, `consumers`, `key_auth`) | none enabled |
| nginx access log `/jsonapi` hits | 2 — both are the probes above |

**JSON:API is not used by this site and has no consumers.** The vulnerable
code path cannot execute here. The update is still correct: the advisory is
public and the module is core to Commerce. Re-verified after the update: still
uninstalled, 0 routes, `/jsonapi` → 404. No access behaviour was weakened or
changed to preserve old behaviour — nothing needed preserving.

## 4. The update

`--with-dependencies` was dry-run first: it would also have bumped
**14 unrelated Symfony packages** (`6.4.44 → 6.4.45`, `service-contracts
3.7.1 → 3.7.3`, `json-schema 6.11 → 6.12`). None is required by
entity 1.8.0 (which requires only core), so the strict form was used.

| | |
|---|---|
| command | `ddev composer update drupal/entity` |
| lock file operations | **1 update, 0 installs, 0 removals** |
| packages changed | `drupal/entity 1.7.0 → 1.8.0` — **only** |
| why any other package changed | none did (`composer show` before/after differs by exactly this one line) |
| `composer.json` | unchanged |
| on disk | `entity.info.yml` version `8.x-1.8`; `entity.module` byte-identical to upstream tag `8.x-1.8` |
| `drush updatedb:status` | **No database updates required** — `updb` therefore not run |
| `drush cr` | run |
| Drupal's own view | `pm:list` → `entity 8.x-1.8`; Update Status re-fetched: `entity` status **CURRENT**, projects flagged not-secure: **0**; the status-report error is gone |

## 5. Security verification

`composer audit` after the update: the **Entity API advisory is cleared**.
One advisory remains and is recorded separately:

| package | installed | advisory | runtime impact |
|---|---|---|---|
| `composer/composer` | 2.10.2 | CVE-2026-84361 (PKSA-ym19-cy3j-z6df), fixed in 2.10.3 — command execution via a malicious package's Perforce URL | **none on the site.** It is in `packages-dev`, pulled by `drupal/core-dev`; it is the developer tool that runs `composer` itself, not code Drupal loads. Not a Drupal module vulnerability. Fix when convenient: `composer update composer/composer`. |

Also visible in Update Status now that the security error no longer masks
them — **not security releases, not part of this update, left alone**:
core 10.6.16, commerce 3.3.9, mailchimp 3.1.5, persistent_login 2.3.0
(bug-fix releases), and `flag` 5.0.3 whose 5.0.x branch is marked
*unsupported* (5.1.0 recommended). The status report's top line therefore
reads "Unsupported release" instead of "Not secure!".

## 6. Migration regression — the full suite, before and after

Every verifier was run before the update and again after, with the same
runner, and the two outputs diffed after stripping timings.

| suite | before | after |
|---|---|---|
| `final_admin_parity.php` | All checks passed | All checks passed |
| `admin_menu_parity.php` | UNEXPLAINED 0 | UNEXPLAINED 0 |
| `admin_menu_tree_parity.php` | CLEAN at every depth | CLEAN |
| `audit_admin_routes.php` | every D7 admin path accounted for | same |
| `verify_admin_pages.sh` | 40 of 40 HTTP 200 | 40 of 40 |
| `verify_admin_visibility.php` (`-d memory_limit=3G`) | 22 datasets reconcile | 22 reconcile |
| `verify_form_parity.php` | 6 forms, 0 differences | 6, 0 |
| `verify_exposed_label_binding.php` | 6 of 6 = D7 | 6 of 6 |
| `verify_exposed_form_geometry.mjs` (real Chrome) | 6 of 6 metrics match D7 | 6 of 6 |
| `verify_quiz_properties.php` | All checks passed | All |
| `verify_multichoice_data.php` | 391 of 391 identical to D7 | 391 of 391 |
| `verify_multichoice_form.php` | 5 of 5, original state restored | 5 of 5 |
| `verify_referral_admin.php` | 51 of 51 | 51 of 51 |
| `verify_wistia_settings.php` | 24 of 24 | 24 of 24 |
| `verify_pathauto.php` | 27 of 27 | 27 of 27 |
| `verify_abandoned_carts.php` / `_flow.php` | 17 of 17 / all passed | same |
| `verify_certificate_admin.php` / `_mapping.php` | 9 of 9 / 43 of 43 | same |
| `verify_sale_report_totals.sh` | $4,716.00 · $3,200.00 · $349,606.35 | **unchanged** |
| `verify_sale_report_aggregate.php` | aggregate = row sum in every window | same |
| `verify_evaluation_submissions.php` | 25 sampled, 0 differences; 20,398 flaggings | same |
| `verify_webform_course_link.php` / `_rows.php` | 0 unmapped | same |
| `verify_content_model.php` | 38 of 38 field instances, 0 missing | same |
| `verify_node_field_values.php`, `verify_c2.php`, `verify_secrets.php` | ok | ok |
| `verify_order_totals.php`, `verify_order_backfill.php`, `verify_adjustments.php`, `verify_promotion_windows.php` | Phase-3 gate scripts whose "expected" constants pre-date the post-freeze test data — treated as fingerprints | **output identical** |
| `d7_write_check.php` | WRITE_CHECK: CLEAN | **CLEAN** |

**30 of 33 outputs byte-identical.** The three that differ are the verifiers'
own side-effects, present in the *before* run in exactly the same form:

* `verify_abandoned_cart_flow` — sends one Mailpit message per run (31→32, then 32→33).
* `verify_multichoice_form` — samples a random question (nid 252, then 266); both restored.
* `verify_order_backfill` — orders 4662 and 87819 are **user 1's own two draft
  carts**; Commerce's cart refresh rewrites their `changed` whenever uid 1
  browses, which every verifier and capture does. The pre-update run shows the
  same stamp movement.

Also exercised, explicitly, after the update — **write** round trips as user 1
(create → reload → edit → reload → delete, every table count asserted back to
baseline): product + variation (price, default variation, access for admin and
for anonymous), draft order + order item (total 2 × 12.34 = 24.68, access for
owner and for another user), user + role add/remove, course node with a second
revision, page node, entity queries with access checks, the
`commerce_products` view. **22 of 22 pass.** Multichoice question writes are
covered by `verify_multichoice_form.php` (save, change, restore).

## 7. Database — before/after, all 435 tables

Row counts compared for every table; `CHECKSUM TABLE` for 40 business tables;
the six whose checksum moved were then compared **row by row** against the
pre-update dump.

| table | change | cause |
|---|---|---|
| `cache_*` (10 tables) | rebuilt | `drush cr` |
| `cachetags` +8, `sessions` +14, `captcha_sessions` +7, `watchdog` +40, `key_value_expire` +2 | operational | logins (`drush uli`) and page renders by the verifiers and captures |
| `key_value` | `update.last_check`, `asset.css_js_query_string`, `twig_extension_hash_prefix` changed; one `entity_autocomplete` selection key added; 11 `pathauto_state.commerce_product` keys removed | Update Status re-fetch; cache rebuild; an autocomplete widget render; see the product row below |
| `users_field_data` | **1 row**: uid 1 `access`/`login` timestamps | the `drush uli` logins |
| `commerce_order` | **2 rows**: 4662, 87819 — `changed` only, both uid 1 draft carts | cart refresh (above) |
| `certificate_node` | **1 row**: nid 236, `cnid` 760 → 776, content identical (`manual/manual/268`) | `verify_certificate_admin.php` changes and restores the first manually-mapped node's mapping; `CertificateMappers::saveMapping()` is delete + insert, so the auto-increment moves |
| `commerce_product`, `_field_data` | 173 → **162** (existing rows: **0 changed**) | see below |

**The product delta is not the update.** `verify_pathauto.php` creates a probe
*video* node; `ce_rules_behavior`'s `VideoProductFactory` (the ported D7 rule)
creates a `video_product` for it; the verifier deletes the node but not the
product. **Twelve** such orphans, "Zzz Pathauto Probe Video" (ids 199–210), were
already present at baseline from earlier rounds, and each suite run leaked one
more (210 = pre-update run, 211 = post-update run). At 18:35–18:36 IST all
thirteen were deleted **through the admin UI** — watchdog shows
`POST /product/211/delete` from the products list, then a bulk delete from
`/admin/commerce/products/delete`, from a host browser session as user 1 —
i.e. an interactive clean-up that happened while this work was running, not
anything this update or its scripts did. The verifier's leak is a pre-existing
hygiene defect, recorded here, **not fixed** (out of scope for a security
update).

Migrated business data: orders, order items, payments, products (existing
rows), users, roles, nodes, revisions, quiz results and answers, multichoice
answers, webform submissions, flaggings, aliases, referral tables, certificate
snapshots, report history — **checksums unchanged**. The `config` table
checksum is unchanged, so roles, permissions, views, form displays and field
configuration are byte-identical to before the update.

## 8. Visual and markup regression

**Markup.** 60 admin pages and forms (every listing and settings page in
`verify_admin_pages.sh`, plus product add/edit/view, order add/edit/view,
course/quiz/multichoice/certificate/page add/edit, user add/edit, roles,
permissions, field and form-display pages, revisions) were fetched as user 1
before and after, normalised for tokens/build-ids/hashes/relative times, and
diffed. All 60 return the same HTTP status. The one change on every admin page
is the **security-warning message block disappearing** — the intended effect.
With that block removed, **48 of 59 are byte-identical**; the other 11 are:

* `/admin/modules` — `Version: 8.x-1.7` → `8.x-1.8`; `/admin/reports/status` — the security error replaced by the (pre-existing) *flag* "unsupported release" notice.
* carts, people, content, orders, files lists — relative "ago" times, and files sorted on a tied `changed` value.
* products list — the probe products above.
* course add/edit, quiz 236 edit — two certificate templates share one title (nodes 312 and 356) and swap places in a `<select>`; `user/6/edit` and `node/236/edit` — tab pairs with **equal weight** (`ce_referral.rewards` = `commerce_order.address_book.overview` = 20; `entity.node.edit_form` = `ce_quiz.node_take` = 0) swap order after a cache rebuild. Entity API's `links.task.yml` is one unchanged deriver line. Tab *sets* identical.

Labels, fields, field order, form layout, buttons, tabs, tables, filters and
pagination are therefore unchanged; the date-picker and exposed-form geometry
checks (`verify_form_parity`, `verify_exposed_form_geometry`) passed identically.

**Pixels.** Since the two versions differ in exactly one file, a controlled A/B
was run: 19 pages screenshotted in headless Chrome (1600 px, deterministic
viewport) with 1.8.0, then again with the 1.7.0 `entity.module` swapped back in
(file only; restored byte-for-byte to upstream `8.x-1.8` afterwards and
re-verified). **18 of 19 pixel-identical at zero offset**; `/report` differs
only in rows 0–66 — the admin toolbar strip, where the capture's toolbar-hiding
raced the toolbar JS — page content identical.
(The first naive before/after screenshot pass, taken with the banner present
and a non-deterministic viewport sequence, is kept in `pages_before/png` and
`pages_after/png` for the record; it is superseded by the A/B in `png_ab/`.)

## 9. Rollback status

**Not needed — nothing was rolled back.** The path remains available:

```
ddev snapshot restore entity-1-8-0-pre          # database
git checkout -- composer.lock                   # or: cp backups/entity-1.8.0-pre/composer.lock .
ddev composer install                           # returns drupal/entity to 1.7.0
ddev drush cr
```

## 10. Acceptance

| criterion | |
|---|---|
| Entity API 1.8.0 installed (composer, lock, disk, Drupal) | ✓ |
| security advisory cleared (`composer audit`, Update Status, status report) | ✓ |
| no unexpected dependency changes (1 package, `composer.json` untouched) | ✓ |
| no migration functionality, menu, route, form, field/widget broken | ✓ (suite identical) |
| no permission regression (`config` checksum unchanged; access checks pass) | ✓ |
| no Commerce / Quiz / Report regression (read and write) | ✓ |
| no data changed unexpectedly (row-level diff explained; probe-product deletion was an external UI action) | ✓ |
| sale totals unchanged ($4,716.00 · $3,200.00 · $349,606.35) | ✓ |
| visibility correct (22 datasets reconcile) | ✓ |
| JSON:API remains secure (not installed; nothing weakened) | ✓ |
| no visual regression (markup + pixel A/B) | ✓ |
| `composer audit` reviewed; one dev-only advisory recorded | ✓ |
| D7 untouched (SELECT-only account; `WRITE_CHECK: CLEAN` ×3) | ✓ |

**PASS.**
