# Contract-Centric Commission Architecture — Full Implementation Plan

**Status:** ✅ Program complete (Phases 0–9). Deferred items in §13 each need their own approval.  
**Updated:** 22 Sep 2026  
**Related:** Dual-entry (done), Engine binding Chunks 1–3 (done), `COMMISSIONING_STATUS_AND_GAPS.md`

**Process rule:** One block at a time → test → report → **STOP** → wait for approval → next block.  
No automatic phase jumping. No big-bang rewrite.

---

## 1. Goal (one sentence)

**Project → Contract → Commission Configuration** is the only business SoT for contract commission rules; **Commissioning** runs/calculates/posts/ledgers/reports/pays from that config (+ Team Assignment allocation).

---

## 2. Locked decisions (do not re-litigate mid-implementation)

| ID | Decision |
|----|----------|
| **Q1** | `commissioning_rules` = reusable **templates** |
| **Q2** | Contract supports **1..N** commission rules |
| **Q3** | Apply = **COPY** into Contract-owned config (not live-only reference) |
| **Q4** | RuleResolver **fallback** during transition → later **mandatory** applied config |
| **Q5** | **Logical** ownership Contract/Project now; **physical** table/module move later |
| **Q6** | Member `commission_duration` — ✅ **decided and wired 23 Sep 2026**: a member's duration can only **shorten** their window, never extend it. `lifetime` = "do not cut me short", so the contract configuration's window applies; `one_time` = a single window at the contract start. Whichever window closes first wins. The member themselves is switched off on expiry; whether the BD pool then flows to PD is decided by the contract's redistribution setting. |
| **Q7** | `/commissioning/rules` → **Template Administration** |
| **Q8** | Legacy Project `commission_rules` / shell earnings → **preserve/classify**; cleanup later |

---

## 3. Target architecture

```text
Templates (commissioning_rules)          ← admin library only
        │ Apply = COPY (not live dependency)
        ▼
Project → Contract
   ├─ Team Assignment                    ← PD/BD/team pools + member %
   └─ Commission Configuration[]         ← 1..N Contract-owned rule copies
        │
        ▼
Effective Contract Commission Configuration
        │
        ▼
Commission Engine                        ← calculate / eligibility / duration / allocate
        │
        ▼
Run → item.rule_snapshot                 ← freeze applied policy
        │
        ▼
Ledger / Funds / Reports / Payment hooks
```

### Owns what

| Owner | Responsibility |
|-------|----------------|
| **Contract / Project (logical)** | Business rules on the contract, apply/edit/activate Contract rules, Team Assignment |
| **Commissioning** | Templates, Runs, Engine, Snapshots, Ledger, Funds, Reports, Disbursement hooks |
| **Not in this program** | Payroll redesign, Finance redesign, new formulas, Q6 duration, early physical move, Q8 delete |

### Must preserve

- Assignment % / team-pool Phase 2 math  
- FIXED_AMOUNT / NULL fallback  
- Posted-run immutability / `rule_snapshot`  
- Idempotent recalculate (draft/calculated)  
- No second competing % SoT  

---

## 4. Current state (after Phase 7 + 6.3 flag)

| Layer | State |
|-------|--------|
| Schema | Multi-row allowed; `name`, `sort_order`, `policy_snapshot` added; unique 1:1 **dropped** |
| Model/Repo | Supports multi-row order; `findAllByContract`; `hasPolicySnapshot()` |
| Apply API | **COPY**: `policy_snapshot` materialized (template + overrides); `template_rule_id` provenance; `rules[]` + row endpoints (`/rules`, `/rules/{id}`); legacy `/policy` BC |
| RuleResolver | Active snapshot rows → division/designation match → first wins; no match → `applied_policy_no_match`; legacy-only rows → live template; no active row → runtime fallback. Flag `require_applied_config` (OFF) turns the last two into `applied_policy_required` |
| Engine / formulas | Formulas unchanged. Post reads redistribution / BD-duration from frozen item `rule_snapshot` (Phase 7 fix) |
| UI | Contract panel = multi-rule list (add/edit/remove copies); Rules page = Template Administration; Commissions list = operational entry |
| Backfill / report | `commissioning:backfill-policy-snapshots`, `commissioning:unbound-contracts` + `GET /contract-commissions/unbound` (not yet run on a real environment) |
| Legacy Project rules | Untouched (Q8) |

**Known green checkpoint:** Modules/Commissioning **130 passed (839 assertions)** after 6.3 flag; Modules/Project **328 passed** (Phase 4.3 gate).

---

## 5. Full phase map

| Phase | Name | Status |
|-------|------|--------|
| 0 | Discovery | ✅ Done |
| 0.5 | Architecture interview | ✅ Done |
| 1 | Target architecture plan | ✅ Done |
| **2** | Contract Commission = copied multi-rule SoT (service/API/UI) | ✅ Done (2.1–2.5) |
| **3** | Effective config = Assignment + Commission Config | ✅ Done |
| **4** | Engine consumes Contract-owned copies (fallback kept) | ✅ Done |
| **5** | Template Admin UI + ownership UX cleanup | ✅ Done |
| **6** | Data backfill + cutover flag (default off) | ✅ Done |
| **7** | Ops verification (runs/ledger/reports) | ✅ Done (1 gap fixed) |
| **8** | Full regression | ✅ Done (2 pre-existing failures outside program) |
| **9** | Final audit + deferred items (physical move / Q6 / Q8) | ✅ Done |

---

## 6. Phase 2 — Contract Commission Configuration (detail)

**Objective:** Contract can store and manage **copied** multi-rule configuration. Formulas untouched. Engine still live-binding until Phase 4.

### Block 2.1 — Schema / model ✅ DONE

- Multi-row + `policy_snapshot` + ordering  
- Repo deterministic `findByContract`  
- Tests: `ContractCommissionConfigurationSchemaTest`  

### Block 2.2 — Service: Apply = COPY ✅ DONE

**Do:**
- On apply: materialize `policy_snapshot` from template fields + merge `policy_overrides` into snapshot allocation  
- Set `name` from template (or request)  
- Keep `template_rule_id` as **provenance only** for new writes  
- Support adding another rule row (multi-rule create) without destroying existing rows  
- Explicit update/deactivate/delete **one** Contract rule by `configuration_id`  
- Backfill helper (service-internal or artisan later in Phase 6): existing live rows → snapshot from current template (optional in 2.2 vs 6 — prefer **Phase 6** for mass backfill; 2.2 only new applies)

**Do not:**
- Change RuleResolver to read snapshot yet  
- Change CalculationEngine  
- Change FE beyond what API needs for tests  
- Physical module move  

**Files (likely):**
- `ContractCommissionConfigurationService` (+ interface)  
- Possibly small DTO/helper `PolicySnapshotBuilder`  
- Repository: `createForContract`, `updateById`, `deleteById` (keep upsert for BC)  
- Tests: Config API + new service/unit tests proving snapshot independence from later template edits  

**Acceptance:**
1. Apply stores non-empty `policy_snapshot`  
2. Editing template after apply does **not** change stored snapshot  
3. Contract can have 2+ active configuration rows  
4. Existing single-policy apply/clear still works for 0–1 row contracts  
5. No engine test regressions  

**STOP after tests.**

### Block 2.3 — API alignment ✅ DONE

**Do:**
- Extend GET show/index to expose `rules[]` (or `policies[]`) while keeping legacy `policy` key for BC (document deprecation)  
- Endpoints for: apply/add rule, update rule, remove one rule, clear all  
- Requests/Resources validation for multi-rule  
- Permissions unchanged unless missing  

**Do not:** redesign URL namespace to Project yet (Q5 logical later / physical later).  

**Acceptance:** API tests cover multi-rule CRUD + BC single `policy` shape.  
**STOP.**

### Block 2.4 — UI (Contract Commission panel only) ✅ DONE

**Do:**
- Contract drawer / Commission panel: list 1..N rules; apply from template = copy; edit Contract copy fields that exist in snapshot  
- Do **not** overhaul Rules page (that is Phase 5)  

**Acceptance:** User can configure multiple Contract rules without visiting Rules as SoT.  
**STOP.**

### Block 2.5 — Phase 2 regression gate ✅ DONE (22 Sep 2026)

Result: Modules/Commissioning 91 passed (634 assertions); Modules/Project 328 passed (1655 assertions); FE tsc clean, oxlint 0 errors (21 pre-existing warnings in legacy commission-earning/form files), vite build OK.


Run: Config schema/API, RuleResolver, Engines, Run API, Team Assignment / share tests if touched.  
**STOP — Phase 2 complete only when green.**

---

## 7. Phase 3 — Effective configuration (Assignment + Commission)

**Objective:** One clear “effective config” read model; **no formula changes**.

### Block 3.1 — Effective config assembler (service/DTO) ✅ DONE

Combine for a contract + as-of date:
- Share (PD/BD/team pools)  
- Members + `commission_percent`  
- Contract-owned rule copies (`policy_snapshot` rows)  

Expose via existing Contract Commission GET (enrich) or internal DTO used by later engine.  

### Block 3.2 — Tests ✅ DONE

Prove effective view matches Team Assignment + copied rules; allocation semantics unchanged.  
**STOP.**

---

## 8. Phase 4 — Engine input refactor

**Objective:** Engine prefers Contract-owned **copies**; fallback RuleResolver remains (Q4).

### Block 4.1 — Resolve from `policy_snapshot` ✅ DONE

- When active config has `policy_snapshot`: build in-memory rule-like object from snapshot (not live template)  
- `policy_source` = `applied_configuration` (or `contract_owned_snapshot`)  
- Invalid/missing snapshot with binding → keep current invalid-no-fallback semantics if status active  
- No snapshot + no binding → runtime RuleResolver fallback  

### Block 4.2 — Multi-rule match (minimal) ✅ DONE

Locked: active snapshot rows → filter `team_division` / `role_designation_id` (runtime predicates) → `sort_order`, `id` → first wins. No match → `applied_configuration_no_match` / run item `applied_policy_no_match`; runtime never competes for a bound contract. Mixed snapshot + legacy rows → snapshot rows authoritative.


Define match order among Contract rules (e.g. designation/division filters inside snapshot, then `sort_order`).  
**Do not** invent new business match rules beyond what templates already encode unless product specifies.

### Block 4.3 — Regression (critical) ✅ DONE (22 Sep 2026)

Result: Modules/Commissioning **111 passed (747 assertions)**; Modules/Project 328 passed (1655); FE tsc clean, oxlint 0/0, vite build OK. Baseline before Phase 4 (Block 2.5 gate): 98 / 673 — +13 tests, no failures, no expectation edits.

**Transitional (documented, unchanged in 4.3):**

```text
snapshot-backed active rows
    → authoritative

legacy snapshot=null rows
    → not used when snapshot-backed configuration exists

Phase 6.1
    → backfill legacy rows
```


Must stay green:
- Assignment % Phase 2  
- Applied allocation overrides  
- Posted immutability (binding/template change after post)  
- Template edit after copy must **not** change new calculates for that contract  
- Fallback without binding  

**STOP.**

---

## 9. Phase 5 — Commissioning config UX cleanup

### Block 5.1 — Rules UI → Template Administration ✅ DONE

Nav labels, page title, help text: templates only; link “apply on Contract”.  

### Block 5.2 — Ownership messaging ✅ DONE

Commissioning Commissions list = operational entry to Contract config (or redirect emphasis to Contract).  
Contract drawer = primary business config.  

**No delete of Rules CRUD** (templates still needed).  
**STOP.**

---

## 10. Phase 6 — Data migration / cutover design

### Block 6.1 — Backfill snapshots ✅ DONE

`php artisan commissioning:backfill-policy-snapshots [--company=] [--dry-run]` — idempotent; inactive/missing template rows skipped + reported (feed for 6.2). **Not yet run on any real environment.**


For existing rows with `template_rule_id` and null `policy_snapshot`: materialize copy (template + overrides). Idempotent command/migration.  
Posted runs untouched.

### Block 6.2 — Unbound contract report ✅ DONE

`php artisan commissioning:unbound-contracts --company=ID` and `GET /commissioning/contract-commissions/unbound` (read-only). Lists (a) contracts with share/members but no active snapshot rule (`resolution`: runtime_fallback | live_template) and (b) rows with null `policy_snapshot` with the reason 6.1 skipped them.


List contracts that still rely on RuleResolver fallback.

### Block 6.3 — Mandatory applied-config cutover ✅ DESIGN + IMPL DONE (flag default OFF)

**Goal:** after cutover, a contract that has commission allocation (share and/or active members) is calculated **only** from Contract-owned snapshot rules. Live-template rows and runtime `RuleResolver` fallback stop supplying policy for such contracts. Contracts with no allocation at all are unaffected (nothing to pay).

#### Flag

```php
// Modules/Commissioning/config/config.php
'require_applied_config' => env('COMMISSIONING_REQUIRE_APPLIED_CONFIG', false),
```

Default **off** = today’s Q4 transitional behavior. Flag is read once per run in `CommissioningRunService` and passed into the resolver context (or checked in `RuleResolver::resolve()`); no per-member DB reads.

#### Behavior matrix (flag ON)

| Contract state | Today (flag off) | Flag on |
|---|---|---|
| ≥1 active snapshot row, member matches | snapshot rule | snapshot rule (unchanged) |
| ≥1 active snapshot row, no match | `applied_policy_no_match` skip | unchanged |
| only legacy rows (snapshot null), template active | live template | **skip** `applied_policy_required` (reason: legacy binding not backfilled) |
| only legacy rows, template inactive/missing | `applied_policy_invalid` skip | `applied_policy_invalid` (unchanged) |
| no active row, allocation exists | runtime fallback | **skip** `applied_policy_required` (reason: no contract-owned rule) |
| no active row, no allocation | runtime fallback / nothing | unchanged (out of scope — no pool to allocate) |

Skipped rows carry `meta.policy.policy_source = applied_configuration_required` and are visible in run items like the existing invalid / no-match cases. Runs still reach `calculated`; nothing throws.

**Preview / `rules/preview-match`:** same resolver → same outcome, so ops can see the effect before running.

**Reports / ledger / posted runs:** untouched — cutover only affects *new* calculations.

#### Rollout (per company, then global)

```text
1. php artisan commissioning:backfill-policy-snapshots --company=X --dry-run
2. php artisan commissioning:backfill-policy-snapshots --company=X
3. php artisan commissioning:unbound-contracts --company=X
   → fix skipped rows (activate/replace template, or add a rule on the contract)
   → repeat until contract_count = 0 for contracts that should pay commission
4. Staging: COMMISSIONING_REQUIRE_APPLIED_CONFIG=true → calculate a draft run per
   active contract → confirm no applied_policy_required rows (or only expected ones)
5. Production: flag on. Watch first run cycle; unbound report must stay at 0.
```

**Rollback:** flag off → transitional behavior returns instantly; no data migration to undo (snapshots stay, they are already authoritative for bound contracts).

#### Failure mode / operator signal

- `applied_policy_required` rows in run items + a count on the run summary (`skipped_count` already exists; add per-reason breakdown only if ops asks).
- Unbound report is the pre-flight and the post-flight check.
- No hard error, no blocked runs: a missing configuration is a business gap, not a system fault.

#### Comms

- Finance/Ops: “from <date> commission is paid only from rules configured on the contract (Projects → Contracts → Commission). Global rule templates no longer apply by themselves.”
- PMs: Contract drawer → Commission Rules is where to configure; ‘Live template’ badge rows must be backfilled or replaced before <date>.

#### Implementation estimate (separate approval)

- config key + env; resolver branch (~20 lines) + run-service reason mapping (1 case); 4–5 tests (flag off unchanged, flag on: legacy skip, unbound skip, snapshot unaffected, no-allocation unaffected).
- No schema, no API shape change, no UI beyond the existing badge.

**Implemented 22 Sep 2026:** `commissioning.require_applied_config` (env `COMMISSIONING_REQUIRE_APPLIED_CONFIG`, default false) → `RuleResolver` returns `applied_configuration_required` for unbound / legacy-only contracts; run items skip as `applied_policy_required`. Snapshot rows, no-match, invalid, contexts without contract, posted runs: unchanged. **Turning the flag on in any environment is a separate approval** (rollout steps above).
**STOP.**

---

## 11. Phase 7 — Operations alignment ✅ DONE (22 Sep 2026)

**Gap found & fixed (1):** `CommissioningRunService::resolveRedistributionRule()` re-loaded the **live** `commissioning_rules` row by `rule_id` at post time (then fell back to any global active rule with redistribution) to drive redistribution + BD-duration expiry. Calculate used the snapshot, post used the live template — template edit/delete between calculate and post could change the posted outcome. Now post hydrates the rule from the frozen item `rule_snapshot` (included items first, then any item that matched); the live/global lookup remains only for legacy runs without snapshots. Regression: `test_post_redistributes_from_item_snapshot_after_template_deleted` (fails without fix: 0.00 vs 12.00).

**Verified green:** Run calculate/preview/approve/post, ledger (`approve_post_allocates_sixty_forty_and_writes_ledger`), funds/unlock (`CommissioningFundApiTest`, reads item `rule_snapshot`), settlements/separate payouts (`CommissioningSeparatePayoutApiTest`), reports JSON (`CommissioningReportApiTest`), payment hook/events (`PaymentIntegrationTest`), snapshot flow calculate→post (`calculate_uses_contract_snapshot…`, new post test), posted immutability (3 tests), fallback (`calculate_without_binding…`). No other operational service performs a live rule lookup (grep). Modules/Commissioning **124 passed (814 assertions)**.

Checklist only (fix if gaps):
- Runs calculate/preview/approve/post  
- Ledger / funds / settlements  
- Reports JSON  
- Payment events still fire  

No competing Contract rule authoring left in ops flows.  
**STOP.**

---

## 12. Phase 8 — Regression hardening ✅ DONE (22 Sep 2026)

| Check | Result |
|---|---|
| Modules/Commissioning | **130 passed** (839 assertions) |
| Modules/Project | **328 passed** (1655 assertions) |
| Full backend `php artisan test` | 1257 passed, **2 failed — both pre-existing, outside program** (see below; both resolved afterwards in §14, final state 1258 passed / 0 failed) |
| Commissioning with `COMMISSIONING_REQUIRE_APPLIED_CONFIG=true` | 110 passed, **20 failed — expected**: tests that rely on runtime fallback / legacy live binding without a contract-owned rule. Flag stays OFF; list = fixtures to adapt before enabling |
| FE tsc / oxlint / style-boundary / vite build | clean / 0 errors (21 pre-existing warnings in legacy commission-earning + form files) / clean / OK |
| Scope: Phase 2.2+ diff outside Commissioning | only `core/config/navigation.ts` (nav label, 5.1). Project-module files on the branch are pre-Phase-2 team-assignment work |
| Exclusions (payroll/finance/legacy project commission tables/formulas) | not touched (grep) |
| Standard spot-check | new controller methods try/catch ✓, Form Requests ✓, service writes in `DB::transaction` ✓, migration `down()` ✓. Known: repository uses `Model::query()` (pre-existing 2.1 pattern; standard says `$this->model`) — deferred refactor |

**Baseline:** Block 2.5 98/673 → 4.3 111/747 → Phase 7 124/814 → **Phase 8 130/839**. No expectation edits in the program.

**Pre-existing failures (out of program, not fixed):**
1. `tests/Feature/CommissionE2ESRSScenarioTest` — inserts `clients.name` (column does not exist; table has `client_name`/`business_name`). Introduced in `fea8c107` (21 Sep, before Phase 2). → **removed** (§14).
2. `tests/Feature/BasicRoleAutoAssignmentTest::test_syncing_user_roles_preserves_basic_role` — role sync drops `basic`. Nothing on this branch touches roles/permissions/seeders (last change Aug 27). → **production bug, fixed** (§14).

**Flag-ON impact list (20 tests):** CommissioningRunApiTest ×14 (runtime/legacy-binding based: calculate one-twenty, idempotent, sixty-forty ledger, immutability ×3, applied-over-runtime (legacy live), inactive-template, without-binding, overrides, legacy inactive, binding-added-later, assignment-phase2, separate payout), AdjustmentApi ×1, FundApi ×2, ReportApi ×1, RuleApi preview ×1, RealModeRunSmoke ×2. Note: under the flag a legacy row with an inactive template reports `applied_policy_required` (not `applied_policy_invalid`) because the cutover check precedes the live-template path — both are skips; documented, not changed.


Minimum suites:
- `CommissioningRunApiTest`  
- `RuleResolverTest`  
- `CommissionEnginesTest`  
- `ContractCommissionConfiguration*`  
- Project: Team Assignment, Contract Commission shares, assignment validation  

Investigate failures as invariant breaks, not silent expectation edits.  
**STOP.**

---

## 13. Phase 9 — Final audit + deferred work

### In-scope audit checklist ✅ VERIFIED (22 Sep 2026, by code inspection + tests)

- [x] **Single business SoT: Contract Commission copies** — the only writes to `contract_commission_configurations` are `ContractCommissionConfigurationService` (`applyPolicy` BC, `addRule`, `updateRule`, `backfillPolicySnapshots`), all through the repository, all in `DB::transaction`. No other service writes commission policy.
- [x] **Templates are templates only** — nothing auto-applies a template; `template_rule_id` is read as provenance. `commissioning_rules` CRUD is admin-only and, once copied, template edits/deactivation/deletion do not affect a contract (tests: snapshot-after-edit, after-deactivate, after-delete at post).
- [x] **No duplicate % SoT** — Commissioning never writes `contract_commission_shares` / `contract_members` (only `ProjectSpineTestSeeder`, a test fixture). Member % and pools are read through `ProjectContextPort`; snapshots carry only salary/fund/payout allocation.
- [x] **Engine does not own business config** — engines (`CalculationEngine`, `AllocationEngine`, `DurationResolver`, `EligibilityEvaluator`, `RedistributionEngine`) touch no repository, DB or config; they receive a rule/snapshot. Only `RuleResolver` reads configuration, by design.
- [x] **Posted runs immutable** — `post` guards `already posted` / `only approved`; `rule_snapshot` is written once at calculate and never updated (grep); post now reads redistribution/BD-duration from that frozen snapshot (Phase 7).
- [x] **Fallback only if still in transition** — runtime fallback and legacy live-template paths are reachable only while `require_applied_config` is false; with the flag on both become `applied_policy_required`.

**Final acceptance criteria (§18):** 1–8 all met. 5 is met with the transition still on by design (flag OFF).  

### Deferred (separate approvals)

| Item | When | State at program close |
|------|------|------------------------|
| **Q5 physical** move table/API into Project module | After logical UX stable | Not started. Logical ownership is in place (contract panel is primary); a move means table rename/migration + route namespace + FE client. Needs its own approval. |
| **Q6** `commission_duration` engine wiring | ✅ Done 23 Sep 2026 | `MemberContext.commissionDuration` carries it; `memberDurationRule()` maps lifetime→contract_duration and one_time→one_time from the contract start, and `narrowerDuration()` keeps whichever window closes first. Known consequence: `one_time` is measured from the contract start, so if payment clears in a later month a one-time member never earns — see §22. |
| **Q8** delete legacy Project `commission_rules` / shell earnings | After classify + no consumers | Untouched. 21 pre-existing FE lint warnings live in `project/components/commission-earning/*` — the shell that Q8 would remove. |
| Payroll/Finance/disbursement redesign | Out of program | Untouched. |
| Repository `Model::query()` vs `$this->model` (CODING-STANDARD §9) | Deferred refactor | `ContractCommissionConfigurationRepository` follows the 2.1 pattern; flagged in Phase 8, not changed (out of scope). |
| Cutover flag enablement | Separate approval | `COMMISSIONING_REQUIRE_APPLIED_CONFIG` implemented, OFF everywhere; 20 fixtures need adapting first (Phase 8 list). |
| Pre-existing failing tests | ✅ Resolved 23 Sep 2026 | See §14. Full backend suite now 1258 passed / 0 failed. |

---

## 14. Component action summary

| Component | Action | Phase |
|-----------|--------|-------|
| Shares + Team Assignment | KEEP | — |
| Member `commission_percent` | KEEP | — |
| Member `commission_duration` | KEEP column / ignore | Q6 later |
| `commissioning_rules` | KEEP as templates | 5 (label) |
| `contract_commission_configurations` | EXPAND (done 2.1) → COPY writes | 2.2–2.4 |
| RuleResolver live template | MODIFY → snapshot first | 4 |
| RuleResolver fallback | KEEP then DEPRECATE | 4 / 6.3 |
| Engines / Runs / Ledger | KEEP semantics | 4 input only |
| `rule_snapshot` | PRESERVE | forever |
| Project legacy commission_* | PRESERVE | 9 / Q8 |
| Physical module move | DEFER | 9 / Q5 |

---

## 15. Risk register

| Risk | Mitigation |
|------|------------|
| Multi-row breaks `paginateSummaries` join duplicates | ✅ Fixed in Block 2.2 (primary-row subquery join + regression test) |
| Premature engine switch breaks live bindings | Phase 4 only after snapshots exist for new applies + backfill plan |
| Template edit confusion | Product copy in Phase 5; tests in 2.2/4 |
| Dual `policy` vs `rules[]` API | BC field kept until FE migrated |
| Accidental formula drift | Engines tests every engine-touching block |

---

## 16. Explicit exclusions (entire program unless re-approved)

- New commission formulas / business rules  
- Payroll / Finance / bank disbursement redesign  
- `commission_duration` runtime wiring  
- `teamId` scoping fixes  
- Assignment-history redesign  
- Unrelated RBAC / Project refactors  
- Dropping `commissioning_rules` or Project legacy tables  
- Force-push / destructive DB without approval  

---

## 17. Implementation order (checklist)

```text
[x] 0 Discovery
[x] 0.5 Interview
[x] 1 Architecture plan
[x] 2.1 Schema/model
[x] 2.2 Service copy-on-apply + multi-rule write
[x] 2.3 API rules[] + multi-rule endpoints
[x] 2.4 Contract Commission UI multi-rule
[x] 2.5 Phase 2 regression gate
[x] 3.1 Effective config assembler
[x] 3.2 Phase 3 tests
[x] 4.1 Engine reads policy_snapshot
[x] 4.2 Multi-rule match
[x] 4.3 Phase 4 regression
[x] 5.1 Template Admin UI
[x] 5.2 Ownership UX
[x] 6.1 Snapshot backfill
[x] 6.2 Unbound report
[x] 6.3 Cutover design + flag impl (flag OFF; enabling in any env = separate approval)
[x] 7 Ops verification (1 gap fixed: post reads item rule_snapshot)
[x] 8 Full regression
[x] 9 Final audit (deferred items listed, each needs its own approval)
```

---

## 18. Final acceptance criteria (program done)

1. Configure at **Project → Contract → Commission**.  
2. Multiple Contract-owned **copied** rules.  
3. Template change does not mutate Contract copies or posted runs.  
4. Team Assignment remains allocation SoT.  
5. Engine uses Contract copies; fallback only if transition still on.  
6. Commissioning = ops (runs/ledger/reports/payment), not competing Contract rule book.  
7. Historical snapshots safe.  
8. Relevant suites green.

---

## 19. Next action

Program closed. Nothing further without a new approval. Candidates, in the order I would take them:

1. Fix the two pre-existing failing tests (small, unblocks a clean full-suite run).
2. Q8 classify legacy Project `commission_rules` / shell earnings (also clears the 21 lint warnings).
3. Cutover: adapt the 20 fixtures, then enable `COMMISSIONING_REQUIRE_APPLIED_CONFIG` per environment.
4. Q6 `commission_duration` wiring / Q5 physical move.

---

## 20. Post-program follow-up (23 Sep 2026)

Approved after Phase 9 to clear the two pre-existing failures found by the Phase 8 global suite run. Both were outside the contract-centric program; neither touched Commissioning code.

### 20.1 Basic role stripped on role sync — production bug, fixed

`UserRoleRepository::syncForUser()` deleted every `user_roles` row for the user before inserting the requested ones, so the baseline `basic` role (auto-assigned on user create / employee import) was lost whenever a user's roles were synced. Real impact, not test-only: changing someone's roles in the UI silently removed their baseline permissions (own profile, attendance punch, own payslip).

Fix: exclude the company's `basic` role from the delete and `updateOrInsert` the requested roles, so an explicitly supplied basic role still works.

Regression test already existed and was the reproduction: `BasicRoleAutoAssignmentTest::test_syncing_user_roles_preserves_basic_role` — kept as-is, both assertions (`administrator` + `basic`) unchanged.

Commit `aab021fc`.

### 20.2 `CommissionE2ESRSScenarioTest` — obsolete placeholder, deleted

Written against a proposed schema the PMS spine never adopted — `services`, `team_members`, `contract_assignments`, `commission_pools`, `commission_earnings` (none of these tables exist; the spine uses `pms_services`, `service_team_members`, `contract_members` plus the Commissioning run/ledger tables). Its assertions were tautological: it inserted the pool rows and the earning itself, then asserted those same values.

Deleted rather than rewritten — the behaviours it described are covered against the real schema:

| Behaviour | Real coverage |
|---|---|
| Pools 100 → 30 → BD 12 / PD 18 | `CommissionEnginesTest` (ShareContext 30.00/12.00/18.00 → base 12.00), `ContractCommissionConfigurationApiTest` (`team_pool_amount` 30.00), `RealModeRunSmokeTest` |
| Act-as vs permanent membership (same Rahim scenario) | `ContractMemberApiTest::test_can_assign_contract_member_with_act_as_rahim_scenario` — asserts `is_act_as` **and** `permanent_team` stays SEO Team / SEO Service; `ContractAssignmentValidationServiceTest` |
| Posted earnings unchanged after a rule edit | `CommissioningRunApiTest::test_post_is_immutable_and_rule_change_does_not_mutate_snapshot` |

No replacement E2E test added (separate decision if wanted).

Commit `65649be0`.

### 20.3 Verification

```text
BasicRoleAutoAssignmentTest        4 passed (14 assertions)
--filter="Role|Permission|UserRole"  96 passed (292 assertions)
Modules/Commissioning            130 passed (839 assertions)
Modules/Project                  328 passed (1655 assertions)
Full backend php artisan test    1258 passed, 0 failed, 0 incomplete
```

---

## 21. Deferred-item working documents (23 Sep 2026)

Prepared as documents only — no code, schema, route, permission or test was changed for any of these.

| Item | Document | State |
|---|---|---|
| Cutover flag enablement | `CUTOVER_VERIFICATION_RUNBOOK.md` | owner confirms **not deployed to staging/production**, so there is no fallback habit to break. Runbook §0 recommends enabling via env var at first deploy, keeping the code default OFF. Awaiting go-ahead |
| Q8 legacy Commission Earnings | `Q8_COMMISSION_EARNINGS_RETIREMENT_PLAN.md` | owner confirms **nobody uses it yet**, so the usage gate is satisfied and the staged sequence collapses to two commits (§0). One blocker found: `project.commission-rules-*` permissions are shared with the current Commissioning config page, so permission removal must come later. Awaiting go-ahead; nothing deleted |
| Q5 physical move | `Q5_PHYSICAL_MOVE_DESIGN.md` | design; recommends deferring the backend move and taking only the frontend boundary half |
| Repository `$this->model` | `REPOSITORY_PATTERN_PROPOSAL.md` | measured: all 10 Commissioning repositories use `Model::query()`, 0 constructors, 45 call sites; proposes a doc correction plus an optional 10-file alignment block |
| BD/PD earnings report + legacy tables | `BD_PD_EARNINGS_REPORT_DECISION_NOTE.md` | Q8 commit 1 done (`dc80fc45`). **Commit 2 blocked**: both legacy tables are still read by the surviving BD/PD report. Three options costed; awaiting product decision. Includes the separate broken "Recorded Earnings" frontend path |
| Q6 `commission_duration` | — | **not implemented, unchanged.** Blocked on three product decisions: member-vs-rule duration precedence, unit and start date, and expiry behaviour (redistribute or stop) |

Local dev environment state as inspected: the Block 2.1 expand migration was **pending** and has been
applied; `commissioning_rules` 0, `contract_commission_configurations` 0, `commissioning_runs` 0;
`unbound-contracts` reports 3 contracts, all `runtime_fallback`; `backfill --dry-run` 0 rows.

---

## 22. Known consequence of Q6 — `one_time` and late payments

The two durations are measured from different points:

| Window | Measured from |
|---|---|
| Contract configuration | the cleared payment date (`start_condition: payment_cleared`) |
| Member (`one_time` / `lifetime`) | the contract start date, as decided by the owner |

`lifetime` is unaffected — it has no end of its own, so the configuration's window governs.

`one_time` is affected: it opens for the contract's **start month** only. If the first payment
clears in a later month, the member's single window has already closed before the configuration's
window opens, and that member never earns. In the SRS fixture (contract starts 1 Jan, payment
clears 1 Mar) a `one_time` member earns nothing at all.

Not changed here, because it follows directly from the owner's decision to count from the contract
start. If one-time should instead mean "the first run this member qualifies for", that is a small
change to `memberDurationRule()` — reference the payment date for `one_time` while keeping the
contract start for `lifetime` — and needs its own decision.
