A narrow `documents:write` token, minted cookie-only at
`POST /api/tokens/documents` and accepted by the vault upload route alone.
A scoped caller draws on its own hourly bucket per token
(`DOCUMENT_UPLOAD_LIMIT_PER_HOUR`, default 120) and gets a receipt
`{ id, duplicate }` instead of the stored row.
Uploads take optional `sourceSystem` / `sourceId` fields. The source key is
unique per account across live and deleted documents, and the tombstone
purge carries it to a small ledger, so re-sending a document the owner
deleted answers 200 `deleted: true` and stores nothing. `aiRead=defer`
indexes locally and skips the summary and lab staging.
The detail sheet shows where an imported document came from; backup and
restore carry both fields; Delete All Data clears the ledger.
Refs #1038, #938
A content-index attempt that failed (raster failure, a scan with no
text layer, a provider error) recorded its reason only in the server's
observability stream. In the vault, hasContentIndex false looked
identical for a document nobody ever tried and one that failed three
times, and the detail sheet had nothing to say about either.
Apply the summaryState pattern to the index. Two additive columns on
InboundDocument (migration 0333) record when the last attempt finished
and why it produced no index, as a closed reason set: local-empty,
local-unsupported, decrypt-error, raster-failed, provider-error,
pdf-needs-anthropic, empty-transcription. The writer sits at the choke
point the auto job and the corpus backfill share, and in the manual
index route; a success clears the reason. The provider path's specific
failure now survives the local fallback, since "no text" is less
actionable than "the PDF would not render". rasterizePdf names its
failure class so a build that cannot rasterize at all is distinguished
from a PDF that would not render. New guard in both index paths: an
empty provider transcription never becomes a successful empty index.
The detail sheet renders the reason in plain words (muted, six
locales). The DTO and OpenAPI carry the two fields; the disaster
recovery backup and restore carry them verbatim like their summary
siblings.
Refs #776
Every arm here is the same shape as the one that already exists for
conditions, because the vault's filing model is settled and a second shape
would be the thing to explain rather than the thing to use: encounterIds on
upload and on PATCH with replace-set semantics, linkEncounter / unlinkEncounter
beside the episode pair in the bulk action, and an encounterId list filter.
Both directions ride ONE table. The visit's sheet writes encounter_document_links
from its end and the document reads it from the other, declared as a second
direction in the link service rather than a fourth table. A visit's kind
crosses the wire as the enum constant rather than a rendered name: unlike a
condition label, which is the person's own words, a kind is one of eight closed
values whose human name the reader's own bundle owns.
The suggestion is offered where the document is being looked at, resolved by
the shared rule rather than by this surface: one candidate pre-selects with an
undo, two offer a picker with nothing pre-selected, none offers nothing at all.
It resolves with no AI provider configured, and it renders nothing once the
document already names a visit.
The bulk verb has a caller — the vault's selection bar files a whole selection
against a visit, mirroring the condition verb and hiding itself on an account
with no visits. Nothing here is one-ended.
OpenAPI regenerated in the same commit.
A browser-issued <img src>, <iframe src> or <a href download> cannot
carry the record-session assertion, so the fence refused all three on
any session that had been inside a shared record — permanently, since
none of them will ever send a header.
- add a structural guard: no client file may name a record-fenced route
in a src=, href= or window.location sink
- fetch the thumbnail, the inline preview and the download through the
app transport and hand the DOM an object URL, revoked on unmount
- make the two API probes in the managed-profiles browser spec assert
the same context the app beside them asserts
A focused card deleted on Backspace as well as Delete. The timeline's
roving tabindex gives a card focus the moment someone tabs into the
grid, Backspace is the reflex "go back" key, and a card carries no
delete control at all — so the reflex press removed a medical document
with nothing but a transient undo toast behind it.
Delete alone now deletes. The key resolution moves into a pure helper
in vault-utils so the contract is testable without a render harness,
and the card stops keeping that contract in a code comment: the overlay
button carries aria-keyshortcuts, and a kbd chip appears while the card
holds focus. The label is per-locale (Entf / Suppr / Supr / Canc).
An invalid ?doc=<id> deep-link (a deleted document, a mistyped id) 404s
its detail fetch forever. The fenced send-gate never cleared, so every
message stayed blocked behind a phantom "untitled" attachment banner
that could never resolve — a bad link was a dead end instead of
degrading to a normal chat. Dropping a staged id the moment its detail
fetch settles into an error now clears the doc-scope, drops the banner,
and lets the coach send normally; a toast explains the link is gone.
Also gives the document vault processing feedback: the auto-index (and
optional AI summary) job that starts right after upload is
fire-and-forget server-side, so a freshly uploaded card sat with no
signal while it indexed. The vault list now polls while any recently
uploaded document is still unindexed (bounded to a few minutes so an
old, permanently-unindexed document never keeps it polling), and the
card shows a "Processing…"/"Ready" chip that clears on its own once the
index lands.
Document vault: card list drops a stray glyph and gives the real filename
its own line (was truncating on the meta row); the detail sheet's close
button aligns correctly — ResponsiveSheet reserved the right gutter for a
close button it wasn't rendering, now conditional on showCloseButton. The
open-in-coach action reads 'Coach fragen'.
Bulk share: a multi-selection in the overview shares several documents in
ONE link (documents-only privacy preserved, owner-scoped, up to 50 per
link; >50 refuses with a toast rather than silently truncating).
Reminders can link to WAIST_CIRCUMFERENCE (additive MeasurementReminderType
enum value, OpenAPI regenerated). The WHO-5 and SCI well-being reminder
labels were absent in all six locales and rendered their raw dotted keys
(the shared 'measurementReminders.types' prefix is what showed as the stray
'Measurement Reminders Types'); added in every language. A new
type-labels-i18n test asserts every dynamic reminder-type key resolves
non-empty in all six bundles — the static coverage guard cannot see the
dynamic key that let this through.
Render a small JPEG preview for each stored document and show it at the
card's leading edge, falling back to the kind icon when none exists.
- Generate previews in the background on upload (new document-thumbnail
pg-boss queue) plus a boot-time corpus backfill for existing documents;
upload never blocks on or fails because of it. Images are downscaled via
@napi-rs/canvas, a PDF's first page is rasterised then downscaled, every
other type is skipped. Generation never throws.
- Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table
(migration 0236) so the list/detail queries that omit the original blob
never drag the preview either. Same AES-256-GCM string codec + fail-closed
decrypt as the content index; wired into the key-rotation walk.
- Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail
(auth + module gate + userId-scoped query + fail-closed decrypt), always
image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an
authed lazy <img>, onError falls back to the icon.
- Add hasThumbnail to the document DTO so the card only fetches a preview
that exists.
Also drop the "search looks inside your documents" caption from the overview
(the index-all prompt stays) and the per-card searchable marker; both were
noise. Remove the now-unused i18n keys across all six locales.
Auto-index on upload means content search is silent-by-default, so the old
per-document index chore no longer reads as a task. Consolidate every AI
reading action into one inviting block on the detail sheet, led by a
prominent Read with AI action, and show each document's searchable status as
a pill that distinguishes an AI-read document from a locally-indexed one.
- New Read-with-AI block: prominent read/re-read action plus suggest and
session-only summary, gated on provider availability; the calm settings
pointer replaces the actions (never an error) when no provider is set up.
- Searchable status pill reflects the auto-index: Read by AI (provider read
the original, highlighted), Searchable (local), or a quiet not-yet state.
- Card searchable marker distinguishes an AI-read document with a Sparkles
glyph from a locally-indexed one; both carry an accessible label.
- Drop the manual index/re-index chore copy; keep the corpus baseline
affordance in the filter bar for older un-indexed documents.
Surface the content-search index's source (vision vs local extraction) on
the list + detail DTO so the vault UI can tell an AI-read document from a
locally-indexed one. Threads the source through both read paths and the
serialisers, regenerates the OpenAPI contract, and adds a narrowing helper
for the free-String column.
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.
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-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 timeline windows a flat item list (month labels + chunked card rows)
with @tanstack/react-virtual over the shell's scroll container — single
scroll owner, bounded DOM at four-digit corpus sizes. Columns are
measured by a ResizeObserver so the row model and the painted grid never
disagree. Cards are compact-density: kind glyph, foreground title, one
muted meta line, condition pills, a download-only badge for attachment-
class formats, and a hover/focus selection checkbox above the whole-card
click target. In-flight and failed uploads share the same footprint with
a live progress ring and translated failure copy.
Plan-Task: W2-T2, W2-T4 (upload cards)