diff --git a/messages/de.json b/messages/de.json index 9f4402d06..ef7fb3863 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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", diff --git a/messages/en.json b/messages/en.json index d161c041f..238396831 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/es.json b/messages/es.json index c827a0e75..477047e58 100644 --- a/messages/es.json +++ b/messages/es.json @@ -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", diff --git a/messages/fr.json b/messages/fr.json index 498ea6b2b..3ab4ed449 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -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", diff --git a/messages/it.json b/messages/it.json index f10d86210..6d2a00f08 100644 --- a/messages/it.json +++ b/messages/it.json @@ -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", diff --git a/messages/ko.json b/messages/ko.json index f4015502f..347493505 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -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": "진료용 리포트", diff --git a/messages/pl.json b/messages/pl.json index 9276ce2cd..126af9283 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -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", diff --git a/prisma/migrations/0343_ai_capability_switches/migration.sql b/prisma/migrations/0343_ai_capability_switches/migration.sql new file mode 100644 index 000000000..0829a291a --- /dev/null +++ b/prisma/migrations/0343_ai_capability_switches/migration.sql @@ -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"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 92ae22ddd..1745fce4b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 / diff --git a/src/app/api/admin/backups/[id]/restore/route.ts b/src/app/api/admin/backups/[id]/restore/route.ts index 2a2049d47..cbcf966e9 100644 --- a/src/app/api/admin/backups/[id]/restore/route.ts +++ b/src/app/api/admin/backups/[id]/restore/route.ts @@ -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), diff --git a/src/app/api/admin/settings/__tests__/route.test.ts b/src/app/api/admin/settings/__tests__/route.test.ts index 56d33df68..9745f6a41 100644 --- a/src/app/api/admin/settings/__tests__/route.test.ts +++ b/src/app/api/admin/settings/__tests__/route.test.ts @@ -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, }); }); diff --git a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts index 60fef2dba..41ad2bf8c 100644 --- a/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts +++ b/src/app/api/admin/settings/assistant-flags/__tests__/route.test.ts @@ -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 () => { diff --git a/src/app/api/admin/settings/assistant-flags/route.ts b/src/app/api/admin/settings/assistant-flags/route.ts index 6445eca22..af5c45ec5 100644 --- a/src/app/api/admin/settings/assistant-flags/route.ts +++ b/src/app/api/admin/settings/assistant-flags/route.ts @@ -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)); diff --git a/src/app/api/admin/settings/module-availability/route.ts b/src/app/api/admin/settings/module-availability/route.ts index da9dea71a..2f751edc4 100644 --- a/src/app/api/admin/settings/module-availability/route.ts +++ b/src/app/api/admin/settings/module-availability/route.ts @@ -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>, ) .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, + ), }); }); diff --git a/src/app/api/admin/settings/route.ts b/src/app/api/admin/settings/route.ts index 3c32e5953..a4baf8ee4 100644 --- a/src/app/api/admin/settings/route.ts +++ b/src/app/api/admin/settings/route.ts @@ -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, }); }); diff --git a/src/app/api/feature-flags/__tests__/route.test.ts b/src/app/api/feature-flags/__tests__/route.test.ts index 71a4b06f2..5794d542b 100644 --- a/src/app/api/feature-flags/__tests__/route.test.ts +++ b/src/app/api/feature-flags/__tests__/route.test.ts @@ -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, }); }); diff --git a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts index 479be460f..68aa38d33 100644 --- a/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts +++ b/src/app/api/insights/biomarker-assessment/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts index d0bb9f16f..9f0ea1def 100644 --- a/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts +++ b/src/app/api/insights/blood-pressure-status/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts index fbd8d1a08..5954961ac 100644 --- a/src/app/api/insights/cards/__tests__/route.test.ts +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/insights/comprehensive/__tests__/route.test.ts b/src/app/api/insights/comprehensive/__tests__/route.test.ts index 082cc649e..57adde4ab 100644 --- a/src/app/api/insights/comprehensive/__tests__/route.test.ts +++ b/src/app/api/insights/comprehensive/__tests__/route.test.ts @@ -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).mockResolvedValue( diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts index aa9ddb6d4..c22a3e35b 100644 --- a/src/app/api/insights/correlations/__tests__/route.test.ts +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -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 - ).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 - ).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 () => { diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts index 7ae6557da..20eab84b8 100644 --- a/src/app/api/insights/correlations/route.ts +++ b/src/app/api/insights/correlations/route.ts @@ -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 / diff --git a/src/app/api/insights/metric-status/__tests__/route.test.ts b/src/app/api/insights/metric-status/__tests__/route.test.ts index a98484540..196d36573 100644 --- a/src/app/api/insights/metric-status/__tests__/route.test.ts +++ b/src/app/api/insights/metric-status/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/insights/pregenerate/__tests__/route.test.ts b/src/app/api/insights/pregenerate/__tests__/route.test.ts index cec99b006..f95eacf20 100644 --- a/src/app/api/insights/pregenerate/__tests__/route.test.ts +++ b/src/app/api/insights/pregenerate/__tests__/route.test.ts @@ -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); diff --git a/src/app/api/user/ai-provider/__tests__/route.test.ts b/src/app/api/user/ai-provider/__tests__/route.test.ts index 40861013e..5d813d452 100644 --- a/src/app/api/user/ai-provider/__tests__/route.test.ts +++ b/src/app/api/user/ai-provider/__tests__/route.test.ts @@ -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", diff --git a/src/components/admin/assistant-section.tsx b/src/components/admin/assistant-section.tsx index ad1dab0c4..37a657333 100644 --- a/src/components/admin/assistant-section.tsx +++ b/src/components/admin/assistant-section.tsx @@ -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} /> - mutation.mutate({ assistantCorrelationsEnabled: checked }) + mutation.mutate({ assistantDocumentAiEnabled: checked }) } disabled={disabledSubs} /> diff --git a/src/components/admin/module-availability-section.tsx b/src/components/admin/module-availability-section.tsx index 5d1aafeb8..7e1f8513a 100644 --- a/src/components/admin/module-availability-section.tsx +++ b/src/components/admin/module-availability-section.tsx @@ -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 ( +
+ {}} + disabled + /> + + {t("admin.assistant.title")} + +
+ ); + } return ( — 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(" — assistant.insightStatus gate", () => { coach: true, briefing: true, insightStatus: false, - correlations: true, + documentAi: true, }), ); expect(html).toBe(""); @@ -78,7 +78,7 @@ describe(" — assistant.insightStatus gate", () => { coach: false, briefing: false, insightStatus: false, - correlations: false, + documentAi: false, }), ); expect(html).toBe(""); diff --git a/src/components/insights/metric-correlation-card.tsx b/src/components/insights/metric-correlation-card.tsx index 8eaba186e..81a0913f6 100644 --- a/src/components/insights/metric-correlation-card.tsx +++ b/src/components/insights/metric-correlation-card.tsx @@ -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; diff --git a/src/components/settings/__tests__/modules-section.test.tsx b/src/components/settings/__tests__/modules-section.test.tsx index 5949a2222..0edd5d450 100644 --- a/src/components/settings/__tests__/modules-section.test.tsx +++ b/src/components/settings/__tests__/modules-section.test.tsx @@ -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", () => ({ diff --git a/src/hooks/__tests__/use-feature-flags.test.tsx b/src/hooks/__tests__/use-feature-flags.test.tsx index 329e23088..150e1a8ed 100644 --- a/src/hooks/__tests__/use-feature-flags.test.tsx +++ b/src/hooks/__tests__/use-feature-flags.test.tsx @@ -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, }, }); diff --git a/src/hooks/use-feature-flags.ts b/src/hooks/use-feature-flags.ts index 68fe8451b..33880d0d4 100644 --- a/src/hooks/use-feature-flags.ts +++ b/src/hooks/use-feature-flags.ts @@ -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/` 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 { diff --git a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts index 67640b226..d2681a915 100644 --- a/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts +++ b/src/lib/analytics/score/__tests__/config-change-is-not-a-health-event.test.ts @@ -400,7 +400,7 @@ beforeEach(() => { coach: false, briefing: false, insightStatus: false, - correlations: false, + documentAi: false, }); hasAnyConfiguredProvider.mockResolvedValue(false); buildMedsTodayBlock.mockResolvedValue({ diff --git a/src/lib/dashboard/__tests__/snapshot.test.ts b/src/lib/dashboard/__tests__/snapshot.test.ts index f98613d9b..6c658260c 100644 --- a/src/lib/dashboard/__tests__/snapshot.test.ts +++ b/src/lib/dashboard/__tests__/snapshot.test.ts @@ -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, diff --git a/src/lib/export/__tests__/full-backup-payload.test.ts b/src/lib/export/__tests__/full-backup-payload.test.ts index 93626363b..e22bb20e7 100644 --- a/src/lib/export/__tests__/full-backup-payload.test.ts +++ b/src/lib/export/__tests__/full-backup-payload.test.ts @@ -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"), diff --git a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx index 12bba422f..0b5adcbfb 100644 --- a/src/lib/feature-flags/__tests__/coach-cascade.test.tsx +++ b/src/lib/feature-flags/__tests__/coach-cascade.test.tsx @@ -80,7 +80,7 @@ function buildClient(flags: Partial): QueryClient { coach: true, briefing: true, insightStatus: true, - correlations: true, + documentAi: true, ...flags, }, }); diff --git a/src/lib/feature-flags/__tests__/index.test.ts b/src/lib/feature-flags/__tests__/index.test.ts index fa39c067b..e01e3c970 100644 --- a/src/lib/feature-flags/__tests__/index.test.ts +++ b/src/lib/feature-flags/__tests__/index.test.ts @@ -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" }; diff --git a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts index e487f5813..fbb554f3b 100644 --- a/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts +++ b/src/lib/feature-flags/__tests__/no-dead-assistant-flag.test.ts @@ -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 = { + 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 } }; + 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 } }; - 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([]); + }); }); diff --git a/src/lib/feature-flags/index.ts b/src/lib/feature-flags/index.ts index 35aa82242..b8a628e85 100644 --- a/src/lib/feature-flags/index.ts +++ b/src/lib/feature-flags/index.ts @@ -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.`. */ +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/` 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/-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 { +export function loadAssistantSwitches(): Promise { return memoizePerRequest("assistant-flags", async () => { try { const settings = await prisma.appSettings.findUnique({ @@ -72,56 +67,55 @@ export async function getAssistantFlags(): Promise { 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 { + 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." } }`. * - * { data: null, error: "...", meta: { errorCode: "assistant.disabled." } } - * - * 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, diff --git a/src/lib/mcp/__tests__/tools.test.ts b/src/lib/mcp/__tests__/tools.test.ts index af886b612..e410d51b6 100644 --- a/src/lib/mcp/__tests__/tools.test.ts +++ b/src/lib/mcp/__tests__/tools.test.ts @@ -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; diff --git a/src/lib/modules/__tests__/gate.test.ts b/src/lib/modules/__tests__/gate.test.ts index be671ea46..b9e665bd9 100644 --- a/src/lib/modules/__tests__/gate.test.ts +++ b/src/lib/modules/__tests__/gate.test.ts @@ -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( diff --git a/src/lib/modules/operator-availability.ts b/src/lib/modules/operator-availability.ts index 10a10e607..022f6e36c 100644 --- a/src/lib/modules/operator-availability.ts +++ b/src/lib/modules/operator-availability.ts @@ -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; */ 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 { + 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 { 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 `{ : 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)) { - 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; } } diff --git a/src/lib/modules/registry.ts b/src/lib/modules/registry.ts index 281944342..7fc3f4cf4 100644 --- a/src/lib/modules/registry.ts +++ b/src/lib/modules/registry.ts @@ -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, diff --git a/src/lib/validations/admin.ts b/src/lib/validations/admin.ts index 12725cc4a..03c0fd463 100644 --- a/src/lib/validations/admin.ts +++ b/src/lib/validations/admin.ts @@ -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(); diff --git a/src/lib/validations/backup.ts b/src/lib/validations/backup.ts index d163e6e36..9873bbfbc 100644 --- a/src/lib/validations/backup.ts +++ b/src/lib/validations/backup.ts @@ -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+$/), diff --git a/tests/integration/admin-backups-canonical-roundtrip.test.ts b/tests/integration/admin-backups-canonical-roundtrip.test.ts index d28b3ed81..487df88b1 100644 --- a/tests/integration/admin-backups-canonical-roundtrip.test.ts +++ b/tests/integration/admin-backups-canonical-roundtrip.test.ts @@ -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"),