# Migration execution — 2026-08-26

The first end-to-end execution of the database migration. Everything below was **run**, not
authored: every figure is a measurement.

**D7 remained strictly read-only throughout.** Baseline re-verified before and after:
`users 13,250 · node 620 · field_data_body 543 · commerce_order 87,640 · flagging 132,256 ·
acl 261`. The only D7-side action was `ddev start` on the source project so its database server
would accept connections; no D7 file, schema or business row was written.

---

## 1. Decisions still open — the migration cannot complete without these

### 🛑 W-5 — 27 discounts have no start date, and Commerce 3 requires one

**New, found 2026-08-26.** `ce_commerce_discount` mapped **neither** bound of the discount window,
nor either usage limit. Four D7 field tables were never read. Consequences, both silent:

- `start_date` is a **required** base field whose `default_value_callback` is
  `Promotion::getDefaultStartDate` — **the current request time**. Every one of the 118 promotions
  would have been stamped with the migration date.
- `end_date` unmapped is NULL, and Commerce reads NULL as **no end**. The 91 discounts with a real
  window — including 57 and 58, whose windows closed in 2020 — would have become **permanently
  live promotions**.
- `usage_limit` / `usage_limit_customer` unmapped default to 0 = unlimited, discarding 29 overall
  limits and 101 per-person limits.

**Fixed for the 91 discounts that carry a window**, with two details that had to be measured
rather than assumed:

1. **The dates are wall-clock in the STORE timezone, not UTC.**
   `Promotion::getStartDate($tz)` builds `new DrupalDateTime($stored, $store_timezone)`. A UTC
   rendering would shift every window by 4–5 hours and move each boundary onto the previous day.
   D7 agrees: its condition builds `new DateTime()`, which takes the site's own
   `date_default_timezone`. The stored D7 integers are already local midnight.
2. **The end bound is the END of the day.** D7:
   `$end->setTime(24, 0, 0); return $time >= $start && $time <= $end;` — and the module repeats it
   as `REQUEST_TIME >= value2 + 86400`, commented *"we want the discount to remain valid through
   that day"*. Commerce's end bound is **exclusive**, so the faithful value is the **next local
   midnight**.

Verified against the data itself: discount 1 is labelled *"$20 6/20 to 6/30"* and maps to
`2016-06-20T00:00:00` → `2016-07-01T00:00:00`.

**🛑 What is still undecided.** D7's `commerce_discount_date` field instance is **optional**
(`required: b:0`, default `blank`), and its condition returns TRUE outright when the field is
absent — so **27 discounts are unbounded at both ends**. Commerce can express the upper bound (a
NULL `end_date` means exactly that, and is written). It **cannot express the lower one**:
`start_date` is required and has no null.

Every candidate value is a timestamp that does not exist in D7, so `ce_discount_window` **raises
instead of guessing**, exactly as `store_id` and `promotion_id` did before their decisions were
taken. Dry run over all 118: **91 clean, 27 raise, 0 skipped.**

**Blast radius if left undecided:**

| | |
|---|---:|
| promotions that cannot migrate | **27 of 118** |
| coupons that would lose their promotion link | **26 of 120** |
| usage-ledger rows that would become orphans | **924 of 2,977 (31%)** |
| migrations blocked | **4** — `ce_commerce_discount`, `ce_commerce_coupon`, `ce_commerce_discount_usage`, `ce_commerce_order_coupons` |

The 27 are not marginal — they include `discount_admin_100_off` (302 uses),
`discount_referral_discount` (296) and `discount_south_carolina_voucher` (148).

**To unblock:** approve a start value and set `unbounded_start` on `ce_commerce_discount`. It
applies **only** to the rows D7 left blank; the other 91 keep their real windows.

### 🛑 Content model — Phase 3.3 has not run, and 22 migrations depend on it

`ce_node` failed **all 620 rows**: *"Node type article was not found."* D10 has **zero**
`node.type.*` config. `PHASE_NUMBERING_MAP.md` records Phase 3.3 — *"Content model — 10 content
types, 71 fields, 6 vocabularies"* — as **not started**, gated on a field-by-field diff against D7.
`HOMEPAGE_DEPENDENCY_INVENTORY.md` already says *"no content types at all — 🛑 blocked"*.

This is a **prerequisite phase, not a defect**, and building it is not a mechanical fix: it decides
10 content types and 71 fields. It also raises a question this migration set does not answer —
**`ce_node` maps only base properties** (nid, vid, type, title, uid, status, created, changed,
promote, sticky). It carries **no field values at all**, and no other migration carries them, while
core's `d7_node:<type>` migrations (which do) are present and unused. Whether node field content
arrives via the content-model phase or via core's node migrations is an architecture decision.

**Blocked by it (22):** `ce_node`, `ce_acl_node`, `ce_certificate_node`,
`ce_certificate_node_settings`, `ce_certificate_snapshot`, `ce_content_access`, `ce_flagging`,
`ce_flag_counts`, all 7 `ce_quiz_*`, all 6 `ce_webform_*`.

### 🛑 W-7 — 3,047 discount line items cannot be order items

D7 records an order-level discount as a **line item with a negative total and no product
reference**. Commerce 3 declares `purchased_entity` **required** on every order item and models
order-level discounts as **adjustments** instead. Measured:

| D7 line-item type | rows | has a product reference |
|---|---:|---:|
| `product` | 113,271 | 113,271 |
| `product_discount` | 154 | 154 |
| **`commerce_discount`** | **3,047** | **0** |

They are skipped and logged `NOT_MIGRATED` with the complete source row, never attached to a
substitute product — which would falsify what was bought.

**Consequence, and it is financial.** Those 3,047 rows sit on **2,963 orders, 2,812 of them
completed**. Commerce derives each order total from the items it can see, so until the discounts
are represented those orders will show totals **higher than D7**.

**The decision:** represent them as Commerce order **adjustments**, or add an order-item type with
no purchasable entity. Both are outside a mechanical fix.

### 🛑 Payment gateways do not exist, and one of them must not

`ce_commerce_payment` references `commerce_payment_gateway` **config entities**, and none exists.
D7 uses two payment methods:

| D7 payment method | transactions | success | failure |
|---|---:|---:|---:|
| `authnet_aim` | 26,772 | 21,367 | 5,391 |
| `commerce_payment_example` | 25 | 25 | 0 |

`drupal/commerce_authnet` **is** installed. But decision 4 states the example gateway must **never**
become an active D10 gateway, while its 25 historical transactions still migrate — so the gateway
configs have to be created deliberately, not inferred. The migration is written to fail on the
reference rather than invent one, and that is where it stands.

Also measured: **38 transactions reference an order that no longer exists** (now correctly logged,
see defect 13), and the failure count is **5,391**, not the 5,405 the file's comment claimed.

### 🛑 W-8 — the gateway payload has no destination in Commerce 3

Verified against the live schema: `commerce_payment` has **no `payload` field and no `data`
field**. `ce_commerce_payment` mapped both, and a destination property that does not exist is
**silently ignored** — the same failure mode as `line_item_id` on the order item. So those two
mappings wrote nothing, and `ce_strip_payment_secrets` — the plugin §23 depends on — was never
reached. It was not protecting anything, because nothing was arriving.

**The security half is satisfied by construction**: there is no column for a credential or a CVV
to land in. Confirmed by `scripts/reconcile/verify_secrets.php`.

**The preservation half is not.** §27 treats the gateway response — the masked card number, the
AVS result, the customer's transaction detail — as audit history for **26,797 payments**, and
Commerce 3 offers no home for it. Two parts *do* have a home (`avs_response_code`,
`avs_response_code_label`) and could be extracted from the D7 payload; the rest would need a custom
field or a raw archive table. Left unmapped deliberately so it reads as an open decision rather
than as working code.

### ⚠️ W-6 — 131 orders whose total will not match D7

`total_price` **cannot be migrated**. `OrderStorage::doOrderPreSave()` calls
`recalculateTotalPrice()` on **every** save, unconditionally, deriving the total from order items
and adjustments. A mapped total is overwritten before it reaches storage.

Measured: D7's recorded order total already equals the sum of its line-item totals for
**87,509 of 87,640 orders (99.85%)**, so Commerce's derivation reproduces D7 exactly for those.

**131 differ:**

| status | orders | net difference |
|---|---:|---:|
| completed | **79** | **+$2,566.67** |
| cart | 52 | −$19,155.86 |

Forcing a match would mean inventing an adjustment that does not exist in D7. Reported, not
papered over.

---

## 2. Defects found by executing, and fixed

Every one passed `php -l`, YAML validation and static review. None was findable without running.

| # | defect | consequence had it shipped |
|---|---|---|
| 1 | **`getMigration()` does not exist.** Three process plugins called it. `MigrateExecutableInterface` does not declare it; core keeps `$migration` protected with no accessor | **Fatal.** The first orphan-logging row of the first import died. It also names the column R-24 groups by — every orphan would have filed under `unknown`. Fixed with a `ResolvesMigrationId` trait |
| 2 | **`ce_block`: `static_map` with both `bypass` and `default_value`** | Core raises *"Setting both default_value and bypass is invalid."* — all 31 enabled blocks failed |
| 3 | **`ce_block` dropped core's `plugin`, `settings` and `_role_ids` pipelines** while overriding `region` | A block config entity with no plugin id cannot be constructed: *"The block '' did not specify a plugin."* Restored from core, region override kept |
| 4 | **`ce_acl_list` mapped `number`; D10 renamed the column to `figure`** | PHP warning per row and a NULL written regardless of source. Harmless here only by luck — all 261 D7 values are NULL |
| 5 | **`ce_user_role`'s `static_map` on `permissions` never fired.** `permissions` is an array; StaticMap implodes it to build the lookup key, so the three "explicit no-op" entries could not match | Its only effect was an *"Array to string conversion"* warning per role. The real drop is done by core's destination |
| 6 | **`ce_commerce_customer_profile` mapped `type: type`** — D7's bundle is `billing`, D10 has only `customer` | All **19,590** rows would have failed "bundle not found", exactly as the nodes did |
| 7 | **The billing address was never read.** Commerce 1 keeps it in an addressfield; the join was missing | All **19,590 billing addresses lost** — while the file's own header said *"without them orders lose the address they were billed to"* |
| 8 | **The order total and billing link were never read** — same Commerce 1 field-storage shape | **24,412 order→address links** lost; see W-6 for the total |
| 9 | **`billing_profile` is `entity_reference_revisions`.** A bare id raises `TypeError`; `billing_profile/target_id` alone saves **silently wrong** | All 24,412 links would vanish while the migration reported success. New `ce_revision_reference` plugin sets both ids |
| 10 | **`ce_flag` mapped a bare `entity_type`.** Flag 4.x needs a flag-type **plugin id** (`entity:node`) and a link type | All 5 flag definitions failed: *'The "" plugin does not exist.'* |
| 11 | **`ce_flag` never read `flag_types` or the `options` blob** | Bundle restrictions lost — `bought`→video and `enrolled`→course are the **entitlement carriers**, and unrestricted flags would be flaggable on content D7 never allowed. Labels ("Enroll") lost too |
| 12 | **Runbook ordering:** `ce_acl_node` listed before `ce_node`; `ce_taxonomy_term` before `ce_taxonomy_vocabulary`; 5 migrations absent from the list entirely | Both would fail their lookups. Corrected order derived by topological sort |

| 13 | **The orphan-logging pattern never fired.** `MigrationLookup::skipInvalid()` calls `stopPipeline()` on an empty value and `MigrateExecutable` **breaks out of the plugin chain** — so a guard placed *after* a lookup is unreachable for exactly the rows it exists to catch | This is the order documented in `SkipOrphanUsage`'s own docblock and used by `ce_commerce_discount_usage` and `ce_commerce_payment`. **C-1's preservation guarantee was inert** — its 6 orphaned rows would have been neither logged nor skipped, and 38 orphaned payments likewise. Guards now run FIRST against the raw column; docblock corrected |
| 14 | **`line_item_id: line_item_id` was silently ignored** — the order item's id key is `order_item_id`, so the property did not exist | No error. The first run created 24,239 order items with **auto-assigned ids** instead of D7's `line_item_id` (74–148,210), while appearing to preserve them |
| 15 | **Nothing populated the order's `order_items` field.** `Order::getItems()` reads that field, not a reverse query on `order_id` | **Every one of the 87,640 orders would have reported zero items and no total**, with both migrations reporting complete success. New `ce_commerce_order_items` grouped partial-update pass added |
| 16 | **`ce_commerce_line_item` mapped `type: type`** — D7 has three line-item types, D10 has one order-item bundle | All 116,472 rows would have failed "bundle not found". D7's type is now preserved in the order item's `data` bag |
| 17 | **`purchased_entity` was unmapped** though it is a REQUIRED base field | All 113,425 product line items would have failed |
| 18 | **`ce_commerce_payment` used `orphan_class: dead_parent`**, which is not one of `OrphanLogger`'s classes | Nothing validates the string, so it would have written a class the R-24 reconciliation does not count. Corrected to `missing_order` |
| 19 | **A stale id-map table** for `ce_commerce_line_item` had no `destid1` column | 6,182 rows failed on `Unknown column 'destid1'` while their entities were still written — 24,239 order items created but unmapped. Table dropped and rebuilt; audit confirms no other migration is affected |
| 20 | **`ce_log_orphan` returned early on a value of `0`**, so a reference of "0" was never logged | 6 D7 line items carry `order_id = 0` and imported **unlogged**, leaving R-23's 12 orphaned line items under-accounted. Zero is genuinely ambiguous — for `uid` it is the anonymous user and must NOT be logged — so a `treat_zero_as_missing` option was added and applied only to `order_id` |
| 21 | **`migrate:rollback` on a PARTIAL-UPDATE migration deletes the ORDER.** `EntityContentBase::rollback()` deletes the destination entity; migrate has no concept of "only this field was written" | Rolling back `ce_commerce_order_items` or `ce_commerce_order_coupons` would have **deleted real orders**, not merely unset one field. Both files now carry an explicit warning; re-runs truncate the id map instead |
| 22 | **The `order_items` grouped pass tried to CREATE orders that do not exist** | The 12 R-23 line items group onto 2 non-existent order ids; an unresolved destination id makes the entity destination create rather than update, failing with "Missing bundle for entity type commerce_order". Now guarded and logged |
| 23 | **`ce_commerce_payment` mapped `payload` and `data`, neither of which exists on Commerce 3's payment entity** | Silently ignored, like `line_item_id`. `ce_strip_payment_secrets` — the plugin §23 depends on — was therefore never reached; it was not protecting anything. See W-8 |
| 24 | ⭐ **`ce_commerce_product` never mapped the price.** All 162 variations migrated with `price = NULL`, though `price` is required and D7 holds a price for every product | **The "silently free order", by a different door.** `OrderRefresh` re-resolves an order item's unit price from its purchasable entity unless the item is flagged `overridden_unit_price`, and `OrderStorage` refreshes **every draft order on save** — 63,111 of 87,640 orders are carts. Saving them looked up a NULL variation price and **overwrote the historical unit price with nothing: 10,253 order items wiped.** Every row-count check still passed |
| 25 | **`overridden_unit_price` was never set on migrated order items** | Even with variations priced, Commerce would restate every historical price to today's list price on the next draft refresh. What a customer paid in 2016 is a fact; the flag tells Commerce the migrated price is authoritative |

---

## 3. Reconciliation notes recorded during the run

- **R-18 drift (94 inactive promo rules vs 93)** reproduced. Already a documented, non-blocking
  residual in `DECISIONS_PHASE_3.md`, deferred to the freeze point. Not a new finding.
- **Permissions:** D7 holds **485 grants**, matching the documented figure exactly. For every
  non-admin role, **zero** permissions that D10 offers were missed. The shortfall is entirely
  "providing module not installed yet" — D10 currently exposes **204** permissions with 36 modules
  enabled. `administrator` stores 0 because `is_admin = TRUE` implies all, which is core's own
  behaviour. **Re-run `ce_user_role --update` after Phase 3.3** or those grants stay lost.
- **Blocks:** D7 has 31 enabled blocks; 12 migrated, 19 skipped because their D7 module has no D10
  plugin (`mailchimp_signup`, `ksc_commerce_cart_status_block`, `imageblock`, `devel`,
  `course_actions`, `commerce_checkout_progress`, plus custom blocks awaiting `d7_custom_block`).
  Already tracked as Phase 3.3 frontend work.
- **R-23 — the 12 orphaned line items are now fully accounted**, and they split two ways:
  **6** `product` rows with `order_id = 0` → logged `missing_order` / PRESERVED_UNLINKED;
  **6** `commerce_discount` rows (3 with `order_id = 0`, 3 pointing at the absent order 1820) →
  skipped under W-7 and logged `unrepresentable_link` / NOT_MIGRATED. 6 + 6 = 12.
- 🛑 **The 194 / 27 exception sets cannot be reproduced exactly, and decision 14 says they must be.**
  `Order::preSave()` clears the customer reference when the customer does not exist:
  `if ($this->getCustomerId() && $customer->isAnonymous()) { $this->setCustomerId(0); }`.
  Measured: **163 orders had their `uid` zeroed**, every one of them pointing at a D7 uid that is
  **not a D7 user** — the same 163 `dead_uid` orphans `ce_commerce_order` logged. **140 of them are
  `completed`**, where D7 has zero completed orders with `uid = 0`.

  D7's own 194-set counts `o.uid` values rather than actual users, so it includes those phantom
  uids; D10 cannot. Result: **194 → 184** and **27 → 103**. Both deltas are fully explained by the
  zeroing, and the D7↔D10 query definitions were checked and are equivalent.

  **Ownership is not lost.** All 163 orphan rows carry the original uid in both `missing_parent_id`
  and the complete source-row JSON — verified 163/163. But reproducing the counts would require
  either creating placeholder users (forbidden), redefining the anomaly queries, or re-baselining
  the targets (forbidden). **That is a decision.**
- **Commerce takes ownership of billing profiles.** `Order::preSave()` does
  `$billing_profile->setOwnerId(0)` and saves it. The `uid` of the 24,412 referenced profiles will
  therefore **not** match D7. This is Commerce 3 semantics, not a mapping choice, but it must be
  accounted for in reconciliation rather than read as data loss.

---

## 4. The freeze has not happened

D3-10 schedules the D7 freeze for **2026-08-27 21:00 IST** — *tomorrow*. Every figure produced by
this run is therefore **provisional**, exactly as `DECISIONS_PHASE_3.md` states. The migration is
re-runnable, and should be re-run against the frozen source before any of these counts is treated
as final.


---

# 5. RECONCILIATION — run 2026-08-26, after the order-items pass

## D7 unchanged ✅

`users 13,250 · node 620 · field_data_body 543 · commerce_order 87,640 ·
commerce_line_item 116,472 · commerce_payment_transaction 26,797 · flagging 132,256 · acl 261`
— identical to the pre-run baseline. The only residual is the `d10_migration_reader` account.

## Per-migration — 20 migrations, all clean

Every one reconciles: source = imported + skipped + failed, id map agrees, destination agrees,
0 rows unprocessed, 0 failures.

| migration | source | imported | skipped | orphan |
|---|---:|---:|---:|---:|
| `ce_acl_list` | 261 | 261 | 0 | 0 |
| `ce_acl_user` | 33,003 | **33,003** | 0 | 258 |
| `ce_user` | 13,250 | 13,250 | 0 | 0 |
| `ce_user_role` | 5 | 5 | 0 | 0 |
| `ce_block` | 188 | 12 | 176 | 0 |
| `ce_taxonomy_vocabulary` / `ce_taxonomy_term` | 7 / 100 | 7 / 100 | 0 | 0 |
| `ce_file` | 4,330 | 4,330 | 0 | 29 |
| `ce_url_alias` | 13,969 | 13,969 | 0 | 0 |
| `ce_flag` | 5 | 5 | 0 | 0 |
| `ce_referral_admin_discount` / `ce_referral_discount` | 24 / 361 | 24 / 361 | 0 | 6 |
| `ce_report_history` | 779 | 779 | 0 | 0 |
| `ce_commerce_product` | 162 | 162 | 0 | 0 |
| `ce_commerce_customer_profile` | 19,590 | 19,590 | 0 | 123 |
| `ce_commerce_order` | 87,640 | **87,640** | 0 | 163 |
| `ce_commerce_line_item` | 116,472 | 113,425 | 3,047 | 3,053 |
| `ce_commerce_order_items` | 86,863 | 86,861 | 2 | 2 |
| `ce_wistia_media` / `_track` | 32,737 / 62,137 | 32,737 / 62,137 | 0 | 648 |

## ⭐ Order totals — 0 unexplained differences

| | orders |
|---|---:|
| **match D7 exactly** | **83,837** |
| no derivable total (D7 recorded one but supplied no line items) | 780 |
| **W-7 only** — discount line items skipped | 2,900 (net +$102,949.68) |
| **W-6 only** — D7's own total already disagreed with its own line items | 70 (net +$20,269.19) |
| both W-6 and W-7 | 53 |
| 🛑 **unexplained** | **0** |

Every difference traces to a documented cause. There is no unexplained money discrepancy.

## Security ✅

**R-22 — 0 password-bearing keys across all 87,640 migrated orders**, though 6,547 D7 orders carry
`custom_account_info`. Decision 9 held. §8e is satisfied structurally — Commerce 3 has no column a
credential could occupy (which is also W-8).

## Orphan log — 4,282 rows, every one carrying its complete source row

| disposition | rows |
|---|---:|
| PRESERVED_UNLINKED | 1,233 |
| NOT_MIGRATED | 3,049 |

⚠️ **R-24's target of 1,213 cannot be assessed yet.** The `NOT_MIGRATED` class is new — 3,047 W-7
skips plus 2 order-group skips — and did not exist when 1,213 was set. `PRESERVED_UNLINKED` stands
at 1,233 against 1,213, but the flagging, node, quiz and webform migrations have not run and will
add more. **Do not adjust the target**; re-assess once the blocked migrations complete.

## Assertion results — 29 did not pass, and every one is accounted for

| category | count | why |
|---|---:|---|
| blocked on the content model | 18 | flaggings (8), nodes, quiz (6), webform/answers/content-access (3) |
| blocked on payment gateways | 6 | the five `R-3stuck:*` rows and `ANOMALY-64`, now correctly reporting **n/a** rather than a false zero |
| real, and needing a decision | 3 | `R-24`, `ANOMALY-194` (184), `ANOMALY-27` (103) |
| no target defined | 2 | `R-13` products = 162 |

**Passing outright:** R-1 users 13,249 · R-5 acl_user **33,003** · R-10b files 4,330 ·
R-10c aliases 13,969 · R-11a/b wistia 32,737 / 62,137 · R-12 referral 361.

Separately verified against D7 and exact: the **order state distribution** (draft 63,111 ·
completed 22,008 · checkout_checkout 1,801 · checkout_review 652 · **checkout_complete 42** ·
checkout_payment 18 · canceled 6 · pending 2) — decision 6 holds — and all **24,412** billing-profile
links.


---

# 6. W-5 APPROVED AND IMPLEMENTED — the Commerce promotion chain is complete

**Approved:** `1970-01-01` as the start bound for the 27 discounts D7 left unbounded.

Implemented as `unbounded_start` on `ce_discount_window`, applied **only** to rows where D7
recorded no start bound. The other 91 keep their measured windows; the sentinel is never
substituted for a real value.

### Validation — availability parity, not just row counts

`scripts/reconcile/verify_promotion_windows.php` re-derives each discount's availability the way
**D7's own condition** does (`commerce_discount_date_condition`, including the `setTime(24,0,0)`
end-of-day rule) and compares it to Commerce's `Promotion::available()` test, both in the store
timezone:

| | |
|---|---:|
| discounts compared | **118** |
| availability matches D7 | **118** |
| mismatches | **0** |
| 🛑 expired in D7 but active in D10 | **0** |
| sentinel rows (`start = 1970-01-01`) | 27 |
| of those carrying an end date (must be 0) | **0** |

The epoch predates the site's first order (2014-07-02) by 44 years, so `start_date <= now` is true
at every instant D7 would also have said true. It represents unboundedness; it does not claim the
discount began then.

### The chain, migrated and reconciled

| migration | source | imported | skipped | orphan | destination |
|---|---:|---:|---:|---:|---:|
| `ce_commerce_discount` | 118 | **118** | 0 | 0 | 118 promotions (114 active, matching D7) |
| `ce_commerce_coupon` | 120 | **120** | 0 | 1 | 120 coupons |
| `ce_commerce_discount_usage` | 2,977 | **2,971** | 6 | 6 | 2,971 usage rows |
| `ce_commerce_order_coupons` | 2,627 | **2,627** | 0 | 724 | 1,905 links |

**C-2 verified end to end:** coupon 69 `Sari50` → promotion **57**; coupon 54 → promotion 54 (its
only reference sits at delta 1, which a delta-zero rule would have dropped); coupons 9 and 13 carry
no promotion, faithfully; **exactly one** `unrepresentable_link` row records the lost 69→58 link
with 421 bytes of source JSON; **0** duplicate coupon codes; promotion 58 migrated intact with no
coupon attached.

**C-1 verified:** 6 usage rows whose promotion was deleted in D7 are logged `missing_promotion` /
NOT_MIGRATED and skipped — which only happens because the pipeline-order defect (defect 13) was
fixed first. Before that, C-1's guarantee was inert.

### The coupon ledger reconciles exactly

**2,629 D7 links = 1,905 migrated + 713 dead-coupon + 11 Commerce-stripped.**

## Defects found in this phase

| # | defect | consequence |
|---|---|---|
| 26 | **`migration_lookup` treats an ARRAY source as one COMPOSITE key**, not several ids | It resolved only the first coupon and discarded the rest, undoing the grouped source. It also dropped the **713 links (27%) pointing at coupons deleted in D7** with no error and no log — which decision 7 forbids. Replaced with `ce_multi_lookup`, which looks each id up separately and logs every failure with its complete source row |
| 27 | ⭐ **A process plugin that does not declare `handle_multiples` is called ONCE PER ARRAY ELEMENT, and only the LAST return survives** | Instrumented on order 8227: `ids=["97"] resolved=["97"]` then `ids=["98"] resolved=[]` — and the empty second result replaced the first. **0 links were written across all 2,627 orders** while the migration reported "2627 created" and the orphan log looked correct |
| 28 | **Commerce removes coupons from draft orders after the migration writes them.** `PromotionOrderProcessor` drops coupons failing availability validation, and `OrderStorage` refreshes every draft on save | 11 links lost, every one on an abandoned cart whose promotion has expired. No completed order affected, so no financial or entitlement impact — but it happens *after* the destination finishes, so no process plugin can see it. Recorded by a dedicated post-pass (`log_coupon_link_loss.php`) rather than left silent |


---

# 7. W-7, CONTENT MODEL, PAYMENTS AND NODE FIELDS — 2026-08-27

## W-7 approved and implemented — order adjustments

3,047 D7 `commerce_discount` line items now migrate as Commerce **order adjustments**
(`ce_commerce_order_adjustments`), `locked = TRUE` so the order processors cannot strip them.

| check | result |
|---|---|
| orders where adjustment **count and sum** both match D7 | **2,961** |
| mismatches | **0** |
| D7 discount total vs D10 locked-adjustment total | −110,048.88 vs −109,691.88; the −357.00 delta is exactly the 6 line items on the 2 non-existent orders, all logged |
| orders whose total matches D7 | **86,731** (was 83,837) |
| W-7 discrepancies | **0** (was 2,900) |
| unexplained differences | **0** |

Nothing invented: amounts are D7's own line-item totals, labels are the D7 discounts' own labels,
`source_id` is the migrated promotion, and no product or purchasable entity was created.

## W-5 approved — 1970-01-01 for the 27 unbounded discounts

Availability re-derived the way D7's own condition does and compared to `Promotion::available()`:
**118 compared · 118 match · 0 mismatches · 0 expired-in-D7-but-active-in-D10.**

## Content model built from D7

10/10 content types · 36/38 node field instances · 620 nodes with **nid and vid parity 0
mismatches** · all 8 hard-coded homepage nids present.

## Node field values — the gap found and closed

`ce_node` carries base properties only, so after it ran the nodes existed and were empty (0 rows in
`node__body`). New fields-only companion `ce_node_fields`, one derivative per bundle, reusing core's
`d7_node` source and core's own `migrate_drupal.field_discovery` so every D7 field type is handled
by its registered core field plugin rather than hand-mapped. It maps `nid` and the fields only, so
every base property `ce_node` owns — `uid` and its dead-user orphan logging included — is untouched.

**3,084 of 3,269 D7 field value rows migrated; every field matches D7 exactly.** The 185-row
shortfall is precisely the two fields with no D10 target.

## Payments

**26,797 imported, 0 failed.** R-4 exact: 21,392 completed · 5,391 failed · 14 pending. 38 payments
reference orders deleted in D7 and are logged, keeping their money and state.

Both D7 gateways were created as config entities with **`status = FALSE`**, satisfying decision 4
(the example gateway must not be a *live* gateway while its history still migrates). No credentials
were invented — D7 holds none. Both use the `manual` plugin; see
`scripts/reconcile/create_payment_gateways.php` for why, and what would change if a real
Authorize.Net gateway is configured later.

## Defects found in this phase

| # | defect | consequence |
|---|---|---|
| 29 | **`Order::getItem()` does not exist** in `ReferralCreditOff` | Aborted the adjustments import outright. Never reached until W-7 made the referral promotion applicable |
| 30 | **The referral offer applied twice** — a locked historical adjustment *and* a live one for the same promotion | 8 draft carts discounted twice (order 40218 read 75.00 against D7's 90.00). Guarded: a promotion already recorded as a locked adjustment is not re-applied |
| 31 | **Core bug — `FieldInstanceSettings` calls `array_filter()` on an entityreference `sort` setting with no guard** | `array_filter(NULL)` is a TypeError in PHP 8; it aborted `d7_field_instance` at 54 of 106, silently omitting `field_quiz` and `field_course_title` — the course→quiz and certificate→course relationships. Worked around in `hook_migrate_prepare_row` by normalising only the shape |
| 32 | **`ce_webform_roles.rid` was `INT`** because the schema was generated from D7 | D10 role ids are machine names; both rows failed on "Incorrect integer value: 'authenticated'" |
| 33 | Two further instances of the **pipeline-stop trap** (`ce_certificate_node_settings`, `ce_quiz_result_answers`) | One wrote a row against `nid 0`; the other hit a NOT NULL violation. Both now guard first and log |
