feat(ai): durable provider-health ledger and credential-expired surfacing

Promote the volatile per-worker last-working cache to a Postgres
provider-health ledger so the fallback chain shares one health signal
across every instance, modelled on the atomic-upsert rate limiter.

The ledger holds a durable negative cache: an auth-class failure
(401/403) benches that provider for a cooldown instead of re-burning the
dead round-trip on every generation; a sustained hard failure backs off
briefly. The local model is never benched — it stays the guaranteed
floor of the chain. Every ledger interaction fails open, so a DB error
falls back to today's chain walk exactly.

When the user's primary provider fails auth-class, the Coach emits a
distinct credential_expired frame that deep-links to reconnect rather
than a generic "try again later".

Adds the provider_health table (migration 0112) and the
errorCredentialExpired copy across all locales.
This commit is contained in:
Marc-André Bombeck committed 2026-06-04 00:50:13 +02:00
1 parent 6db3bef413
commit fbd45f3044
15 files changed
+949 -3

No files matched your search

@@ -0,0 +1,43 @@
-- v1.11.0 W1 — durable, multi-instance provider-health ledger.
--
-- Promotes the volatile per-worker last-working cache in
-- `provider-runner.ts` to Postgres, modelled on the atomic-upsert
-- rate-limiter table so every worker shares one health signal. One row
-- per (user, provider chain entry):
-- - the runner records each generation outcome (ok / hard_failed /
-- auth_failed) and reads the row to deprioritise or skip a provider
-- inside its backoff window instead of re-burning a dead round-trip;
-- - an auth-class failure (401/403) sets `last_result = 'auth_failed'`
-- plus a `next_retry_at` cooldown — a durable negative cache that
-- also drives the proactive `credential_expired` surfacing.
--
-- Purely additive: one new table, no enum changes, no backfill. Until a
-- provider fails the table is empty and behaviour matches today.
-- Reversibility: forward-only; dropping the table loses only the cache,
-- and the runner repopulates it on the next outcome.
CREATE TABLE IF NOT EXISTS "provider_health" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"provider_type" TEXT NOT NULL,
"last_result" TEXT NOT NULL,
"last_status" INTEGER,
"consecutive_failures" INTEGER NOT NULL DEFAULT 0,
"last_ok_at" TIMESTAMP(3),
"last_failure_at" TIMESTAMP(3),
"next_retry_at" TIMESTAMP(3),
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "provider_health_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "provider_health_user_id_provider_type_key"
ON "provider_health" ("user_id", "provider_type");
CREATE INDEX IF NOT EXISTS "provider_health_user_id_last_result_idx"
ON "provider_health" ("user_id", "last_result");
ALTER TABLE "provider_health"
ADD CONSTRAINT "provider_health_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+39
View File
@@ -424,6 +424,10 @@ model User {
// scoped, time-limited, revocable read view of the health record to a
// clinician holding the raw `hls_` token (stored only as an HMAC hash).
clinicianShareLinks ClinicianShareLink[]
// v1.11.0 W1 — durable per-provider AI-health ledger (one row per
// provider chain entry) so a known-bad credential is negatively cached
// across workers instead of re-burning a 401 every generation.
providerHealth ProviderHealth[]
// v1.7.0 profile — optional patient-identity fields surfaced on the
// health-record export cover (PDF) and the FHIR `Patient` resource.
@@ -2387,6 +2391,41 @@ model RateLimit {
@@map("rate_limits")
}
/// v1.11.0 W1 — durable, multi-instance provider-health ledger.
///
/// Promotes the volatile per-worker `lastWorkingCache`
/// (`provider-runner.ts`) to Postgres, modelled on the atomic-upsert
/// rate-limiter pattern so every worker shares one signal. One row per
/// (user, providerType): the runner records each outcome and reads the
/// row to (a) deprioritise / skip a provider inside its backoff window
/// instead of re-burning a dead round-trip every call, and (b) hold a
/// durable negative-cache for an auth-class failure (401/403 =
/// credential dead) far longer than the old 1h in-memory TTL, surfacing
/// it rather than silently retrying.
///
/// `nextRetryAt` is the backoff gate: while `lastResult = "auth_failed"`
/// and NOW() < `nextRetryAt`, the provider is skipped (negative cache).
/// On a success the row clears to `ok` with a null `nextRetryAt`.
/// Server-internal: never a Measurement, never ingested from a client.
model ProviderHealth {
id String @id @default(cuid())
userId String @map("user_id")
providerType String @map("provider_type") // ProviderChainType tag
lastResult String @map("last_result") // "ok" | "hard_failed" | "auth_failed"
lastStatus Int? @map("last_status") // HTTP status of the last failure; null on ok/network
consecutiveFailures Int @default(0) @map("consecutive_failures")
lastOkAt DateTime? @map("last_ok_at")
lastFailureAt DateTime? @map("last_failure_at")
nextRetryAt DateTime? @map("next_retry_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, providerType])
@@index([userId, lastResult])
@@map("provider_health")
}
// ─── Idempotency Keys (mobile-client replay protection) ──
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by