# DECISIONS_PHASE_3.md

**Architectural decisions taken during Phase 3 implementation.**
Project: CE Online Training Courses · Drupal 7.105 → Drupal 10.6.15
This file lives in the **D10** repository. It supplements, and never overrides,
`ceonline/CLAUDE.md` §42 and `ceonline/PHASE_2_MIGRATION_DECISIONS.md`.

---

## D3-1 · Rules disposition amended to 121 + 16 + 9 = 146

**Approved: 2026-08-25** · Supersedes the count in `RULES_MIGRATION_ANALYSIS.md`,
`RULES_BEHAVIOR_MAPPING.md` §0 and `RECONCILIATION.md` R-16 / R-19b.

### What was found

The live D7 `rules_config` table holds **146** rows spanning ids **1–149**, not the audited 142.
Ids 21, 53 and 82 are confirmed genuinely absent, exactly as documented — so the audit's
"142 = ids 1–145 minus three" was correct **for the population it measured**. Four rows exist
above id 145:

| id | name | label | active | module |
|---:|---|---|:--:|---|
| 146 | `commerce_discount_rule_discount__50_off_admin` | $50 Off- Admin | **1** | `commerce_discount` |
| 147 | `commerce_discount_rule_discount__6_off` | $71off | **1** | `commerce_discount` |
| 148 | `commerce_discount_rule_discount__65_off` | $65 Off | **1** | `commerce_discount` |
| 149 | `commerce_discount_rule_discount__25_off_july_4th_` | $25 Off- July 4th | 0 | `commerce_discount` |

**Three are ACTIVE** — live promotional behaviour customers can currently receive.

### Decision

All four are **Class D — configuration-only**, identical in kind to the approved 117. The
disposition becomes:

| Class | Was | **Now** | Destination |
|---|---:|---:|---|
| **D** — configuration | 117 | **121** | Commerce 3 promotions / configuration entities |
| **C** — custom behaviour | 16 | **16** | `ce_rules_behavior` — **unchanged** |
| **E** — not reproduced | 9 | **9** | Recorded by ID — **unchanged** |
| **Total** | 142 | **146** | |

**Explicitly NOT authorised by this decision:**

- ❌ No custom `ce_rules_behavior` code for ids 146–149. They are configuration, not behaviour.
- ❌ No change to the 16 Class C rules (ids 3, 4, 5, 6, 8, 10, 11, 12, 13, 14, 15, 16, 93, 99, 104, 145).
- ❌ No change to the 9 Class E rules (ids 2, 7, 9, 87, 88, 96, 97, 98, 100).
- ❌ No installation of Rules 4.x or ECA. The approved architecture is unchanged.
- ❌ No modification of D7. The four rules stay exactly as they are in the source.

### Consequences for reconciliation

- **R-16** target becomes **146**.
- **R-19b** class totals become **121 + 16 + 9 = 146**.
- **R-17 / R-18** — id 149 is inactive and 146–148 are active, so the expected split moves from
  22 / 92 to **25 / 93**. See §3: one inactive row was unexplained under a broader filter and must
  be confirmed at the freeze point.
- Migrating the three **active** discounts matters commercially: they are live offers. Losing them
  silently would change what customers pay.

---

## D3-2 · R-21 acceptance criterion changed to a freeze-point relationship

**Approved: 2026-08-25** · `RECONCILIATION.md` R-21 amended (the single edit made to that file).

### What was found

| Source | `commerce_order_revision` rows |
|---|---:|
| `ceonline/db.sql` (2025-07-04) | 154,053 |
| `RECONCILIATION.md` as audited | **196,546** |
| Live D7 (2026-08-25) | **220,536** |

The documented figure matches **neither**. Decisively: the newest `commerce_order_revision`
timestamp is **2026-08-24 13:31:48**, which is **3h 42m before** the audit checkpoint commit
`70fee084e` (2026-08-24 17:13:28). No D7 write has occurred since. The live value at the moment
the audit was published was therefore **already 220,536**, so 196,546 was captured from an
earlier state than the document containing it.

### Decision

**196,546 is a documentation error and must not be used as an acceptance target.**
It is **not** replaced with 220,536 either — that number will itself be stale by Phase 3.5.

R-21 now asserts a relationship:

> **Archived `commerce_order_revision` rows must reproduce the source row population at the
> formally declared D7 freeze point.**

Decision 12 is untouched: revisions are archived to CSV alongside the D10 site, never dropped.

### Why not just update the number

Because the same failure would recur. A literal count is only valid at an instant; the migration
needs an invariant. Every other R-target measures a stable historical population — R-21 measures
one that grows whenever an order is re-saved.

---

## D3-3 · D7 freeze point required before Phase 3.5

**Approved as a requirement: 2026-08-25** · Not yet scheduled.

### Evidence

| Table | Newest write |
|---|---|
| users | 2026-08-11 13:39:05 |
| nodes | 2026-08-11 13:42:39 |
| flaggings | 2026-08-11 05:01:05 |
| quiz results | 2026-08-10 03:29:04 |
| **orders + revisions** | **2026-08-24 13:31:48** |

Business activity ceased **2026-08-11**. A single order then appeared on **2026-08-24**, two weeks
later — `newest_order_created` = `newest_order_changed` = newest revision, with exactly one
revision that day. D7 is **effectively static but not formally frozen**.

### Requirement

1. Establish a formal D7 freeze date/time.
2. No D7 business or administrative writes after that point.
3. Re-run the complete reconciliation **at** the freeze point.
4. That final result becomes the migration acceptance baseline.

**The implementation must not freeze, stop, disable or modify D7.** The freeze is a business/
operational action, executed only on an explicit command.

---

## D3-4 · U-10 — R-5 expressed as a composite assertion

**APPROVED 2026-08-25.** Closes U-10 and freeze precondition **P-3**.

R-5's target of **33,003 is unchanged**. What changes is how it is *satisfied*:

> **R-5** — `acl_user` grants — **33,003 — exact**, satisfied as
> **live D10 grants (32,745) + `PRESERVED_UNLINKED` orphan-log entries (258) = 33,003**.
> The 258 belong to 83 users that no longer exist in D7. They are preserved and logged, never
> deleted, and **no placeholder user is created**. Provisional until re-measured at the freeze point.

**Required migration behaviour.** The ACL migration attempts every one of the 33,003 rows. Where the
user resolves, a real D10 grant is written. Where it does not, the row is written to
`ce_migrate_orphan_log` with `disposition = PRESERVED_UNLINKED`, `missing_parent_type = user`,
`missing_parent_id = <uid>` and the complete source row JSON-encoded in `source_row`.
**No row is skipped silently and none is deleted.** R-5 passes only when both halves sum to 33,003.

**Not authorised by this decision:** creating placeholder users · deleting any grant · lowering the
33,003 target · altering R-14 / R-15 (unaffected — these 83 users do not exist in D7 either).

---

## D3-5 · U-14 — R-23 preserved, new R-24 added

**APPROVED 2026-08-25** (Option A). Closes U-14 and freeze precondition **P-4**.

**R-23 remains exactly 443 and is NOT redefined.** All six of its categories verified exact against
live D7; overwriting the figure would destroy the provenance of a verified audit result.

A **new, separate criterion** carries the full population:

> **R-24** — Total referential-integrity orphans preserved and logged — **1,213**, comprising the
> **443** of R-23 plus **770** discovered during Phase 3 relationship reconciliation:
> `wistia_media_track` dead uid **424** · `acl_user` dead uid **258** · webform submissions dead uid
> **73** · product→video **7** · referral ledger dead uid **6** · course→video **1** ·
> `wistia_media_track` with no media row **1**.
> Every row present in the `ce_migrate` orphan log. **None dropped.** Provisional until the freeze point.

Two questions, two assertions: R-23 asks *"did the audited orphan population survive?"*;
R-24 asks *"did **every** referential-integrity orphan survive?"* Both must pass.

`scripts/reconcile/d7_orphans.sh` §O-3 updated 2026-08-25 from the stale **756** to **770**
(it predated the L-8 / L-9 findings). Comment output only — **no query changed**.

---

## D3-6 · U-15 — Wistia uid destination stays VARCHAR

**APPROVED 2026-08-25** (Option A). Closes U-15 and freeze precondition **P-5**.

`wistia_media.uid` and `wistia_media_track.uid` migrate to **VARCHAR**, matching the source. No
foreign key is added.

**Why.** D7 does not enforce this integrity, so enforcing it in D10 *would be a behaviour change*.
An FK-typed destination would reject or silently drop the **649** dead-uid rows (225 media + 424
track) — the exact trap `DATABASE_ANALYSIS.md` §72 warned about — and would make those rows
unrepresentable, conflicting with decision 7. Integrity is enforced in the **migration process and
the reconciliation gates**, where a violation is logged and visible, not in a column type where it is
enforced by silent rejection.

All **94,874** rows migrate (32,737 + 62,137). The 649 dead-uid rows are additionally recorded in
the orphan log and counted in R-24. All values are numeric today, so a future tightening remains
possible.

**Related, and NOT covered by this decision:** the **4 non-numeric `cid = 30` values** (U-17 / N-2)
would break a CAST-based nid lookup the same way. The webform migration must tolerate them.

---

## D3-7 · U-16 — ACL update/delete grants preserved

**APPROVED 2026-08-25** (Option A). Closes U-16 and freeze precondition **P-6**.

Measured: `acl_node` grant flags in use — **view 134 · update 53 · delete 52**, i.e. **105 bindings
carry update or delete**. `grant_update` and `grant_delete` appear in **zero** project documents;
`RULES_MIGRATION_ANALYSIS.md` §369 and `RULES_BEHAVIOR_MAPPING.md` RB-14 describe the *view* grant
only. **That is silence, not authorisation to discard them.**

**Required migration behaviour.** The ACL migration carries **all three grant flags**, not
`grant_view` alone. RB-14 and `EntitlementTest` must be extended to assert update/delete where
present. R-14 / R-15 gain a per-flag dimension: **no user loses any grant type.**

**Documented fallback.** If `acl` ^2.0 / `content_access` ^2.1 provably cannot express update or
delete grants, those bindings are recorded as a logged extended-entitlement class rather than
dropped. The inability must be **demonstrated, not assumed**.

> ⚠️ **Not yet measured — must be quantified at the freeze point.** The figures above are
> `acl_node` *bindings*. **The number of USERS holding update/delete through those 105 bindings has
> not been measured** and must be, before implementation.

📄 `RULES_BEHAVIOR_MAPPING.md` C-10 / RB-14 and `RECONCILIATION.md` L-3 describe view grants only and
are now **narrower than the approved behaviour**. Amending them is a **D7-repository documentation
edit and remains UNAUTHORISED** — see §5.

---

## D3-8 · U-3 — `field_ui` enabled

**APPROVED 2026-08-25 (YES).** Phase 3.2 scope.

`field_ui` is Drupal core, enabled in D7, and restores the field-management UI behind admin-parity
check **W1**. It adds no business behaviour and no business data.

Prepared as `.logs/task13-fieldui.sh` — enable, re-export config, then assert the invariants
`system.theme.default = stark` · `system.theme.admin = claro` · `node.settings.use_admin_theme = true`
and re-run the secret scan. **Rollback:** `drush -y pm:uninstall field_ui` + re-export.

---

## D3-9 · U-5 — `/admin/dashboard` removal accepted

**APPROVED 2026-08-25 (REMOVE).** Closes U-5.

The D7 core `dashboard` module was removed from Drupal core in D8 and has no successor. Of its five
placed blocks, **three sat in `dashboard_inactive`** and never rendered; the two active ones —
"Who's new" and "Search form" — have D10 core equivalents (`/admin/people`, the search block).

**No replacement page is built.** No custom code, view or report depended on `/admin/dashboard`;
recreating it would be new construction, not migration. Recorded as an accepted, deliberate
difference from D7 for the acceptance argument — **not** an oversight.

---

## D3-10 · P-7 — D7 freeze scheduled for 2026-08-27 21:00 IST

**SCHEDULED 2026-08-25.** Closes freeze precondition **P-7**. All nine preconditions are now met.

| Field | Value |
|---|---|
| Freeze instant | **2026-08-27 21:00:00 IST (UTC+05:30)** = 15:30:00 UTC = epoch **`1787844600`** |
| Mechanism | Block D7 admin logins + notify all D7 administrators |
| Notified | D7 administrators / operations team |

**The freeze has NOT yet occurred** — it is 2 days and 21 hours away. Every figure in this file
stays **provisional** until re-measured at that instant by the §4 capture in
`D7_FREEZE_CHECKLIST.md`.

`scripts/reconcile/freeze_verify.sh` was written for this and **refuses to run before the instant**
(exit 2), so a premature run cannot contaminate the baseline. It compares **raw Unix integers**, not
`FROM_UNIXTIME()` strings, because the latter render in the MySQL session time zone and a silent
5h30m offset would make a post-freeze write look compliant.

### 🛑 One clarification required before 2026-08-27

*"Block D7 admin logins"* is ambiguous, and the three implementations are **not** equivalent:

| Implementation | Writes to D7? | Verdict |
|---|---|---|
| Web-server / firewall block of `/user/login` and `/admin` | none | ✅ **preferred** |
| Drupal maintenance mode (`variable` table) | one non-business row | ⚠️ acceptable, apply **before** the capture |
| Blocking admin **user accounts** (`users.status = 0`) | **business data** | ❌ **must not be used** |

Account blocking would write to the `users` table, change the blocked-user count (currently exactly
**27** — also the size of a preserved exception set under decision 14), corrupt the R-1 baseline of
13,249, and make the freeze-held check fail by design. `freeze_verify.sh` **F-2** detects it: if
blocked ≠ 27, that is a STOP condition to be reported, never absorbed by re-baselining.

ℹ️ Blocking *admin* logins does not stop **customer** checkout, which is what produced the lone
2026-08-24 order. Activity has been silent since 2026-08-11, so residual risk is low — and F-1
covers orders regardless.

---

## 3. Measurement corrections — targets were NOT changed

Two Phase 3 "drift" findings were **my query errors**, not source drift. In both cases the
**query** was corrected and the documented target left alone.

| ID | Documented | First measurement | Cause | Resolution |
|---|---:|---:|---|---|
| **64-set** | 64 | 74 | My query counted every non-`completed` status, including `cart` (5) and `canceled` (5). The documented meaning is *stuck before completion* | Excluding cart/canceled/pending yields **exactly 64** = `checkout_complete` 42 + `checkout_payment` 18 + `checkout_review` 3 + `checkout_checkout` 1. **Target confirmed correct** |
| **R-17 / R-18** | 22 / 92 | 114 / 4 | I measured `commerce_discount.status`; R-17 measures **active promo rules**, i.e. `rules_config.active` | Corrected to `module='commerce_discount'`. Gives 25 / 94 under a broader filter — **one inactive row still unexplained**, to be confirmed at the freeze point |

**194 and 27 re-confirmed exactly.** R-22 `custom_account_info` = **6,547**, matching.

---

## 4. Unresolved

### Baseline re-run — 2026-08-25, after D3-1 / D3-2

**16 PASS / 1 DRIFT.** R-16 = **146** ✅ confirms D3-1. R-17 = **25** ✅. R-17b = **118** ✅.
R-21 now reports rather than asserts: **220,536 rows, newest 2026-08-24 13:31:48**.
**194 / 27 / 64 all exact.**

The single remaining DRIFT is R-18: **94 inactive rules against an expected 93**.

### U-1 · RESOLVED 2026-08-25 — there is no orphan rule

My "one orphaned rule" reading was wrong, and so was the matching query that produced it.
`d7_open_questions.sh` returned **10 rules with no entity and 9 entities with no rule** — and
they **pair up by label**:

| rule id | entity id | shared label |
|---:|---:|---|
| 25 | 6 | $10 off West Coast Gel Nails |
| 26 | 7 | Code in thank you notes … 6/21 to 6/30 |
| 48 | 29 | OPT-IN FOR EMAIL SIGN UP ALL STATES (NOT SC) |
| 134 | 106 | SUMMER2016 - $10 off promo |
| 137 | 109 | Free Advanced Training of $20 or less |
| 140–143 | 112–115 | Black Friday / Cyber Monday deals |

Commerce 1 **truncates the discount name and appends a hash** when generating the rule name
(`…_expire_3001568874`), so my `CONCAT('commerce_discount_rule_', cd.name)` join could never match
a long name. **A query defect, not a data defect.**

The **10th** unmatched rule is **id 87 · `commerce_discount_remove_discount_components_on_products`
· active · unlabeled** — infrastructure, not a discount, and therefore correctly entity-less. It is
already in the **Class E "not reproduced"** list (ids 2, 7, 9, **87**, 88, 96, 97, 98, 100), and
`RECONCILIATION.md` R-17 already accounts for it: *"21 (+ referral, + infrastructure = 22)"*.

So: **119 rules = 118 entity-backed + 1 infrastructure = 118 entities.** The relationship is intact.

**Residual:** R-18 reads 94 inactive against an expected 93 — one inactive promotional rule more
than the audit recorded. R-17 matches exactly (25 = 22 + 3). Inactive rules do not fire, are
Class D configuration, and are preserved either way under decision 12. **Non-blocking**; confirm at
the freeze point.

> ⚠️ Noted for Phase 3.3: **nine discount ENTITIES are `status = 1` while their RULES are
> `active = 0`.** Entity-enabled but rule-disabled means the discount does not fire. When these
> become Commerce 3 promotions, the **rule** state is what determines behaviour — migrating from
> the entity status would silently re-enable nine historical promotions.

### ~~U-1 narrowed to an orphan~~ *(superseded by the above)*

With the corrected filter (`module='commerce_discount'` exactly):

```
rules_config commerce_discount rules : 25 active + 94 inactive = 119
commerce_discount ENTITY rows        : 118
```

**One rule has no entity.** The audit's 22 + 92 = 114 exactly equalled the entity count at the
time, so the documentation assumed a 1:1 rule↔entity relationship. Live D7 breaks that assumption
by one row — a Commerce 1 discount rule whose entity was deleted while the rule survived.

That makes U-1 an **orphan**, which belongs under **R-23** (orphans logged, none dropped) rather
than being a counting error. Under approved decision 7 it is **PRESERVE + LOG**, not delete.
`scripts/reconcile/d7_open_questions.sh` identifies the specific row.

### H-1 · DISPROVEN — and it uncovered something more important

The hypothesis was that the 27 ACL-without-purchase users were the 27 blocked users.
**They are not.** Only **3** of the 27 are blocked; 17 are active.

But 3 + 17 = **20, not 27.** The status query joins `users`; the exception-set query does not.

> 🛑 **SEVEN of the 27 uids have NO ROW IN THE `users` TABLE.**
> They hold ACL grants but the user account no longer exists.

Their orders sit in `cart` 12 · `canceled` 5 · `checkout_checkout` 2 · `checkout_review` 2 ·
`pending` 2 — consistent with accounts deleted after abandoned or cancelled purchases, leaving the
entitlement rows behind.

**Why this matters to the migration.** An ACL grant cannot be recreated in D10 for a user that does
not exist — there is no entity to grant to. These rows fall under
`D10_ARCHITECTURE.md` §6 principle 5: **log as `PRESERVED_UNLINKED`, never silently discard**
(decision 7, PRESERVE + LOG). They also bear directly on:

- **R-5** — `acl_user` must reconcile at exactly 33,003 rows
- **R-14 / R-15** — no user gains or loses access relative to D7
- **L-1 / L-3** — grant→user relationship integrity
- **R-23** — the documented orphan population of 443

Whether these grants are inside the documented 443 or a **new orphan class** is unresolved.
`scripts/reconcile/d7_orphans.sh` quantifies them and runs the same missing-parent test across
flaggings, orders, line items, payment transactions, quiz results and webform submissions.

### ~~H-1 · a hypothesis worth testing before Phase 3.5~~ *(superseded by the above)*

Live D7 has **exactly 27 blocked users** (`status = 0`) — and the second preserved exception set
is **exactly 27 users holding an ACL grant with no completed purchase**.

If those are the same 27 people, the anomaly has a mundane explanation: they bought, then were
blocked or refunded and the order moved out of `completed`, leaving the grant behind. **That would
not change the handling** — decision 14 preserves the set exactly either way — but it converts an
unexplained anomaly into a documented one, which matters for the acceptance argument.
The overlap query is in `d7_open_questions.sh`.

---

## 3b. Orphan register — measured 2026-08-25 (`.logs/orphans.log`)

`RECONCILIATION.md` R-23 documents **443 = 163 + 38 + 12 + 225 + 4 + 1**. Measured against live D7:

| Category | Documented | **Live** | |
|---|---:|---:|---|
| Orders whose user no longer exists | 163 | **163** | ✅ exact |
| Payment transactions with no order (L-6) | 38 | **38** | ✅ exact |
| Line items with no order | 12 | **12** | ✅ exact |
| Quiz results whose user no longer exists | 4 | **4** | ✅ exact |
| Flaggings pointing at a missing node | 1 | **1** | ✅ exact |
| **`wistia_media` rows with a dead uid** | 225 | **225** | ✅ exact |
| **TOTAL** | **443** | **443** | ✅ |

**All six categories reconcile exactly. R-23 = 443 is CONFIRMED against live D7.**
The audit's orphan analysis is sound in full.

`wistia_media.uid` is a VARCHAR, and **all values are numeric** (non-numeric count = 0), so a
CAST-based migration is safe — but an FK-typed destination would still reject the 225 dead-uid
rows, which is exactly the trap `DATABASE_ANALYSIS.md` §72 warned about.

**The sixth is identified.** It is **`wistia_media` rows with a dead uid**, defined in
`DATABASE_ANALYSIS.md` line 206 and `PHASE_2_MIGRATION_DECISIONS.md` **X7** — *not* webform
submissions, which is what I first guessed. `d7_orphans.sh` §O-2b now verifies it.

⚠️ `wistia_media.uid` is a **VARCHAR, not a foreign key** (`DATABASE_ANALYSIS.md` §72) — which is
precisely why an FK-typed D10 destination would *reject or silently drop* these 225 rows. The
verification also counts any non-numeric uid values, since those would break a CAST-based
migration.

Also measured: **flaggings whose user no longer exists = 0** — flag data is fully referentially
intact on the user side.

### 🛑 CONSOLIDATED ORPHAN REGISTER — measured 2026-08-25

| Category | Rows | Status |
|---|---:|---|
| Orders with a dead uid | 163 | documented ✅ |
| Payment transactions with no order | 38 | documented ✅ |
| Line items with no order | 12 | documented ✅ |
| `wistia_media` with a dead uid | 225 | documented ✅ |
| Quiz results with a dead uid | 4 | documented ✅ |
| Flaggings pointing at a missing node | 1 | documented ✅ |
| **R-23 documented subtotal** | **443** | **verified exact** |
| `wistia_media_track` with a dead uid | **424** | 🆕 undocumented |
| **`acl_user` grants with a dead uid** (83 users) | **258** | 🛑 **undocumented — ENTITLEMENT** |
| Webform submissions with a dead uid | **73** | 🆕 undocumented |
| `wistia_media_track` with no `wistia_media` row | **1** | 🆕 undocumented |
| **Undocumented subtotal** | **756** | |
| **TOTAL referential-integrity orphans** | **1,199** | |

> **The undocumented orphan population (756) is larger than the documented one (443).**

This does not invalidate the audit — every one of its six categories verified **exactly**, which is
strong evidence the analysis was careful. It simply did not extend the missing-parent test to
`acl_user`, `wistia_media_track` or `webform_submissions`.

**Consequences:**

1. **R-23 currently asserts 443.** With 1,199 orphans actually present, that criterion needs a
   decision: does R-23 become 1,199, or does it stay 443 with the remainder tracked separately?
2. `wistia_media.uid` and `wistia_media_track.uid` are both **VARCHAR** and **all values are
   numeric** (non-numeric = 0 in both), so a CAST-based migration is safe — but an FK-typed
   destination would reject 225 + 424 = **649 playback rows**.
3. Every one of the 756 must be logged `PRESERVED_UNLINKED` under decision 7. **None may be
   silently dropped**, and an FK-typed destination would drop them by default.

### 🛑 O-1 · THE MOST SERIOUS CLASS — ENTITLEMENT DATA

```
acl_user rows                        33,003   (R-5 target, exact)
distinct uids in acl_user            11,395
GRANT ROWS whose uid has no user      → 258
distinct missing uids                 →  83
those grants point at missing nodes   →   0   (all 258 target LIVE nodes)
```

**258 ACL grant rows belong to 83 users that no longer exist.** Grants per missing uid range from
1 to 10. Every one of the 258 points at a node that still exists — the *node* side is intact; only
the *user* side is gone.

This is **not** in R-23's 443. It is a **new orphan class**.

**Why it blocks Phase 3.5 design.** An ACL grant cannot be created in D10 without a user entity to
grant to. Therefore:

- **R-5 asserts `acl_user` = 33,003 exactly.** The maximum D10 can hold as real grants is
  **33,003 − 258 = 32,745**.
- The remaining **258 must be logged `PRESERVED_UNLINKED`** per `D10_ARCHITECTURE.md` §6
  principle 5 and decision 7 (PRESERVE + LOG) — never silently dropped.
- **R-14 / R-15 are unaffected**: no *existing* user gains or loses access, because these 83 users
  do not exist in D7 either.

**Decision required:** does R-5 become
`live grants (32,745) + orphan-log entries (258) = 33,003`, or does the target change?
Either way **nothing is deleted** — this concerns how the acceptance criterion is expressed,
exactly as R-21 did. **R-5 has not been altered.**

---

## 3c. Relationship reconciliation — L-1 … L-10 baseline (`.logs/relationships.log`)

### ✅ Confirmed exactly as documented

**L-2 — ACL grants are keyed to VIDEO nids.** 32,980 grant rows resolve to `video` nodes and
**zero** to course or product. The structural claim underpinning the whole entitlement model holds.
Grants are owned by `content_access` ACLs named `view_<nid>`.

**L-7 — webform component `cid = 30`** is node 27, `form_key = course`, type **hidden** — exactly as
`RISK_REGISTER.md` R15 describes.

**L-4 / L-5** — 3,973 quiz results across 2,595 users and 56 quizzes, **0** pointing at a missing
quiz node. 108 certificate mappings, **0** with a missing node.

**L-1** — line-item types `product` 113,271 + `commerce_discount` 3,047 + `product_discount` 154 =
**116,472**, matching R-3 exactly.

### 🛑 N-1 · The entitlement model is NOT view-only

```
acl_node grant flags in use:
  grant_view   = 1  ->  134 ACLs
  grant_update = 1  ->   53 ACLs
  grant_delete = 1  ->   52 ACLs
```

`RULES_BEHAVIOR_MAPPING.md` C-10 and `RECONCILIATION.md` L-3 both describe *"the ACL **view**
grant"*. But **105 ACLs carry update or delete grants**, not view.

A D10 implementation that reproduces only view grants would silently drop the update/delete
semantics on those 105 ACLs. **Decision required:** are the update/delete grants in scope, and what
D10 mechanism carries them?

### 🛑 N-2 · The evaluation → course link is broken on ~481 submissions

`cid = 30` carries the course nid, and `course_actions.module` reads `data[30]`.

```
submissions on webform node 27      21,064
data rows carrying cid 30           20,873   ->    191 submissions have NO course value
  of those 20,873:
    resolve to a course node        20,580
    resolve to a NON-course node         3
    do not resolve to any node         286
    non-numeric values                   4
```

So **~481 submissions** (191 missing + 286 unresolvable + 4 non-numeric) cannot be linked to a
course, and **3 point at the wrong node type**. Under decision 7 these are PRESERVE + LOG, not
repairs — but the migration must not assume `data[30]` always resolves, and **4 non-numeric values
would break a CAST-based nid lookup**, exactly as the Wistia uid columns would (U-15).

### 🛑 N-3 · Content-model orphans in the product↔course chain (L-9)

```
products carrying a video reference          150
distinct videos referenced                   149
product refs to a MISSING video                7   <- orphan
courses with videos                          117  (166 course->video rows)
course refs to a MISSING video                 1   <- orphan
videos in MORE THAN ONE course                18   <- the RB-12b defect, quantified
videos referenced by MORE THAN ONE product     1
```

**18 videos belong to multiple courses.** That is the population affected by the preserved
"last course wins" session-overwrite defect (RB-12b) — previously known to exist, now sized.

**8 more orphans** (7 product→video, 1 course→video) in the content model, on top of the 1,199
already registered.

### 🛑 N-4 · Flag → entity type is not uniform

```
flag          entity type   flaggings   users    entities
completed     video            32,050   \
completed     course           21,693   /  53,744 total
bought        video            32,992    11,469     133
enrolled      course           24,783    11,752     114
submitted     course           20,397    10,630      99
in_progress   video               340       250      92
```

**`completed` targets BOTH videos and courses**; the other four are single-target. A migration that
assumes one entity type per flag would corrupt `completed`. Not previously documented at this level.

### N-5 · The L-3 divergence, quantified

```
bought flag on a video with NO ACL grant   402
ACL grant on a video with NO bought flag   390
```

The preserved grantee divergence (CLAUDE.md §15) affects roughly **800 relationships**. This is the
D7 baseline that RB-14a must reproduce — not a defect to correct.

### N-6 · Two further small anomalies

- **6 referral ledger rows have a dead `uid`** (0 dead `referrer_uid`) — another orphan class.
- **8 completed orders have no line item** (22,008 completed, 22,000 with line items). Worth
  understanding before the order migration, since entitlement is granted per line item (C-11).

---

## 4. Unresolved

| # | Item | Blocks |
|---|---|---|
| ~~U-1~~ | ✅ **RESOLVED** — no orphan rule. The mismatch was a name-truncation defect in my join, plus the known infrastructure rule id 87. Residual: R-18 reads 94 vs 93, one inactive rule, **non-blocking** | *closed* |
| ~~U-10~~ | ✅ **RESOLVED — decision D3-4 approved 2026-08-25.** R-5 stays 33,003, satisfied as **32,745 live + 258 `PRESERVED_UNLINKED` = 33,003**. No placeholder users, nothing deleted. Freeze precondition **P-3 MET** | *closed* |
| ~~U-13~~ | ✅ **RESOLVED** — the 225 is **`wistia_media` rows with a dead uid** (`DATABASE_ANALYSIS.md` §206, `PHASE_2_MIGRATION_DECISIONS.md` X7), not webform submissions. Live verification added as `d7_orphans.sh` §O-2b | *closed* |
| ~~U-14~~ | ✅ **RESOLVED — decision D3-5 approved 2026-08-25.** **R-23 stays 443** (verified, not redefined); **new R-24 = 1,213** carries the full population, including the 14 later L-8/L-9 findings. Freeze precondition **P-4 MET** | *closed* |
| ~~U-15~~ | ✅ **RESOLVED — decision D3-6 approved 2026-08-25.** Wistia uid destinations stay **VARCHAR**, no FK. All 94,874 rows migrate; the 649 dead-uid rows are logged and counted in R-24. Freeze precondition **P-5 MET**. ⚠️ The related **4 non-numeric `cid=30` values** stay open under U-17 | *closed* |
| ~~U-16~~ | ✅ **RESOLVED — decision D3-7 approved 2026-08-25.** **Update/delete grants are preserved**, all three flags carried, logged-fallback only if D10 provably cannot express them. Freeze precondition **P-6 MET**. ⚠️ **Residual: the USER population behind the 105 bindings is still unmeasured** — quantify at the freeze point | *closed* |
| **U-17** | 🛑 **N-2 — ~481 evaluation submissions cannot be linked to a course** (191 with no `cid=30` value, 286 unresolvable, 4 non-numeric) and 3 point at a non-course node type. PRESERVE + LOG applies, but the migration must not assume `data[30]` resolves | **Phase 3.5** |
| **U-18** | 🛑 **N-4 — the `completed` flag targets BOTH videos (32,050) and courses (21,693).** Every other flag is single-target. A one-entity-type-per-flag assumption would corrupt `completed` | **Phase 3.5** |
| ~~U-11~~ | ✅ **CONFIRMED as a requirement 2026-08-25** (no decision needed). Promotion firing state derives from **`rules_config.active`**, never `commerce_discount.status`. The nine entity-enabled / rule-disabled discounts migrate to Commerce 3 as **disabled**. Migrating from the entity would silently re-enable nine historical promotions and change what customers pay | *closed* |
| ~~U-17~~ | ✅ **CONFIRMED as a requirement 2026-08-25** (no decision needed). ~481 evaluation submissions with broken course links are **PRESERVE + LOG, never repaired** — repair would mean reconstructing business records from circumstantial evidence. The migration must not assume `data[30]` resolves, and must tolerate the **4 non-numeric values**. The mandated `webform_component` cid→machine-name capture is unchanged | *closed* |
| ~~U-18~~ | ✅ **CONFIRMED as a requirement 2026-08-25** (no decision needed). The **`completed`** flag targets **both** video (32,050) and course (21,693); every other flag is single-target. The flag migration must carry `entity_type` per flagging row and must never assume one entity type per flag. To be asserted explicitly in the flag migration test | *closed* |
| **U-19** | ⚠️ **N-3/N-6 — 8 further content-model orphans** (7 product→video, 1 course→video), **6 referral ledger rows with a dead uid**, and **8 completed orders with no line item**. Total registered orphans rise from 1,199 to **1,213** | Phase 3.5 |
| U-20 | ℹ️ **N-3 — 18 videos belong to multiple courses**, sizing the preserved RB-12b "last course wins" defect. Not a decision; a test population | Phase 3.3 |

### Resolved from existing documentation — 2026-08-25

Four open items turned out to be **already answered by the approved documents**. No new decision was
taken; the documented disposition was simply located and applied.

| # | Item | Documented disposition | Outcome |
|---|---|---|---|
| ~~U-4~~ | `admin_toolbar` | `MODULE_MAPPING.md` §4: `admin_menu` → class **F**, *"Retire. Core Toolbar, **or** `admin_toolbar`."* | ✅ **Closed.** Core Toolbar alone satisfies the disposition. `admin_toolbar` is permitted, not required. Recorded: D7's actual UX was hover drop-downs, so `admin_toolbar` preserves the workflow more closely — but adding it is optional, not a gate |
| ~~U-6~~ | Two Wistia config pages | `RULES_BEHAVIOR_MAPPING.md` §5 constraint 1: *"No behaviour consolidation… Nothing else may be merged."* Consolidation is only ever permitted where explicitly approved (the six product→video lookups) | ✅ **Closed by default.** **Keep both pages separate.** Consolidating would require explicit approval; not consolidating requires none |
| ~~U-7~~ | `webform_migrate` | `PHASE_2_MIGRATION_DECISIONS.md` §10: *"`webform_migrate` **2.0.0-alpha2** supports D10 — this resolves the 'unverified' flag from the audit."* `PHASE_3_IMPLEMENTATION_PLAN.md` §2 pins `^2.0` | ✅ **Closed — and my earlier finding was WRONG.** I probed `^1.2`, which forced `dev-1.x`, and concluded "no stable release". The approved constraint is **`^2.0`**, and 2.0.0-alpha2 exists and is D10-compatible. Restore `drupal/webform_migrate: ^2.0` at Phase 3.5 |
| ~~U-3~~ | `field_ui` | **No project document mentions it.** `PHASE_3_IMPLEMENTATION_PLAN.md` §3 is explicitly the *Phase 3.1 baseline* list, not the final module set | ⚠️ **Not a decision, but not yet enabled.** `field_ui` is enabled in D7 and is core. Enabling it in Phase 3.2 preserves admin-parity check W1 without inventing a requirement. Flagged, not enacted — it needs `drush` and changes exported config |

> ⚠️ **U-7 correction matters beyond U-7.** `webform_migrate` carries the **`cid = 30`** risk:
> `DATABASE_ANALYSIS.md` §263 and `PHASE_2_MIGRATION_DECISIONS.md` §10 both warn that it
> **converts numeric `cid` values to machine names**, which would destroy the hardcoded `data[30]`
> lookup and stop **20,397 course completions** being recorded. The mandated mitigation — capture
> `webform_component` (28 rows) verbatim **before** migrating and build an explicit
> cid→machine-name map — is unchanged and remains mandatory.

### ~~U-5 · `/admin/dashboard`~~ ✅ RESOLVED — decision D3-9, REMOVE approved 2026-08-25

No project document mentions the D7 core `dashboard` module. Measured evidence: 5 blocks placed in
Seven's dashboard regions, of which **3 sat in `dashboard_inactive`** and were never rendered. The
real loss is **2 blocks** — "Who's new" and "Search form", both of which have D10 equivalents.

**Removal accepted. No replacement page is built.** Recorded as a deliberate, accepted difference
from D7 for the acceptance argument — not an oversight. See D3-9.
| U-8 | **My documentation error, corrected.** Role/permission figures in `D10_ADMIN_THEME_MIGRATION.md` §8 came from the stale dump. Live: **485 grants, administrator 274** (not 483 / 272). CE Admin 128, Instructor 39, 5 roles all confirmed | Phase 3.2 |
| U-9 | ⬇️ **DOWNGRADED — the support workflow survives.** `switch users` is indeed a `devel` permission (administrator only, and `devel` is `require-dev`), but live `role_permission` shows **`masquerade as any user` and `masquerade as user` are held by BOTH `administrator` AND `CE Admin`**. `masquerade` ^2.2 is in `require`, so user-switching for support is preserved. Only devel's redundant administrator-only variant is lost. **No decision needed unless someone relies on devel specifically** | Phase 3.2 |
| U-12 | `use PHP for settings` (module `php`, administrator) is a **fifth** permission with no D10 target — `drupal/php` is in composer `conflict`. Same disposition as the other four: not reproduced | Phase 3.2 |
| ~~U-2~~ | ✅ **CLOSED 2026-08-25.** Backups executed and independently verified: `d7-20260825-123510`, database 65,692,030 B (sha256 `1a0526fd…`), files 639,896,826 B (sha256 `bcd2d09b…`), DDEV snapshot, MANIFEST with checksums. Dump proven CURRENT — `commerce_order` 87,640 plus four corroborating counts. Stale `db.sql` recorded as SUPERSEDED | *closed* |
| ~~U-3~~ | ✅ **RESOLVED — decision D3-8, YES approved 2026-08-25.** `field_ui` enabled; `.logs/task13-fieldui.sh` prepared | *closed* |
| ~~U-4~~ | ✅ closed above — `admin_toolbar` permitted, not required | *closed* |
| ~~U-5~~ | ✅ **RESOLVED — decision D3-9, REMOVE approved 2026-08-25** | *closed* |
| ~~U-6~~ | ✅ closed above — keep both Wistia pages separate | *closed* |
| ~~U-7~~ | ✅ closed above — `webform_migrate ^2.0` (2.0.0-alpha2, D10-compatible) | *closed* |

---

## 5. Changes made to the D7 repository

**One file, one row, explicitly authorised 2026-08-25:** `RECONCILIATION.md` R-21, plus an
explanatory note. **Documentation only.** No D7 PHP, module, theme, configuration, schema or
business data was touched, and nothing was committed.

`course_actions.module` remains at md5 `f9913edaad4e3feb434ee201ebb2f55c` with its pre-existing
modification uncommitted, per decision 8.

### 🛑 PENDING — D7 documentation edits that decisions D3-4 … D3-7 imply but do NOT authorise

The approvals of 2026-08-25 changed **how four acceptance criteria are expressed**. Those criteria
live in `RECONCILIATION.md`, which is in the **D7 repository**. Only one edit to that file has ever
been authorised (R-21, D3-2). **No further edit has been made, and none will be without an explicit
instruction.** The approved wording is recorded here in the D10 repository so nothing is lost:

| Target | Change implied | Status |
|---|---|---|
| **R-5** | Composite wording — `32,745 live + 258 logged = 33,003` (D3-4) | ⏳ **awaiting authorisation** |
| **R-24** | New criterion — 1,213 total orphans (D3-5) | ⏳ **awaiting authorisation** |
| **R-23** | **No change** — stays 443 | ✅ nothing to do |
| **L-3** | Currently describes the ACL *view* grant only; D3-7 preserves update/delete too | ⏳ **awaiting authorisation** |
| `RULES_BEHAVIOR_MAPPING.md` **C-10 / RB-14** | Same view-only narrowing as L-3 | ⏳ **awaiting authorisation** |

Until authorised, **`DECISIONS_PHASE_3.md` (this file) is the operative record** for these four, and
the D7 documents are known to be narrower than the approved behaviour. That divergence is recorded
deliberately rather than resolved unilaterally.
