feat(ai): replace the Correlations switch with Reading documents

The operator switches become master, Coach, Daily Briefing, Status
notes and Reading documents. Correlations goes: it gated statistics,
not anything a model writes. Reading documents covers the document
vault, lab scans and medication extraction.

The Coach module's operator availability now follows the Coach
switch, so there is one place to turn the Coach off server-wide. The
migration folds a stored Coach availability of false into the switch,
and gives everyone who hid the Coach the AI analysis opt-out, since
hiding the Coach no longer stops nightly briefing and status notes.

A switch read that fails now fails closed. The insights module is
described as AI analysis in every locale, and the switch guard now
keeps both retired switches out and freezes the callers of the old
operator-only gate.
This commit is contained in:
Marc-André Bombeck committed 2026-09-24 07:36:47 +02:00
1 parent c8a01ac3ed
commit a7888a9011
46 files changed
+758 -422

No files matched your search

+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Testevent wurde gesendet",
"monitoringTestFailed": "Monitoring-Test fehlgeschlagen",
"assistant": {
"title": "Assistenz-Oberflächen",
"description": "Jeder Toggle versteckt eine modellgestützte Oberfläche app-weit, ohne die Provider-Konfiguration zu entfernen.",
"title": "Assistenz",
"description": "Jeder Schalter stoppt die KI-Arbeit, die er abdeckt, und blendet ihren Text aus, Daten und Werte laden weiter.",
"master": {
"title": "Assistent aktiv",
"description": "Hauptschalter für jede modellgestützte Oberfläche. Wenn aus, sind alle Unter-Optionen unten zwangsweise aus."
"description": "Hauptschalter für alle KI-Funktionen. Ist er aus, wird nichts an ein Modell gesendet, und alle Schalter darunter sind aus."
},
"coach": {
"title": "Coach",
"description": "Chat-Drawer, Launch-Button und die mobile FAB auf jeder Insights-Seite."
"description": "Chat, Pflege des Coach-Gedächtnisses und KI-Hinweise. Gespeicherte Gespräche bleiben lesbar und löschbar."
},
"briefing": {
"title": "Tagesbriefing",
"description": "Briefing-Karte, Hero-Erzähltext, Empfehlungen und der Regenerieren-Knopf."
"description": "Das Tagesbriefing, seine Einleitung an anderen Stellen der App und Zeitraum-Zusammenfassungen, die ein Modell schreibt."
},
"insightStatus": {
"title": "Status-Karten",
"description": "Pro-Metrik-Status-Karten auf jeder /insights-Unterseite."
"title": "Statusnotizen",
"description": "Kurze Notizen, die ein Modell pro Messwert und Kennzahl schreibt, Trainingsnotizen und Reaktionszeilen. Der größte Anteil am Token-Verbrauch."
},
"correlations": {
"title": "Korrelationen",
"description": "Erzähl-Kachel zur Korrelation auf der Insights-Übersicht."
"documentAi": {
"title": "Dokumente lesen",
"description": "KI-Lesen gespeicherter Dokumente, Scans von Laborbefunden und das Erkennen von Medikamenten aus Text. Der größte Anteil der Daten, die den Server verlassen."
}
},
"modules": {
"title": "Modulverfügbarkeit",
"description": "Schalte ein Modul für den gesamten Server ab.",
"saveError": "Modulverfügbarkeit konnte nicht aktualisiert werden.",
"detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus — unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten."
"detail": "Ein hier deaktiviertes Modul ist für jedes Konto aus — unabhängig von den persönlichen Einstellungen. Kerndaten (Gewicht, Blutdruck, Puls, Medikamente) sind immer aktiv und lassen sich nicht abschalten.",
"coachFollowsSwitch": "Folgt dem Coach-Schalter unter Assistenz."
},
"invites": {
"title": "Einladungen",
@@ -8846,8 +8847,8 @@
"description": "KI-Chat mit Bezug zu deinen Daten."
},
"insights": {
"label": "Insights",
"description": "KI-Analysen, Briefing und Korrelationen."
"label": "KI-Analyse",
"description": "Briefing, Statusnotizen und Trainingsnotizen, die ein Modell schreibt, nie deine Daten oder Werte."
},
"doctorReport": {
"label": "Arztbericht",
+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Test event sent",
"monitoringTestFailed": "Monitoring test failed",
"assistant": {
"title": "Assistant Surfaces",
"description": "Each toggle hides a model-driven surface app-wide without removing the provider configuration.",
"title": "Assistant",
"description": "Each switch stops the AI work it covers and hides its text, while data and scores keep loading.",
"master": {
"title": "Assistant enabled",
"description": "Master switch for every model-driven surface. When off, every sub-flag below is forced off."
"description": "Master switch for every AI feature. When off, nothing is sent to a model and every switch below is off."
},
"coach": {
"title": "Coach",
"description": "Chat drawer, launcher button, and the mobile FAB on every Insights page."
"description": "Chat, upkeep of the Coach's memory, and AI nudges. Stored conversations stay readable and deletable."
},
"briefing": {
"title": "Daily Briefing",
"description": "Briefing card, hero strip narration, recommendations grid, and the regenerate action."
"description": "The daily briefing, its opening lines elsewhere in the app, and period summaries written by a model."
},
"insightStatus": {
"title": "Status cards",
"description": "Per-metric status cards on every /insights sub-page."
"title": "Status notes",
"description": "Short notes a model writes per reading and metric, workout notes and reaction lines. The largest share of token use."
},
"correlations": {
"title": "Correlations",
"description": "Correlation tile narration on the Insights overview."
"documentAi": {
"title": "Reading documents",
"description": "AI reading of stored documents, lab report scans and medication extraction from text. The largest share of data leaving the server."
}
},
"modules": {
"title": "Module availability",
"description": "Turn a module off for the whole server.",
"saveError": "Could not update module availability.",
"detail": "A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled."
"detail": "A module disabled here is off for every account regardless of personal preference. Core domains (weight, blood pressure, pulse, medications) are always on and cannot be disabled.",
"coachFollowsSwitch": "Follows the Coach switch under Assistant."
},
"invites": {
"title": "Invites",
@@ -8846,8 +8847,8 @@
"description": "AI chat grounded in your own data."
},
"insights": {
"label": "Insights",
"description": "AI analysis, briefing, and correlations."
"label": "AI analysis",
"description": "Briefing, status notes and workout notes written by a model, never your data or scores."
},
"doctorReport": {
"label": "Doctor report",
+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Evento de prueba enviado",
"monitoringTestFailed": "La prueba de monitorización falló",
"assistant": {
"title": "Superficies del asistente",
"description": "Cada conmutador oculta una superficie impulsada por el modelo en toda la aplicación sin retirar la configuración del proveedor.",
"title": "Asistente",
"description": "Cada interruptor detiene el trabajo de IA que cubre y oculta su texto, mientras los datos y puntuaciones siguen cargándose.",
"master": {
"title": "Asistente activado",
"description": "Interruptor maestro para cada superficie impulsada por el modelo. Cuando está desactivado, todas las opciones secundarias se fuerzan a off."
"description": "Interruptor general de todas las funciones de IA. Si está desactivado, no se envía nada a ningún modelo y todos los interruptores de abajo quedan desactivados."
},
"coach": {
"title": "Coach",
"description": "Drawer de chat, botón de lanzamiento y FAB móvil en cada página de Insights."
"description": "Chat, mantenimiento de la memoria del Coach y avisos de IA. Las conversaciones guardadas se pueden seguir leyendo y borrando."
},
"briefing": {
"title": "Briefing diario",
"description": "Tarjeta de briefing, narración del hero strip, cuadrícula de recomendaciones y la acción Regenerar."
"description": "El briefing diario, sus primeras líneas en otras partes de la app y los resúmenes de periodo escritos por un modelo."
},
"insightStatus": {
"title": "Tarjetas de estado",
"description": "Tarjetas de estado por métrica en cada sub-página /insights."
"title": "Notas de estado",
"description": "Notas breves que un modelo escribe por medición y métrica, notas de entrenamiento y líneas de reacción. La mayor parte del consumo de tokens."
},
"correlations": {
"title": "Correlaciones",
"description": "Narración de la tarjeta de correlación en la vista general de Insights."
"documentAi": {
"title": "Lectura de documentos",
"description": "Lectura con IA de documentos guardados, escaneo de informes de laboratorio y extracción de medicamentos a partir de texto. La mayor parte de los datos que salen del servidor."
}
},
"modules": {
"title": "Disponibilidad de módulos",
"description": "Desactiva un módulo para todo el servidor.",
"saveError": "No se pudo actualizar la disponibilidad de módulos.",
"detail": "Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar."
"detail": "Un módulo desactivado aquí queda apagado para todas las cuentas, sin importar la preferencia personal. Los dominios principales (peso, presión arterial, pulso, medicación) están siempre activos y no se pueden desactivar.",
"coachFollowsSwitch": "Sigue el interruptor del Coach en Asistente."
},
"invites": {
"title": "Invitaciones",
@@ -8846,8 +8847,8 @@
"description": "Chat de IA basado en tus propios datos."
},
"insights": {
"label": "Insights",
"description": "Análisis de IA, resumen y correlaciones."
"label": "Análisis con IA",
"description": "Briefing, notas de estado y notas de entrenamiento escritas por un modelo, nunca tus datos ni puntuaciones."
},
"doctorReport": {
"label": "Informe médico",
+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Événement de test envoyé",
"monitoringTestFailed": "Échec du test de surveillance",
"assistant": {
"title": "Surfaces d'assistance",
"description": "Chaque bascule masque une surface pilotée par le modèle dans toute l'application sans retirer la configuration du fournisseur.",
"title": "Assistant",
"description": "Chaque interrupteur arrête le travail d'IA qu'il couvre et masque son texte, tandis que données et scores continuent de se charger.",
"master": {
"title": "Assistant activé",
"description": "Commutateur principal pour chaque surface pilotée par le modèle. Lorsqu'il est désactivé, toutes les options secondaires ci-dessous sont forcées à off."
"description": "Interrupteur principal de toutes les fonctions d'IA. Désactivé, rien n'est envoyé à un modèle et tous les interrupteurs ci-dessous sont désactivés."
},
"coach": {
"title": "Coach",
"description": "Drawer de chat, bouton de lancement, et la FAB mobile sur chaque page Insights."
"description": "Chat, entretien de la mémoire du Coach et rappels d'IA. Les conversations enregistrées restent lisibles et supprimables."
},
"briefing": {
"title": "Briefing quotidien",
"description": "Carte de briefing, narration du hero strip, grille de recommandations, et l'action Régénérer."
"description": "Le briefing quotidien, ses premières lignes ailleurs dans l'application et les synthèses de période écrites par un modèle."
},
"insightStatus": {
"title": "Cartes de statut",
"description": "Cartes de statut par métrique sur chaque sous-page /insights."
"title": "Notes d'état",
"description": "Notes courtes qu'un modèle écrit par mesure et par indicateur, notes d'entraînement et lignes de réaction. La plus grande part de la consommation de jetons."
},
"correlations": {
"title": "Corrélations",
"description": "Narration de la tuile de corrélation sur la vue d'ensemble Insights."
"documentAi": {
"title": "Lecture de documents",
"description": "Lecture par IA des documents enregistrés, scan des comptes rendus de laboratoire et extraction de médicaments à partir de texte. La plus grande part des données qui quittent le serveur."
}
},
"modules": {
"title": "Disponibilité des modules",
"description": "Désactivez un module pour tout le serveur.",
"saveError": "Impossible de mettre à jour la disponibilité des modules.",
"detail": "Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés."
"detail": "Un module désactivé ici est coupé pour chaque compte, quelle que soit la préférence personnelle. Les domaines essentiels (poids, tension, pouls, médicaments) restent toujours actifs et ne peuvent pas être désactivés.",
"coachFollowsSwitch": "Suit l'interrupteur Coach dans Assistant."
},
"invites": {
"title": "Invitations",
@@ -8846,8 +8847,8 @@
"description": "Chat IA fondé sur vos propres données."
},
"insights": {
"label": "Insights",
"description": "Analyses IA, briefing et corrélations."
"label": "Analyse IA",
"description": "Briefing, notes d'état et notes d'entraînement écrites par un modèle, jamais vos données ni vos scores."
},
"doctorReport": {
"label": "Rapport médical",
+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Evento di prova inviato",
"monitoringTestFailed": "Test di monitoraggio non riuscito",
"assistant": {
"title": "Superfici dell'assistente",
"description": "Ogni interruttore nasconde una superficie guidata dal modello in tutta l'app senza rimuovere la configurazione del provider.",
"title": "Assistente",
"description": "Ogni interruttore ferma il lavoro di IA che copre e ne nasconde il testo, mentre dati e punteggi continuano a caricarsi.",
"master": {
"title": "Assistente attivo",
"description": "Interruttore master per ogni superficie guidata dal modello. Quando spento, ogni opzione secondaria qui sotto è forzata su off."
"description": "Interruttore principale per tutte le funzioni di IA. Se è disattivato, nulla viene inviato a un modello e tutti gli interruttori sottostanti sono disattivati."
},
"coach": {
"title": "Coach",
"description": "Drawer chat, pulsante di lancio e FAB mobile su ogni pagina Insights."
"description": "Chat, manutenzione della memoria del Coach e promemoria IA. Le conversazioni salvate restano leggibili ed eliminabili."
},
"briefing": {
"title": "Briefing giornaliero",
"description": "Scheda briefing, narrazione hero strip, griglia raccomandazioni e l'azione Rigenera."
"description": "Il briefing giornaliero, le sue prime righe in altre parti dell'app e i riepiloghi di periodo scritti da un modello."
},
"insightStatus": {
"title": "Schede di stato",
"description": "Schede di stato per metrica su ogni sotto-pagina /insights."
"title": "Note di stato",
"description": "Brevi note che un modello scrive per misurazione e metrica, note sugli allenamenti e righe di reazione. La quota maggiore del consumo di token."
},
"correlations": {
"title": "Correlazioni",
"description": "Narrazione del tile di correlazione sulla panoramica Insights."
"documentAi": {
"title": "Lettura dei documenti",
"description": "Lettura con IA dei documenti salvati, scansione dei referti di laboratorio ed estrazione dei farmaci dal testo. La quota maggiore dei dati che lasciano il server."
}
},
"modules": {
"title": "Disponibilità dei moduli",
"description": "Disattiva un modulo per l'intero server.",
"saveError": "Impossibile aggiornare la disponibilità dei moduli.",
"detail": "Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati."
"detail": "Un modulo disattivato qui è spento per ogni account, indipendentemente dalle preferenze personali. I domini principali (peso, pressione, polso, farmaci) sono sempre attivi e non possono essere disattivati.",
"coachFollowsSwitch": "Segue l'interruttore Coach in Assistente."
},
"invites": {
"title": "Inviti",
@@ -8846,8 +8847,8 @@
"description": "Chat IA basata sui tuoi dati."
},
"insights": {
"label": "Insights",
"description": "Analisi IA, briefing e correlazioni."
"label": "Analisi IA",
"description": "Briefing, note di stato e note sugli allenamenti scritte da un modello, mai i tuoi dati o punteggi."
},
"doctorReport": {
"label": "Referto medico",
+14 -13
View File
@@ -6769,34 +6769,35 @@
"monitoringTestSuccess": "테스트 이벤트를 보냈어요",
"monitoringTestFailed": "모니터링 테스트에 실패했어요",
"assistant": {
"title": "어시스턴트 화면",
"description": "각 토글은 제공자 설정을 지우지 않고 모델 기반 화면만 앱 전체에서 숨겨요.",
"title": "어시스턴트",
"description": "각 스위치는 해당 AI 작업을 멈추고 그 텍스트를 숨기며, 데이터와 점수는 계속 표시됩니다.",
"master": {
"title": "어시스턴트 사용",
"description": "모든 모델 기반 화면의 마스터 스위치예요. 끄면 아래 하위 항목이 전부 강제로 꺼져요."
"description": "모든 AI 기능의 기본 스위치입니다. 끄면 어떤 데이터도 모델로 전송되지 않으며 아래의 모든 스위치가 꺼집니다."
},
"coach": {
"title": "코치",
"description": "모든 인사이트 페이지의 채팅 서랍, 실행 버튼, 모바일 FAB."
"description": "채팅, 코치 메모리 관리, AI 알림. 저장된 대화는 계속 읽고 삭제할 수 있습니다."
},
"briefing": {
"title": "데일리 브리핑",
"description": "브리핑 카드, 히어로 스트립 설명, 추천 그리드, 다시 생성 동작."
"description": "데일리 브리핑, 앱의 다른 곳에 표시되는 브리핑 첫 문장, 모델이 쓴 기간 요약."
},
"insightStatus": {
"title": "상태 카드",
"description": "모든 /insights 하위 페이지의 지표별 상태 카드."
"title": "상태 메모",
"description": "모델이 측정값과 지표마다 쓰는 짧은 메모, 운동 메모, 반응 문장. 토큰 사용량의 대부분을 차지합니다."
},
"correlations": {
"title": "상관관계",
"description": "인사이트 개요의 상관관계 타일 설명."
"documentAi": {
"title": "문서 읽기",
"description": "저장된 문서의 AI 읽기, 검사 결과지 스캔, 텍스트에서 약물 추출. 서버 밖으로 나가는 데이터의 대부분을 차지합니다."
}
},
"modules": {
"title": "모듈 사용 여부",
"description": "서버 전체에서 모듈을 끌 수 있어요.",
"saveError": "모듈 사용 여부를 바꾸지 못했어요.",
"detail": "여기서 끈 모듈은 개인 설정과 관계없이 모든 계정에서 꺼져요. 핵심 영역(체중, 혈압, 맥박, 복약)은 항상 켜져 있고 끌 수 없어요."
"detail": "여기서 끈 모듈은 개인 설정과 관계없이 모든 계정에서 꺼져요. 핵심 영역(체중, 혈압, 맥박, 복약)은 항상 켜져 있고 끌 수 없어요.",
"coachFollowsSwitch": "어시스턴트의 코치 스위치를 따릅니다."
},
"invites": {
"title": "초대",
@@ -8846,8 +8847,8 @@
"description": "내 데이터를 근거로 하는 AI 대화."
},
"insights": {
"label": "인사이트",
"description": "AI 분석, 브리핑, 상관관계."
"label": "AI 분석",
"description": "모델이 쓴 브리핑, 상태 메모, 운동 메모이며 데이터와 점수는 포함되지 않습니다."
},
"doctorReport": {
"label": "진료용 리포트",
+14 -13
View File
@@ -6790,34 +6790,35 @@
"monitoringTestSuccess": "Wysłano zdarzenie testowe",
"monitoringTestFailed": "Test monitorowania nie powiódł się",
"assistant": {
"title": "Powierzchnie asystenta",
"description": "Każdy przełącznik ukrywa powierzchnię opartą na modelu w całej aplikacji bez usuwania konfiguracji dostawcy.",
"title": "Asystent",
"description": "Każdy przełącznik zatrzymuje pracę AI, którą obejmuje, i ukrywa jej tekst, a dane i wyniki nadal się wczytują.",
"master": {
"title": "Asystent włączony",
"description": "Główny wyłącznik dla każdej powierzchni opartej na modelu. Gdy wyłączony, każda opcja podrzędna jest wymuszana na off."
"description": "Główny przełącznik wszystkich funkcji AI. Gdy jest wyłączony, nic nie trafia do modelu, a wszystkie przełączniki poniżej są wyłączone."
},
"coach": {
"title": "Coach",
"description": "Szuflada czatu, przycisk uruchamiający i mobilny FAB na każdej stronie Insights."
"description": "Czat, utrzymanie pamięci Coacha i podpowiedzi AI. Zapisane rozmowy nadal można czytać i usuwać."
},
"briefing": {
"title": "Codzienny briefing",
"description": "Karta briefingu, narracja hero strip, siatka rekomendacji i akcja Regeneruj."
"description": "Codzienny briefing, jego pierwsze zdania w innych miejscach aplikacji i podsumowania okresów pisane przez model."
},
"insightStatus": {
"title": "Karty statusu",
"description": "Karty statusu na metrykę na każdej podstronie /insights."
"title": "Notatki o stanie",
"description": "Krótkie notatki, które model pisze do pomiarów i wskaźników, notatki o treningach i linie reakcji. Największa część zużycia tokenów."
},
"correlations": {
"title": "Korelacje",
"description": "Narracja kafelka korelacji w przeglądzie Insights."
"documentAi": {
"title": "Czytanie dokumentów",
"description": "Czytanie zapisanych dokumentów przez AI, skanowanie wyników badań laboratoryjnych i odczytywanie leków z tekstu. Największa część danych opuszczających serwer."
}
},
"modules": {
"title": "Dostępność modułów",
"description": "Wyłącz moduł dla całego serwera.",
"saveError": "Nie udało się zaktualizować dostępności modułów.",
"detail": "Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć."
"detail": "Moduł wyłączony tutaj jest nieaktywny dla każdego konta, niezależnie od preferencji użytkownika. Domeny podstawowe (waga, ciśnienie, tętno, leki) są zawsze włączone i nie można ich wyłączyć.",
"coachFollowsSwitch": "Podlega przełącznikowi Coach w sekcji Asystent."
},
"invites": {
"title": "Zaproszenia",
@@ -8846,8 +8847,8 @@
"description": "Czat AI oparty na Twoich danych."
},
"insights": {
"label": "Insights",
"description": "Analizy AI, briefing i korelacje."
"label": "Analiza AI",
"description": "Briefing, notatki o stanie i notatki o treningach pisane przez model, nigdy Twoje dane ani wyniki."
},
"doctorReport": {
"label": "Raport dla lekarza",
@@ -0,0 +1,43 @@
-- The assistant switches stop AI work instead of hiding surfaces, and AI stops
-- being able to take data down with it.
--
-- 1. A new "Reading documents" switch covers the document vault's AI reads,
-- lab report scans and medication text extraction, which only the master
-- covered before. Default on, like every other switch.
ALTER TABLE "app_settings"
ADD COLUMN IF NOT EXISTS "assistant_document_ai_enabled" BOOLEAN NOT NULL DEFAULT true;
-- 2. One operator Coach switch instead of two. Where the operator had turned
-- the Coach module off in module availability, the Coach switch takes that
-- answer; the availability key is then removed, since nothing reads it.
UPDATE "app_settings"
SET "assistant_coach_enabled" = false
WHERE jsonb_typeof("module_availability_json") = 'object'
AND "module_availability_json" -> 'coach' = 'false'::jsonb;
UPDATE "app_settings"
SET "module_availability_json" = "module_availability_json" - 'coach'
WHERE jsonb_typeof("module_availability_json") = 'object'
AND "module_availability_json" ? 'coach';
-- 3. "Hide Coach" means the Coach and nothing else from now on. It used to
-- stop the nightly briefing and status notes as well; that promise moves to
-- the AI analysis switch (the `insights` module), so everyone who hid the
-- Coach gets AI analysis switched off and keeps the privacy they chose.
-- `updated_at` moves so a settings form loaded before the upgrade cannot
-- write the old map back over this one.
UPDATE "users"
SET "module_preferences_json" =
CASE
WHEN jsonb_typeof("module_preferences_json") = 'object'
THEN "module_preferences_json" || '{"insights": false}'::jsonb
ELSE '{"insights": false}'::jsonb
END,
"updated_at" = now()
WHERE "disable_coach" = true;
-- 4. The Correlations switch gated statistics, not anything a model writes,
-- so it goes. Destructive by design: the setting is gone, and so is its
-- stored value.
ALTER TABLE "app_settings"
DROP COLUMN IF EXISTS "assistant_correlations_enabled";
+14 -14
View File
@@ -4751,24 +4751,24 @@ model AppSettings {
// 32 chars) plus headroom.
defaultUserTimezone String? @map("default_user_timezone") @db.VarChar(64)
// v1.4.31 — Assistant-surface operator feature flags. The master
// gates every LLM-driven surface app-wide; four sub-flags carve
// the visibility cut per surface (Coach drawer + chat, Daily
// Briefing + advisor, per-metric status cards, correlation
// narration). Defaults preserve the
// v1.4.30 behaviour (every surface visible) so upgrades require
// no admin action. The runtime resolver in
// `src/lib/feature-flags/index.ts` forces every sub-flag false
// when the master is off. Locked per
// `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the same
// flag matrix gates server-routed AND iOS on-device surfaces, so
// operators can opt out of every assistant surface regardless of
// where the model runs.
// The operator's assistant switches. The master stops every AI capability
// (model calls and model-written text alike); the four sub-switches each
// cover a cost or egress profile: the Coach, the daily briefing and period
// narratives, per-reading status notes (status text, workout notes, reaction
// lines) and reading documents (the document vault, lab scans, medication
// extraction). They never gate data: a switch that is off hides and stops AI
// work, and every measurement, score and statistic keeps loading. Resolved
// with every other AI layer in `src/lib/ai/capabilities/`, and published per
// record on `GET /api/auth/me` as `ai`. Defaults keep every capability on so
// an upgrade needs no admin action.
//
// The Coach switch is also the operator's only Coach switch: the Coach key in
// `moduleAvailabilityJson` was folded into it (migration 0343) and is ignored.
assistantEnabled Boolean @default(true) @map("assistant_enabled")
assistantCoachEnabled Boolean @default(true) @map("assistant_coach_enabled")
assistantBriefingEnabled Boolean @default(true) @map("assistant_briefing_enabled")
assistantInsightStatusEnabled Boolean @default(true) @map("assistant_insight_status_enabled")
assistantCorrelationsEnabled Boolean @default(true) @map("assistant_correlations_enabled")
assistantDocumentAiEnabled Boolean @default(true) @map("assistant_document_ai_enabled")
// v1.18.0 — operator-level server-wide module availability. A DISABLED
// allowlist mirroring the per-user `User.modulePreferencesJson`: absent /
@@ -527,8 +527,8 @@ const handler = apiHandler(
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled:
settings.assistantInsightStatusEnabled,
assistantCorrelationsEnabled:
settings.assistantCorrelationsEnabled,
assistantDocumentAiEnabled:
settings.assistantDocumentAiEnabled ?? true,
moduleAvailabilityJson: settings.moduleAvailabilityJson as never,
documentMaxFileBytes: settings.documentMaxFileBytes,
documentQuotaBytes: BigInt(settings.documentQuotaBytes),
@@ -517,7 +517,7 @@ describe("PUT /api/admin/settings — assistant flags", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
};
beforeEach(() => {
@@ -559,18 +559,18 @@ describe("PUT /api/admin/settings — assistant flags", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
});
it("audits the flag it changed", async () => {
await PUT(jsonReq({ assistantCorrelationsEnabled: false }));
await PUT(jsonReq({ assistantDocumentAiEnabled: false }));
expect(auditLog).toHaveBeenCalledWith(
"admin.settings.update",
expect.objectContaining({
details: expect.objectContaining({
assistantCorrelationsEnabled: false,
assistantDocumentAiEnabled: false,
}),
}),
);
@@ -594,7 +594,7 @@ describe("GET /api/admin/settings — assistant flags", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
});
@@ -45,7 +45,7 @@ const ALL_ON = {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
};
beforeEach(() => {
@@ -138,7 +138,7 @@ describe("PUT /api/admin/settings/assistant-flags", () => {
expect(body.data.resolved.coach).toBe(false);
expect(body.data.resolved.briefing).toBe(false);
expect(body.data.resolved.insightStatus).toBe(false);
expect(body.data.resolved.correlations).toBe(false);
expect(body.data.resolved.documentAi).toBe(false);
});
it("rejects an empty body", async () => {
@@ -1,6 +1,12 @@
/**
* `PUT /api/admin/settings/assistant-flags` — operator-side flip
* surface for the five assistant feature flags.
* surface for the five assistant switches: the master, the Coach, the
* daily briefing, status notes, and reading documents.
*
* A switch that is off stops the AI work it covers and hides the text a
* model wrote for it; data, scores and statistics keep loading. The Coach
* switch is the operator's only Coach switch (the Coach row in module
* availability is read-only and follows it).
*
* Dedicated endpoint (separate from the generic
* `/api/admin/settings` PUT) so the admin panel can wire its
@@ -46,7 +52,7 @@ const assistantFlagsSchema = z
assistantCoachEnabled: z.boolean().optional(),
assistantBriefingEnabled: z.boolean().optional(),
assistantInsightStatusEnabled: z.boolean().optional(),
assistantCorrelationsEnabled: z.boolean().optional(),
assistantDocumentAiEnabled: z.boolean().optional(),
})
.strict();
@@ -55,7 +61,7 @@ type AssistantFlagsRow = {
assistantCoachEnabled: boolean;
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantCorrelationsEnabled: boolean;
assistantDocumentAiEnabled: boolean;
};
function buildResponseShape(row: AssistantFlagsRow) {
@@ -64,7 +70,7 @@ function buildResponseShape(row: AssistantFlagsRow) {
coach: row.assistantCoachEnabled,
briefing: row.assistantBriefingEnabled,
insightStatus: row.assistantInsightStatusEnabled,
correlations: row.assistantCorrelationsEnabled,
documentAi: row.assistantDocumentAiEnabled,
});
return {
raw: {
@@ -72,7 +78,7 @@ function buildResponseShape(row: AssistantFlagsRow) {
assistantCoachEnabled: row.assistantCoachEnabled,
assistantBriefingEnabled: row.assistantBriefingEnabled,
assistantInsightStatusEnabled: row.assistantInsightStatusEnabled,
assistantCorrelationsEnabled: row.assistantCorrelationsEnabled,
assistantDocumentAiEnabled: row.assistantDocumentAiEnabled,
},
resolved,
};
@@ -91,7 +97,7 @@ export const GET = apiHandler(async () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
},
});
@@ -101,8 +107,7 @@ export const GET = apiHandler(async () => {
assistantBriefingEnabled: settings?.assistantBriefingEnabled ?? true,
assistantInsightStatusEnabled:
settings?.assistantInsightStatusEnabled ?? true,
assistantCorrelationsEnabled:
settings?.assistantCorrelationsEnabled ?? true,
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
};
return apiSuccess(buildResponseShape(row));
@@ -153,7 +158,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
assistantCoachEnabled: settings.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
};
return apiSuccess(buildResponseShape(row));
@@ -43,11 +43,12 @@ import {
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { getAssistantFlags } from "@/lib/feature-flags";
import {
mergeAvailabilityPatch,
resolveOperatorAvailability,
} from "@/lib/modules/operator-availability";
import { MODULE_KEYS } from "@/lib/modules/registry";
import { MODULE_KEYS, SWITCH_OWNED_MODULE_KEYS } from "@/lib/modules/registry";
export const dynamic = "force-dynamic";
@@ -55,11 +56,17 @@ export const dynamic = "force-dynamic";
* Strict per-module boolean patch. Building the shape from `MODULE_KEYS`
* keeps the schema exhaustive and rejects any non-module key (including
* the four core domains) at the validation boundary.
*
* The Coach is left out and therefore refused: its operator layer is the
* Coach switch on the assistant panel, and a second place to turn it off
* would be a second answer to one question.
*/
const moduleAvailabilitySchema = z
.object(
Object.fromEntries(
MODULE_KEYS.map((key) => [key, z.boolean().optional()]),
MODULE_KEYS.filter((key) => !SWITCH_OWNED_MODULE_KEYS.includes(key)).map(
(key) => [key, z.boolean().optional()],
),
) as Record<(typeof MODULE_KEYS)[number], z.ZodOptional<z.ZodBoolean>>,
)
.strict();
@@ -68,13 +75,19 @@ export const GET = apiHandler(async () => {
await requireAdmin();
annotate({ action: { name: "admin.settings.module-availability.get" } });
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
});
const [settings, switches] = await Promise.all([
prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
}),
getAssistantFlags(),
]);
return apiSuccess({
availability: resolveOperatorAvailability(settings?.moduleAvailabilityJson),
availability: resolveOperatorAvailability(
settings?.moduleAvailabilityJson,
switches.coach,
),
});
});
@@ -128,6 +141,9 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
});
return apiSuccess({
availability: resolveOperatorAvailability(settings.moduleAvailabilityJson),
availability: resolveOperatorAvailability(
settings.moduleAvailabilityJson,
(await getAssistantFlags()).coach,
),
});
});
+3 -4
View File
@@ -101,8 +101,7 @@ export const GET = apiHandler(async () => {
assistantBriefingEnabled: settings?.assistantBriefingEnabled ?? true,
assistantInsightStatusEnabled:
settings?.assistantInsightStatusEnabled ?? true,
assistantCorrelationsEnabled:
settings?.assistantCorrelationsEnabled ?? true,
assistantDocumentAiEnabled: settings?.assistantDocumentAiEnabled ?? true,
});
});
@@ -145,7 +144,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
"assistantCoachEnabled",
"assistantBriefingEnabled",
"assistantInsightStatusEnabled",
"assistantCorrelationsEnabled",
"assistantDocumentAiEnabled",
] as const;
for (const field of booleanFields) {
if (data[field] !== undefined) {
@@ -320,6 +319,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
assistantCoachEnabled: settings.assistantCoachEnabled,
assistantBriefingEnabled: settings.assistantBriefingEnabled,
assistantInsightStatusEnabled: settings.assistantInsightStatusEnabled,
assistantCorrelationsEnabled: settings.assistantCorrelationsEnabled,
assistantDocumentAiEnabled: settings.assistantDocumentAiEnabled,
});
});
@@ -71,7 +71,7 @@ describe("GET /api/feature-flags", () => {
coach: boolean;
briefing: boolean;
insightStatus: boolean;
correlations: boolean;
documentAi: boolean;
};
};
error: null;
@@ -83,7 +83,7 @@ describe("GET /api/feature-flags", () => {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
});
@@ -94,7 +94,7 @@ describe("GET /api/feature-flags", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
const res = await GET(req());
@@ -109,7 +109,7 @@ describe("GET /api/feature-flags", () => {
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
});
});
@@ -120,7 +120,7 @@ describe("GET /api/feature-flags", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
const res = await GET(req());
@@ -135,7 +135,7 @@ describe("GET /api/feature-flags", () => {
coach: false,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
});
});
@@ -128,7 +128,7 @@ describe("GET /api/insights/biomarker-assessment", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq("bm-1"));
expect(res.status).toBe(403);
@@ -90,7 +90,7 @@ describe("GET /api/insights/blood-pressure-status — assistant-flag gate", () =
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
@@ -94,7 +94,7 @@ describe("GET /api/insights/cards — assistant-flag gate", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
@@ -109,7 +109,7 @@ describe("GET /api/insights/cards — assistant-flag gate", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq());
expect(res.status).toBe(403);
@@ -232,7 +232,7 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantDocumentAiEnabled: false,
});
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(buildComprehensiveAggregate as ReturnType<typeof vi.fn>).mockResolvedValue(
@@ -194,36 +194,21 @@ describe("GET /api/insights/correlations", () => {
expect(secondBody.data).toEqual(firstBody.data);
});
it("returns 403 + errorCode when the correlations flag is off", async () => {
it("serves the statistics with every assistant switch off", async () => {
// Correlations are computed, not written by a model, so no AI switch
// decides whether they load. The switch that used to gate them is gone.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
assistantEnabled: true,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: false,
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("assistant.disabled.correlations");
});
it("returns 403 when the master flag is off (sub-flag forced)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
prisma.appSettings.findUnique as ReturnType<typeof vi.fn>
).mockResolvedValueOnce({
).mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantDocumentAiEnabled: false,
});
const res = await callGet(makeReq());
expect(res.status).toBe(403);
expect(res.status).toBe(200);
});
it("returns 429 when the shared analytics-read budget is exhausted", async () => {
+3 -3
View File
@@ -23,7 +23,6 @@ import { apiError, apiSuccess } from "@/lib/api-response";
import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache";
import { annotate } from "@/lib/logging/context";
import { checkAnalyticsReadRateLimit } from "@/lib/rate-limit";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import { prisma } from "@/lib/db";
@@ -70,8 +69,9 @@ export const GET = apiHandler(async () => {
const m = await requireModuleEnabled(user.id, "insights");
if (!m.enabled) return m.response;
// Operator can hide the correlation surface entirely.
await requireAssistantSurface("correlations");
// No assistant switch: this is statistics, not model output. The retired
// Correlations switch gated it and nothing model-written; an operator's AI
// switches never take a computation down.
// Reader's locale for the narrated `interpretation` — the correlation cards
// render this string verbatim, so it MUST be localised (cookie / User.locale /
@@ -133,7 +133,7 @@ describe("GET /api/insights/metric-status", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callGet(makeReq("SLEEP_DURATION"));
expect(res.status).toBe(403);
@@ -101,7 +101,7 @@ describe("POST /api/insights/pregenerate", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callPost(makeReq());
expect(res.status).toBe(200);
@@ -115,7 +115,7 @@ describe("POST /api/insights/pregenerate", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
} as never);
const res = await callPost(makeReq());
expect(res.status).toBe(403);
@@ -100,7 +100,7 @@ beforeEach(() => {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
vi.mocked(providerCredentialPolicy).mockReturnValue("personal");
vi.mocked(hasActiveConsentForSurface).mockResolvedValue(false);
@@ -513,7 +513,7 @@ describe("GET /api/user/ai-provider — the shared-provider offer", () => {
coach: false,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
await expect(read()).resolves.toMatchObject({
serverProviderHealth: "healthy",
+16 -12
View File
@@ -12,10 +12,12 @@ import { SettingsToggle } from "./_shared";
import { apiGet, apiPut } from "@/lib/api/api-fetch";
/**
* v1.4.31 — operator-side panel for the five assistant feature
* flags. The master toggle gates the whole assistant; four
* sub-toggles carve specific surfaces (Coach, Daily Briefing,
* per-metric status cards, correlation narration).
* Operator-side panel for the five assistant switches. The master stops
* every AI feature; four sub-switches each stop one cost or egress profile:
* the Coach, the daily briefing, status notes (per-reading notes, workout
* notes, reaction lines) and reading documents (the vault, lab scans,
* medication extraction). A switch stops AI work and hides AI text; data,
* charts and scores keep loading either way.
*
* UX:
* - Master toggle at the top. When off, the sub-toggles are
@@ -33,14 +35,14 @@ interface AssistantFlagsResponse {
assistantCoachEnabled: boolean;
assistantBriefingEnabled: boolean;
assistantInsightStatusEnabled: boolean;
assistantCorrelationsEnabled: boolean;
assistantDocumentAiEnabled: boolean;
};
resolved: {
enabled: boolean;
coach: boolean;
briefing: boolean;
insightStatus: boolean;
correlations: boolean;
documentAi: boolean;
};
}
@@ -69,8 +71,10 @@ function useUpdateAssistantFlags() {
},
onSuccess: (data) => {
client.setQueryData(queryKeys.adminAssistantFlags(), data);
// Bust the runtime `/api/feature-flags` cache so the operator
// sees the toggled surface react within the same session.
// Every record's resolved AI capabilities ride the account payload,
// and the deprecated switch projection is still read on the web;
// bust both so the operator sees the change within the session.
client.invalidateQueries({ queryKey: queryKeys.authMe() });
client.invalidateQueries({ queryKey: queryKeys.featureFlags() });
toast.success(t("common.saved"));
},
@@ -143,11 +147,11 @@ export function AssistantSection() {
disabled={disabledSubs}
/>
<SettingsToggle
label={t("admin.assistant.correlations.title")}
description={t("admin.assistant.correlations.description")}
checked={raw?.assistantCorrelationsEnabled ?? true}
label={t("admin.assistant.documentAi.title")}
description={t("admin.assistant.documentAi.description")}
checked={raw?.assistantDocumentAiEnabled ?? true}
onCheckedChange={(checked) =>
mutation.mutate({ assistantCorrelationsEnabled: checked })
mutation.mutate({ assistantDocumentAiEnabled: checked })
}
disabled={disabledSubs}
/>
@@ -1,5 +1,6 @@
"use client";
import Link from "next/link";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ToggleLeft } from "lucide-react";
import { toast } from "sonner";
@@ -11,6 +12,7 @@ import { queryKeys } from "@/lib/query-keys";
import {
MODULE_KEYS,
MODULE_REGISTRY,
SWITCH_OWNED_MODULE_KEYS,
isCodeDisabledModule,
} from "@/lib/modules/registry";
import type { ModuleKey } from "@/lib/modules/registry";
@@ -106,6 +108,32 @@ export function ModuleAvailabilitySection() {
// Default-available until the read resolves; an explicit `false`
// from the operator layer is the only thing that turns it off.
const available = availability?.[key] ?? true;
// The Coach has one operator switch, on the assistant panel. The
// row stays so the operator sees its state here, read-only, with
// the way to the switch that decides it.
if (SWITCH_OWNED_MODULE_KEYS.includes(key)) {
return (
<div
key={key}
className="space-y-1"
data-slot="module-availability-switch-owned"
>
<SettingsToggle
label={t(def.labelKey)}
description={t("admin.modules.coachFollowsSwitch")}
checked={available}
onCheckedChange={() => {}}
disabled
/>
<Link
href="/admin/coach"
className="text-primary text-xs underline-offset-4 hover:underline"
>
{t("admin.assistant.title")}
</Link>
</div>
);
}
return (
<SettingsToggle
key={key}
@@ -32,7 +32,7 @@ function makeClient(flags: {
coach: boolean;
briefing: boolean;
insightStatus: boolean;
correlations: boolean;
documentAi: boolean;
}): QueryClient {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
@@ -50,7 +50,7 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
}),
);
expect(html).toContain("Your pulse is stable");
@@ -64,7 +64,7 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
coach: true,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
}),
);
expect(html).toBe("");
@@ -78,7 +78,7 @@ describe("<InsightStatusCard> — assistant.insightStatus gate", () => {
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
}),
);
expect(html).toBe("");
@@ -3,7 +3,6 @@
import { Spline } from "lucide-react";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useTranslations } from "@/lib/i18n/context";
import type {
CorrelationKind,
@@ -58,7 +57,6 @@ interface MetricCorrelationCardProps {
export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
const { t } = useTranslations();
const flags = useFeatureFlags();
const key = SLUG_TO_CORRELATION[slug];
// Read the thick slice; correlations live only on the default payload.
@@ -70,7 +68,8 @@ export function MetricCorrelationCard({ slug }: MetricCorrelationCardProps) {
});
const analytics = data as AnalyticsWithCorrelations | undefined;
if (!flags.correlations || !key) return null;
// Statistics, not model output: no AI switch decides whether they show.
if (!key) return null;
const result = analytics?.correlations?.[key];
if (!result || result.status !== "ok") return null;
@@ -73,7 +73,7 @@ const ALL_ON_FLAGS: AssistantFlagSet = {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
};
const flagsSpy = vi.fn<() => AssistantFlagSet>(() => ALL_ON_FLAGS);
vi.mock("@/hooks/use-feature-flags", () => ({
@@ -53,7 +53,7 @@ describe("useFeatureFlags", () => {
coach: false,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
},
},
}),
@@ -73,7 +73,7 @@ describe("useFeatureFlags", () => {
coach: false,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
},
});
+6 -5
View File
@@ -12,8 +12,9 @@ import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
* shape directly so callers can rely on a typed structure.
*
* The matrix gates every assistant-driven surface on the web —
* Coach + chat, Daily Briefing, per-metric status cards, correlation
* narration. iOS reads the same
* Coach + chat, Daily Briefing, per-metric status notes, reading
* documents. Deprecated: `ai` on `GET /api/auth/me` (read through
* `useAiCapability`) is the resolved answer per capability. iOS reads the same
* endpoint and uses the same matrix; the contract is locked per
* `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5.
*/
@@ -27,8 +28,8 @@ export interface AssistantFlagSet {
briefing: boolean;
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
insightStatus: boolean;
/** Correlation narration tile on the mother page. */
correlations: boolean;
/** Document vault reads, lab report scans, medication text extraction. */
documentAi: boolean;
}
interface FeatureFlagsPayload {
@@ -41,7 +42,7 @@ export const DEFAULT_ASSISTANT_FLAGS: AssistantFlagSet = Object.freeze({
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
@@ -400,7 +400,7 @@ beforeEach(() => {
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
});
hasAnyConfiguredProvider.mockResolvedValue(false);
buildMedsTodayBlock.mockResolvedValue({
+2 -2
View File
@@ -217,7 +217,7 @@ beforeEach(() => {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
fakePrisma.measurement.findMany.mockResolvedValue([]);
fakePrisma.moodEntry.findMany.mockResolvedValue([]);
@@ -655,7 +655,7 @@ describe("buildDashboardSnapshot — briefingState matrix", () => {
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
});
const snap = await buildDashboardSnapshot(
fakePrisma,
@@ -78,7 +78,7 @@ const appSettings = {
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantDocumentAiEnabled: false,
moduleAvailabilityJson: { nutrients: false, insights: true },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: BigInt("9876543210"),
@@ -80,7 +80,7 @@ function buildClient(flags: Partial<AssistantFlagSet>): QueryClient {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
...flags,
},
});
+41 -17
View File
@@ -24,6 +24,7 @@ import {
ASSISTANT_FLAGS_DEFAULT,
AssistantDisabledError,
getAssistantFlags,
loadAssistantSwitches,
requireAssistantSurface,
resolveAssistantFlags,
} from "../index";
@@ -42,7 +43,7 @@ describe("resolveAssistantFlags", () => {
coach: true,
briefing: false,
insightStatus: true,
correlations: false,
documentAi: false,
};
expect(resolveAssistantFlags(input)).toEqual(input);
});
@@ -53,14 +54,14 @@ describe("resolveAssistantFlags", () => {
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
};
expect(resolveAssistantFlags(input)).toEqual({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
});
});
});
@@ -78,7 +79,7 @@ describe("getAssistantFlags", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
const flags = await getAssistantFlags();
expect(flags).toEqual({
@@ -86,7 +87,7 @@ describe("getAssistantFlags", () => {
coach: false,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
});
});
@@ -96,7 +97,7 @@ describe("getAssistantFlags", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
const flags = await getAssistantFlags();
expect(flags).toEqual({
@@ -104,14 +105,37 @@ describe("getAssistantFlags", () => {
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
documentAi: false,
});
});
it("falls back to defaults on a Prisma error", async () => {
it("fails closed on a Prisma error: every switch reads off", async () => {
// A database blip must not switch every AI egress on. Safe now, because
// no data read depends on a switch.
FIND.mockRejectedValue(new Error("db down"));
const flags = await getAssistantFlags();
expect(flags).toEqual(ASSISTANT_FLAGS_DEFAULT);
expect(flags).toEqual({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
});
});
});
describe("loadAssistantSwitches", () => {
it("tells a read failure apart from a set of switches that are off", async () => {
FIND.mockRejectedValue(new Error("db down"));
expect(await loadAssistantSwitches()).toBeNull();
FIND.mockResolvedValue({
assistantEnabled: false,
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantDocumentAiEnabled: true,
});
expect((await loadAssistantSwitches())?.enabled).toBe(false);
});
});
@@ -122,7 +146,7 @@ describe("requireAssistantSurface", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
const flags = await requireAssistantSurface("coach");
expect(flags.coach).toBe(true);
@@ -134,7 +158,7 @@ describe("requireAssistantSurface", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
await expect(requireAssistantSurface("coach")).rejects.toThrow(
AssistantDisabledError,
@@ -147,7 +171,7 @@ describe("requireAssistantSurface", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
await expect(requireAssistantSurface("briefing")).rejects.toThrow(
AssistantDisabledError,
@@ -155,9 +179,9 @@ describe("requireAssistantSurface", () => {
});
it("AssistantDisabledError carries the surface-tagged errorCode", () => {
const err = new AssistantDisabledError("correlations");
expect(err.surface).toBe("correlations");
expect(err.errorCode).toBe("assistant.disabled.correlations");
const err = new AssistantDisabledError("insightStatus");
expect(err.surface).toBe("insightStatus");
expect(err.errorCode).toBe("assistant.disabled.insightStatus");
});
});
@@ -173,7 +197,7 @@ describe("per-request memoisation", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
// Pin a stable stub object so every `getEvent()` inside this test
// returns the same reference — the memo keys on identity, so a
@@ -195,7 +219,7 @@ describe("per-request memoisation", () => {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
});
mockEvent = { addWarning: vi.fn(), id: "req-1" };
@@ -1,21 +1,39 @@
/**
* Every assistant switch an operator can flip has to move something.
* Every assistant switch an operator can flip has to move something, a
* switch that was retired stays retired, and the retired gate only shrinks.
*
* `healthScoreExplainer` did not. It gated a caption beside the
* Health-Score delta; the caption's component went when the health
* score was replaced by the reference composite, and the "?" control
* the admin description still named had been retired a release before
* that. The toggle stayed on the admin panel, in the flag matrix and in
* the API contract for two releases, so an operator could turn it off,
* be told it saved, and change nothing at all. A switch that lies is
* worse than no switch.
* `healthScoreExplainer` did not move anything. It gated a caption beside the
* Health-Score delta; the caption's component went when the health score was
* replaced by the reference composite, and the toggle stayed on the admin
* panel, in the flag matrix and in the API contract for two releases, so an
* operator could turn it off, be told it saved, and change nothing at all.
* `correlations` was the same shape one level down: it gated statistics, which
* no model writes, so turning it off removed a computation and stopped no AI
* work. A switch that lies is worse than no switch.
*
* Two guards, one point. The first pins the removal so the flag cannot
* drift back in pieces. The second is the general rule the removal came
* from: no sub-flag is allowed to exist without a surface that reads it.
* Plumbing does not count — the admin panel, the resolver, the hook and
* the two routes carry every flag by construction, so a dead one would
* look alive if they were allowed to vouch for it.
* Four guards:
*
* 1. The retired switches stay removed, in every place they lived.
* 2. No sub-switch exists without a capability it covers, and none without a
* surface that reads it. Plumbing does not count: the admin panel, the
* loader, the hook and the two switch routes carry every switch by
* construction, so a dead one would look alive if they could vouch for
* it. A switch whose reader lands with the routes it covers is named in
* `AWAITING_READER` with the reason, and the entry fails the moment a
* reader appears, so it cannot outlive its reason.
* 3. `requireAssistantSurface` is the retired gate. It answers only the
* operator layer; `requireAiCapability` answers every layer. The routes
* that still call it are frozen below, in both directions: a new caller
* fails, and a caller that moved to the capability gate fails until its
* entry is removed. When the list is empty the function and
* `AssistantDisabledError` are deleted and this guard asserts zero.
* 4. The matchers find what they are meant to judge (non-zero counts), so a
* matcher that stopped matching cannot agree with an empty world.
*
* Mutation checks: adding `await requireAssistantSurface("coach")` to any
* route outside the frozen list turns guard 3 red by file name; removing the
* `documentAi` entry from `AWAITING_READER` turns guard 2 red; putting
* `assistantCorrelationsEnabled` back on the schema turns guard 1 red.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
@@ -25,22 +43,49 @@ import {
ASSISTANT_FLAGS_DEFAULT,
resolveAssistantFlags,
} from "@/lib/feature-flags";
import {
AI_CAPABILITIES,
AI_CAPABILITY_KEYS,
AI_OPERATOR_SWITCHES,
} from "@/lib/ai/capabilities/types";
const ROOT = path.resolve(__dirname, "../../../..");
const LOCALES = ["de", "en", "es", "fr", "it", "pl", "ko"] as const;
/** Names the retired switch went by, on the wire and in the database. */
const RETIRED_NAMES = [
"healthScoreExplainer",
"assistantHealthScoreExplainerEnabled",
"assistant_health_score_explainer_enabled",
interface RetiredSwitch {
/** The wire name, also the admin copy key. */
name: string;
/** Names it went by on the schema and in the database. */
columnNames: string[];
/** The migration that dropped the column. */
migration: string;
column: string;
}
const RETIRED_SWITCHES: RetiredSwitch[] = [
{
name: "healthScoreExplainer",
columnNames: [
"assistantHealthScoreExplainerEnabled",
"assistant_health_score_explainer_enabled",
],
migration: "0288_drop_health_score_explainer_flag",
column: "assistant_health_score_explainer_enabled",
},
{
name: "correlations",
columnNames: [
"assistantCorrelationsEnabled",
"assistant_correlations_enabled",
],
migration: "0343_ai_capability_switches",
column: "assistant_correlations_enabled",
},
];
/**
* The files that carry every flag whatever it does: the operator panel
* that offers the switch, the resolver and hook that shape the matrix,
* and the two routes that read and write it. None of them is evidence
* that a flag gates anything.
* The files that carry every switch whatever it does. None of them is
* evidence that a switch gates anything.
*/
const PLUMBING = [
"src/lib/feature-flags/index.ts",
@@ -50,6 +95,47 @@ const PLUMBING = [
"src/app/api/admin/settings/assistant-flags/route.ts",
];
/**
* Switches with no reader yet, and why that is correct for now. An entry is
* a claim with an expiry: the test below fails as soon as a reader appears.
*/
const AWAITING_READER: Record<string, string> = {
documentAi:
"The capabilities it covers (documentAi, labsOcr, medicationExtract) are " +
"resolved and published, but the document, lab-scan and medication " +
"extraction routes move onto requireAiCapability in the same release; " +
"until then the master switch is what stops them.",
};
/**
* Every file that still calls the retired gate. Only ever shrinks.
*/
const RETIRED_GATE_CALLERS = [
"src/app/api/insights/biomarker-assessment/route.ts",
"src/app/api/insights/blood-pressure-status/route.ts",
"src/app/api/insights/bmi-status/route.ts",
"src/app/api/insights/cards/route.ts",
"src/app/api/insights/chat/[id]/route.ts",
"src/app/api/insights/chat/messages/[id]/feedback/route.ts",
"src/app/api/insights/chat/route.ts",
"src/app/api/insights/coach-read/route.ts",
"src/app/api/insights/coach/facts/[id]/route.ts",
"src/app/api/insights/coach/facts/route.ts",
"src/app/api/insights/coach/nudge-status/route.ts",
"src/app/api/insights/coach/seeded-question/route.ts",
"src/app/api/insights/coach/seen/route.ts",
"src/app/api/insights/generate/route.ts",
"src/app/api/insights/medication-compliance-status/route.ts",
"src/app/api/insights/metric-status/route.ts",
"src/app/api/insights/mood-status/route.ts",
"src/app/api/insights/narrative/route.ts",
"src/app/api/insights/pregenerate/route.ts",
"src/app/api/insights/pulse-status/route.ts",
"src/app/api/insights/weight-status/route.ts",
"src/app/api/medications/extract/route.ts",
"src/app/coach/page.tsx",
];
function sourceFiles(dir: string, acc: string[] = []): string[] {
for (const entry of readdirSync(dir)) {
if (entry === "generated" || entry === "__tests__") continue;
@@ -63,75 +149,169 @@ function sourceFiles(dir: string, acc: string[] = []): string[] {
return acc;
}
describe("the Health-Score explainer switch stays removed", () => {
it("is absent from the resolved flag matrix, master on and master off", () => {
const on = resolveAssistantFlags({ ...ASSISTANT_FLAGS_DEFAULT });
const off = resolveAssistantFlags({
...ASSISTANT_FLAGS_DEFAULT,
enabled: false,
/** Comment lines out, so prose naming a call is not read as one. */
function code(file: string): string {
return readFileSync(file, "utf8")
.split("\n")
.filter((line) => !/^\s*(\*|\/\/|\/\*)/.test(line))
.join("\n");
}
const rel = (file: string) =>
path.relative(ROOT, file).split(path.sep).join("/");
const SOURCES = sourceFiles(path.join(ROOT, "src"));
describe("retired switches stay removed", () => {
for (const retired of RETIRED_SWITCHES) {
describe(retired.name, () => {
it("is absent from the resolved switch set, master on and master off", () => {
const on = resolveAssistantFlags({ ...ASSISTANT_FLAGS_DEFAULT });
const off = resolveAssistantFlags({
...ASSISTANT_FLAGS_DEFAULT,
enabled: false,
});
for (const shape of [ASSISTANT_FLAGS_DEFAULT, on, off]) {
expect(Object.keys(shape)).not.toContain(retired.name);
}
});
it("declares no column on the Prisma schema", () => {
const schema = readFileSync(
path.join(ROOT, "prisma/schema.prisma"),
"utf8",
);
for (const name of retired.columnNames) {
expect(schema, `${name} is back on the schema`).not.toContain(name);
}
});
it("is read and written nowhere in the source tree", () => {
const readers = SOURCES.filter((file) =>
retired.columnNames.some((name) => code(file).includes(name)),
).map(rel);
expect(readers).toEqual([]);
});
it("carries no operator-panel wording in any locale", () => {
for (const locale of LOCALES) {
const bundle = JSON.parse(
readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"),
) as { admin: { assistant: Record<string, unknown> } };
expect(
Object.keys(bundle.admin.assistant),
`${locale} still offers the switch`,
).not.toContain(retired.name);
}
});
it("ships the migration that dropped the column", () => {
const sql = readFileSync(
path.join(
ROOT,
`prisma/migrations/${retired.migration}/migration.sql`,
),
"utf8",
);
expect(sql).toContain(retired.column);
expect(sql).toContain("DROP COLUMN");
});
});
for (const shape of [ASSISTANT_FLAGS_DEFAULT, on, off]) {
expect(Object.keys(shape)).not.toContain("healthScoreExplainer");
}
});
it("declares no column on the Prisma schema", () => {
const schema = readFileSync(
path.join(ROOT, "prisma/schema.prisma"),
"utf8",
);
for (const name of RETIRED_NAMES) {
expect(schema, `${name} is back on the schema`).not.toContain(name);
}
});
it("carries no operator-panel wording in any locale", () => {
for (const locale of LOCALES) {
const bundle = JSON.parse(
readFileSync(path.join(ROOT, `messages/${locale}.json`), "utf8"),
) as { admin: { assistant: Record<string, unknown> } };
expect(
Object.keys(bundle.admin.assistant),
`${locale} still offers the switch`,
).not.toContain("healthScoreExplainer");
}
});
it("ships the migration that dropped the column", () => {
const sql = readFileSync(
path.join(
ROOT,
"prisma/migrations/0288_drop_health_score_explainer_flag/migration.sql",
),
"utf8",
);
expect(sql).toContain("assistant_health_score_explainer_enabled");
expect(sql).toContain("DROP COLUMN");
});
}
});
describe("no assistant sub-flag is a dead switch", () => {
it("finds a surface that reads every flag the operator can turn off", () => {
const plumbing = new Set(PLUMBING.map((rel) => path.join(ROOT, rel)));
const sources = sourceFiles(path.join(ROOT, "src")).filter(
(file) => !plumbing.has(file),
);
const corpus = sources.map((file) => readFileSync(file, "utf8")).join("\n");
describe("no assistant sub-switch is a dead switch", () => {
const subSwitches = Object.keys(ASSISTANT_FLAGS_DEFAULT).filter(
(flag) => flag !== "enabled",
);
const subFlags = Object.keys(ASSISTANT_FLAGS_DEFAULT).filter(
(flag) => flag !== "enabled",
);
expect(subFlags.length).toBeGreaterThan(0);
it("publishes exactly the switches the capability table knows", () => {
expect([...subSwitches].sort()).toEqual([...AI_OPERATOR_SWITCHES].sort());
});
for (const flag of subFlags) {
const gatedOnTheServer = corpus.includes(
`requireAssistantSurface("${flag}")`,
it("covers at least one capability with every switch", () => {
for (const toggle of subSwitches) {
const covered = AI_CAPABILITY_KEYS.filter(
(key) => AI_CAPABILITIES[key].operatorSwitch === toggle,
);
const readOnTheClient = new RegExp(`flags\\.${flag}\\b`).test(corpus);
expect(
gatedOnTheServer || readOnTheClient,
`the "${flag}" switch gates nothing — wire it to a surface or remove it`,
covered.length,
`"${toggle}" covers no capability`,
).toBeGreaterThan(0);
}
});
const plumbing = new Set(PLUMBING.map((file) => path.join(ROOT, file)));
const corpus = SOURCES.filter((file) => !plumbing.has(file))
.map(code)
.join("\n");
function hasReader(toggle: string): boolean {
if (corpus.includes(`requireAssistantSurface("${toggle}")`)) return true;
if (new RegExp(`flags\\.${toggle}\\b`).test(corpus)) return true;
const capabilities = AI_CAPABILITY_KEYS.filter(
(key) => AI_CAPABILITIES[key].operatorSwitch === toggle,
);
return capabilities.some((key) =>
new RegExp(
`(?:requireAiCapability|getAiCapability)\\(\\s*"${key}"|aiCapabilityForJob\\([^)]*"${key}"`,
).test(corpus),
);
}
it("finds a surface that reads every switch the operator can turn off", () => {
expect(subSwitches.length).toBeGreaterThan(0);
for (const toggle of subSwitches) {
if (toggle in AWAITING_READER) continue;
expect(
hasReader(toggle),
`the "${toggle}" switch gates nothing — wire it to a surface or remove it`,
).toBe(true);
}
});
it("drops an awaiting entry as soon as its switch has a reader", () => {
for (const toggle of Object.keys(AWAITING_READER)) {
expect(subSwitches, `${toggle} is not a switch`).toContain(toggle);
expect(
hasReader(toggle),
`"${toggle}" has a reader now — remove it from AWAITING_READER`,
).toBe(false);
}
});
});
describe("the retired gate only shrinks", () => {
const CALL = /(?:await|return|=)\s*requireAssistantSurface\s*\(/;
const callers = SOURCES.filter(
(file) =>
rel(file) !== "src/lib/feature-flags/index.ts" && CALL.test(code(file)),
)
.map(rel)
.sort();
it("finds the callers it is meant to judge", () => {
// A matcher that stopped matching would agree with an empty list.
expect(callers.length).toBe(RETIRED_GATE_CALLERS.length);
});
it("gains no new caller", () => {
const added = callers.filter(
(file) => !RETIRED_GATE_CALLERS.includes(file),
);
expect(
added,
"a new route calls requireAssistantSurface; use requireAiCapability",
).toEqual([]);
});
it("forgets a caller once it has moved to the capability gate", () => {
const moved = RETIRED_GATE_CALLERS.filter(
(file) => !callers.includes(file),
);
expect(
moved,
"these no longer call requireAssistantSurface; remove them from the list",
).toEqual([]);
});
});
+67 -75
View File
@@ -2,67 +2,62 @@ import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import type {
AiOperatorSwitch,
AiOperatorSwitchSet,
} from "@/lib/ai/capabilities/types";
/**
* v1.4.31 — Assistant-surface operator feature flags.
* The operator's assistant switches: input one of the AI capability resolver
* (`src/lib/ai/capabilities/`), and nothing more.
*
* Five boolean toggles on `AppSettings` carve the visibility cut for
* the LLM-driven surfaces. The master flag is a single kill-switch;
* the four sub-flags carve specific surfaces.
* `AppSettings.assistant*Enabled` holds a master and four sub-switches. The
* master always wins: every sub-switch reads false when it is off, before the
* set leaves this module, so no reader composes `master && sub`.
*
* `assistant.enabled = false` forces every sub-flag false in the
* resolved shape — the master always wins. This means a server-side
* caller never has to compose `master && sub`; reading
* `flags.coach` already accounts for both layers.
*
* Per `.planning/RESPONSE-TO-IOS-TEAM-2026-05-16.md` §3 R5: the
* matrix gates BOTH server-routed AND iOS on-device assistant
* surfaces. The `GET /api/feature-flags` endpoint projects this
* shape so iOS reads the same authoritative set the web reads.
* What a switch decides is AI work and AI text, never data. Whether a given
* capability is available for a given record is the resolver's answer, which
* also weighs modules, provider-work authority, provider presence and consent;
* a route that serves or calls a model asks the resolver, not this file.
*/
/** The four assistant sub-flags the operator can carve. */
export type AssistantSurface =
"coach" | "briefing" | "insightStatus" | "correlations";
/** A sub-switch name. Also the last segment of `assistant.disabled.<switch>`. */
export type AssistantSurface = AiOperatorSwitch;
export interface AssistantFlagSet {
/** Master kill-switch — when false, every sub-flag is forced false. */
enabled: boolean;
/** Coach drawer, chat SSE, history rail, feedback. */
coach: boolean;
/** Daily Briefing card + advisor recommendations + regen icon. */
briefing: boolean;
/** Per-metric status cards on every `/insights/<metric>` sub-page. */
insightStatus: boolean;
/** Correlation narration tile on the mother page. */
correlations: boolean;
}
/** The resolved switch set, master applied. */
export type AssistantFlagSet = AiOperatorSwitchSet;
/** All-on default; mirrors v1.4.30 behaviour for fresh installs. */
/** Every switch on: the column defaults, and what a fresh install reads. */
export const ASSISTANT_FLAGS_DEFAULT: AssistantFlagSet = Object.freeze({
enabled: true,
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
});
/** Every switch off: the answer when the switches could not be read. */
const ASSISTANT_FLAGS_OFF: AssistantFlagSet = Object.freeze({
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
documentAi: false,
});
/**
* Load the assistant flag set from `AppSettings`. Read-through pattern
* matches `getGlobalServiceAvailability()` — null/error/missing-row all
* fall back to defaults so the assistant stays visible on first boot.
* Load the switches, or `null` when they could not be read.
*
* The master always wins: every sub-flag is forced false when the
* master is off, before the resolved set leaves this function.
* A missing row reads as the column defaults (every switch on), which is what
* an instance that never saved its settings has. A read ERROR is different: it
* is `null`, and the capability resolver turns that into `check_failed` for
* every capability. Failing closed is safe now that no data depends on a
* switch; failing open meant a database blip switched every AI egress on.
*
* v1.4.33 — memoised per-request so a single Coach-drawer open that
* fires five gated fetches in parallel (`/api/insights/chat`,
* `/api/insights/comprehensive`, six `/api/insights/<metric>-status`
* routes, the rail list) reads `AppSettings.singleton` once instead of
* five times. The cache lives on the active `WideEventBuilder` so it
* goes away with the request; behaviour outside an event context (unit
* tests, background jobs) stays unchanged.
* Memoised per request, so a page that fires several gated reads at once reads
* the singleton row once.
*/
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
export function loadAssistantSwitches(): Promise<AssistantFlagSet | null> {
return memoizePerRequest("assistant-flags", async () => {
try {
const settings = await prisma.appSettings.findUnique({
@@ -72,56 +67,55 @@ export async function getAssistantFlags(): Promise<AssistantFlagSet> {
assistantCoachEnabled: true,
assistantBriefingEnabled: true,
assistantInsightStatusEnabled: true,
assistantCorrelationsEnabled: true,
assistantDocumentAiEnabled: true,
},
});
const master = settings?.assistantEnabled ?? true;
return resolveAssistantFlags({
enabled: master,
enabled: settings?.assistantEnabled ?? true,
coach: settings?.assistantCoachEnabled ?? true,
briefing: settings?.assistantBriefingEnabled ?? true,
insightStatus: settings?.assistantInsightStatusEnabled ?? true,
correlations: settings?.assistantCorrelationsEnabled ?? true,
documentAi: settings?.assistantDocumentAiEnabled ?? true,
});
} catch {
getEvent()?.addWarning(
"Failed to load assistant feature flags, using defaults",
"Failed to load assistant switches; every AI capability is off for this request",
);
return ASSISTANT_FLAGS_DEFAULT;
return null;
}
});
}
/**
* Pure resolver — given a raw set of master + sub-flag values, return
* the operator-effective shape. Exposed for unit tests and for the
* admin PUT handler that needs to echo the resolved set after a write.
* The switch set, failing closed: every switch reads off when the row could
* not be read. For callers that need a plain set rather than the distinction
* between "off" and "unknown".
*/
export async function getAssistantFlags(): Promise<AssistantFlagSet> {
return (await loadAssistantSwitches()) ?? ASSISTANT_FLAGS_OFF;
}
/**
* Pure resolver: given the raw column values, return the operator-effective
* set. Exposed for unit tests and for the admin handlers that echo the
* resolved set after a write.
*/
export function resolveAssistantFlags(raw: AssistantFlagSet): AssistantFlagSet {
if (!raw.enabled) {
return {
enabled: false,
coach: false,
briefing: false,
insightStatus: false,
correlations: false,
};
}
if (!raw.enabled) return { ...ASSISTANT_FLAGS_OFF };
return { ...raw };
}
/**
* Error class thrown by `requireAssistantSurface()` when the operator
* has disabled the surface. The api-handler catches it and returns
* the 403 envelope per the iOS contract (R5):
* The operator-switch-only refusal, `403 { meta: { errorCode:
* "assistant.disabled.<switch>" } }`.
*
* { data: null, error: "...", meta: { errorCode: "assistant.disabled.<surface>" } }
*
* Older iOS clients that predate the v1.4.31 contract surface this as
* a generic 403 (their existing 403-handler covers the case); v1.4.31+
* clients read the `errorCode` to render an inline operator-disabled
* notice.
* Being retired. It answers only the operator layer, so a route gated on it
* refuses when a switch is off and says nothing about modules, consent or the
* provider; `requireAiCapability` (`src/lib/ai/capabilities/gate.ts`) answers
* all of them with the same code for the operator case. The routes that still
* call `requireAssistantSurface` are frozen by
* `src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts`: the list
* may only shrink, and this class goes with its last caller.
*/
export class AssistantDisabledError extends Error {
readonly surface: AssistantSurface;
@@ -136,10 +130,8 @@ export class AssistantDisabledError extends Error {
}
/**
* Server-side gate helper. Throws `AssistantDisabledError` when the
* relevant surface (or the master) is off. Routes call this near the
* top, after auth and rate-limit, and let the apiHandler catch turn
* it into the 403 envelope.
* Throws `AssistantDisabledError` when the switch (or the master) is off.
* Retired in favour of `requireAiCapability`; see the class above.
*/
export async function requireAssistantSurface(
surface: AssistantSurface,
+2 -2
View File
@@ -24,7 +24,7 @@ vi.mock("@/lib/feature-flags", () => ({
coach: true,
briefing: true,
insightStatus: true,
correlations: true,
documentAi: true,
})),
}));
// v1.22.0 — `search` reads the record directly via Prisma; stub it so the
@@ -1410,7 +1410,7 @@ describe("get_ecg_recordings — v1.30 coverage review (G3)", () => {
coach: true,
briefing: true,
insightStatus: false,
correlations: true,
documentAi: true,
} as never);
const result = (await tool("get_ecg_recordings").run(CTX, {})) as {
present: boolean;
+14 -4
View File
@@ -279,19 +279,29 @@ describe("resolveModuleEnabled — operator layer (two-layer AND)", () => {
).toBe(true);
});
it("operator-off short-circuits a delegated module (coach) regardless of user state", () => {
// Even with the assistant master flag on and no per-user opt-out, the
// operator module-availability kill-switch wins.
it("the operator's Coach switch short-circuits the coach module regardless of user state", () => {
// The Coach's operator layer is the Coach switch, carried into the
// availability map; no per-user state can reopen it.
expect(
resolveModuleEnabled(
"coach",
inputs({ disableCoach: false }),
true,
operator({ coach: false }),
resolveOperatorAvailability({}, false),
),
).toBe(false);
});
it("ignores a coach key left in the availability blob", () => {
// Folded into the Coach switch; only the switch decides.
expect(resolveOperatorAvailability({ coach: false }, true).coach).toBe(
true,
);
expect(resolveOperatorAvailability({ coach: true }, false).coach).toBe(
false,
);
});
it("operator-off short-circuits cycle even for a FEMALE account", () => {
expect(
resolveModuleEnabled(
+45 -15
View File
@@ -28,12 +28,25 @@
* CORE domains (weight, BP, pulse, medications) are NOT `ModuleKey`s, are
* never written here, and the admin write endpoint refuses them — the
* measurement engine + meds can never be disabled.
*
* The Coach is the one module whose operator layer does not live in the blob.
* The operator has exactly one Coach switch, `AppSettings.assistantCoachEnabled`
* (master applied), and the Coach's availability here IS that switch. Two
* operator Coach switches meant two places to look for one answer; migration
* 0343 folded the blob's `coach` key into the switch and removed it, and a
* `coach` key that still turns up in the blob is ignored.
*/
import { prisma } from "@/lib/db";
import { getAssistantFlags } from "@/lib/feature-flags";
import { getEvent } from "@/lib/logging/context";
import { memoizePerRequest } from "@/lib/request-cache";
import { MODULE_KEYS, isModuleKey, type ModuleKey } from "./registry";
import {
MODULE_KEYS,
SWITCH_OWNED_MODULE_KEYS,
isModuleKey,
type ModuleKey,
} from "./registry";
/**
* The resolved operator availability map: one boolean per toggleable
@@ -51,6 +64,7 @@ export type OperatorModuleAvailability = Record<ModuleKey, boolean>;
*/
export function resolveOperatorAvailability(
raw: unknown,
coachSwitch = true,
): OperatorModuleAvailability {
const blob =
raw != null && typeof raw === "object" && !Array.isArray(raw)
@@ -62,9 +76,27 @@ export function resolveOperatorAvailability(
// Only an explicit `false` disables; everything else is available.
out[key] = blob[key] !== false;
}
// The Coach follows the operator's Coach switch, never the blob.
out.coach = coachSwitch;
return out;
}
/** The raw availability blob; a read error reads as "nothing disabled". */
async function loadAvailabilityBlob(): Promise<unknown> {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
});
return settings?.moduleAvailabilityJson ?? null;
} catch {
getEvent()?.addWarning(
"Failed to load operator module availability, defaulting to all-available",
);
return null;
}
}
/**
* Load the operator availability map once per request. Mirrors the
* `getAssistantFlags()` / `getGlobalServiceAvailability()` read-through:
@@ -73,21 +105,19 @@ export function resolveOperatorAvailability(
*/
export async function getOperatorModuleAvailability(): Promise<OperatorModuleAvailability> {
return memoizePerRequest("operator-module-availability", async () => {
try {
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { moduleAvailabilityJson: true },
});
return resolveOperatorAvailability(settings?.moduleAvailabilityJson);
} catch {
getEvent()?.addWarning(
"Failed to load operator module availability, defaulting to all-available",
);
return resolveOperatorAvailability(null);
}
const [blob, switches] = await Promise.all([
loadAvailabilityBlob(),
getAssistantFlags(),
]);
return resolveOperatorAvailability(blob, switches.coach);
});
}
/** A module key whose operator layer the blob owns (every key but the Coach). */
function isBlobOwnedKey(key: string): key is ModuleKey {
return isModuleKey(key) && !SWITCH_OWNED_MODULE_KEYS.includes(key);
}
/**
* Narrow + validate an operator write patch (the admin route's parsed
* body) into a sanitised `{ <ModuleKey>: boolean }` map. Drops any key
@@ -110,13 +140,13 @@ export function mergeAvailabilityPatch(
!Array.isArray(existing)
) {
for (const [k, v] of Object.entries(existing as Record<string, unknown>)) {
if (isModuleKey(k) && typeof v === "boolean") {
if (isBlobOwnedKey(k) && typeof v === "boolean") {
merged[k] = v;
}
}
}
for (const [k, v] of Object.entries(patch)) {
if (isModuleKey(k)) {
if (isBlobOwnedKey(k)) {
merged[k] = v;
}
}
+18 -9
View File
@@ -200,17 +200,18 @@ export function isCoreDomain(key: string): key is CoreDomainKey {
* The registry. Order is the Settings-hub display order (grouped by
* category in the UI; this flat order is the canonical iteration order).
*
* `insights` decision (documented): disabling `insights` hides only the
* AI-ANALYSIS surfaces — the Daily Briefing, the per-metric AI status
* cards, the correlation narration, the Health-Score explainer. It does
* NOT hide the raw weight / BP / pulse charts or any measurement data:
* those are core and render from live measurements regardless. `insights`
* is the narrative layer, not the data layer. (The operator-level
* assistant master flag is a separate, server-wide kill-switch; this
* per-user toggle sits below it.)
* `insights` is the person's AI opt-out, labelled "AI analysis". Turned off,
* no model writes anything for the record (the daily briefing and its lifts,
* period narratives, per-metric status notes, workout notes, reaction lines),
* by day or by night, and no text a model wrote earlier is shown. It never
* hides data: the Insights area, its charts, scores, ECG recordings and
* statistics are not owned by this module and stay whatever it says. The AI
* capability resolver (`src/lib/ai/capabilities/`) reads this key as the
* `user_disabled` layer for every capability it owns. The operator's assistant
* switches are a separate, server-wide layer above it.
*
* `coach` is the per-user opt-out half of the existing two-layer model
* (`User.disableCoach` AND the operator master flag). It is surfaced here
* (`User.disableCoach` AND the operator's Coach switch). It is surfaced here
* so the Modules hub lists it alongside its siblings, but it delegates —
* the module blob never owns it.
*/
@@ -408,6 +409,14 @@ export function isCodeDisabledModule(key: ModuleKey): boolean {
return CODE_DISABLED_MODULE_SET.has(key);
}
/**
* Modules whose OPERATOR layer is an assistant switch rather than a key in
* `AppSettings.moduleAvailabilityJson`. The Coach has one operator switch,
* `assistantCoachEnabled`; the availability blob's `coach` key was folded into
* it and is ignored, and the admin availability write refuses it.
*/
export const SWITCH_OWNED_MODULE_KEYS: readonly ModuleKey[] = ["coach"];
/** The two delegated keys, resolved by their existing source of truth. */
export function moduleDelegatesTo(
key: ModuleKey,
+3 -3
View File
@@ -88,8 +88,8 @@ export const adminSettingsSchema = z
// against Intl.supportedValuesOf at runtime; this schema just
// guards the shape + length.
defaultUserTimezone: z.string().max(64).optional(),
// v1.4.31 — assistant-surface operator feature flags. Master
// kills every sub-flag; sub-flags carve specific surfaces. The
// The operator's assistant switches. The master stops every AI
// capability; the sub-switches each stop the AI work they cover. The
// dedicated admin endpoint at
// /api/admin/settings/assistant-flags also accepts these via
// its own schema; this entry exists so the generic
@@ -100,6 +100,6 @@ export const adminSettingsSchema = z
assistantCoachEnabled: z.boolean().optional(),
assistantBriefingEnabled: z.boolean().optional(),
assistantInsightStatusEnabled: z.boolean().optional(),
assistantCorrelationsEnabled: z.boolean().optional(),
assistantDocumentAiEnabled: z.boolean().optional(),
})
.strict();
+5 -1
View File
@@ -949,7 +949,11 @@ const appSettingsBackupSchema = z
assistantCoachEnabled: z.boolean(),
assistantBriefingEnabled: z.boolean(),
assistantInsightStatusEnabled: z.boolean(),
assistantCorrelationsEnabled: z.boolean(),
// Optional: a backup written before the switch existed carries none, and
// restores it at its default. A backup from before the Correlations switch
// was retired still carries that key; `passthrough` keeps it harmless and
// the restore writes nothing for it.
assistantDocumentAiEnabled: z.boolean().optional(),
moduleAvailabilityJson: z.unknown().nullable(),
documentMaxFileBytes: z.number().int(),
documentQuotaBytes: z.string().regex(/^\d+$/),
@@ -164,7 +164,7 @@ describe("canonical disaster-recovery backup round-trip", () => {
assistantCoachEnabled: false,
assistantBriefingEnabled: false,
assistantInsightStatusEnabled: false,
assistantCorrelationsEnabled: false,
assistantDocumentAiEnabled: false,
moduleAvailabilityJson: { insights: true, nutrients: false },
documentMaxFileBytes: 12_345_678,
documentQuotaBytes: BigInt("9876543210"),