> ## Documentation Index
> Fetch the complete documentation index at: https://docs.causeloop.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Remediation

> The tenant-scoped CAP lifecycle, human-in-the-loop reviews, the tenant audit trail, and the branded XLSX export.

Remediation is the tenant's own corrective-action-plan (CAP) system: `services/api/remediation_api.py`, built on the `tenant_template` schema's `caps` and `reviews` tables (present since migration `20260722_t001` but unwritten until this router shipped) plus a new per-tenant `audit_log` table (`20260725_t004`). It is the real, RLS-isolated replacement for the legacy `services/api/main.py` `/mvp1/caps` / `/mvp1/review_decision` system, which has no tenant isolation at all — that system keys everything by a process-global `dataset_id`.

<Note>
  The router is prefixed `/remediation` (`/remediation/caps`, `/remediation/reviews`) deliberately — a bare `/caps` or `/reviews` would collide with an older product-control route and the legacy `/mvp1/caps`/`/mvp1/reviews` system already registered in the same app. An earlier, unprefixed version of this router silently shadowed the product-control route through FastAPI's registration-order route matching, caught only by a test asserting the legacy endpoint was actually being hit.
</Note>

## The CAP model

A `CapRecord` (`services/api/remediation_api.py`) has a small set of first-class columns plus a JSONB `detail` blob for everything CAPA-shaped:

| Field                                                                                                                                        | Notes                                                                                                                                                                                                                                                                                                                 |
| -------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`, `target_type`, `target_id`                                                                                                             | What the plan is for — `target_type` is `"cluster"` (a loop) for every plan the console currently creates; `target_id` is the loop id.                                                                                                                                                                                |
| `title`, `status`, `version`                                                                                                                 | `version` backs optimistic concurrency (see below).                                                                                                                                                                                                                                                                   |
| `due_date`, `created_at`, `updated_at`                                                                                                       | Plain date/timestamp columns.                                                                                                                                                                                                                                                                                         |
| `owner`                                                                                                                                      | Free-text role/name, stored in `detail`.                                                                                                                                                                                                                                                                              |
| `problem_statement`, `root_cause`, `containment_action`, `corrective_action`, `preventive_action`, `success_metric`, `verification_evidence` | The CAPA text fields — all stored in `detail`, all default to `""`.                                                                                                                                                                                                                                                   |
| `path_tier`                                                                                                                                  | `"automated" \| "governed" \| "expert"` — the three-tier remediation path.                                                                                                                                                                                                                                            |
| `proof_window_days`                                                                                                                          | Default observation window before a completed fix counts as proven: **14** days for `automated`, **30** for `governed`, **60** for `expert` (`DEFAULT_PROOF_WINDOW_DAYS`) — an autonomous AI fix is watched the shortest; a human-led strategic intervention, which typically changes more surface area, the longest. |
| `steps`                                                                                                                                      | `{contain, correct, prevent}`, each `{done_at, done_by, note}` — per-step completion tracking.                                                                                                                                                                                                                        |
| `proof_started_at`, `proof_verified_at`                                                                                                      | `proof_started_at` is set automatically the first time the `prevent` step completes — see below.                                                                                                                                                                                                                      |

Every CAPA field, the path tier, the per-step tracking, and the proof-window dates live in `caps.detail` JSONB rather than as first-class columns; the module's own docstring is explicit that there's no fixed schema worth a migration per field, since this module is the only reader/writer of that JSON.

### Status transitions

```python theme={null}
CAP_TRANSITIONS = {
    "proposed": ["approved"],
    "approved": ["in_progress"],
    "in_progress": ["blocked", "completed"],
    "blocked": ["verified", "rejected"],
    "completed": ["verified", "rejected"],
    "verified": [],
    "rejected": [],
}
```

```mermaid theme={null}
stateDiagram-v2
    [*] --> proposed
    proposed --> approved
    approved --> in_progress
    in_progress --> blocked
    in_progress --> completed
    blocked --> verified
    blocked --> rejected
    completed --> verified
    completed --> rejected
    verified --> [*]
    rejected --> [*]
```

`PATCH /remediation/caps/{cap_id}` rejects any `status` value not in the current status's transition list with `422`. `verified` and `rejected` are terminal — nothing transitions out of them.

## Create and update flows

### `POST /remediation/caps` — idempotent adoption

The console's "Adopt as plan" action (`RemediationView.tsx`'s `handleAdopt()`) calls this with a suggested fix's full CAPA text. Because the UI gives no inline confirmation and the same suggested cause stays visible on the Loops page's aggregation panel afterward, clicking "Adopt as plan" twice used to silently create duplicate CAPs. `create_cap` now checks for a **non-terminal** existing CAP on the same `(target_type, target_id)` with the same `problem_statement` (falling back to `title` when the statement is empty) and returns it verbatim — no new row, no audit entry — instead of inserting a twin.

### `PATCH /remediation/caps/{cap_id}` — optimistic concurrency

Every `CapRecord` carries a `version`. `UpdateCapRequest` requires `expected_version`; the update statement is `WHERE id = :id AND version = :expected_version`, and a mismatch (someone else updated it since you loaded it) returns **409** with "This CAP changed since you loaded it — refresh and retry." rather than silently overwriting. The frontend's `useUpdateTenantCap()` hook (`src/lib/api/hooks-tenant-remediation.ts`) treats a 409 specially: instead of surfacing an error toast, it invalidates the `tenant-caps` query so the UI refetches the current state.

### `POST /remediation/caps/{cap_id}/steps/{step}` — per-step completion

Marks one of `contain`/`correct`/`prevent` done or not-done, with an optional human note — this is what makes the CAPA steps in the console's Plan modal (`src/components/overlays/PlanModal.tsx`) functional rather than decorative. Completing `prevent` for the first time stamps `proof_started_at`, since prevention is the structural fix the observation window is proving held. Same `expected_version` optimistic-concurrency contract as the PATCH route above.

### `DELETE /remediation/caps/{cap_id}`

Hard-deletes a CAP and writes a `cap_deleted` audit row (added alongside idempotent adoption, so a duplicate created before that fix could be cleaned up).

<Warning>
  `services/api/product_app.py`'s `REMEDIATION_OPERATIONS` allowlist — the actual deployed product surface — currently admits only `GET /remediation/caps`, `POST /remediation/caps`, `PATCH /remediation/caps/{cap_id}`, `GET /remediation/reviews`, `PATCH /remediation/reviews/{review_id}`, `GET /remediation/audit-log`, and `GET /remediation/export.xlsx`. `POST /remediation/caps/{cap_id}/steps/{step}` and `DELETE /remediation/caps/{cap_id}` are implemented in `remediation_api.py` but are not in the current allowlist. Only the steps route has a shipped caller — `PlanModal.tsx`'s step-toggle buttons use `useCompleteTenantCapStep()` — so those step completions fail against the deployed product app; nothing in the frontend issues `DELETE` to `/remediation/caps/{cap_id}` today (no delete hook exists in `hooks-tenant-remediation.ts`), so that route is router-only cleanup capability with no console surface. Because the allowlist is built by selecting only matching `(method, path)` route objects, an unlisted method on an otherwise-registered path fails differently depending on whether another method shares that path: `POST /remediation/caps/{cap_id}/steps/{step}` has no other registered method at that path and 404s, while `DELETE /remediation/caps/{cap_id}` shares its path with the registered `PATCH` route and returns 405 Method Not Allowed instead. Verify `product_app.py`'s `REMEDIATION_OPERATIONS` set before relying on step completion working end-to-end in a given environment.
</Warning>

All four mutating CAP routes (`POST /remediation/caps`, `PATCH /remediation/caps/{cap_id}`, `POST /remediation/caps/{cap_id}/steps/{step}`, `DELETE /remediation/caps/{cap_id}`) require `caps.write` (`require_permission`) and a valid CSRF token (`require_csrf`); the review mutations below require `reviews.write` instead.

## Reviews — loop-level human-in-the-loop approval

A `ReviewRecord` is `{id, target_type, target_id, decision, notes, reviewed_by_email, created_at, updated_at}`, where `decision` is `"approved" | "rejected" | "escalated"` or `null` (a review can exist before a decision is made). `POST /remediation/reviews` creates one (writing a `review_decision` or `review_created` audit action depending on whether a decision was supplied inline); `PATCH /remediation/reviews/{review_id}` (`decide_review`) resolves a pending review **exactly once** — the update is `WHERE id = :id AND decision IS NULL`, and a review that already has a decision returns **409** ("This review has already been decided.") rather than letting a second reviewer silently overwrite the first reviewer's audit decision.

<Note>
  `POST /remediation/reviews` is implemented in the router but is **not** in the current `REMEDIATION_OPERATIONS` allowlist — only `GET /remediation/reviews` and `PATCH /remediation/reviews/{review_id}` are deployed. This is not a dead hook: `LoopDrawer.tsx`'s loop-level Approve/Reject/Escalate buttons call `useCreateTenantReview()` (`src/lib/api/hooks-tenant-remediation.ts`) via a `fireDecision()` handler that posts `{ target_type: "loop", target_id: loopId, decision, notes }`. Because the route isn't in the allowlist, those shipped decision buttons fail against the deployed product app.
</Note>

## Audit log

`GET /remediation/audit-log` is a read-only, tenant-scoped activity trail: every CAP/review mutation in this router writes a `TenantAuditLog` row (`action`, `resource_type`, `resource_id`, `detail`, plus `actor_email`, `user_id`, `session_id`, and `ip_address` from the authenticated `TenantPrincipal` and the request). Optional `resource_type`/`resource_id` query params filter to one target; `limit` defaults to 50 and is clamped to `[1, 200]`. This is the tenant-schema counterpart to `control.audit_log`, which only ever records employee/control-plane actions (see [Tenancy and data model](/architecture/tenancy-and-data-model)) — "who did what, when, in which session" is answerable per tenant here, not just for staff actions.

## Branded XLSX export

`GET /remediation/export.xlsx` (requires `exports.create`) calls `insights_api.load_visible_snapshot()` for the tenant's one coherent `(dataset_version, model_version)` pair, loads every CAP and review, and hands all of it to `services/api/reports/branded_export.build_branded_workbook()`. The workbook (openpyxl, brand palette matching the frontend's own CSS tokens — ink `#14162F`, magenta `#D6336C`/`#B01556`, periwinkle `#8B7BE8`/`#5346B8`) has six sheets:

| Sheet              | Content                                                                                                                                                                                                                                                                                                                         |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Cover**          | `causeloop.ai` wordmark, tenant name, CAP count, review-decision count.                                                                                                                                                                                                                                                         |
| **Loops**          | One row per theme: theme id/name, issue count, share, severity score and level (from the `themes` + `severity` insight collections).                                                                                                                                                                                            |
| **Causes & Fixes** | One row per root-cause group from `cause_remediation` (bone, root cause, rationale, containment/corrective/preventive action, owner role, verification evidence, success metric) — or, for a tenant with no `cause_remediation` collection yet, a coarser fallback built from `fishbone`'s categories so the sheet isn't empty. |
| **CAPs**           | Every CAP: target, title, status, path tier, owner, due date, and each step's `done_at` plus the proof-window dates.                                                                                                                                                                                                            |
| **Reviews**        | Every review decision: target, decision, notes, reviewer email, timestamp.                                                                                                                                                                                                                                                      |
| **Issues**         | Every issue: id, theme name, text, created-at.                                                                                                                                                                                                                                                                                  |

The response filename is `causeloop-<tenant-slug>-export.xlsx`. The frontend's `downloadExport()` (`src/lib/api/remediationExport.ts`) fetches `${API_PREFIX}/remediation/export.xlsx` directly (not through TanStack Query — this is a one-shot blob download, not cached state), reads the actual filename back off `Content-Disposition` if present, and triggers a browser download via a throwaway `<a>` element and `URL.createObjectURL`.

## Frontend wiring

`RemediationView.tsx` is the primary list surface for the tenant CAP/review hooks (`src/lib/api/hooks-tenant-remediation.ts`), but it is not the only consumer: `Pagebar.tsx` (`src/components/shell/Pagebar.tsx`) calls `useTenantCaps()` for its CAP count badge, `PlanModal.tsx` (`src/components/overlays/PlanModal.tsx`) calls `useTenantCaps()`, `useCreateTenantCap()`, `useUpdateTenantCap()`, and `useCompleteTenantCapStep()` for its create/edit/step-toggle flows, and `LoopDrawer.tsx` (`src/components/overlays/LoopDrawer.tsx`) calls `useTenantReviews()` and `useCreateTenantReview()` for loop-level review reads and decisions (see the Reviews section above). `RemediationView.tsx` itself merges two sources into one `PlanItem[]` list:

* **Managed plans** — real CAPs from `GET /remediation/caps`, filtered to `target_type === "cluster"`.
* **Suggested plans** — the adapted insight snapshot's `causes` array (sourced from `cause_remediation`, browser-side; see [Insights console](/features/insights-console)), for any loop that doesn't already have a managed CAP matching that suggestion's `problem_statement`.

This merge is deliberate: adopting **one** suggested plan used to hide every other suggestion for the same loop (a loop with 49 drafted fixes would show only its 2 adopted CAPs). Managed and suggested items now coexist — each managed CAP stands in for the specific suggestion it was adopted from (matched by `problem_statement`), and every other suggested cause-group for that loop stays listed alongside it with a "Suggested" chip and an "Adopt as plan" action.

<Tip>
  There is **no optimistic UI update** anywhere in this surface. Every mutation (`useCreateTenantCap`, `useUpdateTenantCap`, `useCompleteTenantCapStep`) calls `queryClient.invalidateQueries({ queryKey: ["tenant-caps"] })` on success and refetches instead of writing a locally-guessed new state. A `409` conflict on update/step-complete triggers the same invalidation (the row changed elsewhere; refetch and let the user retry against current data) rather than showing a raw error toast; every other error surfaces via `pushErrorToast`.
</Tip>

Mutations are gated client-side by `canReview(session)` (role rank ≥ Analyst) — the "Adopt as plan" button is disabled with a tooltip ("Auditors have read-only access") for a session below that rank, mirroring the server's own `caps.write`/`reviews.write` permission checks.

## Related

* [Insights console](/features/insights-console)
* [Tenancy and data model](/architecture/tenancy-and-data-model)
* [Security model](/architecture/security)
* [Sources](/features/sources)
