# RULES_BEHAVIOR_IMPLEMENTATION.md

**Progress of `ce_rules_behavior` — the 16 Class C rules.**
Started 2026-08-25. Specification: `RULES_BEHAVIOR_MAPPING.md`. Test contract: the 6 kernel test
classes already in `tests/src/Kernel/` (32 methods, all `markTestIncomplete`).

**Everything here is RUNTIME-UNVERIFIED.**

---

## 1. ⭐ The checkout ordering contract — implemented

`CheckoutCompleteSubscriber` reproduces the four rules that fire on `commerce_checkout_complete`.

| D7 order | D7 weight | Rule | Method | D10 priority |
|---:|---:|---:|---|---:|
| 1 | **−10** | 4 | `setCreatedDate()` | **400** |
| 2 | **0** | 5 | `completeOrder()` | **300** |
| 3 | **1** | 6 | `convertAnonymousOrder()` | **200** |
| 4 | **4** | 8 | `sendOrderMail()` | **100** |
| — | — | *(7)* | **inactive — not implemented** | — |

**Symfony priority is inverted relative to D7 weight** — higher priority runs first, lower weight ran
first. `D7_WEIGHT_MAP` records the mapping explicitly so the ordering test can assert it, and so
nobody "tidies" the constants into ascending order. Verified strictly descending.

Priority gaps of 100 are deliberate: behaviour can be inserted between two steps later without
renumbering, which would risk reordering the contract.

### Why the order is business-visible

- **Rule 4 before rule 5** → `commerce_order.created` is the **checkout completion** timestamp, not
  the cart creation timestamp. **Every historical report depends on this.**
- **Rule 6 after rule 5** → the order is already `completed` when `uid` is reassigned, which is what
  makes rule 15 fire against a completed order.

`INACTIVE_RULE_IDS = [7]` is a constant so the test asserts absence **by rule id**, not by "no such
method" — which would also pass if someone merely renamed it.

---

## 2. Deliberately inert — three methods that must not guess

`setCreatedDate()` is implemented. The other three log a warning and do nothing, for specific
reasons. **A step that is unimplemented and logs nothing is indistinguishable from one that ran and
did nothing — which is exactly how a migration ships a missing business rule.**

### `completeOrder()` — rule 5

Blocked on the **Commerce 3 checkout build**. Decision 6 makes state assignment business-critical
here: `checkout_complete` **is not** `completed`, and its **42 migrated orders must grant no
access**. Writing a state change against a workflow that does not exist yet would either no-op
silently or move orders into a state the migration has not defined.

### `convertAnonymousOrder()` — rule 6

🛑 **The D7 rule has THREE conditions and only two are known.** `RULES_BEHAVIOR_MAPPING.md` C-5
records the third as *"confirm on decode at implementation time"*.

> Implementing two of three conditions would make this rule fire **more often than D7 does**,
> reassigning orders that D7 leaves anonymous. That is a business-data change, not a gap.

Also recorded: **both** writes are required — `commerce-order:uid` *and*
`commerce-order:commerce-customer-billing:uid`. Missing the second silently breaks customer-profile
ownership; the order changes hands but the billing profile does not.

### `sendOrderMail()` — rule 8

Needs `mailsystem`/`mimemail` and the golden-master email body. RB-8 requires a byte-comparable diff
against D7 for a user **with** and **without** `field_first_name`.

⚠️ **Preserved D7 oddity:** the mail `language` is taken from `commerce-order:state` — an order state
string used as a language code. Nonsense, and exactly what D7 does.

---

## 3. The event name is not guessed either

```php
public const EVENT = 'commerce_order.place.post_transition';
```

Commerce 3 has no single event with D7 `commerce_checkout_complete`'s semantics; the closest is the
order-placed workflow transition. **Which event is correct depends on the Commerce 3 checkout flow
built in the Commerce phase.** Binding to the wrong event would run all four steps at the wrong
moment — or never — while every unit test still passed. Defined in **one** place so it changes once.

---

## 4. One shared save, not four

None of the four methods calls `save()`. All four mutate one order, saved once by the transition —
matching D7, where the Rules engine saved after the whole chain rather than after each rule. Saving
per step would produce four order revisions where D7 produced one, inflating
`commerce_order_revision` and corrupting the **R-21** archive-completeness target.

---

## 4b. ⭐ The entitlement engine — C-10 and C-11 implemented

`EntitlementGranter` (rule 14) + `OrderStateSubscriber` (rule 15). These two wrote all **33,003**
`acl_user` rows in D7. Getting them wrong fails R-5, R-14, R-15, L-1, L-2 and L-3 at once — or
worse, passes while granting the wrong people access to the wrong videos.

### Four preserved behaviours, each easy to lose

**(a) The grant targets the VIDEO node** — never the course, never the product. The chain is
`line item → commerce_product → field_video_reference → VIDEO nid`. This is why **L-2 measured
32,980 grants resolving to `video` nodes and ZERO to course or product**.

**(b) 🛑 The ACL grant and the flag go to DIFFERENT users.**

```
ACL view grant  ->  line-item:order:owner   (the ORDER OWNER)
`bought` flag   ->  site:current-user       (whoever is LOGGED IN)
```

Normally the same person. When an **admin completes an order for a customer** they diverge: the
customer gets the access, the admin gets the flag. `CLAUDE.md` §15 requires this **preserved, not
corrected** — it is one mechanism behind the **194 / 27** exception sets, and N-5 sized the result at
**~800 diverged relationships**. The code says so at the call site, so nobody "fixes" it.

**(c) `permission_check = 1` here** — versus `0` in C-7/C-8/C-9. Per call site, not module-wide.

**(d) Multi-video products grant only the FIRST video** — preserved bug **B4**. A product referencing
three videos gives access to one. `first()` only; deliberately not a loop.

Grants are written **view-only** (`update = 0`, `delete = 0`), matching D7's action. That does not
contradict decision **D3-7**: this rule writes view grants, while the 105 `acl_node` bindings that
carry update/delete come from elsewhere in the system and are preserved separately.

### 🛑 C-11 is NOT transition-only — and that was a deliberate choice

D7 fires on `commerce_order_update` and then **tests** the state. It does not listen for a
transition. The difference is observable:

| | fires |
|---|---|
| a **transition** listener | once, when the order *becomes* completed |
| **D7** | on **every save** of an order that **is** completed |

`CLAUDE.md` §16 is explicit: *"A re-save of an already completed order can trigger the D7 behaviour
again… Do not automatically convert it into transition-only behaviour."*

So `OrderStateSubscriber` subscribes to the entity **update** and gates on the state value, mirroring
D7's shape rather than modernising it. The idempotency that makes re-running harmless lives in
`EntitlementGranter` — an existing grant or flag is simply not duplicated, which is what **RB-15b**
asserts (no duplicate flags, no change in the `acl_user` row count).

### The gate protecting 106 orders

`GRANTING_STATE = 'completed'` is the **single** condition. **RB-15c** requires that an order in any
other state grants nothing — which is what keeps the **64 stuck orders** and the **42
`checkout_complete` orders** (decision 6) without access. That one comparison is all that stands
between those 106 orders and incorrect entitlement, so `checkout_complete` must never be added to it.
The constant's docblock says exactly that.

---

## 4c. C-7 / C-8 / C-9 — completion and playback

`CourseCompletionSubscriber` (rule 11) and `VideoPlaybackSubscriber` (rules 12 + 13), on a shared
`FlagOperations` service.

### `permission_check` is per call site — and it is NOT uniform

| Rule | Call site | Value |
|---|---|:--:|
| 11 · course completed | `CourseCompletionSubscriber` | **0** — bypass |
| 12 · video ended | `VideoPlaybackSubscriber::onVideoEnded` | **0** — bypass |
| 13 · video started | `VideoPlaybackSubscriber::onVideoStarted` | **0** — bypass |
| 14 · entitlement | `EntitlementGranter` | **1** — enforce |

`RULES_BEHAVIOR_MAPPING.md` §362 states this explicitly. Applying one value everywhere would either
**silently drop flaggings** (1 on 11/12/13, when a learner lacks the permission) or **grant
entitlement flags D7 would have refused** (0 on 14). `FlagOperations` takes it as a required
argument with **no default**, so every call site must state which rule it reproduces.

### 🛑 The `$_SESSION['course_url']` write — load-bearing

`CourseRedirectTarget` replaces D7's raw `$_SESSION` write with a `PrivateTempStore` — same value,
same lifetime, same consumer, D10-native storage. A technical rewrite under §5.

**Preserved defect RB-12b — last course wins.** D7 loops over every course containing the finished
video and overwrites the session value each iteration, so for a video in multiple courses an
effectively arbitrary one wins. N-3 measured **18 videos in multiple courses**, so this is not
hypothetical. The loop is reproduced exactly: no first-wins, no "pick the right course", no prompt.
Each of those would be new behaviour.

It also emits **one message per course**, so a video in two courses produces two identical prompts.
Preserved.

### The re-watch guard

Rule 13 requires **NOT `in_progress` AND NOT `completed`**. The `completed` half is what makes
re-watching a finished video a no-op (RB-13). Drop it and a finished video slides back to "in
progress", then re-fires the completion message on the next `ended` event.

### The `completed` flag is dual-target — two writers

`CourseCompletionSubscriber` writes the **course** half (21,693 flaggings); `VideoPlaybackSubscriber`
writes the **video** half (32,050). Together 53,744, matching **R-6**. Different business facts
sharing one flag name — the flag migration must carry `entity_type` per row or one half is corrupted.

### One noted D10 difference, no business effect

D7 set `repeat = 1` on the message action, so an identical message could appear twice in a request.
Drupal 10's messenger de-duplicates identical status messages within a request. The **flag** guard
still prevents duplicate flagging, which is what RB-11 asserts. Recorded rather than worked around.


---

## 4d. C-1 / C-6 / C-12 — cart and pricing

### 🛑 C-12 (rule 16) — a clamp, not a duplicate check

D7's condition is **`quantity == 2`**, an exact equality — not `>= 2`:

| action | quantity | clamped? |
|---|---:|:--:|
| add the same video twice | 2 | ✅ yes → 1 |
| add **three at once** | 3 | 🛑 **no** |

A customer adding three copies in one action keeps all three and can buy the same video three times.
`RULES_BEHAVIOR_MAPPING.md` C-12: *"Do not 'fix' it to >= 2 — that would change behaviour."*
**RB-16b asserts the defect** — a build that clamps 3 has **failed**.

Written as `!== 2` against an integer cast so the intent cannot be mistaken for a sloppy comparison
and "tidied".

### 🛑 C-6 (rule 10) — insert only, and that is the point

Implemented as `hook_node_insert()`, because Drupal 10 has **no entity-insert event** —
`hook_ENTITY_TYPE_insert()` *is* the D10 equivalent of D7's `node_insert--video` trigger.

**There is deliberately no `hook_node_update()` counterpart**, and the `.module` says so in capitals.
Adding one would re-sync the price and fail **RB-10b**, which asserts that editing a video's price
from 49.00 to 59.00 leaves the product at the original price. It would also change what existing
customers are charged.

**SKU collisions are preserved**: the SKU is the raw node title, so two videos with the same title
produce two products with the same SKU. No uniquifying suffix, no rejection.

### ⚠️ The ×100 is D7 STORAGE FORMAT, not a business rule — verified, not assumed

D7 step 4 is `data_calc node:field-price × 100`, because **Commerce 1 stored minor units**
(49.00 → 4900).

**Checked against the installed Commerce 3.3.8 source:**

```
modules/price/src/Plugin/Field/FieldType/PriceItem.php
    $properties['number'] = DataDefinition::create('string')
    sample value in the same file:  'number' => '9.99'
minor units appear ONLY as a converter for payment gateways
    (PaymentGatewayBase, MinorUnitsConverterTest) — never as field storage
```

So Commerce 3 stores a **decimal string**. Applying D7's ×100 literally would store `"4900.00"` — a
**hundredfold overcharge on every product**.

The business rule is *"the product price equals the video's `field_price`"*. That is what is
reproduced; the multiplication belonged to D7's storage representation. A **technical rewrite** under
`CLAUDE.md` §5 — identical observable price, different storage.

> RB-10 asserts `commerce_price.amount == 4900` against **D7's** storage. The equivalent D10
> assertion is **49.00 USD**. The test wording will need that translation, or it will fail against a
> correct implementation.

### C-1 (rule 3) — not yet implemented

Needs a Commerce 3 **price resolver plugin** with a priority reproducing D7 weight 10 (running
*after* the discount-component rule). Deferred with the Commerce build rather than guessed, since
resolver ordering is itself a business contract (§1's second ordering contract).


---

## 4e. C-14 / C-15 — messaging

### 🛑 C-14 (rule 99) — D7 is inconsistent about the pass mark ON PURPOSE

| Decides | Uses |
|---|---|
| the **course page** — "have you passed?" | the hard-coded **75** (theme layer, decision 13) |
| **this message** — "have you failed?" | the quiz's **real `pass_rate`** (100 on 35, 75 on 28, **0 on 5**) |

`RULES_BEHAVIOR_MAPPING.md` C-14: *"This rule is independent of the pass-mark defect… migrating
this rule must **NOT** be treated as an opportunity to align them."*

So a learner can see the course page treat them as passed while this message does not fire, or the
reverse. **Both behaviours are preserved and must not be reconciled.** Verified: the subscriber
contains `75` only in docblock prose — **zero occurrences in executable code**. The comparison is
`$score < $pass_rate`.

### 🛑 Preserved defect X10 — `pass_rate = 0` makes failure impossible

The condition is `score < pass_rate`. For the **5 quizzes with `pass_rate = 0`**, no score can be
lower — so **the failure message never fires on those quizzes**, even for a learner scoring 0.

RB-99b asserts exactly this. Reproduced with a raw comparison: no floor, no `max($pass_rate, 1)`,
no special case for zero. Each of those would "fix" it.

### 🛑 Preserved typo — "did not reached"

```
You did not reached the passing score. Click Here to attempt Quiz again.
```

Grammatically wrong in D7, stays wrong. A string contract, like the certificate's `certifiacte`
(C-3). RB-99 diffs it exactly. *Click Here* links to `node/<quiz nid>`.

### C-15 (rule 104) — unconditional, every login

**No conditions at all.** Fires for every user on every login, including administrators and users
whose profile is already complete. It is noisy by design.

> Adding an "only if the profile is incomplete" check would be a **new business rule** — however
> sensible it looks — and would silence a prompt the business has shown on every login for years.

`repeat = 0` ("not duplicated within one request") is satisfied by D10's messenger, which
de-duplicates identical status messages by default. Same situation as C-7.

Implemented as `hook_user_login()`, since Drupal 10 has no user-login *event* — the hook **is** the
D10 equivalent of D7's `user_login` Rules trigger. The link resolves to the user who just logged in,
not a static path.

### C-13 (rule 93) — blocked

Needs the webform migration (its condition hard-codes **`webform-client-form-27`**, defect D20 —
and the nid must be resolved **through the migration map**, not re-hard-coded) plus the mail stack
and `[user:field-referral-link]`. RB-93b exists because **a broken referral token silently kills the
referral programme**.


---

## 4f. C-16 (rule 145) — the referral discount ⚖️ REAL MONEY

`ce_referral\ReferralDiscountCalculator` + `ce_referral\ReferralBalanceBurner`.

The `ce_referral_discount` ledger holds **361 rows of live customer balances** (R-12). A mistake
here does not render wrong — **it charges the wrong amount**.

### The three-branch cascade, in D7's order

1. user **has a previous order** → `get_referral_user_total($uid)`
2. **else** → their unused `referred_discount_amount`
3. **if both produced 0** → sum the balances they earned as a **referrer**, FIFO

Branch 3 is an `if ($amount == 0)` *after* the if/else — **not a third else**. A user who is both a
referee with a used discount and a referrer with a balance reaches it.

**FIFO is by `referrer_disc_created` ASC** — oldest earned balance burns first. Business-visible,
because balances are consumed in sequence.

### 🛑 PRESERVED DEFECT R17 — the unit bug, with the worked example

The SELECT filters and orders on `referrer_remaining_balance` (the **live** balance). The burn then
compares against a **different column**:

```php
$referrer_discount_in_cents = $result->referrer_discount_amount * 100;
//                                     ^^^^^^^^^^^^^^^^^^^^^^^^ the ORIGINAL GRANT
```

```
ledger row: granted $50, already consumed down to $10 remaining
customer is given $10 off            (calculator reads remaining_balance)
burn:  1000c >= 5000c ?  NO  -> partial branch
       remaining = (5000 - 1000) / 100 = $40

A row worth $10 is REWRITTEN TO $40.
The customer gains $30 of credit they never had.
```

And the mirror case: on the **full-usage** branch `$discount_amount` is decremented by the original
grant rather than what was available, so later rows in the same FIFO loop are **under-consumed**.

**Both reproduced exactly.** Swapping in `referrer_remaining_balance` — the obvious one-word fix —
would change every future balance and silently diverge from the 361 historical rows.

> ⚠️ R17's mitigation is **"audit all 361 balances before cutover"**. That is a **business action**
> and is **not** performed by this code. The implementation reproduces D7; it does not clean up
> after it.

### Two more preserved oddities

- The referee UPDATE conditions on **`uid` only, not `id`**. If a user somehow has more than one
  unused row, **all** are marked used by one statement while only the first was read.
- That same UPDATE **resets `referrer_disc_created` to now**, re-dating the row and therefore
  changing its **FIFO position** for future burns.
- The `data` column is an append-only audit trail; all three D7 strings are byte-identical,
  including the leading `" \n"`.

### ⚠️ The ×100 again — same caveat as rule 10

D7 multiplies by **−100**: ×100 for Commerce 1 minor units, ×−1 because a discount is negative.
Commerce 3 stores decimals, so **only the negation is kept**. Applying the ×100 literally would turn
a **$10 referral credit into $1,000 off**.


---

## 5. Status

| Class C rule | Status |
|---|---|
| 4 · created date | ✅ implemented |
| 5 · order state | 🛑 blocked — Commerce 3 checkout flow |
| 6 · anonymous→user | 🛑 blocked — **third D7 condition undecoded** |
| 8 · order email | 🛑 blocked — mail stack + golden master |
| **11 · course completed** | ✅ **implemented** — `CourseCompletionSubscriber` |
| **12 · video ended** | ✅ **implemented** — `VideoPlaybackSubscriber::onVideoEnded` |
| **13 · video started** | ✅ **implemented** — `VideoPlaybackSubscriber::onVideoStarted` |
| **14 · entitlement engine** | ✅ **implemented** — `EntitlementGranter` |
| **15 · entitlement trigger** | ✅ **implemented** — `OrderStateSubscriber` |
| **10 · video → product** | ✅ **implemented** — `VideoProductFactory`, insert-only |
| **16 · repurchase clamp** | ✅ **implemented** — `CartSubscriber` |
| 3 · disabled-product price | 🛑 blocked — Commerce 3 price resolver |
| **99 · quiz failure message** | ✅ **implemented** — `QuizResultSubscriber` |
| **104 · post-login notice** | ✅ **implemented** — `UserLoginSubscriber` |
| 93 · course-complete mail | 🛑 blocked — webform migration + mail stack |
| **145 · referral discount** | ✅ **implemented** — R17 preserved |

**ALL 16 Class C rules addressed** — 11 implemented, 5 blocked on other migrations. None untouched.

### What remains in this module

The 5 blocked rules all wait on other migrations, not on decisions:

| Rule | Waiting on |
|---|---|
| 5 · order state | Commerce 3 checkout flow |
| 6 · anonymous→user | **the third D7 condition, still undecoded** |
| 8 · order email | mail stack + golden-master body |
| 93 · course-complete mail | webform migration + `[user:field-referral-link]` |
| 3 · disabled-product price | Commerce 3 price resolver |

Two calculator methods in rule 145 (`userHasOrder`, `getReferralUserTotal`) also wait on the
Commerce build. Returning FALSE routes to branch 2, which is D7's behaviour for a user with no prior
order — a default that never over-credits.
