Freshness was tracked only per-integration, so a provider returning a
healthy 200 with an empty body for ONE metric type recorded no failure and
the whole integration stamped green — a metric dead since launch was
indistinguishable from a healthy-idle one. Compute the last-value
timestamp per (source, type) straight from the Measurement table (no
migration) and surface it per integration on the status endpoint, so the
Settings card can flag a silently-dead metric the green pill hides. The
card UI badge + per-cadence staleness classification are the deferred
remainder.
The fail-closed crypto loader throws a stack trace on a missing/short
ENCRYPTION_KEY rather than a sentence an operator can act on, and
check-env is a dev/CI tool the image operator never runs. Add a boot-time
readiness summary (instrumentation register) that prints a clear line per
core secret — with the exact openssl fix on a bad one — before anything
throws, plus a one-glance status of the optional channels and the
Secure-cookie transport hint. Never logs secret values; never throws.
Mutations ran in TanStack's default 'online' network mode, which PAUSES a
write fired offline: isPending stays true forever, onError never fires,
and the paused mutation is dropped on reload — a health entry vanishes
with no 'not saved' signal. Switch mutations to 'always' network mode so
an offline write rejects immediately and the form surfaces an honest
failure, and add a global OfflineMutationToaster backstop for call sites
without their own onError. Correct the offline banner copy, which promised
changes would auto-save.
- getUserTodayBounds: a 25h fall-back day keeps a 23:30 dose inside today,
a 23h spring-forward day does not bleed tomorrow's 00:30 dose, ordinary
days stay 24h, and a negative-offset zone anchors on the local day.
- dailyComplianceRatesFromLedger: a Los-Angeles user's two same-local-day
doses land in ONE bucket where the old fixed-Berlin key split them.
- classifyIntakeTiming: an 08:00-local dose reads on_time with the user's
zone and very_late without it.
- hourInTz: reads the wall-clock hour in the given zone.
- hero-strip greeting: resolves the hour in the stored zone, not the
browser clock; existing greeting fixtures re-pinned to explicit UTC
instants mapped to Berlin wall-clock.
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
Theme provider now initializes to the SSR-stable default and reconciles
the stored preference in a mount effect instead of reading localStorage
in the useState initializer — closes the same React #418 hydration seam
the sidebar already fixed (the inline script still handles the pre-paint
class, so no FOUC). Add a process-wide Intl formatter cache and route the
per-row workout/medication list helpers through it so a formatter is
constructed once per (locale, options) rather than once per row per
render.
The clinician-facing export failed SOFT on an undecryptable allergy
reaction (→ null, rendered as a bare '—' indistinguishable from 'no
reaction recorded') while a sibling dose-change note fails CLOSED. Extract
a pure decryptAllergyReaction helper that keeps 'unreadable' distinct from
'unset', log the swallowed decrypt, and render an honest localized marker
in the PDF so a clinician is never shown a silent blank where an
unreadable reaction exists. Read-path only — the fail-closed write posture
is untouched.
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
Measurements were already paged, but the medication-intake, mood, and
cycle tables in the same per-user payload were still unbounded full-table
findMany reads — peak heap tracked the largest tenant's history. Give
them the same id-keyset page walk (full row projection, so payload bytes
are unchanged) so at most one page is resident per round-trip.
The daily-series reader only fell back to live SQL on a coverage MISS
(zero rollup rows); a transient rollup-table error (statement_timeout,
deadlock, connection reset) was unwrapped and 500'd the tile on the
single-type route even though live SQL could serve it — and diverged from
the batched series route, which already caught. Wrap the primary daily
read and the long-range tiered read: treat a throw as a coverage miss and
fall through to the live date_trunc aggregate.
Fitbit's handleCollectionFetchError soft-skipped a 403 but RETHREW any
other hard failure, aborting every endpoint ordered after the bad one in
the same resource and stalling the watermark so the whole integration
stayed dead. Record hard failures on a per-cycle AsyncLocalStorage ledger
and return 0 instead of rethrowing — the sibling endpoints keep fetching,
and the ledger still fails the cycle so success is not stamped and the
next tick refetches. Also wrap the per-metric map fan-out in the same
per-collection catch so one malformed point can't skip its siblings.
Five timezone-correctness fixes across the medication / compliance surfaces:
- getUserTodayBounds now derives both bounds through the DST-safe
startOfLocalDayInTz, so the today window is 23h on spring-forward and
25h on fall-back instead of a hardcoded 24h sampled at now. A 23:30
local dose no longer drops off the med cards, dashboard, PWA badge and
reminder idempotency near midnight on a transition day.
- dailyComplianceRatesFromLedger takes the user's zone and buckets each
per-day rate with userDayKey, so a non-Berlin user's evening dose is
filed on the local day it was due rather than the next Berlin day.
- classifyIntakeTiming interprets the window HH:mm in an optional zone;
the achievements perfect-day check passes the user's zone and reads the
scheduled minute-of-day in-zone, so a dose far from UTC is graded
against local wall time.
- The /insights greeting resolves the hour in the user's stored zone via
the new hourInTz helper, matching the dashboard hero.
- The doctor-report and schedule-anchored compliance queries load
schedules through SCHEDULE_COMPLIANCE_SELECT instead of a broad
include, so a future engine column reaches both surfaces.
The most common self-host login loop is a Secure session cookie issued
over plain HTTP: the browser drops it and bounces the user back to the
sign-in page with no error. Detect it at cookie-mint time
(detectInsecureCookieTransport) and emit a clear warn-log plus a
session_cookie_secure_transport_mismatch wide-event, pointing at
SESSION_COOKIE_SECURE=false / X-Forwarded-Proto. Non-fatal; login still
completes.
Oura fetched all 8 collections under one Promise.all: a single flaky
endpoint or one throwing mapper rejected the whole batch and imported
nothing (readiness, sleep, activity, spo2 all went dark together). Settle
each collection independently, import what succeeds, and carry the failed
ones as a per-collection ledger — mirroring Google Health / Fitbit. The
reactive 401-refresh is preserved: a shared expired token 401s every
collection, one refresh is done, and only the failed collections retry.
A partial failure records the failure and does not stamp success, so the
freshness surface stays honest and the next tick refetches.
The 60s statement_timeout + pool ceiling applied to the web client in
db.ts were never applied to the worker clients. A pathological nightly
drain on a heavy tenant could run unbounded, pin a connection, and wedge
the shared worker pool. Route both worker clients (reminder handlers and
the Apple Health import worker) through the same getPoolMax() +
buildSessionOptions() helpers so every worker session is timeout-bounded
from its first query.
The upload route awaited an unguarded enqueueDocumentIndex after the
document was already committed. A transient boss.send rejection (a DB
hiccup) would then surface as a 500 for an upload that in fact stored the
file. Guard the send inside the enqueue (a failure annotates and no-ops,
matching the missing-boss path) and drop the await at the call site so the
response never blocks on or fails because of background indexing.
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.
Index a document for content search automatically the moment it is stored,
instead of requiring a manual index action. A fire-and-forget per-document
job runs an AI-first decision tree: when a vision-capable provider is
configured and consented, transcribe the stored original (rich, handles
scanned PDFs and images), budget-reserved exactly like the interactive index
route; otherwise fall back to server-side text-layer extraction via pdf-parse
(no provider, no egress, milliseconds) so content search works at a baseline
even with no AI. Budget exhaustion, a provider that cannot read the file
(a text-layer PDF on a non-Anthropic account), and provider errors all fall
through to the free local path rather than leave a document unsearchable.
Both paths write the same blind, encrypted content index (AES-256-GCM text +
opaque HMAC token tags) — nothing readable at rest. The local path adds the
'local-pdf' / 'local-ocr' source values (free String column, no migration).
Server-side OCR for scanned images and scanned PDFs is left as a documented
seam. Pin pdf-parse + pdfjs-dist into the standalone trace so the wasm text
extractor ships in the runtime image.
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.
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-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.
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.
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.
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
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
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 measured first-load gap on / was never the server: FCP painted the
SSR shell at ~1.8 s but LCP landed at ~5.5 s (mobile-throttled) because
the tiles waited for JS download + hydrate + a CLIENT-side snapshot
fetch — while /api/dashboard/snapshot answers in ~8 ms server-side.
src/app/page.tsx is now a thin RSC wrapper: it runs the same cached
snapshot read the API route uses (extracted to
src/lib/dashboard/snapshot-read.ts, one shared SWR cell for both
readers) and hands the result to TanStack via HydrationBoundary +
dehydrate, so the first HTML carries real tile data and the mounted
cell starts warm (staleTime 60 s, refetchOnMount false — no double
fetch). Query keys come only from the central factory
(dashboardSnapshot(locale) + the dashboardWidgets seed the client cell
already performs); the payload is JSON-round-tripped so the hydrated
shape is byte-equal to the wire shape. Fail-soft: no session or any
builder error renders the page exactly as before. The 1,727-line client
page moves unchanged to src/app/page-client.tsx; no component-tree
rewrite.
Companion: the documented bundle tooling was dead on Turbopack
(ANALYZE=1 silent no-op, bundle-report read a webpack-only JSON), so
H2/H3-class regressions shipped invisibly. scripts/check-bundle-budget.mjs
derives per-route gzip totals from the Turbopack client-reference
manifests + rootMainFiles, prints the report (pnpm bundle-report), and
enforces bundle-budget.json (pnpm bundle-check): per-route caps,
total-client-JS cap, a recharts-duplication guard (fingerprint count
must stay 1), and a catalog guard (no message catalog referenced by any
route manifest). Wired into the existing e2e workflow after next build.
H4
Two independent every-load costs, one per layer:
1. The static EN import in load-locale.ts put the full en.json catalog
(334 KB raw / ~106 KB gz) into the client JS of EVERY route, for
every user in every language. No catalog is statically imported by
client code anymore: the EN fallback floor of the t() chain loads
lazily on the first actual key miss (the locale-integrity guard
keeps every locale key-complete, so the miss path is drift-only),
and the labs biomarker canonical-name lookup fetches EN on demand.
2. The root layout serialized the ENTIRE active-locale catalog into the
RSC flight payload of every document (392 KB of a 505 KB dashboard
HTML, re-downloaded on every hard load, uncacheable). The handoff is
now a versioned, immutable-cacheable boot script
(/i18n/<locale>?v=<build>, new route handler) that assigns
self.__HL_I18N before the hydration scripts run, plus a server-side
cache seed in load-locale.ts behind a typeof-window branch that
Turbopack compiles out of the browser bundle (verified: zero catalog
bytes in any client chunk, zero route manifests referencing one).
SSR text stays correct and the first client render holds the same
bundle the server rendered with — no EN flash, no hydration drift.
The boot script is public (proxy allowlist: the login page needs it
pre-auth; the body is the same catalog JSON that ships in the public
repo) and cache-first in the service worker, so an offline relaunch
finds it next to the cached shell.
Measured (client-reference manifests, gzip): route JS baseline
/ 391->287 KB gz, /insights 377->273, /measurements 379->275,
/insights/mood 394->290 — the ~104 KB gz EN catalog left every route.
H2
URL-backed facets (q / kind / episode / year) parse and serialise through
one pure helper pair so deep links round-trip byte-stable; the API search
builder pins the timeline sort. Month bucketing + row chunking feed the
virtualized grid as a flat item list. The upload-response parser owns the
duplicate contract: HTTP 200 with envelope-level meta.duplicate returns
the existing row, every 413/415 reason maps to a translated-copy class,
and proxy-stripped bodies fall back to the HTTP status. Query keys move
to the vault filter shape and gain the usage read.
Plan-Task: W2-T1 (URL state), W2-T5 (keys)
Full-catalogue coverage pass against the v4 data-types index. Three types
map onto existing MeasurementTypes and are now fetched in the metrics
bundle:
- blood-glucose -> BLOOD_GLUCOSE. `bloodGlucoseMilligramsPerDeciliter` is
already the canonical mg/dL storage unit; meal/timing/specimen enums are
not stored.
- core-body-temperature -> BODY_TEMPERATURE. `temperatureCelsius`, the core
slot — distinct from the surface-temperature buckets.
- daily-sleep-temperature-derivations -> WRIST_TEMPERATURE. The documented
`nightlyTemperatureCelsius` is an ABSOLUTE reading (the mean of the
night's skin-temperature samples), which refutes the earlier deviation-
only premise behind the launch omission. The baseline/stddev derivations
are not stored — the user's own series carries the baseline.
Every other catalogue type is now an explicit documented skip in
mapping.md with its reason (per-sample twins of the chosen daily grains,
zone-minutes types without a HealthLog concept, total-calories folding in
BMR, hydration/nutrition without a module, ECG/IRN behind their scope
policy). The structure probe iterates the type table, so the three new
types are probed automatically.
Daily HRV and resting heart rate returned HTTP 200 with zero imports on a
live Fitbit-band account. Full reconciliation against the v4 reference
(data-types index, DataPoint schema, list/reconcile/dailyRollUp pages)
surfaces three defects:
- The daily-summary `.date` filter prefix is the docs' own contradiction:
the data-types index's filter-parameter column says snake_case
(`daily_heart_rate_variability`), while the list reference's only worked
daily example is `dailyHeartRateVariability.date < "2024-08-15"` — the
camelCase payload key. Live, the snake form is accepted (200) but matches
nothing. Send the worked-example camelCase form first and re-walk once
with snake_case if the very first page 400s; annotate the accepted style
(`googleHealth.dateFilter.style`) and surface it through the structure
probe as `requestShape` so a live account settles the grammar.
- `daily-respiratory-rate` reads `breathsPerMinute` (a plain number); the
previously assumed `dailyRespiratoryRateBpm` leaf does not exist, so
RESPIRATORY_RATE never parsed a single row.
- `height` carries `heightMillimeters` (int64 string), not `heightMeters`;
the heightCm profile seed never fired. Convert mm -> cm.
Also cover the documented Exercise.ExerciseType catalogue in the sport map
(MOUNTAIN_BIKE, TRAIL_RUN, TREADMILL_WALK, STATIONARY_BIKE, SWIMMING_POOL,
FREE_WEIGHTS, CROSSFIT, TABATA_WORKOUT, ...) — the enum's multi-word values
previously fell through to "other".
Tests pin the documented field names per fixture, the camel-first/snake-
fallback walk (including no-fallback for sample shapes and unfiltered
backfills), and the extended sport map. mapping.md records the filter
contradiction and the corrected leaves.
The documents route table picks up every delta: conditionLinks +
servingClass on the document DTO (render/download by servingClass, never
by MIME guess; unknown kind values decode as OTHER), the broadened upload
accept-set with the 413/415 error contract and the 200+meta.duplicate
dedupe outcome, episodeIds on upload and PATCH (replace-set), the kind[] /
episodeId / year list facets, the class-aware /original posture, and the
new /restore, /bulk, and /usage endpoints. Regenerated docs/api/openapi.yaml
committed alongside; openapi:check is clean.
Task: W1-T14.
inboundDocuments leaves CODE_DISABLED_MODULE_KEYS (parked there since
v1.25.3). The normal two-layer resolution applies again: the module ships
dark (opt-in, per-user default off) and an operator false still disables
it server-wide; nothing becomes user-visible without an explicit opt-in.
The gate tests now pin the re-enabled behaviour instead of the hard-off
short-circuit.
Task: W1-T2.
The admin settings GET/PUT carry documentMaxFileBytes (1 MiB – 100 MiB;
the hard ceiling exists because single-shot GCM + bounded in-memory reads
are load-bearing past it, and the upload-time resolver clamps again as
defence-in-depth) and documentQuotaBytes (10 MiB – 1 TiB instance
default). The admin user PUT accepts a nullable per-user quota override;
null clears it back to the instance default. Cookie-only requireAdmin()
guards both surfaces as before — Bearer tokens cannot reach them.
Task: W1-T13.
Delete in the vault is undo-able: DELETE tombstones, the restore endpoint
clears the tombstone, and this pg-boss job (daily 04:10 Europe/Berlin,
single-flight like every other cleanup) hard-deletes tombstones older than
30 days — returning the TOAST space the encrypted blob holds. Until then a
tombstoned row keeps counting against the quota, so deleted bytes are
never invisible backup weight. Staged facts cascade via the FK;
restore-after-purge answers 409 at the API.
Task: W1-T12.
The registry entry for InboundDocument.contentEncrypted gains a codecField
marker: the column now holds two layouts (base64v1 string codec | binary2
binary codec), recorded per row, and its rows are up to cap-sized blobs.
scan/rotate walk such a column with id-cursor pagination (25 rows per
page — never an unbounded blob findMany) and re-encrypt each row under its
OWN codec; an unknown codec value is counted as an error and the row left
untouched, same fail-closed posture as a missing key. The walk is
idempotent, so an interrupted run resumes on re-invocation with the
already-active rows skipped.
Both rotation paths share the logic: the admin-triggered pg-boss job via
rotateCorpus() and the CLI (scripts/rotate-encryption-key.ts), which now
calls the shared batched walk for this column. The registry guard test
keeps schema, registry, and script in lock-step as before.
Unit coverage over a mocked 500-row corpus asserts the batch boundedness
(take + advancing cursor per page), both-codec rotation, per-row
fail-closed on unknown codecs, and interrupted-run resume.
Task: W1-T11.