# TraderAlign — Single Source of Truth Architecture Proposal (Phase 2)

| | |
|---|---|
| **Status** | Proposed — not implemented. This is the design to be reviewed and approved before any migration work (Phase 4) begins. |
| **Depends on** | [`SINGLE_SOURCE_OF_TRUTH_AUDIT.md`](SINGLE_SOURCE_OF_TRUTH_AUDIT.md) — every recommendation below is grounded in a specific, cited finding from that audit, not made in the abstract. |
| **Created** | 2026-08-07 |
| **Governing rule** | *"No page, file, or component may compute a value independently if that value represents a fact the system already knows or can derive centrally."* (Kevin's directive, restated in full in the audit doc.) |

---

## 1. Extend `AnalyticsEngine`, or build new dedicated services? — **Recommendation: both, in clearly separated roles, not a single monolith.**

The audit's findings don't point to one missing engine — they point to **one area with severe, concentrated duplication** (phase-aware balance/drawdown/breach/rules-cascade — six independent implementations, the highest-severity bugs) and **several smaller areas** where a shared function already exists but has drifted, or never existed at all. Treating this as "just extend `engine.php`" would make an already-3,000-line file the dumping ground for everything; treating it as "one new mega-service for everything" would recreate the same monolith under a different name. The audit evidence supports a **small number of clearly-scoped owners**, not one and not a dozen:

### 1a. New: `src/PropFirmRuleEngine.php` — the single highest-priority extraction
**Owns:** phase-aware current balance, phase-aware drawdown (current/max/daily), breach detection, pass/target-progress detection and %, and — critically — **the rules-JSON threshold-resolution cascade itself** (which `rules[...]` key wins per phase, and what the default is when none match).

**Why a new class, not folded into `AnalyticsEngine`:** this is the exact area with six independent implementations (audit §6) and the audit's top five severity findings (§ Consolidated List #1-4, #6, #9, #16, #18, #19, #21). It deserves one unambiguous, small, heavily-tested surface — not another responsibility bolted onto a 3,000-line file that already does several unrelated things (Four-Stage engine, whole-account metrics, style drift, etc.). `AnalyticsEngine::getChallengeMetrics()` becomes a **thin consumer** of `PropFirmRuleEngine`, matching the exact refactor CLAUDE.md's own "Single Source of Truth Rule" entry already recommends (extract `getPhaseAwareBalance()` — this proposal names and scopes that extraction precisely).

**Exact methods, derived directly from the audit's findings, not invented in the abstract:**
- `resolveRules(array $rulesJson, string $phase): array` — the ONE threshold-resolution cascade (max DD %, daily loss %, profit target %), replacing all six independent copies found in §6. Phase-specific key first, flat key second, documented default third — matching `getChallengeMetrics()`'s current (correct) cascade, since that's the version already verified against real two/three-step accounts.
- `phaseAwareBalance(string $accountId): array` — replaces the four correctly-agreeing-today-but-architecturally-separate copies (§1a/1c/1d/1f), returning `{balance, starting_balance, phase_label}`. Internally always derives phase from `trading_accounts.lifecycle_state` first (never `challenge_accounts.current_stage`), with the `array_key_last()` safety check already proven correct in the 2026-08-07 fix.
- `drawdownAndDailyLoss(string $accountId): array` — replaces `getChallengeMetrics()`'s inline overall/daily drawdown math, `api/account/state.php`'s unclamped variant (§2c), and `api/trades/validate.php`'s divergent version (§3b, §2d).
- `breachStatus(string $accountId, float $balanceAfterTrade = null): array` — one function that answers "is this account breached" and, optionally, "would this specific trade breach it" — replacing `getChallengeMetrics()`'s inline check, `validate.php`'s independent (and silently-disabling) check, and both of `calculator-source.html`'s local JS re-derivations (§15, §4).
- `passProgress(string $accountId): array` — replaces the un-capped `rnBuildPhaseCardFromRows()` version (§5) and `validate.php`'s own version, always capping at 100% per the canonical behavior.

### 1b. `AnalyticsEngine` keeps what it already correctly owns, gets its own internal duplicates fixed
No new class needed here — the audit found `AnalyticsEngine::getAccountMetrics()`/`getUserMetrics()`/`computeFourStageRaw()`/`getFourStagePeriodSummary()` are the *correct* existing owners of whole-account balance/equity (for personal/live accounts and general-ledger purposes), win-rate/expectancy/streaks, and per-trade Four-Stage computations. The fix here is **deleting the duplicate call sites that bypass it**, not building something new:
- `dashboard.php`'s own hand-written Expectancy/Profit-Factor/Risk-of-Ruin SQL (§16) — delete, call `getUserMetrics($userId, ['strategy_version_id' => $svid])`, which already supports this exact filter.
- `dashboard.php`'s hardcoded-exponent Risk-of-Ruin formula (§16, Consolidated #7) — delete, call `EdgeHealthEngine`'s version (see 1c).
- `dashboard.php`'s zero-tolerance Strategy Adherence card (§16, Consolidated #8) — repoint from `mv_strategy_adherence` to `computeFourStageRaw()`'s Stage 1 adherence figure, or update the materialized view's underlying query to the current tolerance formula (view-update is lower-effort; repointing the page is more consistent with "the view can go stale again" risk — Phase 4 should decide based on how many other consumers read the view).
- `reports.php`'s own instrument/session `GROUP BY` queries and `dashboard.php`'s materialized-view reads (§16) — both should read the same source; Phase 4 should decide whether that source is the materialized views (refreshed/kept current) or `AnalyticsEngine`-computed (more consistent with everything else, more query cost).

### 1c. `EdgeHealthEngine` becomes the sole scoring authority — its two siblings get deleted, not reconciled
The audit found three "Consistency Score"-labeled computations (§8). Reconciling three formulas into one shared meaning is the honest fix, not documenting them as "three legitimately different metrics" (unlike the All-Time/Last-30-Days distinction, which genuinely is the same formula at two windows and should stay as-is):
- `api/colex/chat.php`'s raw-stddev "Risk Consistency" (§8c) — **delete**, call `EdgeHealthEngine::getPeriodFactors()` instead. No reason for Colex chat to have its own formula for a concept two other pages already compute correctly.
- `TradingArchiveEngine::historicalConsistency()` (§8b) — genuinely computes something EdgeHealthEngine doesn't (a classification-rate blend), so it shouldn't be deleted outright. **Rename it** to something that doesn't collide with "Consistency Score" in the UI (e.g. "Historical Discipline Index"), and have it call `EdgeHealthEngine::scoreConsistency()`'s risk-CV half rather than reimplementing CV-of-risk-percent a second time, adding only its own classification-rate term on top.
- The `GRADE_MAP` duplication (§9, Consolidated #15) — extract to one shared constant (`EdgeHealthEngine::GRADE_MAP` or a small standalone `src/TradeGrading.php`), delete the second copy in `TraderProfileBehaviorEngine.php`.
- The three-way "undisciplined classifications" disagreement (§9, Consolidated #16) — same treatment: one shared constant, every consumer (Reports' C-grade bucket, Trading Archive's star-rating cap, the dead `behavior.php`) reads from it. If the 3-value and 4-value sets are genuinely serving different purposes (grade bucket vs. star-rating severity), name them as two distinct, intentionally-different constants (`UNDISCIPLINED_FOR_GRADING` vs. `UNDISCIPLINED_FOR_SEVERITY_CAP`) rather than one being an accidental copy of the other with a silent extra value.

### 1d. `src/ColexHelpers.php::getPeriodStats()` — rewritten to delegate, not fixed in place
This is a confirmed, real P&L Rule violation (§16, Consolidated #5) — not because its formula philosophy is wrong, but because it independently reimplements a computation `AnalyticsEngine::getUserMetrics()` already does correctly. The fix is deletion of the independent SQL, not patching the four individual bugs found — patching them in place would leave a fifth, still-independent implementation of "user's period stats" that can drift again. Repoint to `AnalyticsEngine::getUserMetrics($userId, ['dateFrom' => ..., 'dateTo' => ...])` and shape its existing correct output into whatever fields `colex.php`'s stats widget needs.

### 1e. Small, targeted shared utilities (not full classes)
- `getReportingPeriodBounds(string $period, ?DateTime $reference = null): array` — genuinely missing today (CLAUDE.md already flags this), independently reimplemented in at least four places (§19, Consolidated #23). Small pure function, `src/RenderHelpers.php` is the natural home given its existing role.
- A single shared `lifecycleStateToPhaseLabel(string $lifecycleState): string` — the `match(true){str_contains(...)}` block currently duplicated verbatim in `journal.php` and `TradingArchiveEngine.php` (§17). Trivial in isolation, but exactly the kind of "small duplicate that's currently harmless" the governing rule says not to tolerate.
- Delete `journal.php`'s dead `rnAccountLifecycleBadge()` (§17, Consolidated #24) outright rather than leaving it as a landmine — this one is pure removal, not extraction, since `apAccountLifecycleBadge()` (`src/AccountPicker.php`) is already the correct, live, shared implementation.

**Why not one single mega-class for all of this?** Because the audit's own evidence doesn't support it — the scoring domain, the phase/balance/breach domain, and the whole-account-metrics domain are used by different consumers, change for different reasons, and (per Kevin's own car-ECU analogy) should be like separate, well-defined subsystems that query each other's authoritative answers when needed — not one god-object that makes the current "3,000-line engine.php" problem worse rather than better. The `PropFirmRuleEngine` extraction is the one place a genuinely new class is warranted, because it's the one area with no existing partial owner and the most severe, concentrated damage.

---

## 2. How every other page fetches (never recomputes) each value

**Primary interface: direct PHP static-method calls**, matching the pattern that already works today (`AnalyticsEngine::getChallengeMetrics()` is already called from 10 files this way, with no API round-trip). This is correct for every consumer that renders server-side — which is nearly the whole app (`public/*.php` page controllers, `src/*.php` engines, `api/colex/chat.php`). No new API endpoints are needed for these consumers; they simply `require_once` the relevant class and call its methods, exactly as they already do for `AnalyticsEngine`/`JourneySegmentation`/`EdgeHealthEngine`.

**Secondary interface: the existing `/api/account/state` and `/api/trades/validate` JSON endpoints, made internally correct** — these exist specifically because `calculator-source.html` is pure client-side JavaScript with no PHP execution context of its own; it cannot call a PHP class directly, and does not need a *new* endpoint, since both already exist and are already fetched. The fix, per the audit's §15 findings, is entirely on the *backend* side of these two endpoints:
- `api/account/state.php` must call `PropFirmRuleEngine`'s balance/drawdown/rules methods instead of its own inline reimplementation (currently formula-correct but architecturally separate — §1c) and its own flat, non-phase-aware rules read (§6 cascade #5).
- `api/trades/validate.php` must call the same `PropFirmRuleEngine` methods instead of its own independent (and, per §1i/§4/§9, actively wrong and silently-breach-disabling) computation.
- Once both endpoints are correct, **every downstream JS consumer in `calculator-source.html` that currently does "fetch-and-display" (§15's category B, low-risk items) stays exactly as it is** — it already just renders what the backend says. The JS consumers currently doing "independently re-compute" (§15's higher-priority B items: `renderAccountImpact()`'s local breach boolean/severity tiers, `renderPropFirmFallback()`, `renderLiveAccountCard()`'s projected-balance math, the persisted trade-object fields, the `pfWarningBanner`) must be rewritten to read pre-computed fields directly from the (now-correct) endpoint response, rather than deriving their own boolean/threshold from raw percentages.
- **The persisted trade-object fields are the one item requiring an actual behavior change, not just a data-source swap**: `account_status_label`, `drawdown_at_entry_pct`, `breathing_room_used_pct`, `breathing_room_remaining`, `trades_remaining_buffer` currently get computed client-side from URL params and POSTed into `trades` via `/api/trades/draft`. These should instead be computed **server-side, inside `api/trades/draft.php`, from `PropFirmRuleEngine`**, at the moment the trade is actually saved — the client should stop sending them as inputs and start receiving them (if needed for display) as part of the save response. This closes the exact "URL params can go stale across a phase promotion" risk flagged in §15's item (C)2, and is the one place this migration changes *where* a computation happens, not just *which copy* runs it.
- **`renderPropFirmFallback()`'s offline mode is the one legitimate exception** — it exists specifically for when the backend is unreachable, so it cannot call a PHP class by definition. Recommend keeping it as an explicit, clearly-labeled "degraded mode" (already true today), but reconciling its threshold values with the primary panel's (§15 found they currently don't literally match — 100%/70% vs 100%/50% for daily-loss severity) so at least the two copies that must coexist agree with each other.

**No REST-style versioned API layer is warranted for this migration** — the app has no external/third-party API consumers today, and introducing one now would be scope creep well beyond what the audit's findings call for. The two existing endpoints are sufficient once corrected.

---

## 3. How phase/state transitions correctly re-anchor computations

This is exactly where the original balance bug happened, and the audit found it is **still not fully closed** — a second, independent instance of the same root bug (`dashboard.php`'s `resolve_trade_id` `passed_phase` branch trusting stale `current_stage`, §17, Consolidated #4) was found this pass, in the same handler as the already-fixed capping logic. The mechanism going forward:

1. **`trading_accounts.lifecycle_state` is the only field any computation is ever allowed to read to determine "what phase is this account in."** `challenge_accounts.current_stage` is written by `api/accounts/create.php`'s `promote` mode alongside `lifecycle_state` (confirmed still kept in lockstep by that one code path), but is **never** to be read directly by any computation — not even as a fallback, since the audit found real, live-confirmed drift between the two columns. `PropFirmRuleEngine`'s every method takes `lifecycle_state`-derived phase as its only input for phase determination; there is no `current_stage` parameter anywhere in its interface, by design, so it's structurally impossible for a future caller to accidentally reintroduce this exact bug through this engine.
2. **The `array_key_last($buckets) === $currentLabel` safety check, already proven correct in the 2026-08-07 fix, becomes `PropFirmRuleEngine::phaseAwareBalance()`'s own internal, non-bypassable step** — every consumer gets this for free rather than needing to remember to implement it, which is exactly how the same bug got reintroduced independently three times before being centralized.
3. **`api/accounts/create.php`'s `promote` mode remains the sole place `current_balance`/`lifecycle_state`/`current_stage` are actually reset/written together at a transition** — this is already correct and doesn't need to change. What changes is that every *read* of the resulting state, everywhere else in the app, goes through `PropFirmRuleEngine` rather than trusting a raw column.
4. **`dashboard.php`'s `resolve_trade_id` handler's `passed_phase` branch must be fixed as part of Phase 4's first migration wave** (not deferred) — it should derive the phase to mark complete from `lifecycle_state` (via the new shared `lifecycleStateToPhaseLabel()` utility or `PropFirmRuleEngine` itself), never from a direct `current_stage` read. This is the audit's #4 consolidated finding and should be treated with the same urgency as the original balance bug, since it's the same failure mode in the same file's sibling code path.

---

## 4. How this gets tested/verified — a wrong central computation now affects everything, not just one page

Given the stakes (a bug in `PropFirmRuleEngine` would propagate to every consumer simultaneously, unlike today's scattered-but-isolated duplicates), verification must be stricter than a typical single-page fix:

1. **Golden-value baseline before any migration, not after.** Before `PropFirmRuleEngine` (or any consolidated function) becomes the source any page actually reads from, run it against every real prop-firm account in the database (spanning every lifecycle state, challenge type, and phase — the audit's own verification passes already established this is a workable, fast check) and record its output. Compare that output against what each of the *current* independent implementations produces for the same accounts today. Any disagreement found at this stage must be resolved (which one is actually correct, per the documented business rule — CLAUDE.md's Phase Boundary Balance Rule) *before* anything is migrated, not discovered after.
2. **Migrate one consumer at a time, never a batch.** Matching the audit's own finding that this codebase's biggest files (`journal.php`, `TradingArchiveEngine.php`) are explicitly flagged elsewhere in CLAUDE.md as needing careful, individually-verified changes — the migration plan (Phase 4) should sequence consumers so each one is switched, verified live against real accounts, and confirmed clean before the next one starts. This is slower but matches the successful pattern already used to fix the original balance bug (real HTTP round-trips, before/after diffs, full revert-and-reconfirm).
3. **A permanent, lightweight drift-detector, not just a one-time migration check.** Add a small dev-only diagnostic (CLI script or a gated admin page) that periodically — or on demand — computes the same fact through every consumer that's *supposed* to be reading from the shared source, and flags if any of them disagree. This operationalizes Kevin's own explicit rule: *"If this rule is violated anywhere after the refactor, that is itself a bug to be logged and fixed, on the same footing as a wrong number."* Without an active check, a future edit reintroducing a local duplicate (exactly how several of this audit's findings originated) would go unnoticed until a trader reports it, same as before.
4. **The Calculator's two endpoints get the same golden-value treatment as the rest of the Calculator migration doc already recommends** — `/api/account/state` and `/api/trades/validate`'s corrected output should be checked against every real trade's already-saved outcome data before being trusted as authoritative, per [`CALCULATOR_BUSINESS_LOGIC_PHP_MIGRATION.md`](CALCULATOR_BUSINESS_LOGIC_PHP_MIGRATION.md)'s own recommended verification strategy — these two efforts should be sequenced together where they touch the same files, not treated as fully independent.

---

## Summary of the recommended shape

```
src/PropFirmRuleEngine.php   ← NEW. Sole owner of: phase-aware balance, phase-aware
                                drawdown/daily-loss, breach detection, pass progress,
                                rules-JSON threshold cascade. lifecycle_state is its
                                only phase-truth input, never current_stage.

api/analytics/engine.php     ← UNCHANGED IN ROLE, INTERNALLY CORRECTED.
  AnalyticsEngine::            getChallengeMetrics() becomes a thin consumer of
                                PropFirmRuleEngine rather than containing the logic.
                                getAccountMetrics()/getUserMetrics()/Four-Stage methods
                                remain the correct owners of whole-account metrics,
                                win-rate/expectancy/streaks, and per-trade computations
                                — dashboard.php's bypassing duplicates get deleted, not
                                the engine changed.

api/analytics/edge_health_engine.php  ← UNCHANGED IN ROLE. Becomes the sole scoring
  EdgeHealthEngine                      authority — Colex's independent formula deleted
                                         and repointed here; Trading Archive's genuinely-
                                         different formula renamed to stop colliding on
                                         the same display label.

Every public/*.php, src/*.php ← Calls the above via direct PHP method calls (no new
page/engine                     API layer needed — this already works today).

calculator-source.html        ← Calls the SAME two existing endpoints
  (JS, no PHP context)           (/api/account/state, /api/trades/validate), now
                                  internally correct because those endpoints call
                                  PropFirmRuleEngine. JS keeps its own trade-mechanics
                                  intelligence (margin/instruments/TP-ladder/warnings)
                                  untouched, per Kevin's explicit boundary. Persisted
                                  account-state trade fields move server-side into
                                  api/trades/draft.php.
```

This is a genuinely scoped proposal, not a rewrite — it names four things to build/extend (one new class, two existing engines corrected internally, two existing endpoints corrected internally) and a small number of shared utilities, all directly traceable to specific audit findings, not invented in the abstract.
