Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .planning/v1.11.1-build/qa-LEDGER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# v1.11.1 W10 QA LEDGER

## Reviewers (6): qa-code-review, qa-security, qa-senior-dev, qa-product-lead, qa-simplifier, qa-i18n-deadcode
Verdicts so far: security PASS (0C/0H/0M), code-review SHIP (0C/0H/1M), product-lead SHIP (0C/0H/2M), senior-dev ship-able (0C/0H/1M). simplifier + i18n pending.

## RECONCILED (fixed this session)
- [FIXED commit pending] M (code-review M-1 / security Info-1 / product-lead M2 / senior-dev L2): conversation-summary.ts fold-loop decrypt not fault-isolated → wrapped in try/catch (flatMap skip). 4 reviewers flagged same spot.
- [FIXED] M (senior-dev): live/rollup tiebreak divergence for ranked types with only off-ladder sources → unified JS collapse + rollup all-time SQL on `source` ASC (matches canonicalMeasurementsFrom + route CTE). Unit test updated.
- [FIXED] L (security L-1): stale `/^[A-Z_]+$/` doc comment in source-rank-sql.ts → `/^[A-Z0-9_]+$/`.

## MUST DO IN RELEASE COMMIT (product-lead M1)
- bump package.json 1.11.0→1.11.1 + `pnpm openapi:generate` (else openapi:check reds — v1.6.0/v1.8.5 trap).

## DEFERRED (Low — backlog, documented)
- code-review L-2 / fast-path redundant loadUserSourcePriority lookups (perf nit; thread userPriorityJson param) — defer.
- code-review L-3 / facts soft-cap drifts >50 (injection bounded top-8) — defer.
- code-review L-4 / route rollup read dropped take:cap (window-bounded) — defer.
- senior-dev L1 / 60s snapshot cache vs fact-delete (≤60s staleness) — CHANGELOG note.
- senior-dev L3 / groupBy=day list view sums all sources while chart is source-aware — defer + note (raw-data view vs canonical chart).
- senior-dev L4 / pre-existing delete-then-insert P2002 race widened by per-source range delete (queue retry mitigates) — defer.
- security Low-2/Info — central redaction covers worker err.message; background-only.
- CHANGELOG known-limitations: latest-tile canonical-source shift (intended R1) + 60s fact-deletion staleness.

## PENDING: read qa-simplifier.md + qa-i18n-deadcode.md when they land; reconcile any new Medium+.

## LAST 2 REVIEWERS (simplifier + i18n/knip) — reconciled
- [FIXED] HIGH (i18n/knip + simplifier S1): 3 net-new unused exports failing the enforcing knip Dead-code CI gate — SUMMARY_PROMPT_VERSION + SUMMARY_TARGET_CHARS (conversation-summary.ts) + FACTS_PROMPT_VERSION (facts.ts). Fix: deleted the 2 pure version constants (no consumer), and CONSUMED SUMMARY_TARGET_CHARS as a real safety cap on the stored summary length. Post-fix knip: 3 gone, exit clean. typecheck clean, 47 compute tests green.
- [DEFERRED — Low/perf, rationale] simplifier S2 / code-review L-2: the userPriorityJson load-once param is threaded by zero callers → fast-paths make 2-4 redundant loadUserSourcePriority lookups/request. Split rating (code-review Low, simplifier Medium); impact = sub-ms indexed PK reads, NOT correctness/security/safety; the param already exists for when it matters. Deferred to backlog to avoid ship-time scope creep across 3 loop sites.
- [DEFERRED — Low] simplifier: ExtractOpts.now dead field (facts.ts) — harmless forward-compat; stale measurement-read.ts docstring ("max count" vs source-name tiebreak) — cosmetic; residual hand-inlined canonicalMeasurementsFrom copy in summaries-slice — equivalent SQL. All Low, backlogged.
- i18n: CLEAN (zero new t() calls, no UI files, prompts server-composed). openapi:check IN SYNC.

## FINAL QA TALLY: 0 Critical / 0 High open / 0 Medium open. All Medium+ reconciled (decrypt isolation, tiebreak parity, knip unused exports). Lows deferred with rationale. SHIP-CLEARED.
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,24 @@
# Changelog

## [1.11.1] — 2026-06-04 — source-aware vitals + Coach long-term memory

Closes the three follow-ups left open by v1.11.0: cross-source vital de-duplication, the Coach's conversation memory, and durable personal facts.

### Added

- **Coach long-term memory.** The assistant now keeps a private, encrypted rolling summary of long conversations and remembers durable facts you have told it — standing preferences, goals, constraints, and conditions you have stated about yourself. Both are descriptive recall, never a diagnosis, and ride the same Coach setting; turning the Coach off stops all of it.
- **A facts surface to review and clear what the Coach has learned.** List your active facts, forget a single one, or forget everything (`GET` / `DELETE /api/insights/coach/facts`, `DELETE /api/insights/coach/facts/{id}`). Facts are stored encrypted at rest.

### Changed

- **Measurement rollups are now source-aware.** When two sources report the same standard vital on the same day (for example a resting heart rate from two devices), charts, summaries, and insights now show the higher-priority source's reading instead of a blend of the two. Cumulative metrics such as steps and energy continue to total per source. Your source-priority settings decide which reading wins.

### Notes

- The "latest" value shown for a vital with two competing sources now follows your source priority for the most recent day, so it matches the chart line. For some metrics this may surface a different device's reading than before.
- After clearing a Coach fact it can take up to a minute to disappear from the assistant's working context, in line with the existing snapshot refresh window.
- On first start after upgrading, each account's rollup cache is rebuilt once in the background; charts fall back to a live read until it converges.

## [1.11.0] — 2026-06-04 — WHOOP, a longitudinal coach, and a clinician-grade record

A multi-feature milestone across three fronts: a second connected provider, deeper Insights, and a shareable clinical record.
Expand Down
163 changes: 162 additions & 1 deletion docs/api/openapi.yaml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.11.0
version: 1.11.1
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.

Expand Down Expand Up @@ -2407,6 +2407,59 @@ paths:
"401": *a1
"422": *a2
"429": *a3
/api/insights/coach/facts:
get:
tags:
- Insights
summary: List the caller's durable Coach facts
description: v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest
first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated
(`requireAssistantSurface("coach")`). Auth via cookie or Bearer; the owner is always narrowed from the session,
never the body. Undecryptable rows are omitted rather than failing the read.
responses:
"200":
description: The caller's active facts.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactsList"
"401": *a1
"422": *a2
"429": *a3
delete:
tags:
- Insights
summary: Forget all of the caller's Coach facts
description: "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns
the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer."
responses:
"200":
description: All active facts cleared; the count is returned.
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactsCleared"
"401": *a1
"422": *a2
"429": *a3
/api/insights/coach/facts/{id}:
delete:
tags:
- Insights
summary: Forget one Coach fact
description: "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is
an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account.
Coach-gated. Auth via cookie or Bearer."
responses:
"200":
description: "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)."
content:
application/json:
schema:
$ref: "#/components/schemas/CoachFactDeleted"
"401": *a1
"422": *a2
"429": *a3
/api/insights/chat/messages/{id}/feedback:
post:
tags:
Expand Down Expand Up @@ -7761,6 +7814,114 @@ components:
- data
- error
additionalProperties: false
CoachFactsList:
type: object
properties:
data:
type: object
properties:
facts:
type: array
items:
type: object
properties:
id:
type: string
category:
type: string
enum:
- preference
- condition
- goal
- constraint
- context
description: "App-side closed category: preference | condition | goal | constraint | context."
text:
type: string
description: Decrypted fact text.
confidence:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: 0..100 server-assigned extraction confidence.
createdAt:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
required:
- id
- category
- text
- confidence
- createdAt
additionalProperties: false
description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.
required:
- facts
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachFactsCleared:
type: object
properties:
data:
type: object
properties:
cleared:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Number of active facts soft-deleted by the bulk clear.
required:
- cleared
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachFactDeleted:
type: object
properties:
data:
type: object
properties:
deleted:
type: boolean
description: True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted
id (idempotent no-op).
required:
- deleted
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CoachMessageFeedbackResponse:
type: object
properties:
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.11.0",
"version": "1.11.1",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
Expand Down
32 changes: 32 additions & 0 deletions prisma/migrations/0115_v1111_source_aware_rollups/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- v1.11.1 — source-aware measurement rollups.
--
-- `measurement_rollups` is a derived cache; the authoritative readings live in
-- `measurements`. Existing rollup rows are SOURCE-BLIND aggregates: they group
-- by (type, day) only, so two sources reporting the same standard vital for one
-- day (e.g. WHOOP + Apple Watch resting heart rate) were AVG-blended into one
-- row. Re-grain the table to (type, day, source) so the read path can collapse
-- overlapping sources to the ladder-canonical reading while cumulative metrics
-- (steps, energy) still sum per source.
--
-- The existing rows cannot be re-grained in place (their source is unknown —
-- they already blend N sources), so purge them. This is non-destructive of user
-- data: the boot-time rollup backfill (`rollup-full-backfill`) and the
-- read-time self-heal (`ensureUserRollupsFresh` + the /api/measurements
-- coverage fallback) re-mint per-source rows on next worker boot / first read.
-- Bounded, idempotent, multi-tenant-safe; no operator action required.
DELETE FROM "measurement_rollups";

-- Additive column. DEFAULT only to satisfy NOT NULL during the (now empty)
-- table rewrite, then dropped so every writer must supply an explicit source.
ALTER TABLE "measurement_rollups"
ADD COLUMN "source" "measurement_source" NOT NULL DEFAULT 'MANUAL';
ALTER TABLE "measurement_rollups" ALTER COLUMN "source" DROP DEFAULT;

-- Re-grain the primary key to include source. The hot descending index
-- (user_id, type, granularity, bucket_start DESC) is unchanged: readers fetch
-- every source for a (user, type) range and collapse in application code, so
-- they never filter by source in SQL.
ALTER TABLE "measurement_rollups" DROP CONSTRAINT "measurement_rollups_pkey";
ALTER TABLE "measurement_rollups"
ADD CONSTRAINT "measurement_rollups_pkey"
PRIMARY KEY ("user_id", "type", "granularity", "bucket_start", "source");
27 changes: 27 additions & 0 deletions prisma/migrations/0116_v1111_coach_facts/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-- v1.11.1 — durable Coach personal facts (Epic B, B-W7).
--
-- Additive, multi-tenant-safe: one new table + index + FK, no column on an
-- existing hot table, no backfill. The fact text is encrypted at rest
-- (AES-256-GCM, same codec as coach_messages.encrypted_content); category /
-- confidence / source stay plain so the injection picker can rank without a
-- per-row decrypt. Soft-delete via deleted_at keeps "forget this" auditable.
CREATE TABLE "coach_facts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"fact_encrypted" BYTEA NOT NULL,
"category" TEXT NOT NULL,
"confidence" INTEGER NOT NULL DEFAULT 50,
"source_conversation_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),

CONSTRAINT "coach_facts_pkey" PRIMARY KEY ("id")
);

CREATE INDEX "coach_facts_user_id_deleted_at_confidence_idx"
ON "coach_facts" ("user_id", "deleted_at", "confidence");

ALTER TABLE "coach_facts"
ADD CONSTRAINT "coach_facts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE ON UPDATE CASCADE;
41 changes: 40 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ model User {
devices Device[]
integrationStatuses IntegrationStatus[]
coachConversations CoachConversation[]
coachFacts CoachFact[]
coachUsage CoachUsage[]
// v1.4.25 W8d — workout passthrough (Apple HKWorkout / Withings
// getworkouts / future Strava). Schema lands now so the v1.5 iOS
Expand Down Expand Up @@ -821,6 +822,13 @@ model MeasurementRollup {
type MeasurementType
granularity RollupGranularity
bucketStart DateTime @map("bucket_start") @db.Timestamptz(3)
/// v1.11.1 — source-aware grain. Rollup rows are minted per source so the
/// read path can collapse overlapping sources (e.g. WHOOP + Apple Watch
/// resting heart rate) to the ladder-canonical reading, while cumulative
/// metrics (steps, energy) sum per source. Always non-null; migration 0115
/// purged the legacy source-blind aggregates and the boot backfill re-mints
/// per-source rows.
source MeasurementSource
count Int
mean Float
minValue Float @map("min_value")
Expand All @@ -839,7 +847,7 @@ model MeasurementRollup {

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@id([userId, type, granularity, bucketStart])
@@id([userId, type, granularity, bucketStart, source])
@@index([userId, type, granularity, bucketStart(sort: Desc)])
@@map("measurement_rollups")
}
Expand Down Expand Up @@ -2680,6 +2688,37 @@ model CoachMessage {
@@map("coach_messages")
}

// v1.11.1 (Epic B, B-W7) — durable, user-scoped personal facts the Coach
// extracts from conversations: stable preferences, conditions the user states
// about themselves, goals, constraints, durable life context. The fact TEXT is
// encrypted at rest (AES-256-GCM, the same codec as CoachMessage). `category`,
// `confidence`, and `sourceConversationId` stay plain so the injection picker
// can rank without paying a per-row decrypt. Descriptive, never diagnostic.
// Soft-delete via `deletedAt` so a user "forget this" action is reversible /
// auditable and re-extraction can avoid resurrecting it.
model CoachFact {
id String @id @default(cuid())
userId String @map("user_id")
factEncrypted Bytes @map("fact_encrypted")
// App-side closed enum (not a DB enum, to keep the migration purely additive
// and avoid an enum-migration per new category): preference | condition |
// goal | constraint | context.
category String
// 0..100 server-assigned extraction confidence.
confidence Int @default(50)
// Provenance — nullable so a future manual-entry surface can leave it null.
sourceConversationId String? @map("source_conversation_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Soft-delete: hides the fact from injection but keeps the row for audit.
deletedAt DateTime? @map("deleted_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId, deletedAt, confidence])
@@map("coach_facts")
}

// Per-user, per-day token-spend ledger for the AI Coach. The dispatcher
// reads `totalTokens` against `MAX_TOKENS_PER_USER_PER_DAY` before
// hitting any provider, so a runaway client cannot drain the operator's
Expand Down
Loading
Loading