# Campaign decision engine — gap analysis & implementation plan

This plan compares the **Untitled-2 spec** (SCALE_UP / MAINTAIN / ROAS–CPA engine) with **what arb.admedia.com runs today**, and defines how to implement the full decision layer **without** new ingestion in this app and **without** auto-executing Google/Meta changes in phase 1.

**Locked architecture (do not re-litigate in code):**

- Platform facts: **Whale `arb_adops_*`** (admediacron). This app **reads only**.
- Internal facts: **Whale** `arb_adops_campaign_performance`, `arb_adops_conversin_*` when present; not every campaign has rows.
- Analysis + verdicts + UI + MCP: **this app** (`campaigns:monitor-adops`, suggestions, MCP).
- No duplicate Meta/Google insights pull in ARB unless a gap is proven in Whale.

**24/7 monitoring model (product requirement):**

- Campaigns must be **evaluated continuously** in operations terms: **at least once per hour, every hour**, aligned with how often platform data lands in Whale.
- **Google/Meta facts** are refreshed on an **hourly ingest** schedule (admediacron → Whale `arb_adops_*` hourly + campaign hourly rollups). ARB does **not** poll Graph/GAQL on a faster cadence.
- **“24/7”** here means: scheduler + monitor run **hourly without a nightly gap**; freshness is bounded by **last successful hourly ingest** (`arb_adops_sync_status.data_through`), not by real-time streaming.
- **Internal/affiliate** data may lag platform hourly stats by **1–2 days** (advertiser reporting). The engine must use **lag-adjusted windows** for revenue/orders (see §B.4, §F.1).
- **One verdict per campaign (product requirement):** each evaluation produces a **single combined verdict** that merges **hour** signals (spend, clicks, CPC, delivery) and **day** signals (7d ROAS, CPA, orders, trends). UI, MCP, and `campaign_performance_suggestions` expose **only that verdict** — not separate hourly vs daily statuses.

---

## A. Are we using the same thing?

**Same intent, different maturity.** Both aim at: read platform + internal data → deterministic verdict → explain → store → expose (UI/MCP) → guardrails before any mutate.

| Area | Spec (Untitled-2) | Today in ARB |
|------|-------------------|--------------|
| **Verdicts** | 8: `SCALE_UP`, `MAINTAIN`, `DECREASE_BUDGET`, `PAUSE`, `WATCH`, `LEARNING`, `INSUFFICIENT_DATA`, `DATA_ISSUE` | 6: `no_action`, `continue`, `pause`, `stop`, `start`, `action_required` |
| **Primary metrics** | ROAS, CPA, CVR, revenue, orders, configurable targets | Spend, clicks, CPC, CTR; affiliate orders/revenue only in a small overlay |
| **Windows** | 1d, 3d, 7d, 14d, 30d (primary **7d**) | **One evaluation:** hour 3h/5h/7h **+** day 1d/3d/7d/14d/30d together → **one** combined verdict |
| **Monitoring cadence** | Recalculate on cron | **Hourly** evaluation (required). Optional **daily re-run** = same combined engine after affiliate batch lands — still **one** suggestion row |
| **Verdict count** | One decision object | **One** `verdict` + `reasons[]` per evaluation; history appends rows, never two competing “current” verdicts |
| **Trends** | ROAS/CPA/CVR/revenue/orders % change + STRONG_POSITIVE … STRONG_NEGATIVE | CPC + clicks % (`PerformanceTrend`); no ROAS/CPA trend |
| **Thresholds** | Layered config: campaign → platform → global (`target_roas`, `minimum_spend`, …) | Few env keys in `config/campaign-performance.php` (`max_cpc`, `zero_click_spend_floor`, …) |
| **Data quality** | Explicit `DATA_ISSUE` | Partial: sync failed + delivery strings → `action_required` |
| **Learning** | `LEARNING` verdict, no scale/pause in learning | `no_action` via `learning_window_days` + Meta learning on hour windows |
| **Scale / budget** | `SCALE_UP`, `DECREASE_BUDGET` + `%` recommendation | **Not implemented** |
| **Confidence** | Numeric 0–1 from objective factors | LLM `confidence` string on suggestions only |
| **Reasons** | Structured JSON array (`metric`, `current`, `target`, `impact`) | Human string + window reasons in snapshot |
| **History** | Rich `campaign_decisions` row per evaluation | `campaign_performance_verdict_logs` + latest `campaign_performance_suggestions` |
| **Guardrails** | Decision guardrails (max % change, cooldown, no action on DATA_ISSUE) | Publish/API guardrails (`platforms.guardrails`); **not** tied to performance verdicts |
| **AI** | Explains; cannot override safety or caps | **Correct pattern today:** PHP verdict first; LLM rewrites reason only |
| **Google** | Same framework as Meta | Meta live in Whale; Google skipped until adops ingest ready |
| **Ingestion in monitor cron** | Steps 1–3 fetch Google/Meta/internal | Monitor **only reads Whale** (correct for ARB) |

**Conclusion:** Reuse **Whale readers**, **monitor cron**, **suggestions/MCP shell**, and **“PHP decides, LLM explains”**. **Replace/extend** the **verdict enum**, **rules**, **metrics/windows**, **config**, **storage shape**, and **MCP tools** to match the spec.

---

## B. Partial data model (critical)

Not every campaign has all signals. The engine must **never** treat “missing internal revenue” as “zero revenue” for pause/scale rules.

### B.1 Capability flags (per evaluation)

Compute once per campaign:

```text
platform_metrics_available   (spend/clicks/impressions in primary window)
platform_conversions_available (conversions/conversion_value in Whale when columns populated)
internal_revenue_available   (arb_adops_campaign_performance or conversin_aggregate rows in window)
internal_orders_available
campaign_mapping_ok          (platform + campaign_id resolvable in Whale structure tables)
ingest_healthy               (sync_status not failed/error for relevant datasets)
```

### B.2 Metric tiers

| Tier | When to use | Example verdicts |
|------|-------------|------------------|
| **A — Platform only** | No internal match | `INSUFFICIENT_DATA`, `PAUSE` only on spend+clicks rules (current CPC floor), `WATCH`, platform `DATA_ISSUE` |
| **B — Platform + internal** | Affiliate/conversin matched | Full ROAS/CPA/CVR, `SCALE_UP`, `DECREASE_BUDGET`, revenue-aware `PAUSE` |
| **C — Platform conversions only** | Meta `conversions_daily` but no ARB revenue | CPA/ROAS on **platform** conversion value; label `revenue_source: platform` in snapshot |

Rules declare **required_capabilities** in config. If unmet → `INSUFFICIENT_DATA` or `DATA_ISSUE` (not `PAUSE`).

### B.3 Internal data sources (existing Whale tables)

| Signal | Tables (already in Whale) |
|--------|---------------------------|
| Affiliate rollup | `arb_adops_campaign_performance` (clicks, sum_cpc, orders, net_revenue by day) |
| Affiliate detail | `arb_adops_conversin_aggregate`, `arb_adops_conversin_mapping` |
| Platform conversions | `arb_adops_conversions_daily_YYYYMM`, rollup fields on `stats_*` |
| Platform spend/clicks | `arb_adops_stats_campaign_daily`, `stats_daily_*`, hourly counterparts |

Extend **`WhaleAdopsReader`** (or a thin **`CampaignPerformanceDataAssembler`**) to return a **single normalized DTO** — do not add ARB-local stats dumps.

### B.4 Data freshness (hourly platform vs slower internal)

| Dataset | Typical cadence | Used in |
|---------|-----------------|---------|
| `stats_campaign_hourly`, `stats_hourly_*` | Hourly (after admediacron) | **Hourly monitor** — spend/clicks/CPC spikes, critical pause |
| `stats_campaign_daily`, `stats_daily_*` | Hourly rollups + daily grain | Hourly (today) + daily pass |
| `conversions_daily_*` | Often daily | Daily / 7d rules; not required every hour |
| `arb_adops_campaign_performance`, `conversin_*` | Often daily | ROAS/CPA on **7d**; mark `internal_stale` if older than config |

**`DATA_ISSUE`** when hourly platform data is missing or stale (e.g. `data_through` older than `max_platform_staleness_hours`, default **2** after the top of the hour). Do not emit `PAUSE`/`SCALE_UP` on empty hour buckets caused by ingest delay — prefer `DATA_ISSUE` or `WATCH`.

**Affiliate / conversion lag (1–2 days):**

- Config: `internal_reporting_lag_days` (default **2**).
- For ROAS, CPA, orders, revenue rules, evaluate on a **mature window**, e.g. 7 days ending at `today - lag_days`, not “calendar last 7 days including yesterday.”
- Missing rows for **the lag tail only** is **expected** → not `DATA_ISSUE`, not “zero revenue.”
- `DATA_ISSUE` for internal data only when mapping is broken, ingest failed, or mature window has **no** rows when history says there should be.

Store on each decision: `evaluated_at`, `platform_data_through`, `internal_data_through`, `internal_window_end` (lag-adjusted date).

---

## C. Target architecture (in-app “library”)

Keep everything under `app/Campaigns/` (no new top-level package until a second consumer exists).

```text
CampaignPerformanceDataAssembler
  → reads WhaleAdopsReader + enrichment queries
  → CampaignPerformanceContext (normalized DTO)

CampaignDecisionConfigResolver
  → global / platform / campaign overrides (DB or config files phase 1)

CampaignMetricsCalculator
  → CTR, CPC, CVR, CPA, ROAS, RPC, spend/day, orders/day (null if denominator 0)

CampaignWindowRollup
  → hour windows: 3h, 5h, 7h (from stats_*_hourly)
  → day windows: 1d, 3d, 7d, 14d, 30d + previous-period mirrors (from daily tables)

CampaignTrendAnalyzer
  → % changes + STRONG_POSITIVE | POSITIVE | STABLE | NEGATIVE | STRONG_NEGATIVE

CampaignDataQualityValidator
  → DATA_ISSUE reasons (sync, mapping, mismatch, stale data_through)

CampaignLearningDetector
  → LEARNING from age + change_events + budget changes in Whale

CampaignDecisionEngine
  → per-window sub-scores (hour + day channels)
  → CampaignUnifiedVerdictCombiner → one verdict + recommended_action + budget_change_%

CampaignDecisionConfidence
  → objective score from volumes + threshold distance + cross-window agreement

CampaignDecisionGuardrails
  → cap %, cooldown, min volume; action_allowed flag; no execution

CampaignDecisionRecorder
  → persist full decision row + link to suggestion

CampaignPerformanceAdopsMonitor
  → orchestrates assembler → engine → guardrails → record → advisor (explain only)
```

**Deprecation path:** Keep `PerformanceWindowVerdict` + `PerformanceVerdictCombiner` behind a config flag (`campaign-performance.legacy_verdict_engine`) until the new engine is validated, then remove.

---

## D. Verdict mapping (old UI → new)

For a transition period, store **both** on the decision record:

| New verdict | Maps from legacy (approx) | UI label |
|-------------|---------------------------|----------|
| `INSUFFICIENT_DATA` | `no_action` (early life) | Insufficient data |
| `LEARNING` | `no_action` (learning) | Learning |
| `DATA_ISSUE` | `action_required` (sync/delivery) | Data issue |
| `WATCH` | `no_action` / borderline `continue` | Watch |
| `MAINTAIN` | `continue` | Maintain |
| `SCALE_UP` | *(new)* | Scale up |
| `DECREASE_BUDGET` | *(new)* | Decrease budget |
| `PAUSE` | `pause`, `stop` | Pause |

Legacy `start` → often `MAINTAIN` or `WATCH` with `recommended_action: ENABLE` (optional sub-field, not a 9th verdict).

---

## E. Configuration (spec §4)

### E.1 Phase 1 — config files

`config/campaign-decisions.php` + env overrides:

- Global defaults for all thresholds in the spec.
- `platforms.meta` / `platforms.google` overrides.
- `learning_period_days`, `cooldown_period_days`, scale/decrease caps.

### E.2 Phase 2 — DB overrides

Table `campaign_decision_configs`:

- `platform`, `account_id`, `campaign_id` (nullable keys)
- JSON `settings` (target_roas, minimum_spend, …)
- Priority: campaign row > platform row > global config.

Do **not** embed thresholds inside `CampaignDecisionEngine` conditionals.

---

## F. Decision priority (spec §6 — implement explicitly)

### F.1 Unified combiner (hour + day → one verdict)

**`CampaignUnifiedVerdictCombiner`** runs after all windows are evaluated. It does **not** pick “hourly OR daily winner”; it merges **channels**:

| Channel | Windows | Typical signals | Affiliate lag |
|---------|---------|-----------------|---------------|
| **Platform intraday** | 3h, 5h, 7h | spend, clicks, CPC, CTR, delivery | N/A |
| **Platform daily** | 1d, 3d, today | spend pace, impressions | N/A |
| **Outcome / economics** | 3d, 7d, 14d, 30d (lag-adjusted) | ROAS, CPA, CVR, orders, revenue | **Exclude last `internal_reporting_lag_days`** |

**Conflict examples (documented in code):**

- Hour channel says **high CPC / spend no clicks** → can drive **PAUSE** even if lagged 7d ROAS still looks fine (protect spend now).
- Lagged 7d ROAS **below minimum** but last 3h quiet → **DECREASE_BUDGET** or **WATCH**, not instant **PAUSE**, if hour channel is not critical.
- Lagged 7d ROAS **strong** + hour channel **critical waste** → **WATCH** or **DECREASE_BUDGET** with reason citing both channels (not **SCALE_UP** until hour channel stable).
- Affiliate not matched → outcome channel skipped; verdict uses platform channels only (`INSUFFICIENT_DATA` for scale rules, not **PAUSE** for “no revenue”).

### F.2 Global priority (single final verdict)

1. `DATA_ISSUE` — platform stale, broken mapping, or internal broken (not “late by design”).
2. `LEARNING` — block `SCALE_UP`, `DECREASE_BUDGET`, non-critical `PAUSE`.
3. `INSUFFICIENT_DATA` — volume too low for the rule being considered.
4. **Critical pause (hour channel)** — configured spend/clicks/CPC on 3h/5h/7h with volume.
5. **Pause / stop (outcome channel)** — lagged 7d ROAS/CPA/orders with volume (spec §5 PAUSE).
6. `SCALE_UP` — lagged 7d targets met + stable trend + hour channel not alarming + cooldown OK.
7. `DECREASE_BUDGET` — outcome below target but still converting.
8. `MAINTAIN` — within bands on mature window + hour channel acceptable.
9. `WATCH` — channels disagree or borderline.

Each step appends to `reasons[]` with `channel: hour|day|outcome` and contributes to `confidence`.

---

## G. Storage (spec §10)

### G.1 New table: `campaign_decisions`

One row per evaluation (keep `campaign_performance_verdict_logs` for audit or migrate to this shape).

Minimum columns from spec §10, plus:

- `engine_version` (e.g. `v2`)
- `capabilities_json` (flags in §B.1)
- `recommended_action`, `recommended_budget_change_percent`
- `rules_triggered` (JSON)
- `reasons` (JSON)
- `metrics_json`, `trends_json`, `data_quality_json`
- `guardrail_json` (`action_allowed`, capped `%`)
- `legacy_verdict` (nullable, for comparison during rollout)
- `window_verdicts_json` — per-window sub-results for explainability (not separate “current” verdicts)
- `channels_json` — hour / day / outcome summaries used by combiner

### G.2 `campaign_performance_suggestions`

**One row per campaign** — always the **latest combined evaluation**:

- `status` → the **single** combined verdict enum value.
- `snapshot` → full decision object including `channels`, `window_verdicts`, lag-adjusted metrics (spec §13).
- `confidence` → numeric from `CampaignDecisionConfidence` (objective; not LLM).

---

## H. MCP / API (spec §14)

Extend existing `CampaignPerformanceServer` (do not new server):

| Tool | Purpose |
|------|---------|
| `campaign_decision` | Full decision object for one `campaign_id` |
| `campaign_decisions` | Filter: verdict, platform, account, needs_action |
| `campaign_decision_history` | Last N rows from `campaign_decisions` |
| `campaign_action_candidates` | `SCALE_UP`, `DECREASE_BUDGET`, `PAUSE` with `action_allowed` |

Keep `query_adops` for ad-hoc analytics; decision tools use the **assembler + engine** only.

Update `campaign_health` to call the new engine (or delegate to `campaign_decision`).

---

## I. Cron & 24/7 monitoring (spec §15, updated)

### I.1 Split responsibility

| Layer | Owner | Cadence |
|-------|--------|---------|
| Fetch Google/Meta (+ write Whale) | **admediacron** | Hourly |
| Fetch affiliate / advertiser conversions | **admediacron** (or batch) | Often **daily**, 1–2 day lag |
| Evaluate → **one combined verdict** | **ARB** `campaigns:monitor-adops` | **≥ once per hour** |
| UI / MCP | ARB | **One** `campaign_performance_suggestions.status` per campaign |

ARB **never** replaces ingest; it reads Whale and merges hour + day + lag-adjusted outcome in **one** engine run.

### I.2 Single evaluation pipeline (every run)

Whether triggered hourly or by the optional daily job, **the same steps** run:

```text
1. List campaigns from Whale
2. Assemble context:
   - Hour series (stats_*_hourly)
   - Day series (stats_*_daily)
   - Outcome series (campaign_performance / conversin_*) on lag-adjusted date range
3. Validate data quality (platform staleness vs internal lag rules §B.4)
4. Roll up ALL windows: 3h, 5h, 7h, 1d, 3d, 7d, 14d, 30d (+ previous-period compares for trends)
5. CampaignUnifiedVerdictCombiner → ONE verdict + reasons + budget %
6. Guardrails → append campaign_decisions history row
7. Upsert campaign_performance_suggestions (overwrite single current verdict)
8. Optional LLM explain (must not change verdict)
```

There is **no** separate `run_type=hourly` vs `run_type=daily` **verdict**. Optional `trigger` on history rows: `scheduled_hourly` | `scheduled_affiliate_refresh` for ops only.

### I.3 Scheduler (ARB)

**Required — 24/7:**

```text
campaigns:monitor-adops --hourly   → every hour at :25
```

Runs the **full** combined pipeline each time (hour + day + lagged outcome).

**Optional — after affiliate file lands:**

```text
campaigns:monitor-adops --daily    → once daily at 06:20 (or after conversin ETL)
```

Same combined engine; useful when **new** advertiser orders/revenue appeared overnight. Still updates the **same** suggestion row — not a second verdict type.

**Deprecation:** `--daily` does not mean “daily-only windows”; it means “extra evaluation after slow data.” Long-term, both flags may collapse to `monitor-adops` with no mode switch.

### I.4 One verdict rule (product)

| Question | Answer |
|----------|--------|
| How many statuses on the board? | **One** per campaign |
| Hourly vs daily cron? | Both call **same** combiner; hourly is required, daily is optional refresh |
| History? | Many `campaign_decisions` rows over time; each row is still **one** combined verdict at that moment |
| MCP `campaign_health`? | Returns that **one** current verdict + `reasons[]` explaining hour vs outcome contributions |

### I.5 Engine config additions

In `config/campaign-decisions.php`:

```text
monitor.max_platform_staleness_hours = 2
monitor.internal_reporting_lag_days = 2      # advertiser delay; mature window ends here
monitor.hour_windows = [3, 5, 7]
monitor.day_windows = [1, 3, 7, 14, 30]
monitor.primary_outcome_window_days = 7    # lag-adjusted
monitor.affiliate_refresh_cron_enabled = true   # optional 06:20 re-run
```

---

## J. AI (spec §11)

Unchanged principle:

- Engine sets `verdict`, `recommended_budget_change_percent`, `confidence`.
- Advisor receives full snapshot + **forbidden** to change verdict or exceed guardrail caps.
- Advisor may add `summary`, `key_signals` in snapshot metadata only.

---

## K. Guardrails (spec §12)

New `CampaignDecisionGuardrails` (separate from `platforms.guardrails`):

- Max increase/decrease % per decision.
- Cooldown since last `SCALE_UP` / `DECREASE_BUDGET` decision for same campaign.
- Min/max daily budget bounds (read current budget from Whale campaigns/adsets).
- `action_allowed: false` when `DATA_ISSUE` or `LEARNING` (except critical safety).
- **Phase 1:** return proposed action only; no Google/Meta API calls.

---

## L. Implementation phases

### Phase 0 — Discovery & flags (1–2 days)

- [ ] Document Whale column coverage per platform (Meta vs Google when live).
- [ ] Add `campaign-performance.decision_engine=v2` feature flag (default off).
- [ ] Add comparison logging: run legacy + v2 side-by-side in monitor, log diffs only.

### Phase 1 — Data layer (3–5 days)

- [ ] `CampaignPerformanceContext` DTO + `CampaignPerformanceDataAssembler`.
- [ ] Window rollups: **3h/5h/7h** from hourly tables + **1/3/7/14/30d** from daily (+ previous periods).
- [ ] `CampaignMetricsCalculator` with null-safe math.
- [ ] Capability flags + `CampaignDataQualityValidator` (**platform staleness** + internal lag §B.4).
- [ ] Unit tests: zero denominators, missing internal rows, timezone day boundaries, **stale data_through → DATA_ISSUE**.

### Phase 2 — Config (2–3 days)

- [ ] `config/campaign-decisions.php` + resolver.
- [ ] Optional migration `campaign_decision_configs` (can defer to Phase 2b).

### Phase 3 — Engine (5–7 days)

- [ ] `CampaignVerdict` enum (8 values) — new enum or rename with migration of stored statuses.
- [ ] `CampaignTrendAnalyzer` (ROAS, CPA, CVR, revenue, orders).
- [ ] `CampaignLearningDetector` (age + `arb_adops_change_events`).
- [ ] `CampaignDecisionEngine` + `CampaignDecisionMatrix` + **`CampaignUnifiedVerdictCombiner`** (hour + day + lagged outcome).
- [ ] `CampaignDecisionConfidence` + structured `reasons[]`.
- [ ] Feature tests: all 8 verdict scenarios from spec §16.

### Phase 4 — Guardrails + storage (2–3 days)

- [ ] `CampaignDecisionGuardrails`.
- [ ] Migration `campaign_decisions` (`trigger`, `platform_data_through`, `internal_window_end`, `evaluated_at`).
- [ ] `CampaignDecisionRecorder`; wire **one** combined pipeline for hourly (+ optional affiliate refresh cron).
- [ ] Scheduler hardening: chunking / overlap limits so **hourly 24/7** never silently stops.

### Phase 5 — UI + MCP (3–4 days)

- [ ] Performance board: new labels, budget % recommendation, data quality badge.
- [ ] MCP tools §H; update server instructions.
- [ ] Map legacy filters (“pause” list) to new verdicts.

### Phase 6 — Rollout (ongoing)

- [ ] Enable v2 for Meta only; compare with media buyers.
- [ ] Enable when Google adops ingest ready (same engine, platform config overrides).
- [ ] Remove legacy `PerformanceWindowVerdict` after sign-off.

### Phase 7 — Execution (out of scope for this plan)

- Auto budget/pause via Google/Meta APIs only after guardrails + human review process defined.

---

## M. Tests (spec §16)

| Verdict | Test focus |
|---------|------------|
| `SCALE_UP` | 7d ROAS ≥ target, orders ≥ minimum, positive trend, not learning |
| `MAINTAIN` | ROAS ≥ minimum_roas, stable trend |
| `DECREASE_BUDGET` | Below target ROAS, still has orders |
| `PAUSE` | Volume met, zero orders / ROAS far below minimum |
| `INSUFFICIENT_DATA` | Low spend/clicks; **no pause** |
| `LEARNING` | New campaign; scale/pause blocked |
| `DATA_ISSUE` | Sync failed, missing mapping, stale `data_through` |
| `WATCH` | Conflicting 3d vs 7d ROAS |

Plus: guardrail caps, cooldown, partial internal data (tier A vs B).

---

## N. Assumptions & TODOs

**Assumptions**

- Campaign timezone for “day” boundaries: **account timezone from `arb_adops_accounts`** when present, else UTC (document in code).
- Currency: use row `currency` / `spend_usd` from Whale; ROAS in account currency unless FX table used later.
- Google campaigns use the same DTO; platform-specific fields live in `context.platform_extras`.
- **Hourly ingest** from Google/Meta is operational before 24/7 monitoring is declared “live”; ARB hourly monitor is only as fresh as Whale `data_through`.
- **Advertiser conversion/revenue** is routinely **1–2 days behind** platform spend; ROAS/CPA rules use **lag-adjusted** windows, not same-calendar-day parity with Meta clicks.

**TODOs (product)**

- Default `target_roas` / `target_cpa` per vertical or account — need business defaults.
- Whether `stop` legacy maps to `PAUSE` or stronger `recommended_action: PAUSE_IMMEDIATELY`.
- Critical safety rules list for learning override.

**TODOs (engineering)**

- Extend `WhaleAdopsReader::dailyStats` to sum platform `conversions` / `conversion_value` for CPA/ROAS tier C.
- Rate-limit or **chunk** monitor when `adops_list_all` scans full book so each **hourly** slot finishes within SLA.
- Coordinate **:25** monitor offset with admediacron hourly completion (document expected ingest finish time).
- Extract shared package for admediacron only if both repos need identical rollup logic.

---

## O. Deliverables checklist (spec §18)

When implementation is complete:

1. Normalized `CampaignPerformanceContext` + assembler  
2. Configurable thresholds (`campaign-decisions.php` + optional DB)  
3. Metric + trend services  
4. Data-quality validator  
5. Decision engine + 8 verdicts  
6. Confidence + structured reasons  
7. `campaign_decisions` history  
8. Decision guardrails (no execution)  
9. MCP tools + UI integration  
10. Monitor cron wired to v2  
11. Tests per §M  
12. This plan updated with “implemented” notes and example JSON decisions  

---

## P. Example target output (Meta, with internal data)

```json
{
  "platform": "meta",
  "account_id": "1950320145707342",
  "campaign_id": "120251915605170683",
  "verdict": "MAINTAIN",
  "confidence": 0.78,
  "recommended_action": "NO_CHANGE",
  "recommended_budget_change_percent": 0,
  "capabilities": {
    "platform_metrics_available": true,
    "internal_revenue_available": true,
    "internal_orders_available": true
  },
  "metrics": {
    "spend_7d": 420.5,
    "clicks_7d": 310,
    "orders_7d": 12,
    "revenue_7d": 890.0,
    "roas_7d": 2.12,
    "cpa_7d": 35.04
  },
  "trend": {
    "previous_7d_roas": 1.95,
    "roas_change_percent": 8.7,
    "status": "POSITIVE"
  },
  "data_quality": { "status": "HEALTHY" },
  "action_allowed": true
}
```

Same campaign **without** internal rows → `capabilities.internal_revenue_available: false`; engine may return `WATCH` or `INSUFFICIENT_DATA` for scale/decrease rules, not `PAUSE` for “zero revenue” alone.
