# ClearConsent — Scan Coverage Accuracy Spec

**Scope:** Two related scan-result correctness fixes.
**Build target:** Cursor (local), then deploy via normal rhythm.
**Priority:** Issue 1 is a consent-compliance bug, not a cosmetic one — treat as high priority.

---

## Issue 1 — A managed script with a leftover duplicate fires unblocked, but shows "✅ Managed"

### Symptom
A tracker (e.g. Meta Pixel) is added to Script Manager, so the scan row shows **✅ Managed**. But the original copy still exists elsewhere (e.g. the WP Header & Footer plugin's `wpheaderandfooter_basics` DB option, or a theme file). That leftover copy loads on every page **before consent**, while the row reassures the user everything is handled.

### Root cause (read before coding)
This is an interaction between three pieces — fixing the badge without understanding it will leave the leak open:

1. **Script Manager registers the slug as "managed."** `ClearConsent_Script_Manager::get_managed_slugs()` now includes e.g. `facebook-pixel`.
2. **The Layer-2 buffer rewriter deliberately skips managed slugs.** In `class-clearconsent-blocker.php` (~line 270): if `in_array( $match['slug'], $managed, true )` the rewriter returns the tag unchanged, on the assumption that Script Manager owns that slug. So the leftover foreign copy is **no longer auto-tagged** and executes immediately.
3. **Coverage classification short-circuits on "managed."** In `class-clearconsent-scan.php`, `resolve_group_blocking_capability()` returns `'managed'` the moment the slug is in `get_managed_slugs()` — *before* it ever inspects where the group's sources actually came from. `resolve_coverage()` then renders the clean green "✅ Managed".

Net effect: the one situation where the buffer steps aside is exactly the situation the badge declares safe. The green badge is masking a live, unconsented tracker.

### The data we already have
Grouping (in the `grouped_results()` builder, ~line 600–690) already records per-source origin:
- `$group['sources']` — keyed map of source kinds (`db_option`, `script_src`, `script_inline`, `buffer_rewrite`, `cookie`, `tag_scan`, `browser_scan`, …).
- `$group['location_entries']` — array of `{ raw, source, type, blocking_capability }`.
- `$group['types']`, `$group['file_locations']`.

So we can already tell *where* a managed slug is appearing. We just need to distinguish ClearConsent's own injected copy from a foreign one.

### Required change A — mark Script Manager's own output with a sentinel
In `ClearConsent_Script_Manager::inject_scripts()` (the inline + `src` branches that emit `<script type="text/plain" data-cc-name="…">`, ~lines 208 and 240), add a sentinel attribute so the scanner can recognise self-injected scripts:

```
<script type="text/plain" data-cc-name="{slug}" data-cc-src="manager">…</script>
```

- Only Script Manager output carries `data-cc-src="manager"`. The buffer rewriter must **not** add it (its tags stay as-is).
- Verify the blocker self-exclusion still works: the buffer must continue to skip any script whose body contains `clearConsentConfig` / `ccScanConfig`. Adding the sentinel must not change that path.

### Required change B — detect foreign duplicates of managed slugs
In the scanner, when classifying a group whose slug is in `get_managed_slugs()`, inspect its sources/`location_entries` and decide:

- **Self only** — every detected source for this slug is either the Script Manager sentinel (`data-cc-src="manager"`) or otherwise attributable to ClearConsent's own injection → keep **✅ Managed**.
- **Foreign copy present** — at least one source for this slug is *not* ClearConsent's own (e.g. `db_option` for `wpheaderandfooter_basics`, a theme `script_inline`/`script_src`, etc.) → this is a duplicate that the buffer is no longer tagging. Downgrade coverage.

Note: because the buffer skips managed slugs, a foreign duplicate will **not** appear with source `buffer_rewrite` — it shows up under its raw origin (`db_option`, `script_inline`, …). That's the tell.

Recommended implementation: add a helper, e.g. `private static function managed_group_has_foreign_copy( array $group ): bool`, and call it from `resolve_group_blocking_capability()` / `resolve_coverage()`. Prefer adding a new capability value `managed_duplicate` rather than overloading an existing one, so the indicator logic stays readable.

### Required change C — the downgraded coverage state
When a foreign duplicate is detected for a managed slug, render a warning state instead of "✅ Managed":

- **Symbol:** ⚠️
- **Label:** `Duplicate`
- **Tooltip:** "This script is managed by ClearConsent, but another copy is still loading from {location}. ClearConsent does not block that copy, so the tracker fires before consent. Remove it from {location}." (Interpolate the foreign location, e.g. "the WP Header & Footer plugin" / the theme file.)
- **Class:** reuse the warn class (`cc-coverage-warn`) or add `cc-coverage-duplicate` if distinct styling is wanted.

### Required change D — name the duplicate's true origin, not its rendered form

This is the correctness fix for the first build pass. The duplicate detector currently names the wrong location. A foreign copy that comes from a plugin (e.g. WP Header & Footer) is detected **twice** and these are not separate duplicates — they are two views of the same script:

- its **rendered form** in the page — source `script_inline`, raw "Hardcoded script: inline", and
- its **origin** — source `db_option`, raw "Database: wpheaderandfooter_basics".

The warning is surfacing the rendered form ("Remove it from Hardcoded script: inline"), which is useless to the user — they never hand-wrote an inline script; a plugin generated it. The message must name the **origin** so the user knows where to actually go.

Required behaviour:

1. **Consolidate** foreign location entries for a managed slug that resolve to the same underlying script, and pick a single most-actionable source to name. Do not list the rendered + origin forms as if they were two problems.
2. **Rank foreign sources by actionability** when choosing which to surface:
   - `db_option` — highest. Translate the option key to the owning plugin name (see map below).
   - `script_src` — name the file (use `file_locations`).
   - `script_inline` — lowest; use only if nothing better exists, and name the file if `file_locations` has one. Never present "inline" as the final answer when a `db_option` or file origin is available for the same slug.
3. **Option-key → plugin-name map.** Maintain a small, extensible lookup so DB-stored scripts are attributed to the responsible plugin. Seed it with:
   - `wpheaderandfooter_basics` → "WP Header & Footer"
   
   Fall back to "the database option `{key}`" when the key isn't in the map, so unknown injectors still degrade gracefully. (Other common header/footer injector plugins can be added to this map over time.)
4. **Copy** should make the plugin origin explicit and the resolution self-serve, e.g.: "This script is managed by ClearConsent, but another copy is still loading from the **WP Header & Footer** plugin. ClearConsent does not block that copy, so the tracker fires before consent. Remove the Meta Pixel from that plugin's header/footer settings."

### Acceptance criteria
1. With Meta Pixel in Script Manager **and** still present in WP Header & Footer, the row shows **⚠️ Duplicate** and the message names the **WP Header & Footer plugin** (not "Hardcoded script: inline"), telling the user to remove it from that plugin's settings. The rendered-inline and db_option detections are consolidated into one actionable instruction.
2. After the leftover copy is removed (Script Manager only), the row returns to **✅ Managed**.
3. Script Manager's own injected scripts are never flagged as their own duplicate (the sentinel prevents self-matching).
4. `node --check` clean on the admin JS; PHP lints clean.
5. A real first-time (no-consent) load is verified: while the duplicate exists the tracker network request fires; the ⚠️ state is shown. (Static checks aren't sufficient for this one — it must be reproduced live.)

### Files
- `includes/class-clearconsent-script-manager.php` — add `data-cc-src="manager"` sentinel in `inject_scripts()`.
- `includes/class-clearconsent-scan.php` — `resolve_group_blocking_capability()`, `resolve_coverage()`, new foreign-copy helper, recognise the sentinel as self, plus the origin-selection logic and option-key → plugin-name map from change D.
- `includes/class-clearconsent-blocker.php` — confirm only that nothing here needs to change; the managed-skip stays, self-exclusion stays.
- `assets/js/clearconsent-admin.js` — render the new ⚠️ Duplicate coverage state if any new `class`/`key` is introduced.

### Do NOT
- Do not "fix" the badge by removing the managed short-circuit without adding the foreign-copy detection — that would just flip every managed row, including correctly-handled ones.
- Do not make the buffer start rewriting managed slugs again as a workaround; that path is intentional and the Script Manager note depends on it.
- Do not touch the blocker self-exclusion for `clearConsentConfig` / `ccScanConfig`.

---

## Issue 2 — "Identify & configure" on server-set (cannot-be-blocked) cookies promises control that doesn't exist

### Symptom
A purely server-set HttpOnly cookie (e.g. `jess-test-cookie`) shows **🚫 Cannot be blocked**, yet also shows an **Identify & configure** button. Identifying it cannot make it blockable — the front end can't gate a cookie PHP already set in the response headers — but the button leads to an "Add service" modal implying consent control.

### Root cause
In `assets/js/clearconsent-admin.js`, `renderScanRow()` shows the **Identify & configure** button whenever `row.slug === 'unknown'`, regardless of `row.blocking_capability`. So it appears on server-set unknown cookies even though consent-gating is impossible for them.

### Product decision
A server-set cookie genuinely cannot be consent-gated from the front end. The only legitimate reason to identify it is **disclosure** (it must still be named in the site's cookie/privacy policy) or to remove it at the source. So the "service that will be consent-gated" framing is wrong for this case.

Chosen approach for v1 (free flow, keep it simple): **do not offer the consent-gating "Add service" path for `server_set` rows.** Replace it with lighter handling.

### Required change
In `renderScanRow()`, when `row.blocking_capability === 'server_set'`:
- Do **not** render the standard "Identify & configure" → "Add service" button.
- Instead render a low-emphasis affordance, e.g. **Mark as known** (so it stops reading as an unresolved "Unknown tracker") and/or a short inline note: "Set server-side — can't be blocked by consent. Disclose it in your cookie policy or remove it at the source."
- If "Mark as known" is more than a quick win, it's acceptable for v1 to simply omit the button on `server_set` rows and rely on the existing 🚫 tooltip, which already says to ensure the privacy policy mentions it.

The existing modal (`showIdentifyModal`) stays as-is for genuinely unknown, *potentially blockable* trackers (script-based unknowns) — only the server-set branch changes.

### Acceptance criteria
1. A server-set HttpOnly cookie row no longer shows a button implying it can be consent-gated.
2. Script-based unknown trackers still get the normal Identify & configure flow.
3. The 🚫 "Cannot be blocked" coverage and tooltip are unchanged.

### Files
- `assets/js/clearconsent-admin.js` — `renderScanRow()` button logic gated on `blocking_capability`.
- Optional: `includes/class-clearconsent-scan.php` if a "known/acknowledged" state needs server-side persistence (only if "Mark as known" is implemented as a real toggle rather than cosmetic).

---

## Issue 3 — A new scan doesn't clear prior results, so rows accumulate / double

### Symptom
Hitting **Scan site** again adds to the previous results instead of replacing them; the same tracker can show up multiple times across repeated scans.

### Root cause
`run_full_scan()` never resets `RESULTS_TRANSIENT` before collecting. Every collection path *merges* into the existing transient — `store_scan_finding()` and `save_findings()` both read existing results, add/update by key, and write back; neither starts clean. Findings are keyed by `slug . '_' . md5( $location )`, so an identical location overwrites (no dup), but when the location string drifts between runs (different `source` — `buffer_rewrite` vs `tag_scan` vs `browser_scan` — or "Found in N files" text, etc.) it produces a *new* key and a duplicate row. On top of that, the Layer-2 buffer rewriter calls `store_scan_finding()` on every front-end page load, so findings accumulate even between scans. Nothing clears them except the manual "Clear results" button (`ajax_clear_scan`).

### Fix
Reset the result set **once**, at the very start of a user-initiated scan, before any collection phase runs. The server scan is the first phase (`ajax_run_scan` → `run_full_scan`, then the admin JS chains the browser scan), so delete the per-scan transients at the top of `run_full_scan()`, before the `wp_remote_get`:
- `delete_transient( RESULTS_TRANSIENT )`
- `delete_transient( TAGGED_TRANSIENT )`

Then let all phases repopulate from clean: the server fetch and the buffer findings written during that fetch, followed by the browser-scan phase which merges via `ajax_store_scan` (do **not** add a clear there — it runs second and must merge into the freshly-collected set).

### Important
- The reset must happen exactly **once**, at the start of the sequence, before any phase collects — never between phases, or the browser-scan findings get wiped.
- This relies on the server scan being first. If the orchestration order ever changes (browser scan first), move the reset into a dedicated "scan begin" step the JS calls before either phase, rather than leaving it inside `run_full_scan()`.
- Leave `ajax_clear_scan` (manual Clear results) as-is.

### Optional robustness (not required for the fix)
The deeper cause of same-tracker duplicates is `md5( $location )` keying drifting across sources. Clearing on new scan resolves the visible doubling; if within-a-single-scan duplicates ever recur, key findings by a stable identity (slug + normalized source/type) rather than the human-readable location string.

### Acceptance criteria
1. Running **Scan site** twice in a row yields the same rows the second time, not doubled.
2. A tracker removed from the site disappears on the next scan (not carried over from the prior result set).
3. Server + browser phases of a single scan still combine (e.g. HttpOnly cookies from the server phase and browser-detected scripts both appear).

### Files
- `includes/class-clearconsent-scan.php` — reset at the start of `run_full_scan()`.

---

## Out of scope
- WooCommerce server-set analytics handling (already covered by the 🔧 "Disable in settings" path).
- Any change to how trackers are detected/grouped beyond reading existing source/location data.
- Multi-page scanning (homepage-only stays as-is for v1).
