diff --git a/CHANGELOG.md b/CHANGELOG.md index 699222b79..49ee651a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [1.37.6] — 2026-08-09 + +### Security + +- A mental-wellbeing screening reminder (PHQ-9, GAD-7, WHO-5, SCI) can no longer be marked done through a crafted request. A screening resolves only from the score a completed check-in produces, so the satisfy and complete routes now refuse a screening reminder outright rather than trusting that the app never offers that action for one. + +### Changed + +- Editing a custom mood tag, a custom tag group or a custom cycle symptom now records an audit entry, the same way deleting one already did. Removing a custom tag or symptom together with its history leaves a trace of the change. Hiding a catalogue tag and reordering the layout stay out of the audit log by design, since those are display preferences rather than a change to your record. + ## [1.37.5] — 2026-08-09 ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 46606fc1f..860444943 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.37.5 + version: 1.37.6 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 543d9c806..e3932cb05 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.37.5", + "version": "1.37.6", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 1c3f94e96..44db57c65 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.37.5"; + /* @sw-version-fallback */ "v1.37.6"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/cycle/symptoms/custom/[key]/route.ts b/src/app/api/cycle/symptoms/custom/[key]/route.ts index 7db44e80f..d11b7df09 100644 --- a/src/app/api/cycle/symptoms/custom/[key]/route.ts +++ b/src/app/api/cycle/symptoms/custom/[key]/route.ts @@ -101,6 +101,19 @@ export const PATCH = apiHandler( select: { key: true, icon: true, isActive: true, labelEncrypted: true }, }); + // Symmetry with the DELETE sibling: an edit to a custom symptom leaves a + // ledger row too. The decrypted label is never logged (see the docstring); + // we record only which fields the write touched and the resulting active + // state, so the trace stays free of reproductive free text. + await auditLog("cycle.symptom.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "cycle.symptom.custom.update" } }); return apiSuccess({ diff --git a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts index b093d3a64..eec847a26 100644 --- a/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts +++ b/src/app/api/cycle/symptoms/custom/__tests__/custom-crud.test.ts @@ -50,6 +50,7 @@ import { PATCH, DELETE } from "../[key]/route"; import { getSession } from "@/lib/auth/session"; import { requireCycleEnabled } from "@/lib/cycle/gate"; import { apiError } from "@/lib/api-response"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -215,6 +216,14 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => { ); expect(res.status).toBe(200); expect(db.cycleSymptom.count).not.toHaveBeenCalled(); + // An edit leaves a ledger row too, symmetric with the DELETE sibling. + // The decrypted label is never in the audit details, only the field names. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "cycle.symptom.custom.update", + expect.objectContaining({ + details: expect.objectContaining({ fields: expect.any(Array) }), + }), + ); }); it("soft-deactivates by default and hard-deletes on ?purge=true", async () => { diff --git a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts index 5a48a0bf1..c6aa2b7ca 100644 --- a/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts +++ b/src/app/api/measurement-reminders/[id]/complete/__tests__/route.test.ts @@ -144,4 +144,33 @@ describe("POST /api/measurement-reminders/[id]/complete", () => { expect(res.status).toBe(404); expect(satisfyReminderMock).not.toHaveBeenCalled(); }); + + it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => { + // A crafted completion of a PHQ-9 screening: the client never offers this + // (it routes to /mental-wellbeing), and a screening may only resolve from + // the server-written score row. The route must not let it read as done. + findFirstMock.mockResolvedValue({ ...ROW, measurementType: "PHQ9_SCORE" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(409); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("still allows a manual completion of a typed numeric reminder", async () => { + // Typed numeric reminders (a BP row here) legitimately support a manual + // "complete"; the screening guard must not catch them. + findFirstMock.mockResolvedValue(ROW); // measurementType: BLOOD_PRESSURE_SYS + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/app/api/measurement-reminders/[id]/complete/route.ts b/src/app/api/measurement-reminders/[id]/complete/route.ts index ce3a8d1fb..23fcc71b6 100644 --- a/src/app/api/measurement-reminders/[id]/complete/route.ts +++ b/src/app/api/measurement-reminders/[id]/complete/route.ts @@ -29,6 +29,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; type RouteParams = { params: Promise<{ id: string }> }; @@ -53,6 +54,19 @@ export const POST = apiHandler( return apiError("Measurement reminder not found", 404); } + // Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI) + // resolves ONLY from the server-written *_SCORE row a completed check-in + // produces — never from a manual "complete." The client routes a screening + // to `/mental-wellbeing` instead of offering this action, so a completion + // landing here is a crafted request; refusing it keeps a screening from + // reading as completed with no assessment behind it. + if (isScreeningReminderType(existing.measurementType)) { + return apiError( + "Screening reminders resolve from a completed check-in, not a manual completion", + 409, + ); + } + const userRow = await prisma.user.findUnique({ where: { id: user.id }, select: { timezone: true }, diff --git a/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts new file mode 100644 index 000000000..796291334 --- /dev/null +++ b/src/app/api/measurement-reminders/[id]/satisfy/__tests__/route.test.ts @@ -0,0 +1,145 @@ +/** + * v1.17.1 — manual "Erledigt" (satisfy) route. + * + * Covers: a free-text Vorsorge resolves through the shared primitive, + * owner-scoped 404 on a cross-user / tombstoned id, and the defense-in-depth + * screening refusal (a screening reminder may only resolve from the + * server-written score row, never a crafted manual satisfy). + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: unknown>(fn: T) => fn, + requireAuth: vi.fn(async () => ({ user: { id: "u1", locale: "en" } })), + requireRecordAuth: vi.fn(async () => ({ + user: { id: "u1", locale: "en" }, + actor: { id: "u1", locale: "en" }, + grantId: null, + })), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +const satisfyReminderMock = vi.fn(); +vi.mock("@/lib/measurement-reminders/satisfy", () => ({ + satisfyReminder: (...args: unknown[]) => satisfyReminderMock(...args), +})); + +const findFirstMock = vi.fn(); +const findUserMock = vi.fn(); +const findUniqueOrThrowMock = vi.fn(); +vi.mock("@/lib/db", () => ({ + prisma: { + measurementReminder: { + findFirst: (...a: unknown[]) => findFirstMock(...a), + findUniqueOrThrow: (...a: unknown[]) => findUniqueOrThrowMock(...a), + }, + user: { findUnique: (...a: unknown[]) => findUserMock(...a) }, + }, +})); + +import { POST } from "../route"; + +const ROW = { + id: "r1", + userId: "u1", + label: "Blutbild", + measurementType: null, // free-text Vorsorge (resolves only on a manual satisfy) + intervalDays: 365, + rrule: null, + anchorDate: null, + endsOn: null, + origin: "VORSORGE", + notifyHour: 9, + location: null, + nextDueAt: new Date("2026-06-25T07:00:00Z"), + lastSatisfiedAt: null, + enabled: true, + createdAt: new Date("2026-06-01T00:00:00Z"), + updatedAt: new Date("2026-06-18T00:00:00Z"), + deletedAt: null, +}; + +function makeRequest(): NextRequest { + return new NextRequest( + "http://localhost/api/measurement-reminders/r1/satisfy", + { method: "POST" }, + ); +} + +const params = { params: Promise.resolve({ id: "r1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + findUserMock.mockResolvedValue({ timezone: "Europe/Berlin" }); +}); + +describe("POST /api/measurement-reminders/[id]/satisfy", () => { + it("satisfies a free-text Vorsorge via the shared primitive", async () => { + findFirstMock.mockResolvedValue(ROW); + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2027-06-25T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.error).toBeNull(); + expect(body.data.id).toBe("r1"); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); + + it("is owner-scoped: a cross-user reminder 404s and never satisfies", async () => { + findFirstMock.mockResolvedValue({ ...ROW, userId: "someone-else" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("404s a tombstoned / missing reminder", async () => { + findFirstMock.mockResolvedValue(null); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(404); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("refuses a screening reminder with 409 and never satisfies (defense-in-depth)", async () => { + findFirstMock.mockResolvedValue({ ...ROW, measurementType: "GAD7_SCORE" }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(409); + expect(satisfyReminderMock).not.toHaveBeenCalled(); + }); + + it("still allows a manual satisfy of a typed numeric reminder", async () => { + findFirstMock.mockResolvedValue({ + ...ROW, + measurementType: "BLOOD_PRESSURE_SYS", + }); + satisfyReminderMock.mockResolvedValue({ + satisfied: true, + nextDueAt: new Date("2026-07-02T07:00:00Z"), + }); + findUniqueOrThrowMock.mockResolvedValue({ + ...ROW, + measurementType: "BLOOD_PRESSURE_SYS", + lastSatisfiedAt: new Date("2026-06-18T08:00:00Z"), + }); + + const res = await POST(makeRequest(), params); + expect(res.status).toBe(200); + expect(satisfyReminderMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/measurement-reminders/[id]/satisfy/route.ts b/src/app/api/measurement-reminders/[id]/satisfy/route.ts index dc24728fb..7af4ccbb7 100644 --- a/src/app/api/measurement-reminders/[id]/satisfy/route.ts +++ b/src/app/api/measurement-reminders/[id]/satisfy/route.ts @@ -15,6 +15,7 @@ import { apiSuccess, apiError, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { satisfyReminder } from "@/lib/measurement-reminders/satisfy"; import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto"; +import { isScreeningReminderType } from "@/lib/validations/measurement-reminders"; type RouteParams = { params: Promise<{ id: string }> }; @@ -39,6 +40,19 @@ export const POST = apiHandler( return apiError("Measurement reminder not found", 404); } + // Defense-in-depth: a screening reminder (PHQ-9 / GAD-7 / WHO-5 / SCI) + // resolves ONLY from the server-written *_SCORE row a completed check-in + // produces — never from a manual "done." The client already routes a + // screening to `/mental-wellbeing` instead of offering this action, so a + // satisfy landing here is a crafted request; refusing it keeps a screening + // from reading as completed with no assessment behind it. + if (isScreeningReminderType(existing.measurementType)) { + return apiError( + "Screening reminders resolve from a completed check-in, not a manual satisfy", + 409, + ); + } + const userRow = await prisma.user.findUnique({ where: { id: user.id }, select: { timezone: true }, diff --git a/src/app/api/mood/tags/[key]/hidden/route.ts b/src/app/api/mood/tags/[key]/hidden/route.ts index fb7dc4d6a..c1ec8157c 100644 --- a/src/app/api/mood/tags/[key]/hidden/route.ts +++ b/src/app/api/mood/tags/[key]/hidden/route.ts @@ -62,6 +62,11 @@ export const PUT = apiHandler( }); } + // No audit row by design: hiding or showing a catalogue tag is a per-user + // display preference, not a health-data write. It destroys nothing (the + // catalogue tag and every entry that used it are untouched), so a ledger + // row here would be noise rather than forensics. The wide-event annotate + // still carries it for observability. annotate({ action: { name: "mood.tag.hidden.set" }, meta: { hidden: parsed.data.hidden }, diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts index 24e5e363e..e3c8d83da 100644 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/custom-crud.test.ts @@ -44,6 +44,7 @@ import { POST } from "../custom/route"; import { PATCH, DELETE } from "../custom/[key]/route"; import { PUT } from "../[key]/hidden/route"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; const SESSION_OK = { session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -247,6 +248,14 @@ describe("PATCH/DELETE /api/mood/tags/custom/:key", () => { ); expect(purge.status).toBe(200); expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } }); + // A purge cascades `mood_entry_tag_links` and unpicks history, so it must + // leave a ledger row recording that it happened. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "mood.tag.custom.delete", + expect.objectContaining({ + details: expect.objectContaining({ purge: true }), + }), + ); }); }); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts index a66d250c8..ce4ca9ad9 100644 --- a/src/app/api/mood/tags/__tests__/groups-crud.test.ts +++ b/src/app/api/mood/tags/__tests__/groups-crud.test.ts @@ -55,6 +55,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../groups/route"; import { PATCH, DELETE } from "../groups/[key]/route"; import { getSession } from "@/lib/auth/session"; +import { auditLog } from "@/lib/auth/audit"; import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags"; const SESSION_OK = { @@ -228,6 +229,13 @@ describe("DELETE /api/mood/tags/groups/:key", () => { expect(db.moodTagCategory.update).not.toHaveBeenCalled(); // Re-home still ran first — purge removes the group, never the tags. expect(db.moodTag.updateMany).toHaveBeenCalled(); + // A group delete/purge leaves a ledger row recording purge + re-home. + expect(vi.mocked(auditLog)).toHaveBeenCalledWith( + "mood.tag.group.delete", + expect.objectContaining({ + details: expect.objectContaining({ purge: true }), + }), + ); }); it("404s a non-custom key and a foreign group", async () => { diff --git a/src/app/api/mood/tags/custom/[key]/route.ts b/src/app/api/mood/tags/custom/[key]/route.ts index 3b28228e7..2c46dd878 100644 --- a/src/app/api/mood/tags/custom/[key]/route.ts +++ b/src/app/api/mood/tags/custom/[key]/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { updateCustomTagSchema, encryptCustomLabel, @@ -89,6 +91,17 @@ export const PATCH = apiHandler( }, }); + // The custom label is encrypted at rest and never logged; the ledger row + // records only which fields the edit touched and the resulting active state. + await auditLog("mood.tag.custom.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "mood.tag.custom.update" } }); return apiSuccess({ @@ -133,6 +146,14 @@ export const DELETE = apiHandler( }); } + // A purge hard-deletes the tag and cascades its `mood_entry_tag_links`, + // unpicking historical entries — a destruction that must leave a ledger row. + await auditLog("mood.tag.custom.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { purge }, + }); + annotate({ action: { name: "mood.tag.custom.delete" }, meta: { purge } }); return apiSuccess({ key, purged: purge }); diff --git a/src/app/api/mood/tags/custom/route.ts b/src/app/api/mood/tags/custom/route.ts index 4aece227e..713099674 100644 --- a/src/app/api/mood/tags/custom/route.ts +++ b/src/app/api/mood/tags/custom/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { createCustomTagSchema, mintCustomTagKey, @@ -86,6 +88,14 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + // The custom label is encrypted at rest and never logged; the ledger row + // records the mint (icon only) so a created tag leaves a trace. + await auditLog("mood.tag.custom.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { icon: created.icon }, + }); + annotate({ action: { name: "mood.tag.custom.create" }, meta: { icon: created.icon }, diff --git a/src/app/api/mood/tags/groups/[key]/route.ts b/src/app/api/mood/tags/groups/[key]/route.ts index bdc07e508..61bbf92e5 100644 --- a/src/app/api/mood/tags/groups/[key]/route.ts +++ b/src/app/api/mood/tags/groups/[key]/route.ts @@ -4,11 +4,13 @@ import { prisma, toJson } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { updateCustomGroupSchema, encryptCustomLabel, @@ -72,6 +74,17 @@ export const PATCH = apiHandler( }, }); + // The group label is encrypted at rest and never logged; the ledger row + // records only which fields changed and the resulting active state. + await auditLog("mood.tag.group.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + fields: Object.keys(parsed.data), + isActive: updated.isActive, + }, + }); + annotate({ action: { name: "mood.tag.group.update" } }); return apiSuccess({ @@ -144,6 +157,14 @@ export const DELETE = apiHandler( return rehomed.count; }); + // Non-destructive to tags and links (they re-home), but the group row is + // retired or purged; the ledger records which, and how many tags re-homed. + await auditLog("mood.tag.group.delete", { + userId: user.id, + ipAddress: getClientIp(request), + details: { purge, rehomedCount }, + }); + annotate({ action: { name: "mood.tag.group.delete" }, meta: { purge, rehomed_count: rehomedCount }, diff --git a/src/app/api/mood/tags/groups/route.ts b/src/app/api/mood/tags/groups/route.ts index b0b385809..beb867ba0 100644 --- a/src/app/api/mood/tags/groups/route.ts +++ b/src/app/api/mood/tags/groups/route.ts @@ -4,11 +4,13 @@ import { prisma } from "@/lib/db"; import { apiSuccess, apiError, + getClientIp, returnAllZodIssues, safeJson, } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; import { createCustomGroupSchema, mintCustomCategoryKey, @@ -67,6 +69,14 @@ export const POST = apiHandler(async (request: NextRequest) => { select: { key: true, icon: true }, }); + // The group label is encrypted at rest and never logged; the ledger row + // records the mint (icon only) so a created group leaves a trace. + await auditLog("mood.tag.group.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { icon: created.icon }, + }); + annotate({ action: { name: "mood.tag.group.create" }, meta: { icon: created.icon }, diff --git a/src/app/api/mood/tags/layout/route.ts b/src/app/api/mood/tags/layout/route.ts index 2e1ea0464..3294d78a0 100644 --- a/src/app/api/mood/tags/layout/route.ts +++ b/src/app/api/mood/tags/layout/route.ts @@ -121,6 +121,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { }); if ("conflict" in guarded) return guarded.conflict; + // No audit row by design: the layout blob is a per-user display ordering + // (group order + tag placements), not a health-data write. It destroys + // nothing and fires on every drag-to-reorder save, so a ledger row here + // would be noise rather than forensics. The wide-event annotate still + // carries it for observability. annotate({ action: { name: "mood.tag.layout.update" }, meta: {