P2-1. End-to-end: TEXT-mode indexing persists only ciphertext text + opaque
HMAC tokens (A4), a body-only word finds the document through the GIN token
overlap while the list still omits the blob, a word that appears nowhere does
not match, re-indexing upserts in place, and hasContentIndex + the usage gauge
reflect the index. Migration 0228 applies clean on the fresh testcontainer.
P2-1. The list GET now issues a documentContentIndex lookup and unions a
content-token OR branch when a search term is present, so the existing route
test gains the mock + the third OR branch in its where assertion. Fix the
backfill test's findFirst mock signature under strict tsc.
P2-1. Unit tests for the token scheme (whole-word match, stopword drop,
de-accent, no plaintext at rest, subkey follows the active key), HKDF subkey
derivation, the suggest route (drafts only, writes nothing, provider-gated),
the summary route (session-only, budget+audit only), the index route (vision
transcribe→upsert, text posts OCR text, provider/local-OCR gated), and the
backfill (no-provider/no-consent no-op, indexes, budget-reached resume).
P2-1-T7/T8/T9.
- document-content-index-backfill: on-demand per-user pg-boss job that indexes
a user's not-yet-indexed image/PDF documents via one provider transcription
each. Consent + budget gated, id-cursor forward walk, capped per run, stops
on budget exhaustion, resumable. Queue + worker registered in the maintenance
registrar (no cron — deliberate action only). POST /reindex triggers it.
- rotation: register DocumentContentIndex.textEncrypted and add a dedicated
block that re-encrypts the text AND re-tokenises the blind index under the new
active HKDF subkey in one update (P2-D7).
- OpenAPI: suggest / summary / index / reindex paths; hasContentIndex on the
document schema; assistAvailable + contentIndex on usage. Regenerated
docs/api/openapi.yaml.
P2-1-T2/T3/T4/T5/T6.
- assist.ts + [id]/suggest: one provider call proposes a { title, kind,
documentDate } filing draft over the stored original or browser-OCR text.
Runs the full extract gauntlet (module → provider → consent → rate-limit →
budget) but writes nothing — never touches the fact rail or status.
- describe.ts + [id]/summary?mode=summary|text: session-only summary or raw
transcribed text. Descriptive only, no diagnosis; persists only the budget
ledger + audit.
- content-index.ts + [id]/index: normalise → tokenise (de-accent, stopword
drop, whole-word) → HMAC-SHA256 under an HKDF-derived subkey → store the
token array plus AES-256-GCM ciphertext of the text. Vision path transcribes
via the provider (consent + budget); text path indexes posted local-OCR text
(no egress). Gated on the existing AI consent — no new per-user toggle.
- crypto.deriveSubkey: HKDF-SHA256 purpose-bound subkey off the active key.
- list search now unions title/filename ILIKE with a GIN-accelerated
whole-word content-token match; DTO gains hasContentIndex.
- usage gains assistAvailable + contentIndex { enabled, indexedCount,
totalCount }; detail gains hasContentIndex.
P2-1-T1. Migration 0228 adds the 1:1 document_content_index sibling of
inbound_documents: AES-256-GCM ciphertext of the extracted text plus a
deduped HMAC-SHA256 token array (search_tokens) with a GIN index for the
array-overlap match the list search will union with title/filename ILIKE.
Both foreign keys cascade. Nothing readable sits at rest; the index subkey
is HKDF-derived at runtime, so tokens are opaque without the server key.
The Modules hub listed Coach and Cycle as read-only "manage in X" deep-links
because their enabled-state is owned outside `modulePreferencesJson` (Coach in
Coach settings, Cycle in the Account card). Give both a real switch like every
other module, driving the SAME canonical state instead of a second copy:
- Coach → `PATCH /api/auth/me/disable-coach` (`User.disableCoach`, inverted);
the switch reads `!disableCoach`.
- Cycle → `PATCH /api/auth/me/cycle-prefs` (`cycleTrackingEnabled`); the switch
reads the resolved per-user gate.
Precedence stays honest. A module the operator turned off server-wide can never
be re-enabled by a per-user toggle, so the switch renders disabled with a
"disabled server-wide" hint. For Coach the operator layer is BOTH the
module-availability blob AND the assistant master flag (`flags.coach`); for
Cycle it is the module-availability blob. Each delegated row keeps a small
"manage" deep-link beside its switch, since the canonical surface carries more
than on/off (Coach cadence/memory; cycle goal, predictions, lengths).
Flipping either switch invalidates `authMe()` so every gated surface re-gates
live; the cycle flip also evicts its own `cyclePrefs`/`cycle` reads, matching
the canonical cycle card. Reuses the existing endpoints — no new route, no
OpenAPI change.
The greeting drifted to the vertical centre of the taller ring column
and the briefing sat a full step below it — the top row now aligns to
its start and the section gap tightens, so the greeting anchors to the
top edge and the briefing follows closely.
The band-fallback ring green landed on its own hue (#3fb868) while every
positive signal and trend delta beside it used --success (#50fa7b); a
green ring now lands on --success too, so 'green' reads as one green
across rings, signals, and deltas. Computed contrast 11.9:1 dark /
6.1:1 light — well past the graphics floor.
- width: drop the /documents wide-content exception; the page uses the
standard content frame like every other route (the multi-column
timeline still fits, and no page should be wider than the rest).
- upload: retire the standing dashed drop card — it took a vertical band
on every visit for what the header Upload button and the page-wide
drag-and-drop overlay already cover. The hidden input and the
above-80% quota bar stay.
- filter: search shares one row with the tag chips on desktop instead of
sitting on its own line above them.
- detail: move Delete to the bottom-left as a quiet text button; the
bottom-right slot reads as the primary/confirm action, so a
destructive control there is a footgun. Download keeps the trailing edge.
A twice-daily card advances its display-due to the evening slot once the
morning slot's catch-up window has lapsed, so a late-morning "Genommen"
tap posts `scheduledFor = <evening slot>` with `takenAt = now`. Band
attribution correctly refuses the take (it lands in no window), but the
intake routes' source-agnostic convergence probe then bound it to the
evening slot's pending REMINDER row — recording the morning dose as the
evening one, silently consuming a slot the user had not reached, and
jumping the dashboard "next intake" to the following day.
Gate the convergence probe with `mayConvergeOntoSuppliedSlot`: a taken
write may not converge onto a slot whose anchor is in the future relative
to the take (the same forward-grace invariant `resolveCanonicalSlotInstant`
already enforces). Off-window late takes now record standalone (ad-hoc),
leaving both real slots intact; skips and pending echoes still name their
slot deliberately. Applied to both the per-medication and bulk intake
routes.
Chromium's PDF viewer steals focus while it initialises — before the
frame's load event fires (and in environments without a PDF plugin the
load event may not fire at all), so a load-armed guard never engaged and
Escape stayed dead after opening a preview. The guard now opens when the
preview mounts, extends on load, bounces any focus landing on the frame
back to the dialog for its duration, and ends early on a deliberate
pointer interaction with the preview.
Pins the doctor-flow script (type chip, condition chip, text search —
three independent routes to one document), the URL deep-link contract
(?kind / ?episode / ?year / ?doc), delete with undo, the bulk flows
(50-item kind change, per-id partial failure, client-side chunking above
100 ids), keyboard-only navigation over the roving grid, page-wide drop
and clipboard-paste intake, the duplicate-upload contract, the admin
per-file cap (commit-on-Enter, rejection copy naming the limit), the
touch long-press selection on the mobile project, and axe scans over the
timeline, the open detail sheet, and bulk mode.
The fixture seeds the 60-document corpus straight through Postgres —
the upload endpoint's 60/hour bucket cannot carry API seeding across
two Playwright projects — with blobs encrypted in the same binary2
layout the server writes, so previews exercise the real decrypt path.
Idempotent throughout; mutating tests use disposable namespaces.
Chromium's PDF viewer grabs focus asynchronously once the inline preview
initialises, stranding keyboard events inside the embedded document —
Escape stopped closing the detail sheet the moment a PDF rendered. Watch
for the steal over a short bounded window after load and hand focus back
to the dialog container; a user who clicks into the PDF afterwards is
left alone.
The filter bar derived its condition chips from the loaded corpus, so an
episode whose only linked documents sat pages deep in the timeline never
surfaced a chip — the condition route into an older document silently
disappeared at four-digit corpus sizes. The usage endpoint (already read
on page mount) now returns `linkedEpisodes` — every episode carrying at
least one live document link, one indexed grouped query, no blobs — and
the view unions it with the active-filter fallback. Additive DTO change;
OpenAPI regenerated.
Concurrent uploads all read the same pre-insert usage SUM, so a burst
could overshoot the per-user quota by rate-limit x cap in one window. A
transaction-scoped advisory lock keyed on the user id serialises the
sum-and-insert; uploads by different users never queue on each other.
Integration probes pin both races: four parallel uploads against a
two-file quota land exactly two, and two racing identical uploads
collapse onto one row via the partial unique index.
Also aligns the round-trip integration test with the serve-route CSP
posture: the proxy carve-out owns the response CSP, the route sets none.
On phones the four score rings wrapped into a 2-up grid clustered in
the centre with dead margin on either side. They now spread evenly
across the full width — equal space before, between, and after — so the
block reads symmetric on all four sides instead of centred; desktop's
right-aligned row is unchanged.
The e2e job called next build directly, skipping the prebuild hook
that generates the service-worker version stamp and the static i18n
catalog assets — fourteen specs timed out waiting on a UI that never
got its catalog.
The one-shot deep-link consumption moves to the render-phase derived-
state pattern the view already uses for its search draft (the effect
variant trips the set-state-in-effect rule), gated on resolved auth so
a deep link never burns before the module flag is known.
Refs: document-vault plan W3-T4
Rewrite the inboundDocuments module description in every locale to
describe what the module now is — one encrypted, searchable, condition-
linked store — instead of the retired extraction flow (the display name
stays Dokumente/Documents; the module key is wire contract). Harmonise
the Italian and Polish vault terms with each locale's existing
vocabulary (patologia, powiązać). Reverse-coverage guard confirms no
orphaned keys remain from the pre-vault surface.
Refs: document-vault plan W3-T6
The two vault knobs become editable where operators already work: the
app-settings card gains the per-file upload cap (MB, 1-100, mirrors the
hard server ceiling) and the default per-account storage quota (GB,
commit-on-blur with clamped conversion to bytes); the user edit form
gains the per-user quota override (GB, empty = instance default). The
admin users list now carries the override so the form can show it.
Refs: document-vault plan W3-T7
/labs gains a quiet link to the LAB_RESULT-filtered vault view (the
original report PDFs behind the values); the health-record export panel
links to the vault filtered to the current year next to its date-range
picker (navigation only — attaching documents to the export is out of
scope here). Both render only when the documents module is enabled for
the account, in all six locales.
Refs: document-vault plan W3-T5
The illness-episode detail gains a Dokumente card: the episode's most
recent linked documents as compact rows (deep-linking into the vault,
where ?doc= now opens the detail sheet directly), a link-existing picker
(searchable, toggle rows driving the idempotent bulk link/unlink), an
upload entry that lands pre-linked via the episode filter, and a
filtered show-all view. Module-gated — accounts without the documents
module see nothing. An episode without documents keeps a quiet one-line
affordance instead of an empty card. The preview and picker reads get
their own query keys; reusing the vault list key would collide with the
infinite-query shape.
Refs: document-vault plan W3-T4
Roving tabindex over the card grid: arrows move (grid-aware by measured
column count), Home/End jump, Enter opens, Space selects, Delete removes
with the undo toast; focus rides the virtualizer window (scroll-to-row,
bounded retry focus). The container carries list semantics with a
translated label, and the transient upload queue stays a polite live
region. Selection state is conveyed by the per-card checkbox, which the
focus ring reveals for keyboard users.
Refs: document-vault plan W3-T3
Selection grows file-manager gestures — shift-click ranges (additive,
anchored on the last plain toggle) and press-and-hold on touch — and a
floating toolbar appears with the selected count and the bulk verbs:
change type, link condition, delete, clear. All verbs drive the bulk
endpoint in <=100-id chunks with merged per-id results; partial failures
surface as one counted toast, and a bulk delete gets one aggregate undo
that bulk-restores the succeeded rows (both sides no-op-safe by
contract). Escape clears the selection unless a dialog owns the key.
Refs: document-vault plan W3-T2
On desktop the four rings sat 12px apart and the briefing section
followed the greeting row at the intra-block distance — the band read
top-heavy against its own symmetric padding. Rings now space at 24px
on md+ and the section gap steps up to match the card inset, so the
head zone and the briefing zone separate at the same rhythm the
padding sets.
The cached-snapshot read moved from the API route body into
src/lib/dashboard/snapshot-read.ts (shared by the route and the
dashboard RSC prefetch); the bidirectional allowlist follows the
user.disableCoach read to its new home.
H4
Dragging files anywhere over /documents raises a dimmed full-page drop
overlay (Escape dismisses); dropping or pasting files/screenshots feeds
the same upload queue as the picker, pre-linked to the active condition
filter. The overlay is decorative (aria-hidden) — intake announces
through an sr-only live region. Copy translated in all six locales.
Refs: document-vault plan W3-T1
The dynamic /i18n/[locale] route handler answered with Next's default
Cache-Control (private, max-age=0, must-revalidate) and no validator —
its immutable-cache header was overridden, so a non-PWA browser would
re-download the full catalog on every hard load. Static public/ files
keep an ETag (repeat loads revalidate to a 304) and the service
worker's cache-first rule covers the offline relaunch; the ?v= build
key the layout appends busts both per deploy.
scripts/generate-i18n-boot.mjs writes public/i18n/<locale>.js for all
six locales as a prebuild/predev step (the sw-version.js pattern);
output is gitignored. The route handler is removed.
Measured on a production build, authenticated DE session: dashboard
document 522 KB raw / 139 KB gz -> 170 KB raw / 24.6 KB gz; the DE
catalog moves into a one-time 119 KB gz cached asset.
H2
The proxy's serve-route carve-out owns the response CSP (default-src
'none'; frame-ancestors 'self'; no sandbox — Chromium force-downloads
sandboxed PDFs), and middleware headers overwrite route headers, so the
route-level sandbox value never reached a client. Remove it and point
both the route and its header-matrix test at the proxy pin.
Refs: document-vault plan W1-T10 posture reconciliation