# MIGRATION_PLUGINS.md

**`ce_migrate` — migration definitions and plugins.** Written 2026-08-25.
**ALL RUNTIME-UNVERIFIED** — no DDEV in the authoring session, and D10 has no migrated data yet.

Source of truth: `RECONCILIATION.md` (targets), `DECISIONS_PHASE_3.md` (D3-4 … D3-7), `CLAUDE.md`.

---

## 1. What exists

```
migrations/
├── ce_node.yml                nids PRESERVED
├── ce_acl_list.yml            the `acl` table (261)
├── ce_acl_user.yml            R-5 = 33,003 grants
├── ce_acl_node.yml            view/update/delete flags (D3-7)
├── ce_flagging.yml            R-6 = 132,256, dual-target `completed`
├── ce_wistia_media.yml        uid stays VARCHAR (D3-6)
├── ce_wistia_media_track.yml  uid stays VARCHAR (D3-6)
└── ce_block.yml               cetc_new -> cetc_d10 region map

src/Plugin/migrate/
├── source/CeTable.php         generic raw-table source
└── destination/CeTable.php    generic raw-table destination
```

---

## 2. The four things that would break silently

Each of these produces a migration that **reports success** while losing or corrupting data.

### 🛑 Node IDs — 8 of them are hard-coded across the frontend

`22, 23, 24, 25, 28, 29` (homepage panes) · `27` (evaluation webform, bound **three** ways) ·
`36` (the About layout).

Renumber any of them and the homepage renders the wrong content, two templates stop matching, and
course completion stops recording — with **no error and no log entry**. `ce_node` states
`nid: nid` **explicitly** rather than relying on core's default, so a future edit that breaks it
shows up in a diff. `ce_migrate.homepage_node_ids` asserts the result afterwards.

### 🛑 `completed` is the only dual-target flag

```
completed on VIDEO   32,050     rule 12 — finished watching that video
completed on COURSE  21,693     rule 11 — finished the whole course
                     ------
                     53,744     matches R-6
```

Different business facts sharing one flag name. Every *other* flag would let you get away with
assuming one entity type — this one corrupts whichever half you don't model. `entity_type:
entity_type` in `ce_flagging` is the whole point of that migration.

### 🛑 `acl_node` must carry update **and** delete

`view 134 · update 53 · delete 52` — **105 bindings** carry update or delete. Every architecture
document describes only "the ACL view grant"; D3-7 resolved that silence in favour of preserving
them. Migrating only `grant_view` — which a casual reading suggests — drops them with no error.

This does **not** conflict with rule 14, which *writes* view-only grants. New grants are view-only;
these 105 historical bindings are migrated as they stand.

### 🛑 Block regions — core's `d7_block` would collapse the layout

`BlockRegion` takes its fast path only when the source and destination theme **names match**.
`cetc_new != cetc_d10`, so it falls to a static map containing only `bartik` and `seven`, and every
block lands in **`content`**. `ce_block.yml` supplies the identity map for all 11 regions — verified
against `cetc_d10.info.yml` as 11/11 identical.

---

## 3. Why raw-table plugins, not entity migrations

`CeTable` (source + destination) reads and writes rows **exactly as they are**: no user lookup, no
node lookup, no foreign key, no validation.

That is deliberate. Decision 7 (PRESERVE + LOG) and D3-6 both require rows with **dead references to
survive**:

| Orphan class | Rows |
|---|---:|
| Wistia rows with uids of deleted users | 649 |
| ACL grants for users who no longer exist | 258 |
| **All referential-integrity orphans (R-24)** | **1,213** |

An entity-aware destination would drop every one and report success. **Integrity checking belongs in
reconciliation, where a discrepancy is counted and visible — not in a write path, where it is
silent.**

### The 258 orphan ACL grants — how D3-4 is satisfied

D3-4 expresses R-5 as `live grants (32,745) + PRESERVED_UNLINKED (258) = 33,003`.

Because `acl_table` writes **raw rows** and never loads a user entity, **all 33,003 rows migrate**,
including the 258 with dead uids. That satisfies both halves at once: the raw `acl_user` count is
33,003 (R-5 exact) **and** the 258 are recorded in the orphan log for R-24. The orphan log is a
**record of which rows point nowhere**, not a substitute store for rows that failed to migrate.

> ⚠️ Do not "improve" `ce_acl_user` with an entity-aware destination or a user-existence condition.
> Either drops 258 rows of real historical entitlement and fails R-5 by exactly that number.

---

## 4. Deterministic ordering

`CeTable::query()` always applies an `ORDER BY`, defaulting to the first id column. Without it a
re-run can process rows in a different order — which matters for anything FIFO, such as the referral
ledger burning balances by `referrer_disc_created ASC`.

---

## 4b. Second batch — users, content scaffolding, referral ledger

```
ce_user.yml                 R-1 = 13,249, uids PRESERVED
ce_user_role.yml            5 roles, 485 permission grants
ce_taxonomy_vocabulary.yml  6 vocabularies
ce_taxonomy_term.yml        tids PRESERVED
ce_file.yml                 R-10 = 4,330, fids PRESERVED
ce_url_alias.yml            R-10 = 13,969
ce_referral_discount.yml    ⚖️ R-12 = 361. R17 PRESERVED.
```

**15 migrations, all in the `ce_online` group.** The group supplies
`source: key: migrate` to every member, so no individual migration can accidentally read the D10
database as its source — or acquire write access to D7. Dependency graph verified: every referenced
migration exists or is core.

### 🛑 uids must be preserved — for the same reason nids must

`acl_user` holds **33,003 rows keyed by uid**, copied verbatim with no lookup. If uids change, every
grant points at the wrong person: users gain access to courses they never bought and lose access to
courses they did. Flaggings, quiz results, webform submissions and the referral ledger are all
uid-keyed too. Stated explicitly as `uid: uid`.

Also preserved: **the 27 blocked users stay blocked** (`status: status`) — decision 14 keeps the
historical states exactly, and 27 is itself the size of a preserved exception set.

### tids and fids are load-bearing too

- **tids** — course-listing page titles are built from `field_state_tid` / `field_course_type_tid`
  query parameters, and the course PDF is resolved by walking `field_state` terms to `field_pdf`
  (first match wins, then break). A renumbered tid silently changes **which state's PDF a course
  offers**.
- **fids** — the certificate template, menu images and course PDFs are referenced by fid.

### ⚠️ `ce_file` does not move the bytes

It migrates file **entities**. The ~626 MB under `sites/default/files` must be copied separately.
A migration that reports success against an empty files directory leaves every image and PDF broken,
so the copy is a **named runbook step**, not an implied one. The Task 11 backup
(`ceonline-d7-files-20260825-123510.tar.gz`, 639,896,826 B) makes the bytes recoverable
independently of live D7.

### ⚖️ The referral ledger — copied, never recalculated

Per explicit instruction, and R17:

- **no** recomputation of `referrer_remaining_balance`
- **no** reconciliation against `referrer_discount_amount`
- **no** rounding, normalisation or repair of inconsistent rows
- **no** ×100 — the ledger stores decimals; D7 multiplies only at redemption

`referrer_disc_created` is the **FIFO key** for balance consumption, so its exact values must
survive or balances burn in a different order. `order_by` pins read order for re-runnability.

R17's mitigation — *audit all 361 balances before cutover* — is a **business action with an owner**,
not something a migration performs. Adjusting a balance here would change what a real customer can
spend.

### Permissions with no D10 target

`switch users` · `execute php code` · `use PHP for settings` are dropped — devel is require-dev and
`drupal/php` is in composer `conflict`. **U-9 confirmed this does not break support**:
`masquerade as any user` / `as user` are held by **both** `administrator` and `CE Admin`, and
`drupal/masquerade ^2.2` is in the approved manifest. Mapped to `[]` explicitly so the drop is
visible here rather than silent inside core.

---

## 4c. PRESERVE + LOG — the orphan log is now populated

```
src/OrphanLogger.php                        writes ce_migrate_orphan_log
src/Plugin/migrate/process/LogOrphan.php    @MigrateProcessPlugin ce_log_orphan
```

### 🛑 `ce_log_orphan` never skips a row — that is the whole design

The natural-looking choice here is core's `skip_on_empty`, or throwing
`MigrateSkipRowException`. Both are **exactly wrong**. Decision 7:

> *"Do not silently delete orphan records. If an orphan cannot be migrated directly: preserve it
> where technically possible, log it, document why, do not silently discard it."*

So the plugin: checks whether the parent exists → logs if it doesn't → **returns the original value
either way**. The row migrates with its dangling reference intact, exactly as D7 holds it. The log
records the fact so R-24 can count it.

It deliberately has **no skip or drop option**. If a caller wants one, the question to answer first
is why decision 7 shouldn't apply.

### Wired in

| Migration | Field | Expected dead-uid rows |
|---|---|---:|
| `ce_acl_user` | uid | **258** |
| `ce_wistia_media` | uid | 225 |
| `ce_wistia_media_track` | uid | 424 |
| `ce_referral_discount` | uid | 6 |
| | | **913** |

The remaining ~300 toward **R-24 = 1,213** are non-uid classes — orders, line items, quiz results,
the single orphan flagging, webform submissions, and the product→video / course→video content-model
refs — logged by their own migrations as those are written.

### A record, not a holding pen

If a row appears **only** in the orphan log and not in its destination table, something is wrong.
All 33,003 `acl_user` rows migrate **and** the 258 are logged; the log doesn't reduce the count, it
explains part of it.

`disposition` accepts `PRESERVED`, `PRESERVED_UNLINKED`, `NOT_MIGRATED`. **There is no `DROPPED`
value** — decision 7 forbids silent discard, so the schema gives no way to express it. An
unrecognised disposition is refused with an error rather than written, because it would make R-24
undercount.

A failure to *log* never aborts the migration — the row is already written — but it is logged loudly,
because R-24 will then undercount by one.

---

## 4d. Reconciliation — verification, never correction

```
src/ReconciliationRunner.php              writes ce_migrate_reconcile_run
scripts/reconcile/d10_reconcile.sh        ddev exec bash ... [label]
```

**CLAUDE.md §25** is the whole design brief:

> *"Reconciliation means VERIFICATION. It does NOT mean correction. If they differ: STOP. Report
> the difference. Do NOT automatically repair it."*

So the runner **only reads and compares**. Verified: it contains no `update(` or `delete(` call
anywhere. The single table it writes is its own audit log, and D7 is never touched — expected values
are constants captured from the frozen baseline.

When anything drifts, the output restates §25 rather than just printing a number, so whoever sees it
knows the next step is *investigate the mapping*, not *fix the data or move the target*.

### What it asserts

| Group | Assertions |
|---|---|
| Counts | R-1 users · R-5 acl_user · R-6 flaggings · R-10 nodes/files/aliases · R-11 wistia ×2 · R-12 ledger · R-24 orphans |
| Per flag | completed 53,744 · bought 32,992 · enrolled 24,783 · submitted 20,397 · in_progress 340 |
| **Dual-target split** | `completed` on **video 32,050** and **course 21,693**, checked **separately** |
| Node ids | the 8 hard-coded ids present **and plausible** |

### 🛑 Why the `completed` split is checked separately

**A migration that put all 53,744 `completed` flaggings on one entity type would still pass R-6.**
The total is right; the meaning is destroyed. `N-4:node:video` and `N-4:node:course` are the only
assertions that catch it.

### ERROR is not DRIFT

A missing table returns `ERROR`, not a count of zero. *"Zero because the table is absent"* and
*"zero because nothing migrated"* need different responses, and collapsing them into `0 != 13249`
would send someone to debug the wrong thing.

### 🛑 The expected values are provisional

Every figure is a **pre-freeze** measurement, exactly like everything in `DECISIONS_PHASE_3.md`.
They are good enough to build against and **not** good enough to sign off against. The freeze-point
capture (`D7_FREEZE_CHECKLIST.md` §4) replaces them; until then a clean run is encouraging, not
acceptance.

---

## 4e. Commerce — 18 migrations total

```
ce_commerce_order.yml       R-2 = 87,640   decisions 6 + 9
ce_commerce_line_item.yml   R-3 = 116,472
ce_commerce_payment.yml     R-4 = 26,797 / 21,392 successful
src/Plugin/migrate/process/StripSensitive.php   @MigrateProcessPlugin ce_strip_sensitive
```

`commerce_migrate` is **permanently excluded** (no release supports Commerce 3), so these are custom.

### 🛑 Decision 6 — `checkout_complete` is **not** `completed`

42 D7 orders sit in `checkout_complete`. The name invites exactly the wrong mapping.

In D10, `completed` is the **only** trigger for entitlement (`OrderStateSubscriber::GRANTING_STATE`).
Mapping these 42 to `completed` would hand **42 customers course access they do not have today**.

```yaml
completed:         completed          # the only granting state
checkout_complete: checkout_complete  # 🛑 NOT completed - its own state
```

> ⚠️ **SUPERSEDED 2026-08-25.** This block previously read
> `checkout_complete: validation`. That mapping was decision-6-safe (validation
> does not grant) but **lossy**: it collapsed four distinct D7 checkout
> statuses onto one Commerce 3 state, destroying which step each stuck order
> died at while still passing every count-based target. The `ce_order_d7`
> workflow (§6) now preserves each status as its own state. The entitlement
> guarantee is unchanged — `completed` remains the only granting state.

**No `default_value` is set**, deliberately. An unmapped order state raises
`MigrateSkipRowException` and is reported, rather than defaulting to something plausible — a wrong
default here changes who has course access, silently.

### 🛑 Decision 9 — the one place the migration does *not* reproduce D7

**6,547 orders carry plaintext password copies** in `commerce_order.data` (R2 / R-22).
`ce_strip_sensitive` removes them **in transit**: D7 keeps its data untouched, D10 never receives it.

Everywhere else the rule is *reproduce D7 exactly, including its bugs*. This is the documented
exception, because reproducing it would copy **live credentials into a new system**.

Two design details:

- **Malformed blobs pass through unchanged and are reported**, not silently emptied. Losing an entire
  order's metadata would be worse than a row needing review — but it is flagged as **unsanitised**,
  because it may still hold a credential.
- **The log names the key, never the value.** A sanitiser that writes the secret into a migration
  message has not removed it.

Only the named keys are stripped. `data` also carries legitimate order metadata, and removing more
would lose business information.

### Orphans that are financial history

| Orphan | Rows | Treatment |
|---|---:|---|
| orders with a dead uid | 163 | preserved + logged |
| line items with no order | 12 | preserved + logged |
| **payment transactions with no order** | **38** | **preserved + logged** |

The 38 are records of **money that moved**. Discarding them because the order row is gone would
destroy financial history.

Also preserved: **8 completed orders have no line item at all** (N-6). That is a D7 state — do
**not** synthesise line items to tidy it.

### Decision 4 — the test gateway

`commerce_payment_example` transactions **migrate** (they are history) but **no active D10 gateway is
created** for it. The gateway id stays on the record; nothing can charge through it because no such
gateway exists.

⚠️ **No Authorize.Net credentials are read, copied or logged.** Only transaction records move;
credential rotation is a separate business/security action (§23).

---

## 4f. ⭐ Webform — the highest-risk detail in the migration

```
scripts/reconcile/d7_webform_components.sh        capture the map from D7
src/WebformComponentMap.php                       the map service
config/install/ce_migrate.webform_component_map.yml   DELIBERATELY EMPTY
```

`DATABASE_ANALYSIS.md` §263 and `PHASE_2_MIGRATION_DECISIONS.md` §10 both call this *"the single
highest-risk detail in the whole migration"*. Here is why, concretely.

### The failure mode

`webform_migrate` **converts numeric `cid` values to machine names**. Two pieces of D7 business
logic read those numbers **directly**:

```
course_actions.module:243   $submission->data[30]['value'][0]   <- THE COURSE NID
webform_document.module     $submission->data[27]['value'][0]   <- the comment
```

After conversion the numbers are gone and nothing records what each one became. `data[30]` then
resolves to nothing and **20,397 course completions stop being recorded** — with no error, no failed
migration, and no obvious symptom until someone notices completions have stopped.

From the D10 side this is **irreversible**. The map has to be written down while D7 still holds it.

### ⚠️ Three different 27s

| | |
|---|---|
| **node 27** | the evaluation webform itself |
| **component 27** | the free-text comment |
| **component 30** | the **course nid** |

Easy to conflate, and conflating them swaps the comment for the course reference.

### 🛑 The config ships EMPTY, on purpose

`ce_migrate.webform_component_map.yml` contains `map: {}` and nothing else. Verified: the service
contains **no hard-coded machine name anywhere** — every return is `NULL` or comes from config.

A plausible guess (`field_30`, `course`, `field_course`) would produce a webform that **looks
migrated** while silently losing every course link. An unmapped cid returns `NULL` so the caller
skips; it never invents.

`summary()` refuses in plain terms if the map is missing:

> *"🛑 The webform migration MUST NOT run before this. Converting cids to machine names without the
> map is irreversible from the D10 side."*

### What the capture script records

| | |
|---|---|
| **C-1** | the full 28-row component map — **this is the artefact to keep** |
| **C-2** | the two load-bearing components on node 27 |
| **C-3** | volumes — R-8: 21,064 submissions / 441,970 data rows |
| **C-4** | the `cid=30` course link, including the **4 non-numeric values** that would break a CAST-based nid lookup, exactly like the Wistia uid columns |
| **C-5** | 73 submissions with a dead uid |

Keep the C-1 output with the freeze-point logs.

---

## 5. Still to write

| Migration | Blocked on |
|---|---|
| users, roles, permissions | — next |
| taxonomy, files, URL aliases | — next |
| Commerce: stores, products, orders, line items, payments | Commerce 3 build |
| webform + submissions | `webform_migrate ^2.0`, and the **`cid = 30`** map |
| quiz nodes, questions, results | custom quiz implementation (decision 5) |
| certificates | `quiz_certificate` view export |
| `ce_referral_discount` ledger | uses `ce_table`; R17 preserved |
| views / Page Manager pages | Layout Builder / Views rebuild |

---

## 5. Quiz — custom implementation (approved decision 5)

### 5a. Why custom, not `drupal/quiz`

`PHASE_2_MIGRATION_DECISIONS.md` §8 settles this in one line: **"No business rule
changes either way."** It is a technical choice, not a business one. Two facts decided it:

1. §8's stated default is CUSTOM — *"defaulting to custom if the alpha proves unstable"*.
2. The contrib module's one real advantage does not exist: **"no D7 migration ships — results are custom either way"**. There is no migration path to inherit, so the alpha buys nothing.

It also keeps an ALPHA package off the critical path, which §8 flagged as the main risk.

### 5b. The D7 table shape is reproduced deliberately

`ce_quiz.install` recreates `quiz_node_results`, `quiz_node_properties` and
`quiz_node_relationship` with D7's columns. This is not laziness:

- **Results are revision-keyed.** `quiz_node_results.vid` points at a quiz node *revision*. §8 names this the migration's main complication: *"nid/vid remapping must be threaded through"*. Keeping the column preserves the link between an attempt and the version of the quiz actually taken.
- **Code already written queries these tables by name** — `_cetc_d10_best_quiz_result()`, the dashboard certificates tab, `ce_certificate`. Their `ORDER BY score DESC, time_end DESC` is itself a preserved business rule.
- **A 1:1 shape makes the migration a raw copy**, so 3,973 results move with no transformation — and therefore no opportunity to alter a score.

⚠️ These are *not* the contrib Quiz module's tables. `drupal/quiz` is not installed and must not be, or the names collide.

### 5c. 🛑 Two different pass marks, and they disagree

| | source | value |
|---|---|---|
| (a) | `quiz_node_properties.pass_rate` | the real per-quiz mark — 100 on 35, 75 on 28, **0 on 5** |
| (b) | hard-coded in the **theme layer** | always 75, with `>=` in `template.php` and `>` in the tpl (defect D-1) |

A quiz with `pass_rate = 100` and a student scoring 80 is a **FAIL by (a) and a PASS by (b)**. Both behaviours exist in D7 simultaneously and users see both.

`QuizResultStorage` implements **only (a)**. `cetc_d10` keeps its own hard-coded 75 **on purpose**. Reconciling the two would change what the course-progress indicator shows for real students. Decision 13 / §18: *"Do NOT fix the pass-mark logic during migration."*

### 5d. 🛑 The five zero-pass-rate quizzes (defect X10)

`pass_rate = 0` means **every score passes** — failure is impossible. It looks exactly like corrupt data, and the instinct is to default it to 75.

Doing so would **retroactively fail every student who passed those five quizzes**, and could withdraw certificates already issued and downloaded.

Guards in place:
- `getPassRate()` returns `?int` — `NULL` means "no row", `0` means "the mark is zero". A caller cannot conflate them.
- No `?: 75` fallback anywhere (verified — zero matches).
- Reconciliation asserts `pass_rate = 0` still matches **5**, so a later "fix" fails loudly.
- Capture script Q-5 reports `would_fail_at_75` — the literal blast radius.

### 5e. The 51 abandoned attempts migrate too

R-7 is **3,973 = 3,922 evaluated + 51 abandoned**. The abandoned rows have `is_evaluated = 0` and usually no `time_end`. They look like junk; §27 lists quiz data among what "must be preserved".

**Both R-7a (all) and R-7b (evaluated) are asserted.** A migration filtering to `is_evaluated = 1` would pass R-7b cleanly and drift on R-7a by exactly 51. Checking only the evaluated count would report success while dropping 51 historical rows.

### 5f. ⚠️ OPEN — the distribution sums to 68, but there are 59 quizzes

`35 + 28 + 5 = 68`. There are **59 quiz nodes**. The documented distribution therefore cannot be counting distinct quizzes. Two explanations:

- **(i)** the figures count `quiz_node_properties` **rows**, which are per-revision — harmless.
- **(ii)** some quizzes **changed pass mark between revisions** — meaning a student's pass/fail depends on *which revision they took*.

(ii) is a materially different finding and must be reported, not absorbed. `d7_quiz.sh` **Q-3/Q-4** settle it against the live database. Until then the ROW figures are the assertion and DISTINCT is informational; `passRateChangedAcrossRevisions()` reports the affected quizzes.

**This was caught by arithmetic, not by testing** — the original check used `COUNT(DISTINCT nid)` and could never have matched 68.

### 5g. Files

| File | Role |
|---|---|
| `ce_quiz/ce_quiz.install` | the three tables, D7 shape |
| `ce_quiz/src/QuizResultStorage.php` | best result, pass rate, questions-as-taken |
| `ce_migrate/migrations/ce_quiz_properties.yml` | runs **first** — a score is meaningless without the pass mark |
| `ce_migrate/migrations/ce_quiz_relationship.yml` | keyed `(parent_vid, child_vid)` — revisions, not nodes |
| `ce_migrate/migrations/ce_quiz_result.yml` | 3,973 attempts, scores copied raw |
| `scripts/reconcile/d7_quiz.sh` | Q-1…Q-9 baseline, SELECT-only (all 9 blocks verified) |

**Runtime-unverified.** Nothing here has been run against a database.

---

## 6. Commerce 3 foundation — the order workflow

### 6a. Why the stock workflows cannot hold the D7 data

D7 Commerce 1 has a **two-level** model; Commerce 3 collapsed it into one `state` field.

| | D7 Commerce 1 | Commerce 3 stock |
|---|---|---|
| coarse | `state`: cart · checkout · pending · completed · canceled | — |
| fine | `status`: cart · checkout_checkout · checkout_review · checkout_payment · checkout_complete · pending · processing · completed · canceled | `state`: draft · validation · fulfillment · completed · canceled |

`commerce_checkout.module:346` generates one status **per checkout page** (`'checkout_' . $page_id`) — which is exactly why there are four `checkout_*` statuses, and why they are data rather than an enum someone chose.

**The 64 stuck orders span four of them:** `checkout_complete` 42 · `checkout_payment` 18 · `checkout_review` 3 · `checkout_checkout` 1.

🛑 Mapping all four onto `validation` would have satisfied **every count-based reconciliation target** — 64 in, 64 out — while permanently destroying which step each order died at. An order that failed at *payment* and one abandoned at the *review screen* are different business facts (§27). **A green reconciliation run would not have caught it.**

### 6b. `ce_order_d7`

`ce_commerce/ce_commerce.workflows.yml` keeps every D7 status as its own state: 9 states, 8 transitions.

- **`checkout_complete` is its own state, separate from `completed`.** Decision 6. The name is the trap.
- **Exactly one transition enters `completed`** — verified programmatically. `completed` is the only state `OrderStateSubscriber::GRANTING_STATE` grants on, so this is the entitlement boundary. Nothing else may be added to that set.
- **`cart` is the one deliberate exception** → `draft` + Commerce 3's `cart` flag. That is structural in Commerce 3, not cosmetic; cart handling breaks otherwise. Classified **TECHNICAL REWRITE** — same observable result, native mechanism.

### 6c. A gap caught by checking, not by reading

The first draft left **`checkout_checkout` unreachable** — no transition led into it. D7 enters checkout at precisely that status (`commerce_checkout.module:332`, `default_status => 'checkout_' . commerce_checkout_first_checkout_page()`), so no new order could ever have entered the flow. Fixed with a `begin_checkout` transition.

Found by asserting reachability over the parsed YAML. Reading it would not have surfaced it — the file looks complete.

### 6d. Verified

| check | result |
|---|---|
| every transition endpoint is a declared state | ✅ none dangling |
| unreachable states | only `draft` (the initial state) |
| transitions into `completed` | exactly **1** (`complete`) |
| `checkout_complete` distinct from `completed` | ✅ |
| `drupal/rules`, `drupal/eca`, `drupal/php` | in composer **`conflict`** — install actively refused, stronger than absence |

**Runtime-unverified.** The workflow has not been installed or exercised.

---

## 7. Order + order-item migrations — three traps in one file

### 7a. ⭐ Money: the conversion **divides**

Verified against D7 source, `commerce.module:790`:

```php
$divisors[$currency_code] = pow(10, $currency['decimals']);
return $amount / $divisors[$currency_code];
```

D7 Commerce 1 stores **minor units** — `$9.99` is `999`. Commerce 3 stores a **decimal string**; `Price::__construct(string $number)` takes `'9.99'` and `Calculator::assertNumberFormat()` rejects anything else.

🛑 **"Commerce wants minor units" is true of the *gateway* layer, not storage.** `MinorUnitsConverter::toMinorUnits()` exists precisely because gateways want cents — which is what makes the belief so durable. Multiplying here turns a $9.99 order into **$999.00 across every historical order**, in records that are financial documents.

⚠️ **And the divisor is not always 100.** It is `pow(10, fraction_digits)`: 100 for USD, **1 for JPY, 1000 for KWD**. A hard-coded `/100` silently hundred-folds every zero-decimal currency. `ce_minor_units_to_decimal` never hard-codes it — verified, no `/100` or `*100` in executable code.

It delegates to Commerce's own `MinorUnitsConverter`, which uses `Calculator::divide` — **bcmath string arithmetic**. Float division on money loses precision (`0.1 + 0.2 !== 0.3`); this is not the place for it.

The plugin **refuses rather than guesses**: non-numeric amount → `MigrateSkipRowException`; missing currency → likewise. There is **no default currency**, because the divisor depends on it and assuming USD would stamp the wrong currency onto a financial record.

### 7b. ⭐ State: verified programmatically

| property | result |
|---|---|
| `checkout_complete` → `completed`? | **No** — maps to its own state |
| D7 statuses mapping to `completed` | exactly one: `completed` |
| `default_value` on `state` | **absent** — unmapped status raises rather than defaulting |

A default here would change *who has course access*, silently — the worst failure mode this field has.

### 7c. 🛑 New finding — `data` exists on the **revision** table too

`commerce_order.install` defines a `data` column on **both** `commerce_order` **and** `commerce_order_revision`.

R-22 measured 6,547 orders carrying plaintext password copies in `commerce_order.data`. The revision table carries **the same payload**. Stripping only the order column and then migrating revisions would deliver the credentials into D10 **by the back door**, while `ce_commerce_order.yml` looked entirely correct.

Recorded in the migration itself: any revision migration **must** apply `ce_strip_sensitive` to its own `data` column. Decision 12 requires revisions be preserved, so this will come up.

### 7d. The reconciliation check that catches a collapse

`ORDER_STATE_TARGETS` asserts the **per-state** breakdown, not just the total:

```
checkout_complete 42 · checkout_payment 18 · checkout_review 3 · checkout_checkout 1 · canceled 5
```

A migration collapsing all four checkout states onto one would hold the total at 64 and **pass every count-based target**. Only the breakdown catches it. The `checkout_complete = 42` row is decision 6's guarantee in assertion form: if those orders ever appear under `completed`, 42 customers have gained course access they never had.

### 7e. Open

⚠️ The unit-price/total columns are **field-API** columns whose names depend on the field. This file assumes the conventional `commerce_unit_price_amount` / `commerce_total_amount`. **Confirm against the live D7 schema before running** — a wrong column name yields `NULL`, and a `NULL` price is a silently free order.

**Runtime-unverified.**

---

## 8. Payments — R-4 (26,797 / 21,392 success)

### 8a. ⭐ Commerce 3 has no `failed` payment state

Not an oversight — a design decision. **Commerce 3 does not create a payment entity for a declined charge at all**; a failure is an exception, not a stored record. D7 Commerce 1 stores every attempt, so **5,405 non-successful transactions have no home** in the stock workflow.

A stock-workflow migration has only two options and both are wrong:

| option | consequence |
|---|---|
| drop the 5,405 | destroys historical data (§27, decision 7) |
| map them to `completed` | records **5,405 failed charges as successful payments** — a financial misstatement |

`ce_payment_d7` adds `failed` (and `pending`) so neither is needed. Verified: `failure → failed`, no `default_value`.

⚠️ **`completed` means two different things.** The *payment* state and the *order* state share the name and are unrelated — an order is not completed because its payment is. This matters because `order.state = completed` grants course access and `payment.state = completed` does not. **The 64-set exists precisely because those two disagree**: successful payment, order never completed.

### 8b. The payload — what the code actually says

Read from D7 source rather than assumed:

```php
commerce_authnet.module:534   $transaction->payload[REQUEST_TIME] = $response;
```

**The payload stores the gateway RESPONSE, not the request.** Authorize.Net AIM sends `x_login` and `x_tran_key` in the **request only** — so on the code's evidence, merchant credentials should not be in the payload at all.

🛑 **That is a code reading, not a measurement.** `d7_payment_audit.sh` P-2 tests it against real rows. `ce_strip_payment_secrets` is built for the pessimistic case regardless: if the keys are absent it is a free no-op; if present it is the only thing between live merchant credentials and a new database.

**`ce_strip_sensitive` was not sufficient** — it strips top-level keys, and the payload nests the whole response one level down under a `REQUEST_TIME` key. A top-level strip would find nothing, pass the entire response through, and **report success**.

### 8c. ⚠️ What is deliberately *not* stripped

Customer name, address, email and the **masked** card number (`XXXX1234`) stay. That is transaction history — the evidence of what was charged and to whom (§27). A masked PAN is not a PCI violation; deleting it would destroy the audit trail of 26,797 payments to solve a problem that does not exist.

Removed: credentials and CVV only.

### 8d. 🛑 A near-miss worth recording

An earlier draft of `SECRET_KEYS` contained a stray **`'name' => NULL`** entry. It was inert only *by accident* — the filter drops non-string values.

Had it been active, it would have stripped every key called `name`, **erasing the customer's name from all 26,797 payment records** — the exact history the section above insists on preserving. The Accept.js credential nests as `merchantAuthentication: { name, transactionKey }`, which is presumably where the instinct came from; **removing the parent node** gets the credential without touching customer data.

The entry is gone and the reasoning is now a comment in the file so it is not "helpfully" re-added.

**Verified by simulating both payload shapes:**

| shape | removed | preserved |
|---|---|---|
| AIM (`payload[TIME] = response`) | `x_card_code`, `x_tran_key` | customer name, masked PAN |
| Accept.js (nested JSON) | `merchantAuthentication` | `transactionResponse.firstName` |

### 8e. Audit script is value-blind

`d7_payment_audit.sh` — 7 blocks, **SELECT-only and value-blind (verified)**. No query selects `payload`, `data` or `remote_id` content; the `LIKE` predicates name **keys** and are wrapped in `SUM()` so only counts are emitted.

A script that dumped payloads to prove they were safe *would itself be the disclosure* — putting card data into a terminal buffer, a scrollback, and quite possibly a pasted bug report.

**STOP conditions in the audit:** any non-zero `x_login` / `x_tran_key` (live credentials in a business table → rotation becomes a business-owner action), or any non-zero `x_card_code` (CVV must never be stored).

### 8f. Decision 4 — the test gateway

`payment_method` is carried through verbatim so the record still says which gateway processed each charge. What must **not** happen is creating a `payment_gateway` **config entity** for `commerce_payment_example`. Historical transactions are history; a live gateway is a new business capability.

**Runtime-unverified.**

---

## 9. ACL / entitlement — reuse, and the 194 / 27 / 64

### 9a. The contrib migrations are reused, not rewritten

`acl` 2.0.2 ships `d6_d7_acl`, `d6_d7_acl_node` and `d6_d7_acl_user`, with **identical table names** in D10. Inspected before adopting:

- ✅ **`grant_view`, `grant_update`, `grant_delete` and `priority` are all carried** — D3-7 satisfied by reuse rather than by custom code.
- ⭐ **`d6_d7_acl.yml` maps `figure: number`.** D7's column is `number`; D10 renamed it to **`figure`**. A hand-written migration doing `number: number` would have failed or silently dropped the value. **Verified against both `.install` files.**

🛑 **The one thing to watch:** these migrations copy `nid` and `uid` **raw** — no `migration_lookup`. That is correct *only because* node and user ids are preserved. If any renumbering ever occurs, ACL grants point at the **wrong nodes**, which means the wrong people get the wrong course access — silently. This is a hard dependency on id preservation, not an incidental one.

### 9b. ⭐ The 194 / 27 / 64 — `EntitlementAnomalyChecker`

| set | meaning | required D10 outcome |
|---|---|---|
| **194** | paid/completed purchase, **no** ACL grant | remain **without** ACL |
| **27** | ACL grant, **no** completed purchase | **retain** the ACL |
| **64** | successful payment, stuck before completed | remain **non-completed** |

**Every one of these looks like a bug, and that is precisely the danger.** The 194 look like customers wrongly denied what they paid for. The 27 look like people getting content free. The instinct — in a person or in code — is to reconcile them.

Decision 14 forbids it. Granting the 194 would hand out **194 entitlements that do not exist today**. Revoking the 27 would **take access away from 27 real people, possibly mid-course**. Both are business decisions, and neither belongs in a migration.

🛑 **So the class has no write path at all** — not a disabled one, not a guarded one. Verified: **no `insert`, `update`, `delete` or `merge` anywhere in the file.** Adding one requires an explicit approved business action.

### 9c. Two query details that change the answer

- **The 27 uses `NOT EXISTS`, not a `LEFT JOIN`.** A user with several orders where only some are completed must not be counted merely because one of them isn't. A `LEFT JOIN … IS NULL` on a multi-row relation gets this wrong in a way that still returns a plausible number.
- **`STUCK_STATES` deliberately excludes `cart` and `canceled`.** An earlier query counting *every* non-`completed` status returned **74**. The documented meaning is "stuck *before* completion" — `cart` (5) and `canceled` (5) are resolved outcomes, not stuck ones. Excluding them yields exactly **64** = 42 + 18 + 3 + 1. Recorded in the constant so the number can't drift back to 74.

### 9d. DI wiring verified positionally

Adding a sixth constructor parameter to `ReconciliationRunner` meant `services.yml` had to change **in the right position** — an argument appended at the end would have injected the logger as the checker and the checker as the logger. Verified param-by-param: 6 params, 6 args, types aligned.

**Runtime-unverified.**
