# Changelog All notable changes to Rereflect (the open-source, self-hosted edition) are documented here. Every feature is unlocked; the app runs on your own infrastructure with your own (or a local) LLM key. Prior work lives in the git history and the tracking files (`AI-TRACKING.md`, `DEV-TRACKING.md`). ## v1.1.0 — 2026-10-21 ### Fixed — the worker now refreshes customer health through the backend The worker image never contained `health_score_service`, so every worker-driven health recompute (feedback analysis, usage events, HubSpot/Salesforce syncs, the nightly segment job) was a silent no-op. The worker now POSTs to a new internal endpoint after its transaction commits, and the backend runs the one real scoring implementation. **This is off unless you set `INTERNAL_API_SECRET` on both services and `INTERNAL_API_URL` on the worker** (the production compose file sets the URL; the secret is operator-supplied). Unset keeps the old behaviour — health scores refresh only from backend-driven events — and logs it once per process. See `docs/SELF_HOSTING.md`. ### Fixed — copilot stops reporting numbers and actions it does not have - **The Regenerate button is gone.** It was rendered on every assistant message and always returned "not yet implemented". Regeneration is a future feature. - **Usage counters are real.** The final websocket frame now reports actual `tokens_in`/`tokens_out`/`cost_cents`; unpriced or local models report `cost_cents: null` instead of a fabricated zero. - **Report errors carry a `message_id`.** The report branch (and the "No AI model configured" path) no longer bypasses the shared transport, so a client can correlate the error to its turn. - The module docstring no longer advertises a suggestions endpoint that does not exist. ### Changed — usage telemetry is bounded and timezone-safe - **Raw `usage_events` are purged weekly** after `USAGE_EVENT_RETENTION_DAYS` (default 90, env-configurable); rows with no timestamp are kept. - **The daily usage recompute is paged** (1,000 rows per batch) instead of loading every `customer_usage` row into memory. - **Rollup comparisons normalise to naive UTC**, so a tz-aware event can no longer raise `TypeError` mid-recompute. ### Changed — the dead `has_custom_key` field is gone from the AI settings contract It had zero backend read/write sites; only the frontend type and test fixtures carried it. ### Changed — members can no longer run destructive or org-wide actions (admin/owner only) Several mutation routes had no role check, so any signed-in **member** could call them. They now require an **admin or owner**; a member gets `403` ("This action requires admin or owner privileges"). **This is a behaviour change for existing members:** - **Playbooks:** create, edit, delete, run, and bulk run (`run-batch`). - **Churn labels:** marking a customer churned (single and bulk), CSV import of churn events, and recovering a churned customer. - **Workflow settings:** assignment-rules create/edit/delete and the auto-assignment settings. - **Feedback deletion:** delete and bulk-delete. - **Analysis:** `POST /api/v1/analyze/batch`, the org-wide "analyze everything unanalyzed" call that spends LLM budget. There is no button for it in the web UI. Still open to members: creating, editing and flagging feedback, **CSV feedback import**, assigning feedback and changing its status and notes, approving and rejecting pending feedback, and analyzing a single item (`POST /analyze/`). - **Churn-event delete is wider, not narrower.** It previously allowed only the author within 24 hours, or a system admin. An org admin or owner can now delete any churn event too. The 403 message now reads "Only the author within 24 hours, an admin or owner, or a system admin can delete a churn event." - **The web UI hides these controls from members** (playbook pages, mark/import/recover churn, bulk run, feedback delete in every list and the detail page, the Workflow settings page and its sidebar link, which now redirect members to a permitted page). The hiding is a convenience; the server check is the enforcement. - **Upgrade:** there is no flag or setting. To restore access for someone, promote them to admin on the Team page. - **Limits:** the role is read from the user's database row on each request, not trusted from the token. This change gates these routes only; it makes no other claim about the app's security. - **New guard test:** `tests/test_mutation_route_rbac_sweep.py` fails if any `POST`/`PUT`/`PATCH`/ `DELETE` route has neither a role dependency nor an entry on a reasoned allowlist, so the next ungated mutation route is caught in CI. ### Fixed — an owner can update the organization again `PATCH /api/v1/organizations/me` checked `role != "admin"`, so an **owner** got a 403 even though owners outrank admins. It now uses the same admin-or-owner gate as every other admin-level mutation. Members still get 403. ### Fixed — the usage chart no longer skips empty days `GET /api/v1/customers/{email}/usage` returned only days that had at least one event, so the chart could render a discontinuous series or misalign its x-axis. It now returns exactly 30, 60 or 90 daily buckets (the requested window), ending today in UTC, with `event_count: 0` on days without activity. ### Added — run a playbook on any customer-list cohort `POST /api/v1/playbooks/{id}/run-batch` accepted only `emails` or `segment`, so a cohort filtered by risk level or search could be exported, tagged or assigned but not playbook-run. It now accepts `risk_level`, `search` and `include_archived` too, with the same validation, AND semantics against an explicit probability band, 500-customer cap and `count_only` preview. ### Fixed — automation rules could be saved with actions their trigger never runs - **The "Usage Decline Outreach" template now actually notifies.** Usage-trend and churn-probability rules silently skipped every action except running a playbook or emailing the customer, and still logged the rule as **success**. So this template, whose only action is a notification, never notified anyone. Those two triggers can now send notifications. - **Unsupported actions can no longer be saved.** Each trigger lists the actions it can run. The rule editor only offers those, and the API rejects anything else with a message naming the unsupported action. Triggers based on a customer's health, churn probability or usage have no single feedback item, so they cannot assign, change the status of, or draft a reply to one. - **The "Churn Prevention" template is now honest.** It promised to assign the customer and draft a reply, but health-score rules have no feedback item to do either with, so those two steps failed on every run. It now notifies admins, which is the part that always worked. - **Existing rules:** a rule saved earlier with an unsupported action now records a clear error for that action in its execution log, instead of a silent **success**. You can still rename or pause it. Editing its trigger or actions requires removing the unsupported action first. ### Fixed — automation-triggered playbooks could silently never run - **Automation rules that run a churn playbook now actually run it.** A rule's `run_playbook` action saved the playbook execution but sent it to the worker *before* the save was committed. If the worker picked it up first, it found nothing and gave up. The execution stayed **queued** forever and the playbook never ran, while the rule's execution log reported **success**. This affected churn-probability rules, usage-trend rules, health-score / risk-level rules with a playbook action, and the playbook **Trigger automation** step. Manually running a playbook was never affected. - The same ordering fix applies to feedback arriving from webhooks and from the Zendesk/Intercom pull syncs. Those items were always analyzed eventually, by the 30-second catch-up job, but can now be analyzed immediately. - **Stuck playbook executions now recover or close out on their own.** A new worker job runs every 10 minutes: - An execution still **queued** after 15 minutes is sent to the worker again. This covers a lost message or a broker outage at dispatch time. - One still **queued** after 24 hours is marked **failed**, with a reason saying it was never picked up. This includes executions orphaned before the fix above. They are not re-run, because acting on a customer's state from days ago could do the wrong thing. Run the playbook again if it still applies. - One stuck **running** for over an hour (the worker stopped mid-run) is marked **failed**, with a note that some actions may already have been applied. - A playbook run can no longer execute twice if its message is delivered twice. Claiming the run is now a single atomic step. ### Added — AI Copilot suggested actions (tag the customers in an answer) - **A copilot answer can now offer to tag its own customers.** After a `data`/`analysis` query whose result carries a customer-email column, the answer message gains an **actions** item offering **Tag these N customers…** next to the result. Clicking it opens a confirm dialog; the operator types the tag and confirms, and the tag is applied (add-mode) to the customers in that answer. - **The proposal is server-derived, never model-authored.** The server inspects the result shape (a customer-email column) and appends the action itself — the proposal path makes no LLM call and involves no tool-call prompt — so the action appears identically when the copilot runs on a keyless local model. The tag value always comes from the operator in the dialog, never from the model or a server guess. - **The cohort is the frozen answer set, bounded.** The action embeds the exact deduplicated email list the operator saw, capped at **200**; a result larger than the cap offers **no action** rather than a silently truncated one. Emails that no longer belong to the org are absorbed as `skipped`, never errors. - **New route.** `POST /api/v1/copilot/actions/execute` validates the requested action against a whitelist registry, enforces the action's `min_role` at execute time, applies the tag through the org's customer records, and returns the `BulkActionSummary{matched, updated, skipped, errors}` outcome, which is shown inline under the button. - **Honest limits.** This slice ships exactly **one** action type (`tag_customers`), and only for `data`/`analysis` queries — never `general` or `report` answers. Execution is **admin/owner only**: members never see the button, and the endpoint refuses them with a 403 even on a direct call. Each proposal is **one-shot**: a second execution of the same proposal returns the first run's outcome instead of re-tagging. Every execution writes an `AuditLog` row (`copilot_action_executed`) recording action, tag and outcome. ### Changed — Landing site rebuilt as a schematic system - **The marketing site (`rereflect.ca`) has a new visual language.** The previous treatment — a WebGL galaxy hero, smooth-scroll hijacking, a full-height pinned scroll-scrub section, gradient-filled headlines, pill buttons and glass panels — is replaced by a precision-instrument layout: a ruled 12-column grid, hairline-divided cells instead of cards, monospace uppercase figure captions (`FIG. 01 — …`), 2px corner radii and light-weight display type. - **The palette is unchanged; its application is inverted.** The same coral/amber values are now used surgically — hairlines, mono labels, state dots and meter fills — rather than as gradient text and glow shadows. - **New sections.** A processing-stages diagram (ingest → analyse → cluster → act), a prior-art comparison (manual triage vs hosted feedback SaaS vs Rereflect), and a console section that draws the feedback table as real UI rather than a screenshot. The CTA gains a three-command deploy strip (`git clone` → `docker compose up` → `alembic upgrade head`). - **Typography.** Geist and Geist Mono replace Montserrat, Merriweather, Ubuntu Mono and Inter Tight. Fonts are overridden in `services/landing-web`'s own Tailwind config, so the shared `@rereflect/ui` config used by the dashboard is untouched. - **Motion is restrained and centralised** in `lib/landing/motion.ts`: short expo-eased reveals, hairlines that draw themselves, flat meter fills and tabular count-ups. Every one of them is a no-op under `prefers-reduced-motion`, and the scroll-hijacking is gone entirely. - **Lighter bundle.** Drops `three`, `@react-three/fiber`, `@react-three/drei`, `@react-three/postprocessing`, `postprocessing` and `lenis` — the landing site's only remaining runtime dependencies are Next, React, GSAP and lucide-react. - Carried across every page: the integrations index and all ten detail pages, the blog index and post template, privacy and terms. - No backend, worker or dashboard code was touched. ### Added — Microsoft Teams alert notifications - **Alerts can now reach Microsoft Teams.** Connect a Teams **Incoming Webhook** (classic `outlook.office.com/webhook/…` or Power Automate Workflows `webhook.office.com/webhookb2/…` URL) from Settings → Integrations, and Rereflect posts alerts to the channel as Microsoft **MessageCards** (title, text, summary, `#6264A7` accent) — urgent feedback, sentiment spikes, churn risk, volume spikes, and customer health-drop/recovery alerts, once per organization per alert. - **Per-user, per-type toggle.** Settings → Notifications gains a **Teams** channel switch for each alert type, defaulting to **on**; Slack, Discord and Teams are routed independently, and any user switching Teams off does not mute the other channels. - **Automations and playbooks.** The automation `send_notification` action supports the `teams` channel (both the backend engine and the worker mirror), and the playbook editor's `notify` action offers **Teams** as a destination alongside Slack and Discord. - **Honest limits.** Teams is **webhook-only** — no OAuth, no bot registration. It is an **outbound destination only** — no inbound Teams feedback source. MessageCards only (no Adaptive Cards). Digests remain email-only. The automations UI has no channel editor, so a `send_notification` rule reaching Teams must be created via the API (`channels: ["teams"]`); playbook `notify` steps pick Teams directly in the editor. ### Added — Complete playbook action types - **All 7 seeded playbook templates now execute end-to-end.** The churn-playbook engine previously implemented 5 of 11 declared action types, so 6 of 7 built-in templates (Critical Save, Churn Prevention, At-Risk Outreach, Light-Touch Nudge, Power-User Recovery, New-Customer Save, Silent-Churn Watch) contained steps that failed with `unsupported action type` on every run — including auto-executed churn runs. The five missing actions are implemented: - **`notify`** — send the message to the org's connected Slack/Discord integration or as in-app notifications for admins (`channel: slack|discord|dashboard`; `target` is advisory — the integration's configured channel is used). - **`tag`** — add a tag to the customer's health record (bulk-tag constraints: ≤50 chars, ≤20 tags/customer, sorted, deduped). - **`create_task` / `schedule_task`** — persist an internal follow-up task (`playbook_tasks` table) with a due date and priority. - **`trigger_automation`** — fire a named `churn_probability_threshold` automation rule for the customer, respecting the rule's own mode, threshold, and Redis cooldown (rules with `cooldown_hours < 1` are refused — no rule→playbook→rule loops). `usage_trend` rules and per-feedback triggers are not evaluable from a playbook and report why, loudly. - **Failures are loud and visible.** Executions on the playbook detail page now expand per-action results (type, ok/error, error text) instead of burying failures in an unrendered log. - **The playbook editor offers the new action types** with config forms (channel/message, tag name, task fields, automation picker) that round-trip the exact keys the engine reads. - **Seeded templates converge on startup.** New-Customer Save's `trigger_automation` now targets the real `At-Risk Customer Outreach` automation (seeded in shadow mode — the step reports `mode=off/shadow — not fired` until you activate it), and pristine seeded template rows are updated in place; cloned/org-owned playbooks are never touched. - One new table: `playbook_tasks` (write-only in this release — task rows appear in execution results). ### Added — Scheduled & emailed AI reports - **Reports on a fixed cadence, not just on demand.** The My Reports page (`/reports`) gains a **Scheduled** tab where admins/owners create report schedules: one of the four report types (executive summary, customer health, feature prioritization, churn risk), a date range (7/30/90 days), a cadence (daily/weekly/monthly), an hour (UTC), and an optional recipient list (seeded with the creator's email; empty = in-app only). Members see the list read-only. - **The worker materializes them on cadence.** An hourly Celery beat task claims each due schedule exactly once per window (atomic `last_run_at` claim — overlapping beats never double-run a window) and persists a real `Report` row tagged `source="scheduled"`, so scheduled reports appear in the normal Reports list. - **Email delivery when you bring a key.** With `RESEND_API_KEY` set, every recipient on the schedule receives the generated report at its scheduled hour (UTC); with no key, generation still happens in-app and email is skipped silently — zero dependency on a key, and a run is never failed by email. - **Cadence semantics.** Daily = every day at the chosen hour; weekly = the chosen weekday (`Sun`–`Sat`); monthly = the chosen day of month — a day-31 schedule skips shorter months (accepted, documented). All times are UTC. - API: `GET/POST/PATCH/DELETE /api/v1/report-schedules` + `POST /api/v1/report-schedules/{id}/toggle` (create/update/delete/toggle are admin/owner; the list is member-readable). No plan gates — everything unlocked. - See `docs/planning/scheduled-ai-reports/`. ### Added — Automation rules can email the customer (`send_customer_email`) - **Behavior change.** An `active` automation rule carrying a `send_customer_email` action now sends a customer-facing email when it fires — the automation-rule form of the playbook `send_email` step and bulk outreach. It works on every trigger that carries a customer, in the backend engine and all three worker mirrors (feedback, churn-probability, usage-trend). The seeded **At-Risk Customer Outreach** template ships in **shadow mode**: it evaluates and logs, and sends nothing until you flip it to active. - **The shipped outreach protections are honored verbatim.** Per-customer opt-out and the tokenized `List-Unsubscribe` link apply unchanged; the per-recipient outreach cooldown is shared with bulk campaigns, so the two paths cannot double-email the same customer inside the window; with `RESEND_API_KEY` unset the result is a loud `skipped: email not configured`, never a silent success. - **Every send is audited.** Each fire writes an `automation_email_deliveries` row (`queued` → `sent | skipped | failed` + reason), readable per rule on the rule detail page's Email Deliveries tab and via `GET /api/v1/automations/{rule_id}/deliveries` (admin/owner). - See `docs/planning/automation-send-customer-email/`. ### Removed — Dead anomaly-alert senders deleted (delivery unchanged) - **Cleanup, not a behavior change.** The worker's `_send_anomaly_slack`, `_send_anomaly_discord` and `_send_anomaly_email` — plus the email sender they called — were fully implemented but **never called**. Anomaly alerts already route through the main dispatch pipe, which delivers **both** Slack and Discord with per-user preferences and integration-health bookkeeping, so nothing about alert delivery changes. - **Anomaly email is gone entirely — stated honestly.** That path was unreachable before this change; the main pipe never delivered anomaly email (Slack/Discord only). This is cleanup of a dead path, not a regression. ### Changed — Integration, create-issue and feedback-source management is now admin/owner-only in the UI The frontend now mirrors the backend RBAC (members were silently 403-ing on submit): - **Integration detail + new pages redirect members** to Settings → Preferences (`settings/integrations/[id]` and `settings/integrations/new`), matching every other integration page. - **The create-issue wizard shows an admins/owners-only card** for members instead of the wizard (whose mount-time status calls all 403'd, showing every provider as "Not connected"). The page is deliberately not redirected — it is deep-linked from a member-visible feedback detail page. - **Feedback-source write controls are hidden/disabled for members** — the list page's Add Source, per-row Pause/Configure/Delete and per-type "+" are gone; the detail page hides Delete/Save and disables every input/switch/trigger; the new-page wizard hides Create Source. Read views (source list, webhook/email address cards, recent events) stay member-open, and the pending-review page is untouched. These are UI affordances; the backend 403s remain the actual enforcement. ### Removed — Vestigial signup promo banner + dead analytics helpers - The signup page no longer reads a `?promo=` query param or localStorage to show an "invited to Rereflect" banner gated on a hardcoded promo-code list. There is no promo backend and no billing (Rereflect is free and fully unlocked), so the offer could neither be honoured nor withheld. - The now-dead `promoSignup` / `promoCheckoutStarted` analytics helpers are removed too. ### Removed — the orphaned internal realtime-push endpoint - **The internal HTTP endpoint for pushing realtime events (and its shared-secret env guard) is deleted** — it had zero production callers since it shipped (verified repo-wide), so nothing breaks. The WebSocket side (`/ws/events`), the `emit_event` service and its in-process callers are unchanged, and webhook dispatch is unaffected (that is a different mechanism entirely). - Operators who set that env var can drop the variable; the related `.env.example` / `.env.prod.example` entries and the SELF_HOSTING section are removed. ### Security — generic inbound webhook sources now mint a per-source secret (fail closed) - **New generic webhook sources get a secret.** Creating a Webhook source now mints a per-source `secret_token` (shown **once** in the create response as `webhook_secret`) and deliveries to it must send it as the `X-Webhook-Secret` header — missing or wrong → **401 (fails closed)**. - **Existing sources are grandfathered.** Sources created before this change have no secret and keep the capability-URL model; to harden one, PATCH the source with `provider_config.secret_token` (the stored `webhook_id` is preserved), and it starts failing closed too. See *Inbound webhook signing secrets* in `docs/SELF_HOSTING.md`. - *Honest limits:* the secret lives in the source's `provider_config` JSON (same storage as `webhook_id`) — per-source secret encryption is a recorded follow-up; the capability-URL posture only changes for sources created after this release. ### Fixed — OAuth connect state is stateless (Slack, Intercom, Linear) - **The in-process OAuth state dicts are gone.** Slack, Intercom and Linear OAuth `state` is now an HMAC-signed, app-secret-keyed, 10-minute-TTL blob (stateless, same scheme as Salesforce) instead of a module-level Python dict. OAuth callbacks no longer fail when they land on a different backend replica than the one that issued the authorize URL, and nothing unbounded accumulates in memory. - **Forged or expired state fails closed** — tampered, expired, or wrong-key states redirect with `oauth_error=invalid_state`, exactly like invalid states did before. - *Honest limits:* signing uses the app secret (`JWT_SECRET`); a secret compromise invalidates state freshness the same way it would for any HMAC scheme. ### Changed — Slack and inbound-email webhooks now require a signing secret (fail closed) - **Behavior change.** `SLACK_SIGNING_SECRET` and `RESEND_INBOUND_WEBHOOK_SECRET` are no longer optional when you use those sources: an unset secret now rejects every delivery with **401** instead of accepting it unverified. This ends the grace period that shipped with the webhook hardening; the two verifiers now fail closed exactly like the other five (Intercom/Zendesk/Jira/Asana/Linear). - **If you never set them, set them now.** `SLACK_SIGNING_SECRET` (Slack app → Basic Information → Signing Secret) and `RESEND_INBOUND_WEBHOOK_SECRET` (Resend → inbound webhook settings) — otherwise Slack and inbound-email feedback stops arriving. The backend warns at startup when an active Slack integration or email source has no signing secret, and Settings → Integrations flags Slack. - **Already configured:** no change — valid signatures accepted, invalid rejected, exactly as before. ### Added — Intercom webhook replies & ratings now enrich items in real time - **The webhook path is no longer inert.** `conversation.user.replied` and `conversation.rating.added` deliveries used to be silently dropped (dedup'd against the conversation's created row — the #16 follow-up). They now enrich the conversation's existing feedback item in real time: a reply is merged into the item's text (idempotent by part id), the satisfaction rating lands on the item. - **Payload-first, with a fallback fetch.** Real payloads are conversation-wrapped and read payload-first; a payload without conversation parts falls back to a `GET /conversations/{id}` detail fetch. - **Re-analysis once, after commit.** Text-changing enrichments dispatch exactly one re-analysis; rating-only changes dispatch none. - **The pull stays the guaranteed fallback** — unchanged; the create path is untouched (webhook enrichment never creates items). - *Honest limits:* webhook enrichment applies only to conversations whose items already exist (no backfill — same rule as the pull); payloads without parts fall back to a detail fetch; no analysis-quality claim beyond what the pull already makes. ### Added — Intercom settings page shows the remaining-backlog estimate - **"≈ N remaining" on the Connection card.** After a completed pull run, the Intercom settings page shows how much of a large backlog is left ("≈ N conversations left to sync — estimate, drains over runs"), computed from Intercom's `total_count` for that run's sync window. A first connect with a big history now shows real drain progress instead of an opaque ingested count. - **Absent when there is nothing honest to show** — no row when the window is empty, the run failed (the estimate is cleared, never left stale), the connection is disconnected, or the organization connects via OAuth (no pull). - *Honest limits:* the number is an **estimate, not a queue count** — conversations updated during the drain shift later windows and the boundary conversation re-counts itself; token-paste connections only; present only after a completed run; the 20-page/run drain mechanics are unchanged. ### Added — Intercom pull now ingests replies and ratings - **Replies are merged into the item.** The 15-minute pull used to ingest the first message of each conversation only. It now enriches conversations it re-sees with their conversation parts: each new reply is appended to the item's text (HTML-stripped, with an author-attribution line) — one feedback item per conversation, as before, and merging is idempotent (re-running on the same conversation adds nothing). - **The satisfaction rating is captured** into `source_metadata` (`rating`, `remark`, `rated_at` when available) and surfaced on the feedback detail — not appended to the text, so "Rating: 5/5" never skews sentiment analysis. - **Changed items are re-analyzed** through the normal pipeline (sentiment, categories, churn, health). Unchanged items are not touched. - **The webhook stays optional** — for near-instant delivery of new conversations; replies and ratings no longer depend on it. - *Honest limits:* enrichment applies to conversations the pull re-sees after this ships — older conversations stay first-message-only until they get new activity (no backfill); "the full thread is now scored" is the extent of the quality claim; the webhook's `replied`/`rating.added` events remain dedup-inert (a flagged follow-up, not fixed here). **Correction (2026-08-17):** the honest-limits line above said the webhook's `replied`/`rating.added` events were a flagged follow-up — not yet fixed. That follow-up is now shipped — see the Added entry above: webhook replied/rating deliveries enrich the conversation's existing item in real time (payload-first, existing-item-only, no backfill; the pull stays the fallback). ### Added — Intercom write-back: note + close on resolve (opt-in) - **Opt-in, off by default.** Per-org toggle on **Settings → Integrations → Intercom**; `note_and_close` (default) or `note_only`. Enabling does **not** backfill — only transitions to `resolved` after enable fire. - **What happens on resolve:** an Intercom-sourced feedback item transitioning to `resolved` posts a note to the linked conversation (the resolution note, or the default "Marked resolved in Rereflect.") and, with `note_and_close`, closes it. Re-resolve after reopen is a no-op (per-item durable marker). - **Config API:** `PATCH /api/v1/integrations/intercom/writeback` (admin/owner) and the five writeback fields on `GET /api/v1/integrations/intercom/status`. - **The orphan is gone.** `intercom_service.py` — the "Two-Way Sync" code the 1.0.0 notes said "nothing in the app has ever called" — is **deleted**, and its behavior is ported into the worker's `IntercomClient` (`add_note`, `close_conversation`, `fetch_admin_id`) with the worker error taxonomy (missing scope recorded, never a silent `False`). The new task is **registered under exactly the name its dispatchers use**, pinned by a name-consistency test, so the "green tests over code that never executes" class cannot recur. - *Honest limits:* resolved-only trigger; no backfill-on-enable; the worker task is fire-and-forget (no response-time claim); a crash between the Intercom call and the marker write can duplicate a note on retry (close remains a no-op). ### Docs — Landing page: Intercom entry reflects the shipped integration The Intercom page claimed OAuth-only setup ("set INTERCOM_CLIENT_ID…") and webhook-only ingestion ("there is no polling fallback") — both stale since token-paste connect and the 15-minute pull shipped. It now describes connecting with an access token, the pull with an optional real-time webhook, and the opt-in write-back (note + close on resolve). No new claims — only what ships. ### Fixed — Five Celery beat jobs had never run, in any release Four of them were in the beat schedule without being **real** Celery tasks: the functions existed, the schedule named them, and dispatch raised `NotRegistered` every time, so none had ever executed in production: - `churn_calibration.refit_all_orgs` — the weekly per-org isotonic calibration refit (Mondays 07:45 UTC); - `churn_calibration.refit_global_calibration` — the daily global refit (03:00 UTC); - `churn_calibration.purge_old_calibration_models` — the stale-model purge; - `classifier_training.retrain_all_orgs` — the weekly M5.2 per-org classifier refit (Mondays 06:30 UTC). All four were plain functions with **no Celery decorator**, while `celery_app.py` dispatched them by dotted name. The existing schedule-integrity test only checked that a beat's name resolved to an attribute on its module — which an undecorated function passes — so the suite was green the whole time. The practical consequence: **churn probabilities always came from the identity fallback** (`p = score/100`), because the calibrated model that fallback is supposed to replace was never refit. The fifth entry is a different failure of the same family: the 90-day playbook-execution purge (`churn_playbooks.purge_old_executions`) *was* decorated, but its explicit `@shared_task name=` lacked the `src.` prefix its beat entry carries. The "weekly cleanup job" fix earlier in this release repaired only the beat side, so dispatch still raised `NotRegistered` — see the correction on that entry below. All five are now registered under exactly the name their beat entries dispatch, registration-only with no task-body change. `test_beat_schedule_integrity.py` is hardened to assert each scheduled task is a **real registered Celery task** whose registered name equals the beat string, so this class cannot pass a green suite again. Waking up never-run paths is real work, so first production runs may surface latent bugs. ### Added — The churn-label gate is re-derived, not assumed (verdict: keep 500) `CHURN_LABEL_TARGET = 500` was copied verbatim from the pre-pivot hosted PRD, and the AI readiness docs said nobody had re-derived what a per-org churn model actually needs single-tenant. That measurement now exists and is committed: a reproducible harness (`services/backend-api/scripts/eval_churn_label_gate.py`) simulates per-org learning curves for the planned logistic challenger against the calibrated-heuristic incumbent across label volumes, and its verdict is **keep 500** — no threshold change. The simulated crossover, where the challenger clears the +0.02 macro-F1 promotion bar, lands at **200 labels** — at full fidelity and again when 25% of feature snapshots are reconstructed from documented defaults. At 500 the gate clears with margin. Honest about what this is: a simulation is a **bound, not a measurement** — no real org is at label volume, so the curves are synthetic, and the honest limits are stated on the card (`GET /api/v1/settings/ai/churn/label-gate`, rendered as the **Churn Label Gate** card on Settings → AI). The same study answered the promotion-cadence question: at the crossover volume only 57% of pooled simulated orgs cleared the bar on a single run, so the churn head promotes on **consecutive runs**, not M5.2's single-run rule. ### Added — Per-org churn ML model (M5.3) A per-organization churn classifier that can replace the calibrated heuristic where it is measurably better — the heuristic stays the automatic fallback everywhere else. - **A real feature vector and a small local model.** New analysis-engine core (`analyzer/churn_classifier/`): a 28-feature customer vector (health components, usage history, feedback aggregates, segment, renewal proximity — with documented defaults for missing snapshots), a logistic-regression trainer that serializes to a **JSON-only artifact** (no pickle), and a **pure-stdlib predictor** — sklearn is needed to train, never to predict. **No Hugging Face downloads and nothing to bake into an image** — this model is your org's own coefficients, a few KB of JSON, which matters for air-gapped installs. - **Honest A/B, now against the real incumbent.** The challenger is scored against the calibrated-heuristic incumbent on a leakage-free held-out set — and that incumbent only became real with the beat-registration fix above; before it, "beats the heuristic" would have meant "beats a function that never ran." - **Three modes, off by default** (`OrgAIConfig.churn_classifier_mode`, Settings → AI): `off` (byte-identical to today), `shadow` (trains and logs the delta, changes nothing), and `auto` (the ML probability replaces the heuristic on qualifying orgs). In `auto`, `churn_probability` is the ML point estimate and the confidence interval columns are left **NULL rather than fabricated** — the bootstrap CI belongs to the calibrated path, not to an ML prediction. - **Consecutive-runs promotion.** Weekly refit Mondays 06:00 UTC. Because the gate study showed single-run +0.02 promotion is high-variance at the crossover volume, a challenger must clear the +0.02 macro-F1 bar on **two consecutive weekly runs** to promote. Rollback pauses auto-promotion (`churn_autopromote_hold`), rollback/resume work per the M5.2 conventions, and the accuracy tab shows a fourth incumbent-vs-challenger card with version history. - **Honest limits:** the readiness gate is **500 labels** — re-derived and kept by the study above, but the study is a simulation bound, not a measurement. **No real org is at label volume**, so the M5.3 exit ("for a qualifying org, ML beats the heuristic") is **unvalidated**; the spine ships dormant below the gate with shadow-only evaluation until an org qualifies. ### Customer outreach — shared email primitives (opt-out, unsubscribe, cooldown) The foundation for reaching churn-risk customers by email, shared by the playbook `send_email` step and the bulk "Trigger outreach campaign" action (both consume these primitives; the send surfaces themselves land with their own changes): - **Opt-out, honored on every send path** — `customer_health_scores.outreach_opt_out` (default false). Customers opt out via the tokenized **`List-Unsubscribe`** link every outreach email carries (`GET /api/v1/outreach/unsubscribe?token=…` — public, stateless HMAC token keyed by `LLM_ENCRYPTION_KEY`, no token table). Operators can also flip the flag with `PATCH /api/v1/customers/{email}` (admin/owner, `{"outreach_opt_out": bool}` — extra fields 422). An opted-out customer is never emailed; the skip is loud (`skipped: opted out`), never silent. - **Per-recipient cooldown** — one outreach per customer per window per org (Redis DB 1, `outreach_cooldown:{org_id}:{customer_email}`, window `OUTREACH_COOLDOWN_HOURS`, default 24, env-configurable). In-cooldown sends record `skipped: in cooldown`. - **Built-in template registry** — `GET /api/v1/outreach/templates` exposes the two seeded template keys (`re_engagement`, `weekly_digest_entry`) with plain-text bodies; registry is data, so content can iterate without code. - **Honest no-key failure** — with `RESEND_API_KEY` unset, every send attempt records `failed: email not configured` instead of a false success. - **Audit trail schema** — `outreach_campaigns` + `outreach_campaign_recipients` tables (per-campaign + per-recipient status/error rows) land with this change; the bulk send path that writes them ships with its own aspect. Resend-only, BYO-key: no SMTP, nothing phones home. See *Outbound email (Resend)* in `docs/SELF_HOSTING.md`. ### Playbook `send_email` steps now execute (worker engine) The seeded playbooks' `send_email` steps ("At-Risk Outreach" → weekly digest to the CS assignee; "Silent-Churn Watch" → re-engagement email to the customer) actually send now instead of failing every run as `unsupported action type`. Recipients: `customer` (the execution's customer email) or `cs_assignee` (the customer's assigned CS owner — missing owner is a loud per-step failure, never silent). Rendered from the built-in registry via the worker mirror; every outcome (sent/skipped/failed) is recorded loudly in the playbook execution's `action_log`. Opt-out, cooldown, List-Unsubscribe and no-key handling remain the shared sender's (above) — no reimplementation here. ### Bulk "Trigger outreach campaign" — send, preview, audit trail, retry The operator-facing bulk send path on `/customers` now exists (admin/owner): - **`POST /api/v1/customers/bulk/outreach`** — resolve a cohort (`emails[]` list or the same filter vocabulary as the customers list), write one `outreach_campaigns` row + one `outreach_campaign_recipients` row per customer, and enqueue one Celery task per sendable recipient. Responds `202 {matched, queued, skipped, errors}` — the request returns a summary, never a blocking send. - **Loud queue-time skips** — blank/non-email, opted-out (`outreach_opt_out`) and archived customers land in `skipped` with a reason on their recipient row; filter-mode default excludes archived from `matched` exactly like the customers list UI. Cooldown is not checked at queue time — the worker sender re-checks at send time. - **`?count_only=true` preview** — `200 {matched, queued: 0, skipped, errors: []}` with zero mutation, so the UI can show the true count before the 500-cap 422 on the real run. - **Campaign audit trail** — `GET /api/v1/outreach/campaigns` (page/page_size, newest first) returns per-campaign recipient status counts (`queued/sent/skipped/failed`) from one GROUP BY — no N+1. - **Dead-worker recovery** — `POST /api/v1/outreach/campaigns/{id}/retry` re-enqueues only `queued` recipients (terminal rows are immutable); no-op 200 zeros when nothing is queued; cross-org campaign id → 404. - **AI draft** — `POST /api/v1/customers/bulk/outreach/draft` LLM-drafts `{subject, body}` from org context (product name, brand voice, tone) plus optional cohort context (count + dominant segment only — raw emails/search never reach the model). 409 when no LLM is configured. The draft never sends: no campaign rows, no dispatch. Task-name discipline: the worker task `tasks.outreach.send_outreach_email` is dispatched by name from both send endpoints — a single string, pinned by tests in both suites. ### Playbook editor: `send_email` steps are now editable, and the profile can opt a customer out The frontend half of the outreach send surfaces: - **Per-step config in the playbook editor** — a `send_email` step now shows **template** and **recipient** selects (options from `GET /api/v1/outreach/templates`; the two contract-pinned built-in keys `re_engagement` / `weekly_digest_entry` fall back if the registry is unreachable, so editing never bricks). Cloning "At-Risk Outreach" or "Silent-Churn Watch" pre-populates both selects and re-saves the exact seeder shape `{type: "send_email", config: {template, recipient}}`. Unknown template keys from old playbooks render with a warning and are preserved on save, never blanked. The template card badge shows a Mail icon + "Send Email". - **Per-customer opt-out toggle** — the Customer 360 profile header now has a "Send outreach emails" switch (admin/owner) that PATCHes `{"outreach_opt_out": bool}`; the switch is hidden entirely for members. ### Notifications — Discord now has its own per-type channel preference Settings → Notifications now shows a **Discord** channel switch for each alert type, default on. Slack and Discord are routed independently: - A type can now be **Discord-only** (Slack off) or **Slack-only** (Discord off) — previously Discord rode the Slack toggle, so "Slack off" also silenced Discord. - The one behavior change: a type with Slack **off** and Discord **on** now delivers to Discord. Existing installs (both on, or both off) behave exactly as before. - Automations' `send_notification` action still cannot target Discord (unchanged). ### Security — Inbound webhooks accepted unsigned requests, and could write to the wrong organization **If you run Rereflect with more than one organization on a single instance, upgrade.** Set `SLACK_SIGNING_SECRET` and `RESEND_INBOUND_WEBHOOK_SECRET` if you use the Slack or inbound-email sources; see *Inbound webhook signing secrets* in `docs/SELF_HOSTING.md`. Three problems, each of which was the shipped default rather than an edge case: - **Signature verification failed open.** The Intercom, Slack, inbound-email and Linear verifiers each returned "valid" when their signing secret was unset — and none of those secrets appeared in any `.env.example`, compose file or doc, so unset *was* the default state of every install. Zendesk, Jira and Asana were already correct. - **A missing tenant discriminator matched every organization.** The worker's source-matching query is not scoped by organization; each provider branch narrows it only when a payload-supplied id is present. Four branches had no guard for the missing case and fell through to "every active source on the instance." Only Zendesk was guarded — it had been fixed once, in isolation, and the identical shape was left standing everywhere else. - **The internal events push endpoint shipped with a public default secret.** It defaulted to the literal `"dev-secret"` in an open-source repository, compared it non-constant-time, and took the target `org_id` straight from the request body. This one required no integration to be configured at all. **What this means in practice.** On a default install, an unauthenticated caller who knew a webhook URL could cause writes attributed to organizations they had no relationship with. We want to be precise rather than dramatic about what those writes were: a separate payload-shape bug meant Intercom deliveries never produced feedback items, so what actually landed was a source-event log row carrying attacker-supplied JSON under another organization's id. That bug was an accident, not a safeguard — it was the only thing standing between this and arbitrary feedback injection, which is why the tenancy fix landed first and why the shape fix is deliberately still pending. **Fixed:** Intercom and Linear now fail closed; the tenancy guard is applied to all four unguarded branches; the internal events push secret has no default and is compared in constant time. A new test enumerates every signature verifier in the codebase and asserts each fails closed, so this cannot be fixed one verifier at a time again. **Grace period for Slack and inbound email.** Both are in live use, so flipping them closed immediately would have silently stopped real feedback for anyone running without a secret. They still accept unsigned deliveries for now, but log a `SECURITY-SHADOW` warning on every request, warn at startup when an active integration has no secret, and are flagged in Settings → Integrations. A future release rejects them. The test allowlisting them fails the moment that happens, so the grace period cannot quietly become permanent. **Correction (2026-08-17):** the grace period has ended — the flip shipped. Slack and inbound-email webhooks now fail closed: an unset signing secret rejects every delivery with 401. Set `SLACK_SIGNING_SECRET` / `RESEND_INBOUND_WEBHOOK_SECRET`, or those sources stop delivering. See the Unreleased behavior-change entry above. ### Fixed — Intercom ingestion never produced a feedback item, in any release If you connected Intercom and saw events arriving but no feedback appearing, that was this bug, not your configuration. Deliveries were received and authenticated correctly, but the webhook route handed the worker's Intercom adapter only the inner `data` object while the adapter reads `topic` and `data.item` off the **full** envelope. It therefore saw an empty topic and an empty item on every delivery, extracted empty text, and stopped before creating anything. The route now passes the whole envelope. **Why it survived two green test suites.** Both halves were tested and both passed — the adapter against the full envelope, the route against the stripped one — and they disagreed with each other. Nothing tested the *seam*, because the two halves live in different services that cannot import each other. The fix therefore ships with a golden envelope fixture committed once and read by both suites: the backend asserts "this is what I send", the worker asserts "given this, I produce a feedback item". Either side drifting now breaks its own assertion. ### Security — `JWT_SECRET` is now required, and two webhook weaknesses are closed **If you never set `JWT_SECRET`, set one before upgrading — the app will not start without it, and that is deliberate.** `JWT_SECRET` fell back to a value published in this repository, and `.env.example` shipped the variable commented out, so unset was the default state of an install following the docs. Every authentication token on such an install could be forged by anyone who read the source; the same key signs OIDC and Salesforce OAuth state. Generate one with `openssl rand -hex 32`. Existing sessions are invalidated when you set it — tokens signed with the old default should never have been trusted. Setting `JWT_SECRET` to that former default is rejected explicitly. **Zendesk webhook replay.** The signature timestamp was included in the HMAC but never checked for freshness, so a captured delivery could be resent indefinitely and would verify every time. Because status reconciliation runs before de-duplication, a replayed "ticket closed" could undo manual triage repeatedly. Deliveries older or newer than 300 seconds are now rejected, matching the guard Slack's verifier already had. **Credentials in the event log.** The generic inbound webhook stored all request headers verbatim, including the source's own `X-Webhook-Secret` — the credential that authenticates the endpoint — so read access to that table was enough to forge deliveries. Credential headers are now stripped before anything is persisted. ### Fixed — Editing an automation rule's categories did nothing The automation detail page wrote its category-match settings under key names the backend does not read, so editing an existing rule appeared to save and changed nothing about what the rule matched. Creating a rule was unaffected. Rules saved while this was broken still show their values in the editor, and saving them again now persists correctly. ### Added — Intercom now connects with a pasted token and pulls on its own Intercom was the last integration that still required registering an OAuth app, which is awkward on a self-host and is exactly why every other integration — Zendesk, Jira, Asana, HubSpot — takes a pasted credential instead. **Connect with an access token.** Create a private app in your Intercom Developer Hub, copy its Access Token from Configure → Authentication, and paste it into **Settings → Integrations → Intercom**. Rereflect validates it, resolves your workspace and provisions the feedback source. This is Intercom's own recommended path for accessing your own workspace. Existing OAuth connections keep working; an organization uses one path or the other. **Conversations pull every 15 minutes.** Previously Intercom had no pull at all — if the webhook was not wired, nothing ever arrived. This is what the original request ("so feedback flows in automatically") actually asked for. The pull and the webhook share one de-duplication path, so a conversation becomes one feedback item however it reaches you. **Webhook signatures are now verified per workspace.** The 1.0.0 notes recorded this as impossible: *"a valid signature cannot identify a tenant here"*, because the signing key was a single global environment variable. Supplying your app's Client Secret at connect time changes that — deliveries are verified against your workspace specifically. It is optional; without it the pull still works, and webhook deliveries are rejected rather than trusted. **Intercom feedback now reaches Customer 360.** Items carry the customer's email, so they feed health scores and churn signals like every other source. A reply written by one of your own admins will not overwrite the customer on the item. *Honest limits:* up to 15 minutes of latency without a webhook; the pull ingests the first message of a conversation, with replies and ratings arriving via webhook only; a large backlog drains over several runs. No claim is made about analysis quality — this changes whether feedback arrives and whether it links to a customer, nothing else. **Correction (2026-08-15):** the honest-limits line above said replies and ratings arrive "via webhook only" — and in practice that path was inert, so neither reached analysis in any install. The pull now ingests both directly (conversation-parts enrichment; see the Added entry above). The webhook remains optional, for latency. ### Fixed — A weekly cleanup job had never run The beat schedule referenced `tasks.churn_playbooks.purge_old_executions`, missing the `src.` prefix every other entry carries, so it resolved to nothing and the 90-day purge of playbook execution records never ran. Found by a new test that resolves every scheduled task name to a real function — the same "wired at one end, dead at the other" shape as the Intercom envelope bug above, caught by a guard rather than by a user. Fixing it activates a delete, so the timing matters: churn playbooks shipped 2026-07-19, so no execution is 90 days old yet and the first run removes nothing. Left unfixed for another three months, the first successful run would have purged a real backlog with no warning. **Correction (2026-08-14):** that fix repaired only the beat side of the mismatch. The task's own `@shared_task name=` still lacked the `src.` prefix, so dispatch still raised `NotRegistered` — the purge had *still* never run. The task-name side landed on `feat/per-org-churn-model` with a name-consistency test; see "Five Celery beat jobs had never run" above. ### Fixed — We were telling people shipped integrations didn't exist A 1.0.0 user asked us to build Intercom and Zendesk ingestion "so feedback flows in automatically instead of pasting tickets manually." Both already shipped. They asked because our own landing-page FAQ told them Zendesk was **planned** — four months after it shipped — and because the README described the product as ingesting only "CSV, email, webhooks and Slack," omitting Intercom, Zendesk, Jira, Linear and Asana. Corrected everywhere: the FAQ, both README source lines, and the `SELF_HOSTING.md` outbound-call table (which claims to be the exhaustive list of every network call a Rereflect instance makes, and was missing Intercom, Linear and Discord — a gap that undercuts the zero-telemetry claim it exists to support). The landing page also advertised Intercom **"Two-Way Sync"** — adding notes back to conversations and closing resolved tickets. That never worked. The code exists but nothing in the app has ever called it, so the claim is removed rather than softened. **Correction (2026-08-15):** that was true when written. The write-back now ships — opt-in, off by default, note + close on resolve — and `intercom_service.py` is deleted; see the Added entry above. "Two-Way Sync" copy may return only in this shipped form. ### Added — Intercom self-hosting guide `docs/SELF_HOSTING.md` gained a "Connecting Intercom" section. Intercom is the one integration that requires OAuth rather than a pasted token, which means the operator must register an Intercom app and set `INTERCOM_CLIENT_ID`, `INTERCOM_CLIENT_SECRET` and `INTERCOM_REDIRECT_URI` before anything works — and none of that was documented anywhere, so clicking "Connect to Intercom" on a fresh install just failed with no way to find out why. The three variables are now in `.env.example` and `.env.prod.example` too. **Known limitations, stated plainly in the guide:** Intercom is **webhook-only** — unlike Zendesk there is no polling fallback, so if you don't subscribe the webhook, nothing arrives at all. `INTERCOM_REDIRECT_URI` defaults to `localhost:8000` and must be overridden on any real deployment. The integration must be connected *before* you create the feedback source. Intercom feedback is not linked to a customer profile automatically — the contact email lands in `source_metadata`, not on the item itself, so Customer 360 enrichment does not happen the way it does for Zendesk. If you want support tickets flowing in with the least setup, Zendesk remains the better path: a pasted API token, and polling that works without exposing any public URL. **Correction (2026-08-15):** "webhook-only — no polling fallback" was true when the guide was written and stale since the token-paste pull shipped on 2026-08-01: the pull ingests conversations every 15 minutes with no webhook required, and now includes replies and ratings. See the pull entries above. ### Added — Discord alerts Rereflect can now post alerts to Discord. Settings → Integrations → Discord, paste a webhook URL from your server, done — no OAuth app to create. Urgent feedback, sentiment spikes, churn risk, volume spikes, and customer health drop/recovery alerts all reach Discord. This closes the last piece of a 1.0.0 user request for "Slack **or Discord**" pings. Pointing the existing custom-webhook feature at a Discord URL never worked: it saves, because the validator accepts any `https://` URL, but Discord requires a body with `content` or `embeds` and we posted our own envelope, so Discord returned 400 and only the delivery log showed it. **Known limitation:** Discord has no channel preference of its own yet, so it rides on the existing **Slack** toggle in Settings → Notifications. If you enable Discord but switch that toggle off, you get nothing; if you have both integrations, both receive every alert. Also not covered: automation-rule notifications, custom message templates (they're authored in Slack `mrkdwn`, which renders as literal asterisks on Discord), the per-integration alert log, and `429` retry. See `docs/SELF_HOSTING.md`. ### Added — Batch sentiment threshold automation trigger A new automation trigger, `batch_sentiment_threshold`, fires when the share (or absolute count) of a given sentiment across your **whole** incoming feedback stream crosses a threshold over a trailing window. Every existing trigger asks about one customer. The closest one, `sentiment_pattern`, fires when a single customer sends N negative feedbacks within D days — so a spike of 30 angry feedbacks from 30 *different* customers triggered nothing at all. That aggregate case is what this covers, and it was a direct request from a 1.0.0 user who wanted to be pinged for triage. Configurable: `sentiment`, `window_hours` (1–168), `mode` (`percentage` or `count`), `threshold`, and `min_total` — a sample floor, because two negative items out of three is 67% and a percentage threshold without a floor is a false-alarm generator. The shipped "Batch Sentiment Alert" template starts in **shadow mode**. The defaults (50% negative, 24h, floor of 5) are a reasoned starting point, **not a measured calibration** — how often they fire depends entirely on your volume. Review the shadow execution log, tune, then arm it. See `docs/SELF_HOSTING.md`. Delivery reuses the existing notification channels, including the Slack channel fixed earlier in this release. ### Fixed — Churn `resolution_time` factor was permanently dead The `resolution_time` churn factor (10 of the 100 points in every customer's churn score) has never actually run. `_compute_heuristic_churn_risk` imported `FeedbackWorkflowEvent` from `src.models.feedback_workflow_event`, a submodule that has never existed in worker-service — the class is exported from `src.models` directly. The `ModuleNotFoundError` was swallowed by a bare `except Exception: pass`, so `resolution_score_pts` was always `0` and every customer's factor breakdown showed `"Insufficient resolution data"` as if that were a real finding, since churn explainability shipped (M1.4). This is the third instance of the same bug class in this repo — worker-service importing a backend-api-only path inside a bare `try/except` (see GitHub #3, `f5d43234`; and `52c763dd`). **Effect on your data:** on each customer's next analysis, churn scores can rise by up to 10 points, and some customers will move up a risk band, purely because this factor starts contributing for the first time — not because anything about those customers changed. This is a correction, not new risk. Historical `churn_risk_factors` rows are **not** backfilled by this fix (fix-forward, no bulk rewrite); they keep their stale `"Insufficient resolution data"` label until each item is naturally re-analyzed. If you want existing rows recomputed immediately, `services/backend-api/scripts/backfill_churn_factors.py` exists for exactly that and can be run as an opt-in operator command — it is not run automatically by this fix. While fixing this, the other four customer-level factors (`sentiment_trend`, `feedback_frequency`, `pain_severity`, `feature_density`) — which shared the same untested gap, just not the same bug — got DB-backed tests proving each one does score correctly. The `except Exception: pass` blocks around all five customer-level factors now log a warning naming the failed factor instead of swallowing it silently, so this class of bug can't hide again the same way. Per-factor isolation is unchanged: one failing factor still can't void the other eight. ### Security — Slack and Intercom OAuth tokens are now encrypted at rest `Integration.oauth_access_token` is Fernet-encrypted before storage for Slack and Intercom, exactly like every other integration credential. Both OAuth callbacks encrypt on write (saving without `LLM_ENCRYPTION_KEY` returns 422, never 500), all read sites decrypt on read, and the worker's alert/sync paths decrypt locally rather than importing the backend. **If you are upgrading an existing install, set `LLM_ENCRYPTION_KEY` before running migrations.** The upgrade migration encrypts existing plaintext rows in place and **fails closed** when the key is unset — `alembic upgrade head` aborts with a `RuntimeError` containing a generate-a-key command rather than leaving your OAuth tokens in plaintext. Operators who never set the key (integration saves already required it) should generate one with that command, add it to `.env`, and re-run `alembic upgrade head`. See *Integration credential encryption & the upgrade requirement* in `docs/SELF_HOSTING.md`. ### Security — Linear webhook secrets are now encrypted at rest `LinearIntegration.webhook_secret` — the HMAC key that authenticates inbound Linear webhook deliveries — is Fernet-encrypted before storage, closing the last plaintext credential in the system (every other integration already encrypted at rest). The OAuth callback encrypts on write (saving without `LLM_ENCRYPTION_KEY` returns 422, never 500), and the webhook receiver decrypts exactly once at signature verification, logging a diagnostic warning when a stored secret cannot be decrypted instead of accepting the delivery. **Same upgrade requirement as the OAuth tokens above: set `LLM_ENCRYPTION_KEY` before running migrations.** The upgrade migration encrypts existing plaintext rows in place and fails closed when the key is unset. See *Integration credential encryption & the upgrade requirement* in `docs/SELF_HOSTING.md`. ## v1.0.0 — 2026-07-26 **The 1.0 release.** Feature work for this milestone was already complete — every PRD in the roadmap shipped. What 1.0 adds is the engineering foundation a project should have before it asks anyone to depend on it: - **Continuous integration.** The repo had no workflows at all. CI now runs the backend suite (against a real Postgres, migrated from scratch, with a single-Alembic-head assertion), the worker suite, and the frontend's lint + tests — on every pull request. - **A green test suite**, verified in CI: **backend 4501, worker 1345, frontend 1496**. Around sixty tests across the three suites had rotted against the code they cover, and `test_report_ws.py` was excluded wholesale. All of it is fixed, or explicitly and visibly skipped with the reason recorded. Several of the failures were hiding real bugs: deleting a Copilot folder deleted it as a conversation, the automation trigger editor called a hook conditionally, the AI provider card crashed its own render on a missing field, and the sentiment-anomaly suite only passed at certain times of day. - **The last of the SaaS scaffolding removed.** "Promo Codes" still appeared in the admin sidebar and rendered a page whose backend route was never mounted — a guaranteed-broken screen. That surface, the Stripe service stub behind it, and the dead `promo_code_used` column are gone. - **Honest docs and versions.** Every service now reports 1.0.0, shipped PRDs moved out of the repo root into `docs/archive/prd/`, and the stale Stripe/plan-gating/npm references left over from the hosted era are corrected. ### Added — Durable classifier rollback + version history ### Added — Durable classifier rollback + version history The per-org self-improving classifiers (M5.2) already let you roll back an auto-promoted model, but that rollback didn't stick: the classifier refits **weekly (Mondays 06:30 UTC)** and would silently re-promote a fresh challenger over your manual choice within a week. Rollback is now **durable**. Rolling back pauses auto-promotion for that classifier type (a per-type hold, shown as **"Auto-promotion paused"** on the Accuracy tab); the weekly refit still trains and records the incumbent-vs-challenger delta but no longer changes the live model until you click **Resume auto-promotion**. The Accuracy tab now also shows a **version-history list** (fit date, macro-F1, labels, which is live) and lets an admin/owner **roll back to any prior version**, not just the most recent. A *disable-only* rollback (no prior version) does not pause auto-promotion, so a first model can still be trained. Rollback, pause, and resume are recorded in the audit log. Holds are per classifier type — pausing sentiment never affects category or urgency. New endpoints (`/api/v1/settings/ai`): `GET /classifier/versions`, `POST /classifier/resume`, and `POST /classifier/rollback` now accepts an optional `to_version_id`. Completes the M4.2 "model versioning / rollback if accuracy drops" item. ### Added — Local embedding model A second in-process embedding option for the AI Copilot's template matching, alongside the existing cloud/Ollama providers: an opt-in, CPU-only `sentence-transformers` provider (`BAAI/bge-small-en-v1.5`, 384-dim) that runs directly inside the backend — no separate Ollama/endpoint process required. Set per organization (Settings → AI, embedding provider `local`). - **In-process CPU provider.** No GPU, no separate model server; the `sentence-transformers` dependency already ships alongside the transformer sentiment model (M5.1), so this doesn't add a second heavy package family. - **Air-gap pre-bake.** `BAKE_EMBEDDING_MODEL=true docker compose -f docker-compose.prod.yml build backend` bakes the ≈130 MB of weights into the image at build time (backend only — the worker has no embedding consumers); combined with `HF_HUB_OFFLINE=1` / `TRANSFORMERS_OFFLINE=1` for a container with no runtime network access at all. Off by default — a default build makes no network call and bakes no weights. - **Model-keyed template matching.** Switching an org's embedding model/provider re-embeds the built-in query templates automatically; vectors from different providers/models are never mixed, so a model switch can't silently corrupt matching. - **Committed, honest retrieval eval + accuracy card.** An offline harness scores candidate vs. baseline on recall@1, MRR, and false-match rate on held-out negatives; the result is committed and surfaced on the same **Settings → AI → Accuracy** tab as the sentiment/classifier cards. #### Known limits, stated plainly - Candidate `bge-small` beats the `nomic-embed-text` baseline by **+0.089 recall@1** (0.178 vs 0.089) on the committed eval set (n=69: 45 held-out paraphrases / 24 negatives), with no false-match regression — a real, measured improvement. - **Absolute recall@1 is still low** at the strict 0.85 match threshold the Copilot uses: most held-out paraphrases don't clear it and fall through to the LLM path rather than a fast template match. That's the safe failure mode (a slower, still-correct answer), not a broken one. MRR≈0.75 shows the right template usually ranks near the top even when it doesn't clear the threshold. This is not presented as a solved-accuracy claim. - The Ollama embedding-model recommendation is now eval-backed too: `nomic-embed-text` remains the default (neither Ollama alternative clears the recall@1 bar), and `mxbai-embed-large` is documented as an optional modest upgrade (better ranking, fewer false matches, higher RAM/latency) — see [SELF_HOSTING.md](docs/SELF_HOSTING.md#embedding-model-choice-on-the-ollama-path-eval-backed). ### Added — Usage-decline churn-label suggestions A second, CRM-free source of churn-label *suggestions* for the review queue introduced by `crm-churn-labels`. Aimed at self-hosters with product usage telemetry but no HubSpot/Salesforce connection — previously their only way to record a churned customer was typing it into "Mark as churned" one at a time. A customer qualifies only when their usage trend has held in `sharp_decline` for a configurable number of **consecutive calendar days** (`sustain_days`, default 7, range 1–90) — the milder `declining` state never qualifies, and it's a level-based streak read off the daily usage-trend snapshot, not a one-shot edge trigger. Qualifying customers become a pending suggestion in the **existing** churn-suggestions queue (`provider=usage_decline`), alongside CRM suggestions. **A suggestion is never a label** — a human still has to confirm it from the review queue before it becomes a real, trainable churn event. Nothing is auto-applied. - **Off by default, with a shadow mode** (Settings → AI → Usage-Decline Churn Labels). Shadow evaluates and logs what it would suggest without writing anything — recommended first, since a sustained usage decline is weaker evidence than a lost renewal and more prone to false positives (seasonal dips, one customer on leave). - **Population-level outage guard.** If more than 25% of an org's trend-eligible customers (once there are at least 20 of them) qualify in the same run, the entire run is suppressed and logged loudly instead of writing suggestions — the far more likely explanation at that scale is a broken usage pipeline, not simultaneous, independent churn. - **Idempotent per decline episode.** Re-detecting the same ongoing streak never creates a duplicate suggestion; a genuinely new episode after a recovery can suggest again. - **Runs after the daily usage-snapshot commit**, in its own transaction, isolated per customer so a single bad row can't fail the parent job. - **Settings card shows a live precision read-out** — confirmed / rejected / pending counts for this source, since a self-hosted product has no way to report an aggregate number back to us. #### Known limits, stated plainly - **~12–16 day warm-up, plus the `sustain_days` window on top.** With the default of 7 days, routinely three weeks or more between an actual drop in activity and a suggestion appearing. - **The ≥5 active-day baseline floor permanently excludes light-usage customers.** Inherited from the underlying trend classifier — a customer who was never very active can never produce a suggestion, which is also plausibly your most churn-prone segment. - **Only recently-declining customers are visible.** A customer who went quiet or churned before the detector's lookback window has no baseline to compare against and will never surface here. - **Requires usage events to already be flowing.** Inert if you haven't wired up the usage webhook. - **This does not change churn prediction.** It changes label *supply*, not the churn model — whether more labels improve any model is a separate, open question, and no particular label count is promised. ### Added — Usage-trend timeline event and automation trigger The usage-decline signal now reaches the action loop. Previously a declining customer only nudged the usage health component — and since the usage weight defaults to `0`, for most operators nothing observable happened at all. **New `usage_trend` automation trigger.** Fires when a customer enters a worse trend state, and can run a churn playbook via the existing `run_playbook` action (composing with the churn-triggered playbooks shipped earlier). - **Edge-triggered.** It fires on the *transition* into `declining` / `sharp_decline`, not for as long as the customer sits there. A customer who stays `declining` fires once, on the day they get there. - **The first classification never fires.** A customer moving out of `insufficient_history` is a baseline observation, not a change — the same non-destructive first-observation rule the Jira/Zendesk/Asana status-syncs use. Without it, every customer whose history matured on the same day would fire at once. - **Recoveries don't fire.** `declining → stable` is visible on the timeline but triggers nothing, for now. - **Defaults to shadow mode** — uniquely among trigger types. Because the trend needs ~14 days of snapshot history before it can classify anything, a rule armed on day one would sit silent and then execute unobserved. Shadow lets you watch it first, then arm it. **New `usage_trend_change` timeline event** on the Customer 360 profile, so an automated outreach has a visible cause. Unlike the trigger, the timeline records *every* state change in both directions, including recoveries. **Pre-built "Usage Decline Outreach" template** in Settings → Automations, so the trigger is findable without knowing it exists. It ships with a notification action rather than a playbook action — playbook IDs are per-install, so a static template can't reference one; add your own. **Readiness count.** The AI readiness surface now reports how many of your customers hold a real (non-`insufficient_history`) trend state — i.e. whether this trigger can fire for you at all. #### Known limits, stated plainly - **~14-day warm-up.** Snapshot history only began accumulating recently, so expect little to happen at first. - **Light-usage customers are permanently excluded.** Trend classification requires a baseline of at least 5 active days in the prior 14-day window. A customer below that never receives a trend state, so this trigger cannot fire for your quietest accounts. Inherited from the trend detector itself; not a bug, and not something this release changes. - **Up to ~24h latency.** The trigger rides the daily 04:00 UTC recompute. There is no real-time path, and a 14-day-window trend wouldn't benefit from one. - **This does not change churn prediction.** `churn_probability`, its confidence interval, and the calibration model are provably untouched — enforced by a test that drives a real trend transition and asserts every churn field is byte-identical. ### Fixed — Shadow-mode automation runs no longer look like failures Automation executions in shadow mode were logged by the backend with status `shadow`, but the frontend's status type didn't include it, so they rendered as a red **failed** badge with an empty actions column — indistinguishable from a real failure. Shadow runs now render as their own state. Affects all shadow-mode rules, not just the new trigger. ### Added — Product-usage trend as a churn signal Rereflect now detects when a customer's product engagement is **declining**, not just whether it's currently high or low — the case where a customer is quietly disengaging while still nominally active, which the health score previously couldn't see. - Each customer carries a **usage trend** — Stable, Declining, or Sharp Decline — derived daily by comparing their active-days over the last two weeks against their *own* activity about two weeks earlier. It shows on the **Usage Activity** card on the customer profile, with the signed change. - A declining trend applies a **bounded penalty to the usage component of the health score** (and only that component). It never touches churn probability or its calibration — those remain driven by feedback signals, so existing churn models are unaffected. - **Warm-up is honest.** The trend needs about two weeks of daily history before it can say anything; until then a customer shows **"Warming up"** rather than a fabricated "stable". A fresh install therefore shows warm-up for its first ~2 weeks — that's expected, not a fault. - History is stored in a new bounded, self-pruning table (180-day retention), so it can't grow without limit. This is a heuristic decline signal on your own data, stated as such — no accuracy-lift claim, and nothing is shared across tenants. A company-wide holiday can read as a decline; per-org tuning and seasonality handling are future work. ### Fixed — Health-score weights: usage is now editable, and saving no longer wipes usage/CRM The **Settings → AI → Health Score Weights** editor showed four weights but the health score has six components (the usage and CRM weights were added with the product-usage and CRM-enrichment features). Two consequences, both now fixed: - **You can now set the Usage Activity weight from the UI.** Previously it could only be changed through the API, and the in-app instructions pointed at a page that had no weight editor. - **Saving weights no longer silently resets your usage and CRM weights to 0.** Because the save only sent four of the six weights, any usage or CRM weight you had configured was wiped on the next save — with a success message and no warning. The editor now sends all six, and the "must sum to 100" total counts all six. If a weights load fails, saving is now blocked rather than proceeding from defaults — so a transient error can't overwrite a configured weight with 0. ### Fixed — Product-usage metrics now track elapsed time **If you have opted into usage weighting, some customer health scores will go down after this upgrade. That is a correction, not a regression** — those scores were overstated. The rolling-window fields on a customer's usage rollup (`active_days_7d/30d`, `login_count_7d/30d`) were only ever recomputed when a new usage event arrived. For a customer whose product usage slowed or stopped, they stayed frozen at their last-event values indefinitely — only the recency signal decayed. Two consequences: - **Health scores were inflated** for quiet customers, because the frequency part of the usage score (30% of it) kept reporting activity that had long since stopped. - **The `silent_churner` segment could never fire.** It requires fewer than 5 active days in the last 30, and that number never fell. The segment built specifically to surface silent customers was unreachable for exactly those customers; they showed up as `dormant` instead. The daily 04:00 UTC recompute now re-derives these windows against the current time, so a customer who goes quiet — or merely slows down — is reflected in their usage score, health score, and segment. **Orgs that have not opted into usage weighting are unaffected.** The usage weight defaults to 0, and health scores in that case are byte-identical before and after; this is locked by a characterization test. > **If you have opted in, read this before upgrading.** The correction can move a customer's > `risk_level` (in our test fixture, `moderate` → `at_risk`), not just their numeric score. A > risk-level downgrade is on its own sufficient to dispatch a health-drop alert, and > `health_score_threshold` / `churn_risk_level_change` automation rules key off the same > transitions. So the **first daily recompute after upgrading may produce a burst of alerts and > automation runs** for customers whose scores were previously inflated — correct outcomes, all > at once. If you run automations against health or risk level, consider pausing them for the > first run after upgrade. Also adds an `active_days_14d` window field (nullable, populated on the first daily run after upgrade; no backfill and no migration downtime). ### Added — Single sign-on (OIDC) Self-hosted deployments can now wire in their own identity provider for login. It sits **alongside** the existing email/password and Google sign-in — neither of those changes — and, like everything in the open-source edition, it is fully unlocked with no tier or seat gate. - **OIDC authorization-code login** (PKCE, signed state + nonce) against any provider that issues **RS256**-signed ID tokens — Okta, Azure AD, Google Workspace, Keycloak, and other conformant IdPs. - **Configured in-app** at **Settings → SSO** (`/settings/sso`, admin/owner only): issuer URL, client id, client secret (stored Fernet-encrypted, never returned), an email-domain allowlist, and an enable toggle. One enabled configuration per deployment. - **Just-in-time provisioning**: a first-time SSO user is created as a `member` in the configured organization; an existing password/Google account with the same **verified** email is linked rather than duplicated. `email_verified` is required. - **Deny-by-default access**: the email-domain allowlist is deny-all when empty — you must name at least one domain, so a misconfigured multi-tenant issuer cannot mass-provision accounts. - **Server-side hardening**: the operator-supplied issuer and every discovered endpoint (discovery, JWKS, authorize, token) are checked for HTTPS, private-IP/SSRF, and issuer-host containment before any request; the client secret is never logged or sent cross-host. - Requires `LLM_ENCRYPTION_KEY` (the same Fernet key that protects other integration secrets) — now documented in `.env.example`. A dev **Keycloak** service is available via `docker compose --profile dev-idp up keycloak` for local testing. See the **Single Sign-On (OIDC)** section of `docs/SELF_HOSTING.md`. - **Known limitation**: only RS256-signed ID tokens are accepted today (ES256 and other algorithms are not); SAML is supported separately — see the SAML entry below. ### Added — Single sign-on (SAML 2.0) Self-hosted deployments can also wire in a **SAML 2.0** identity provider — a slice-1, SP-initiated implementation covering the common enterprise-login case. It sits alongside password, Google, and OIDC login (none of those change), and, like everything in the open-source edition, is fully unlocked with no tier or seat gate. - **SP-initiated SAML login** against a single operator-configured IdP: the IdP's assertion **must be signed** (unsigned or response-only-signed assertions are rejected). Identity is read only from the SAML library's signature-validated getters — never the raw XML — closing the XML Signature Wrapping (XSW) door. - Strict validation of `Audience` (against the SP entity ID), `Recipient`/`Destination` (against the ACS URL), `NotBefore`/`NotOnOrAfter` (±60 second clock-skew tolerance on the assertion's `Conditions` window; the `SubjectConfirmationData` bearer window gets no added tolerance), and `InResponseTo` — plus one-time replay/unsolicited-response rejection via a server-side pending-request store. - The IdP SSO URL is SSRF-gated (HTTPS + private-IP checks) both at config-save time and again at login time. - **Configured in-app** at **Settings → SSO** (`/settings/sso`, admin/owner only, same page as OIDC): IdP Entity ID, IdP SSO URL, IdP X.509 signing certificate (PEM; the API returns a SHA-256 fingerprint, never the raw PEM back), an optional email-attribute override, an email-domain allowlist, a button label, and an enable toggle. - **Just-in-time provisioning**: a first-time SAML user is created as a `member` in the configured organization; an existing password/Google/OIDC account with the same email (matched case-insensitively) is linked rather than duplicated. SAML has no `email_verified` claim — a validly **signed** assertion's email is trusted outright. - **Deny-by-default access**: the email-domain allowlist is deny-all when empty, same as OIDC. - **One SSO protocol per deployment**: enabling SAML while an OIDC config is enabled (or vice versa) is rejected — at most one of {OIDC, SAML} may be enabled at a time. - No new secret: the pasted X.509 certificate is public material, stored as plain PEM (not Fernet-encrypted), so `LLM_ENCRYPTION_KEY` is not a SAML prerequisite. The dev **Keycloak** service (`docker compose --profile dev-idp up keycloak`) also speaks SAML for local testing. See the **Single Sign-On (SAML 2.0)** section of `docs/SELF_HOSTING.md`. - **Known limitations (slice 1)**: SP-initiated only — no IdP-initiated login; no Single Logout (SLO); no SCIM/directory provisioning; assertions are signed but not encrypted; single IdP and single signing certificate per deployment (see the docs for the cert-rotation procedure and the owner-login lockout fallback). ### Fixed — telemetry: Sentry is now opt-in and off by default Earlier builds initialized Sentry unconditionally with a **hardcoded DSN pointing at the maintainer's Sentry project**, and with `send_default_pii=True` — in the backend, the Celery worker, and the Next.js server/edge/browser runtimes. A self-hosted install therefore sent crash reports (and, on the browser side, session replays) off-box with no disclosure and no way to turn it off short of editing source. That contradicted the project's core claim that your data never leaves your box. - **Sentry now initializes only when you set `SENTRY_DSN`** (and `NEXT_PUBLIC_SENTRY_DSN` for browser-side reporting). Unset — the default — means the SDK is never initialized and the instance makes no outbound calls of its own. - **`send_default_pii` is now `False`** everywhere, so no user emails, usernames or IPs are attached to events even when you do enable it. - **No DSN is hardcoded anywhere.** If you enable Sentry, it reports to *your* project. - Removed the Sentry wizard's leftover `/sentry-example-page` and `/api/sentry-example-api` routes, which shipped in the app and existed only to throw test errors. - Telemetry is now documented in `docs/SELF_HOSTING.md` and guarded by regression tests (`services/backend-api/tests/test_sentry.py`), which previously passed vacuously — they asserted against logic copied into the test body instead of the real module. **If you ran an earlier build:** rebuild your images (`docker compose -f docker-compose.prod.yml build`) — a stale frontend image has the old DSN baked in at build time. ### CRM-sourced churn label suggestions (opt-in, human-confirmed) - **Lost renewals from your CRM become churn *suggestions*** — if you've connected HubSpot or Salesforce, Rereflect can read closed-lost deals/opportunities and propose them as churn labels. **Off by default**, and **default-deny**: nothing is suggested until you enable it *and* name your **Renewal pipelines** (HubSpot) / **Renewal opportunity types** (Salesforce); a deal whose pipeline/type is null or unrecognised produces no suggestion, ever. An organization that ignores this feature sees no change anywhere. The harvest runs inside the existing daily CRM sync (03:15 / 03:45 UTC) — no new schedule. - **Nothing is auto-applied.** Suggestions land in a review queue at **Customers → CRM churn suggestions** (admin/owner) and become labels only when a human clicks **Confirm** with a required reason code. Confirming writes a normal `source='manual'` churn event stamped with your user id and linked back to the suggestion; rejecting writes nothing. Bulk confirm/reject is capped at 500 per action. No suggestion is ever confirmed automatically, by design: **a lost renewal is not always a churn**, which is exactly why a person decides. - **Optional on-demand backfill** — a one-time pass over closed-lost history with an operator-chosen window (12/24/36/60 months, default 24, hard max 60). It is never automatic and is not triggered by enabling the feature; it is resumable, idempotent, cancellable, and live-progress-reported. Runs are capped at 2,000 suggestions and truncation is **surfaced, not silent** — the card names the dropped count and how far back the run actually covered. Like everything here, it produces **suggestions, not labels**. - **Fix — AI readiness now counts only *trainable* labels, so your number may go *down*.** `churn_labels_ready` previously gated on the unfiltered `churn_labels_total`; it now gates on the new `churn_labels_trainable`, which excludes `source='auto_suggested'` events the calibrator never trains on. If your organization has such events, the readiness figure on **Settings → AI → Readiness** will drop after this release. **The old number was overcounting; the new, lower one is the honest one.** A separate `pending_suggestions` field reports queued CRM suggestions and is deliberately **not** counted toward readiness — a pending suggestion is not a label. - **No claim is made about churn-prediction quality.** This feature produces labels. Whether more labels change the model is M5.3's open question (see `AI-TRACKING.md`), and churn prediction remains a calibrated heuristic. See [CRM churn-label suggestions](docs/SELF_HOSTING.md#crm-churn-label-suggestions-opt-in) for setup. ### Public API: bulk feedback writes + custom-category CRUD (v3) - **Bulk feedback writes** — `POST /api/public/v1/feedback/bulk` (`write` scope) applies one patch (`workflow_status` / `tags` / `is_urgent` / `correction` — the same fields as the single `PATCH /feedback/{id}`) to up to 500 feedback ids in a single request. The response reports `matched` / `updated` / `skipped` counts plus a per-id `results` array (`updated` / `noop` / `skipped` / `error`, with a `reason` on skip/error); ids outside your organization (or that don't exist) are skipped rather than erroring. Pass `?count_only=true` to preview how many ids would match without changing anything. - **Custom-category (taxonomy) CRUD** — `GET/POST/PATCH/DELETE /api/public/v1/categories` (read scope for `GET`, write scope for the rest) lets you manage your custom pain-point / feature-request / urgency / general categories over the API, mirroring the existing Settings UI. Creating a duplicate `(category_type, name)` returns `409`; an id from another organization (or that doesn't exist) returns `404`; `category_type` can't be changed after creation. Deleting a category that's still referenced by an active automation rule succeeds (204) but carries an `X-Rereflect-Warning` response header naming the rule(s), so you don't silently break an automation. ### Self-improving on-device models (M5.2) - **Per-organization urgency classifier** — a third self-improving head (after sentiment and category), trained on your own urgency corrections. It's a small, CPU-only, offline binary model (`urgent` / `not_urgent`) whose challenger is promoted only when it beats the built-in keyword+sentiment urgency heuristic on your held-out corrections (≥ +0.02 macro-F1), with one-click rollback. Off by default; independent `off` / `shadow` / `auto` toggle in Settings → AI. - You now teach it by simply flipping a feedback item's **urgent flag** — from the new toggle on the feedback detail page or via `PATCH /api/public/v1/feedback/{id}`; each user-driven change is recorded as a training signal (the analyzer's own automatic flagging is not). - **Add-only in `auto`, by design.** Because the urgent flag drives churn alerts and the urgent queue, the model in `auto` mode can only ever **escalate** an item to urgent — it never silently clears a flag the built-in heuristic raised. `shadow` mode logs both directions so you can judge accuracy first. ### Churn-triggered playbook auto-execution - **Automations can now auto-run a churn-prevention playbook** when a customer's churn probability crosses a threshold — a new `churn_probability_threshold` trigger and `run_playbook` action on the existing automation-rule engine (Settings → Automations), alongside the existing health-score, sentiment, churn-risk-level, and feedback-category triggers. - **Three-way rule mode**: `off`, `shadow` (evaluate and log what a rule *would* do, without running anything), and `active`. Use `shadow` to sanity-check a new rule against real customers before letting it take action. - Auto-runs create the same `ChurnPlaybookExecution` record as a manual/batch run (`triggered_by="auto_probability"`) and appear on the customer's timeline as a `playbook_auto_run` event, so you can see exactly why a playbook fired. - **Fires from the existing churn-probability recompute in the worker**, reusing the identical per-(rule, customer) Redis cooldown as the other automation triggers — see the new "Redis is required for automation cooldowns" note in `docs/SELF_HOSTING.md`. - **Activating a rule seeds cooldowns for every currently-matching customer up front**, so turning on a rule against an existing at-risk cohort doesn't fire a stampede of playbooks in one pass. - SMTP-free, and — like everything in the open-source edition — fully unlocked with no plan gate. This closes the deferred "real-time playbook execution on probability threshold cross" item from the Advanced Churn Prediction PRD (`PRD-ADVANCED-CHURN-PREDICTION.md:465`). ## v0.1.0 — 2026-07-13 First tagged release of the self-hosted edition. Headline theme: **close the integration loop** (feedback status now stays in sync with your work-management and support tools) and **on-device, self-improving models** (your data trains small models that run locally and only ship when they're measurably better). ### Integrations — inbound status-sync (close the loop) Rereflect could already create work items and ingest tickets; now it keeps the feedback item's status in sync when the linked item changes on the other side. - **Jira** inbound status-sync — a linked Jira issue moving to Done (or any status) updates the feedback item's workflow status. Poll-first (every 15 min), opt-in per org, off by default. - **Asana** inbound status-sync — completing (or re-opening) a linked Asana task updates the feedback item. Bidirectional; Asana has no intermediate state, so it maps completed vs. not. - **Zendesk** inbound status-sync — a linked ticket's status (new / open / pending / solved / closed) updates the feedback item, via **both** a 15-min poll and an optional real-time webhook. - Common to all three: opt-in per organization (off by default), a non-destructive first-poll baseline (no retroactive bulk rewrites), a manual "Sync now" action, per-organization status mapping you can override, and an audit trail — every automatic change writes a timeline entry tagged with its source. A hand-set status is never overwritten by a sync unless the linked item genuinely changes. - Built on a shared, provider-agnostic reconcile core, so adding the next tool is incremental. ### Self-improving on-device models (M5.2) - **Per-organization sentiment and category classifiers** that train on your own feedback and your own corrections, run CPU-only and fully offline, and **auto-promote a new model only when it beats the current one on held-out data** — with one-click rollback. Off by default; the built-in analyzer stays the baseline until a challenger proves itself. - Honest by design: these are small models, described as such. Churn remains a calibrated heuristic, sentiment defaults to VADER, and nothing claims accuracy it hasn't shown. ### Notes - All of the above is **opt-in and off by default** — upgrading changes no behavior until you turn a feature on. Database migrations are additive. - Fully compatible with bring-your-own-key cloud LLMs and local/offline models (Ollama or any OpenAI-compatible endpoint). See `docs/SELF_HOSTING.md` for operator setup of each integration.