Add GET /api/documents/inbound/capability (document provider order + vendor-blind
egress class), repoint the vault capability hook to it, and show a per-egress
notice in the detail sheet before a document read leaves the machine. Cover the
document provider order, the egress consent gate, and the notice with tests.
i18n x6 + OpenAPI regenerated.
Co-Authored-By: Marc-André Bombeck
Repoint suggest/summary/extract/index/reindex routes and the content-index
backfill job to the document-scoped resolvers (local-first, codex last) and the
pick-based document-egress consent gate. Labs OCR and Coach/insights untouched.
Co-Authored-By: Marc-André Bombeck
Add a document-class provider resolution that demotes external/train-by-default
providers below local (local -> BYOK no-train API -> operator key -> codex last)
and a pick-based document-egress consent gate that requires an active receipt
for any external provider. Coach/insights keep the app-wide cost-first order.
Co-Authored-By: Marc-André Bombeck
Unit: the shared ShareLinkCreateForm mounts bare (Settings default) and
seeded (document flow) — a pre-attached document rides in as a chip with
the label pre-filled. e2e: the document detail Share action opens the
create flow with that document pre-attached and reaches the one-time QR
reveal; marked slow (two stacked sheets + a real create).
Extract the clinician share-link create flow (create form, document
picker mount, one-time QR + passphrase reveal) from the Settings sharing
section into a reusable ShareLinkCreateForm. Settings mounts it unchanged
alongside the active/revoked lists; the document detail sheet adds a
Share action beside Download that opens the same flow in a bottom sheet
with the current document pre-attached and its title pre-filled as the
label. The one-time QR reveal lands in that sheet, scannable on mobile.
No backend change: the shared POST /api/share-links handles the attached
document set as before.
Add resolveHeroRingOrder unit coverage (default health-score-first,
reconcile against selected set, drop/append, dedupe) plus resolver/serializer
round-trip; hero SSR tests for the default lead + a persisted custom order +
dropped-when-unrendered; Settings picker tests for the pinned health-score
anchor row, its locked-on switch, drag/arrow reorder, and a persisted
non-leading order. Extend the score-rings picker description across six
locales.
Add a persisted hero ring ORDER over the health-score anchor ring plus the
selected score rings. Defaults to the health-score ring on the leading edge;
the user can reorder the full set (health score included) from Settings →
Darstellung with the existing drag-handle + arrow-button pair.
The order rides the dashboard-layout blob (dashboardWidgetsJson) — no
migration. resolveHeroRingOrder reconciles a stored order against the current
ring set on every read/serialize, so a deselected ring drops and a
newly-selected ring appends; the hero orders only the rings it actually
renders.
Give each facet trigger a leading icon and show the text label from sm up
(and on phones only while the facet is active, truncated), so three facets
plus the search never overflow a 360-430px row — the bar stays one line
without a horizontal scroller. Drop the now-dead filter.groupLabel key and
point the vault e2e flows at the dropdown menu items instead of the removed
inline chips.
- Dashboard hero: pull the briefing closer to the ring row (md:gap-3 -> md:gap-2); mobile spacing unchanged.
- Each hero ring is now a focusable link to its natural detail: readiness/recovery/sleep -> their score-anatomy page, doses -> the medications surface, health score -> the Insights overview. Real anchors keep tap-vs-drag native on the mobile carousel.
- Mental-wellbeing: shorten the page title to the short noun form (matches the nav/module label) across all six locales.
- Instrument cards: move the required attribution/licence line off the space under the Start button to a discreet info control at the top-right of the header; a popover carries the verbatim citation. Start now stands alone.
The vault filter bar rendered the type/condition/year facets as a
horizontally-scrolling chip set beside the search field, which wrapped to a
second line on phones. Replace it with three compact dropdown facets — type
(multi-select), condition and year (single-select) — that share one
non-wrapping row with the flexing search and the clear control at every
width. The content-search hint keeps its own helper line below.
Below md the hero wellness rings no longer wrap into a multi-row grid;
a dependency-free CSS scroll-snap track shows one ring centred at a time
with dot indicators, native touch/keyboard swipe, and reduced-motion
respected. At md and up the same nodes revert to the inline
right-aligned row unchanged. Desktop tightens the greeting/ring-row drop
to the briefing one step (md:gap-3).
The usage endpoint counted every DocumentContentIndex row for the user,
including those whose parent document has been soft-deleted (the index
row survives until the hard purge). Against totalCount, which filters
live documents, the gauge could read past 100 %. Scope the indexed count
to live documents so coverage never exceeds the corpus.
Also drop a redundant identical-branch ternary in the per-document
content-index status row.
The list search already unions matches from the encrypted content index
server-side; surface it so it is legible and reachable:
- a subtle "search now looks inside your documents too (whole words)" hint
under the search box once any document is indexed;
- a per-row "searchable" marker on indexed documents;
- an "index all for search" affordance that fires the corpus backfill when
some documents are not yet indexed, driven off the usage coverage gauge.
All of it is gated on the same provider/consent precondition as the assist
layer — with no provider, search degrades to title and filename exactly as
before.
Add an optional AI layer to a stored document, gated on the existing AI
provider and consent — no new toggle. All of it rides the shared OCR
capability probe so the client only posts a transport the endpoint accepts
(vision empty-body POST, or local-OCR text posted from the browser).
- "Suggest details" prefills the title / type / date as reviewable DRAFTS.
Nothing is written on its own: the title lands in the editable field and
is saved through the existing commit path; type and date each apply on an
explicit tap. The card carries a visible "review before saving" affordance.
- A summary / extracted-text button opens a transient panel that renders the
result once and always shows a "not saved / not a diagnosis" note; closing
it discards the text. Nothing persists beyond the view.
- When no provider is configured the AI area is a calm pointer to the AI
settings, never an error; the manual metadata form stays fully usable.
The detail-sheet AI area is lifted into a presentational section component so
the availability gate and the review-first wiring render without the sheet's
dialog portal and are pinned by static-render tests. Strings land in all six
locales.
P3-1-T6. loadShareViewData now returns the link's document set as metadata only
(id, title, kind, documentDate, byteSize, servingClass) — scoped to the
membership rows, the owner, and live rows; the blob column is never selected
(the serve route is the only decrypt path). Register documentCount on the share
summary, documentIds on create, and the public /c/{token}/d/{id} serve route in
the OpenAPI contract; regenerate docs/api/openapi.yaml.
P3-1-T4. Cover token confinement to the frozen set, EXIF strip on egress,
Class A inline vs Class B attachment posture, HTML/SVG never inline, foreign /
soft-deleted / owner-mismatch id → 404, revocation-and-passphrase-before-
decrypt, per-link rate limit, and fail-closed decrypt.
P3-1-T2/T4/T5. Extend createShareLinkSchema with an optional, bounded
documentIds set; validate each id as the caller's own live document and create
the membership rows in the same transaction (frozen write-once). Summary DTOs
gain documentCount (never ids, never bytes).
Add the public share-scoped serve route /c/<token>/d/<id>: token-resolved
(never requireAuth), passphrase-gated via the token-scoped unlock cookie,
revocation/expiry re-checked before any decrypt, confined to the link's frozen
document set (foreign/deleted id → flat 404), fail-closed decrypt, identical
serving-class posture as the owner route (inline Class A / attachment Class B,
nosniff, no-store), EXIF-stripped for raster images on egress, per-link rate
limit.
Carve the serve path out of the proxy's blanket DENY posture with the same
document CSP as the owner route (default-src 'none'; frame-ancestors 'self'),
anchored to exactly two dynamic segments so no lookalike over-matches.
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.
P3-1-T3. Dependency-free metadata stripper for JPEG (drops APP1/APP13/COM),
PNG (drops eXIf/tEXt/zTXt/iTXt/tIME), and WebP (drops EXIF/XMP chunks and
clears the VP8X flag bits). No pixel decode, no native dependency; image data
copies through verbatim. Applied on egress only — the stored original is never
rewritten. Non-raster types (PDF/TIFF/HEIC/Office) and any unparseable input
pass through unchanged (fail-safe).
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.
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.
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.