Skip to main content
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.
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.

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: 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

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).
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.
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.
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.

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) — “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: 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), 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.
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.
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.