Commit Graph
151 Commits
Author SHA1 Message Date
Marc-André Bombeck 326db73883 Merge pull request #709 from MBombeck/work/dw-e
Stop offering a delegate buttons the server will refuse
2026-08-03 12:59:04 +02:00
Marc-André Bombeck 251b534660 Merge remote-tracking branch 'origin/main' into work/dw-e 2026-08-03 12:25:34 +02:00
Marc-André Bombeck d5a9360c52 Give the caregiver a front door and a receipt
Sharing lived in a settings panel and a menu three levels deep. That is where
a grant is managed and not where it is used: somebody who opens this app every
morning to check on a parent does not go through settings to do it, and the
design standards draw the same line, settings being account utilities rather
than feature destinations. So the dashboard carries one compact card listing
the records shared with this account, each row a one-tap switch through the
existing flow.

It is a doorway and not a preview. No numbers, no last reading. A slice of
somebody else's record on the delegate's own dashboard would sit outside the
one frame the design leans on, the banner naming the record you are inside.
An account nobody has shared with sees nothing at all.

The other half is the receipt: a reading, a lab result or a dose recorded
under a switch now says which record it landed in, because "Saved" alone is
the confirmation somebody acting for another person does not need. The undo
on the dose toast goes at the same time, since a delegate cannot remove what
they recorded and an undo they can see is one the server refuses.

Also here: the lines the owner's activity view owes each admitted verb, as a
translated mapping with a fallback, so a later release filing a verb it has
never heard of still renders a sentence rather than a blank row.
2026-08-03 12:12:08 +02:00
Marc-André Bombeck 785a1eacfc Freeze the delegable write set and drive it against Postgres
The surface guard grows a write leg. Membership is decided by the argument
a module passes to the record resolver, not by the identifier, because a
delegable read and a delegable write name the same symbol and mean opposite
things. The call matcher resolves aliased and namespace-qualified calls
back to the original name, and proves it can against synthetic modules.

Two assertions carry the decision not to add a provenance column to eleven
tables. Every module in the write list has to call auditLog, which is the
only writer that stamps the acting account, and none of them may reach the
audit table through the client instead. That decision rested on the trail
existing; it now rests on a test.

The prose on the read list said the write arms keep refusing under a
switch. Eleven of them no longer do, so those sentences are rewritten
rather than left to lie.

The integration suite drives the shipped POST exports through the real
handler, resolver, grant table and Prisma. Five legs per verb: the write
lands under the owner and nowhere else, a read grant is refused with
nothing written, a caller who was never granted is refused the same way,
the request after a revocation is refused with no re-login, and a caller
who never switched is indistinguishable from before. The grant is minted
through the invitation flow rather than planted, and the audit assertion
reads both ends of the row.
2026-08-03 12:11:28 +02:00
Marc-André Bombeck c7960d726d Scope the idempotency replay cell to the record a request claims
A delegate retrying with one `Idempotency-Key` across two people's records met
itself in one cell. The cache tuple was `(caller, key, method, path)`, so the
second record's request replayed the first record's cached 201: the handler
never ran, no row was written for the second person, and the caller was told it
had been — with the other person's id in the body. Nothing errored and nothing
logged a conflict, and five of the write verbs about to become delegable are
wrapped here.

Fold the claimed record into the `key` component, centrally, once. Not into
`userId`: that column is a foreign key to `users` with a cascade behind it and
cannot hold a composite. It keeps naming the caller, which is what makes two
delegates on one record two cells for free. The separator is a byte the key
validator refuses, so a caller cannot hand-craft a key into a cell belonging to
a request they are not making.

A request with no acting account keys byte-for-byte as it did before, so a
client that never heard of sharing cannot be affected.

Accepted and pinned by test: the fold uses the CLAIM, before the grant check,
so within the 24h window a delegate whose access has ended can still replay a
key they had already completed. They receive bytes they already had, nothing
new is written, and a fresh key from them is refused because that request
reaches the handler.

Both frozen surfaces move in this diff. The claim reader gains a guard leg
freezing its consumers, since an unchecked claim is exactly the value a future
caller must not mistake for permission.
2026-08-03 10:18:27 +02:00
Marc-André Bombeck f24ae6deb8 Read the acting-account carrier from one place
The rule that decides what a request says it is acting as — session stamp on
the cookie transport, selector header on Bearer, a header on a cookie request
naming nothing — sat inside the resolver that acts on the answer. A second
caller needs the same question answered before any auth context exists, and
two implementations of "whose record is this" drift; the copy that drifts is
the one nobody reads.

Move the decision, the header name and the two predicates into
`auth/acting-carrier`, as a pure function of what the transport carried. The
resolver keeps what only it may do: turning a claim into a data scope once a
live grant says so. No behaviour changes — the resolver's arms are the same
arms, and its suite passes untouched.

The header-name guard follows the constant to its new file.
2026-08-03 10:13:55 +02:00
Marc-André Bombeck cb8e886ffc Retire the registry entry the hardcoded null needed
The destructive-control registry carried this card because a detector read
its hardcoded `expiresAt: null` as a field being blanked, and an entry
explained at length why blanking was harmless here. The explanation was
right and the cause was not: the null was there because nobody could set a
date, not because none was wanted.

With the control wired the detector no longer fires, so the entry no longer
matches anything and the registry's own staleness check asked for it back.
2026-08-02 21:03:29 +02:00
Marc-André Bombeck 5ff949ea70 Merge pull request #702 from MBombeck/work/backup-plan-reader
Prove a backed-up model travels, instead of proving a name exists
2026-08-02 20:22:24 +02:00
Marc-André Bombeck 06d44fd8b1 Stop one model's relation write answering for another's
A child model rides its parent through the backup, so the check looks
for the relation field name as well as the delegate. It looked for the
bare name anywhere in the declared files, and `symptomLinks` is declared
twice — on `IllnessDayLog` for the illness link, on `CycleDayLog` for
the cycle one. Deleting the illness branch from the restore route left
all twelve checks green, because the cycle write in another file was
answering a question about a different table.

A name that points at exactly one model is still evidence anywhere. A
name shared by several counts only in a file that also operates on the
declaring parent's delegate, and a check asserts the cycle file proves
the cycle link and refuses to prove the illness one.

That leaves the illness link unprovable from source: its include is a
shared constant with no query beside it, and the parent that would
disambiguate it is reached through the episode in another file.
`STRUCTURALLY_UNATTRIBUTABLE` names it with what defeats the matcher,
and naming a model there is not a free pass — it has to be seeded in
the round trip, which is checked here.
2026-08-02 19:34:59 +02:00
Marc-André Bombeck e449a19e55 docs(sharing): say how the cycle gate actually resolves the record
The two cycle entries in the frozen list claimed the gate reads `gender` off
the resolved auth context. It does not: `requireCycleEnabled` names that
parameter `_gender` and ignores it, then re-loads gender and the profile by
the resolved user id through the module gate. The answer is the same — the
owner's — but a reviewer checking the claim would have found a dead argument
and no explanation.

The profile route is the exception and now says so: it passes the auth-context
gender to `isCycleEnabled` itself to compute the flag it returns.

A frozen-list entry is only worth the reviewing it invites, so it has to
describe the mechanism that is there rather than the one the call site looks
like it has.
2026-08-02 18:17:05 +02:00
Marc-André Bombeck 49ffba77f3 Merge remote-tracking branch 'origin/main' into work/sharing-routes
# Conflicts:
#	src/__tests__/delegable-surface-guard.test.ts
2026-08-02 18:07:11 +02:00
Marc-André Bombeck 210380f46d Merge pull request #690 from MBombeck/work/sharing-client
Account sharing, the part people touch
2026-08-02 17:13:30 +02:00
Marc-André Bombeck 7a39a649be feat(sharing): open the medication reads to a delegate
Ten routes: the cabinet list, the medication detail, its cadence, schedule
revisions, side effects, inventory, phase config and intake history, plus the
account-wide intake list and the batched compliance read.

`GET /api/medications/compliance` is the one delegable read that spends a
quota, and its rate-limit bucket now keys on the ACTOR while everything below
it scopes to the record. Both halves matter: a delegate hammering the route
must burn their own allowance rather than lock the owner out of their own
cabinet, and switching records must not hand the same caller a fresh one. The
test reads the `rate_limits` row rather than inferring it from a 429, because
a bucket keyed on the wrong person still answers 200.

`GET /api/medications/[id]/api-endpoint` stays refused. It returns two scalar
fields and no secret, and it tells a delegate whether the owner holds a live
ingest credential and for which medication — in the same file as the PUT that
mints one that would survive revocation.

The two intake validation breadcrumbs move onto `auditLog()` for the actor
stamp. The POST arm's breadcrumb stays as it is: that arm cannot be reached
under a switch.
2026-08-02 17:06:23 +02:00
Marc-André Bombeck fe765a7ae2 feat(sharing): open the cycle reads to a delegate
Four routes: the cycle history, the day log, the profile and the custom
symptom vocabulary.

All four gate on `requireCycleEnabled(user.id, user.gender)`, and both
arguments now come off the record. That is the correct reading and the one
that had to be verified rather than assumed: whether cycle tracking applies
is a property of the record, so a delegate who tracks nothing still sees the
owner's cycle, and a delegate who tracks their own sees nothing when the
owner does not. Both directions are asserted.
2026-08-02 16:57:07 +02:00
Marc-André Bombeck 10f9a83356 feat(sharing): open the sleep, nutrient, illness and workout reads
Eight routes: the two sleep reads, the nutrient overview and its daily
series, the illness episode list, detail and day logs, and the workout list.

The illness day-log read is the one worth reading twice. Its queries are
episode-scoped rather than user-scoped, and what makes them safe is the
`loadOwnedEpisode(id, user.id)` hop above them. That hop now resolves against
the record, so a delegate cannot reach their own episode from inside somebody
else's account — asserted, because it is the kind of thing that would look
fine and behave wrong.

`GET /api/workouts/[id]` stays refused. It differs from the list beside it by
one conditional include that decrypts a paragraph written about the owner.
2026-08-02 16:52:59 +02:00
Marc-André Bombeck 20f5ae27a0 test(e2e): give the account-switching journey its own session
The switch is stamped on the session row, not held in the tab. Every
authenticated spec opens its context from the same storageState file, so
the sharing journey switching that jar into the owner's record switched
it for whatever spec happened to be mid-navigation on the other worker.

The failures land nowhere near the cause: /settings/ai painting "Not
part of shared access" instead of the AI panel, an axe scan reading a
banner its spec never asked for, a waitForResponse waiting on a request
the refused surface never sends. Which spec loses depends on scheduling,
which is why it moved between runs.

Log the delegate in a second time and hand that jar to the journey. Same
account, its own session row, no reach outside the file. The three login
captures collapse into one helper while the third one is added, and the
fixture's login-bucket reset already covers the extra attempt.

The guard matches the two things a switching spec cannot avoid naming,
the switcher entry it clicks and the endpoint that entry calls, and
checks both that the match set is non-empty and that the shared jar is
still what the ordinary specs read. Verified red by pointing the journey
back at the shared jar.
2026-08-02 16:49:47 +02:00
Marc-André Bombeck 8c070c953b feat(sharing): open the mood, custom-metric and record reads to a delegate
Six routes: the mood-entry list and detail, the custom-metric list, detail
and entries, and the personal-record list. The custom-metric entries read
establishes ownership on the metric before it queries by metric id, and that
hop now resolves against the record — a delegate cannot reach their own
metric from inside somebody else's account.

`GET /api/mood/tags` stays refused. It looks like plain record vocabulary and
one select in the middle of it pulls the owner's tag layout, which is a
presentation preference and belongs to the person rather than the record.

The mood list's validation breadcrumb moves onto `auditLog()` for the actor
stamp, the same change the measurement reads took.
2026-08-02 16:44:52 +02:00
Marc-André Bombeck af0f749740 feat(sharing): open the lab and clinical-record reads to a delegate
Ten routes: lab results and their detail, the biomarker catalogue and its
detail, allergies, family history, the mental-health screener history, and
the health-profile facts. All GET arms; every write arm in the same files
keeps `requireAuth()` and refuses under a switch.

The family-history payload is the one admitted read that carries third-party
health information by design — it describes the owner's relatives — and it is
admitted because a caregiver reading a family history is what the feature is
for and the rows are the owner's own.

The mental-health module gate resolves against the record, not the caller.
That is the recorded decision: the module map says which parts of a record
exist, so a delegate who enabled the screener on their own account gains no
screener view inside somebody else's. The integration test asserts both
directions.
2026-08-02 16:38:39 +02:00
Marc-André Bombeck 255cbf5fe0 feat(sharing): open the measurement reads to a delegate
Six routes move from `requireAuth()` to `requireRecordAuth("read")`: the
measurement list, the single reading, the two series reads, and the two
reminder reads. Each is admitted in the delegable classification with its
five answers written; each enters the frozen list with the argument for why
a delegate reading through it cannot extend their own reach.

Only the GET arms move. The POST, PUT and DELETE arms in the same files keep
`requireAuth()`, which refuses outright while a switch is active, so a file
entering the list admits a read and nothing else.

The two validation breadcrumbs on the list and series reads now write through
`auditLog()` rather than a bare `prisma.auditLog.create`. That helper is the
only writer that stamps `actorUserId`, and without the stamp a delegate's
malformed query lands on the owner's record looking like the owner's own.

Integration coverage drives the shipped GET exports against real Postgres:
the owner's rows reach a delegate holding a live grant, the delegate's own
rows do not, a caller without a grant is refused `sharing.access.denied`, the
request after a revocation is refused with no re-login, and a caller who
never switched sees exactly what they saw before.
2026-08-02 16:30:46 +02:00
Marc-André Bombeck 1ae137eb84 test(guards): sweep the directories the structural guards were skipping
`fs.globSync` never matches a leading dot, so every guard built on
`**/*.{ts,tsx}` walked past `src/app/.well-known` — the two OAuth
discovery documents and the apple-app-site-association handler sat
outside fourteen frozen-set sweeps. An unlisted `getSession()` planted
in the authorization-server route left the session-surface guard at nine
passed; it fails now.

Move those fourteen onto one shared walker built on `readdirSync` with
`recursive`, which has no dot rule, and give it a floor every caller has
to state, so a walk that comes back too small throws instead of
reporting a clean tree.

Give the same floor to the guards that compared a discovered set against
a frozen list with nothing asserting the set had been read: gutting the
walker in the inventory-write guard left its invariant test green,
because zero offenders reads exactly like no offenders. Raise the
migration-count floor off one, and assert the locale bundles are found
before the per-locale cases are generated from them.

Document, at each walker that prunes on purpose, what is pruned and that
dot-prefixed directories are not.
2026-08-02 16:21:19 +02:00
Marc-André Bombeck 5199de78af feat(sharing): the shared-access panel and the record-activity view
Settings gains one section for one concept: who else can open this health
record, whose this account may open, and when they were opened. Split across
two pages it would be two half-answers.

An invitation confers nothing until the other person accepts it, and the card
says so above the field rather than after the submit — that is the part
somebody needs to know before they type a name. Refusals are named from the
stable errorCode, because "no account with that name" and "that person already
has access" are different situations with different next steps.

Ended grants stay in the list. The row is the consent record, and "who had
access, from when to when, and who ended it" is the question the panel exists
to answer; a list that dropped them could not. Revoking asks once and then does
it: reducing access must never be harder than granting it was.

`GET /api/account/activity` answers the owner's other question. One row per
person per day with a count, not one per request — a hundred identical lines
is a way of answering nothing. The window is published with the rows rather
than assumed: audit history is purged on a schedule the operator sets, so the
view prints the number the server resolved from the same setting the purge job
reads. An instance running ninety days says ninety.

Six locales ship with it.
2026-08-02 14:26:39 +02:00
Marc-André Bombeck 562123d4d1 feat(sharing): publish which records an account may open
`GET /api/auth/me` grows an `accountAccess` block: the accounts this caller
may act on, the one the browser is inside right now, and the booleans a UI
binds directly — `canSwitch`, and `canWrite` per account. Every value is
resolved server-side. A client that worked out what it may do from grant rows
would be a second program deciding one person's access to a health record, and
the two answer differently the first time an expiry or a revocation appears.

`canWrite` is false for every grant in v1 because invitations write READ only.
It is resolved through the grant state machine rather than hardcoded, so the
day a WRITE grant exists the answer changes without this file being edited.

The route becomes an actor surface. It has to be: the switcher, the banner
naming whose record is open, and the way back out all read this payload, and
under a bare `requireAuth()` the whole app shell would refuse the moment a
switch was on. Every field it returns is still the caller's own.

The active record is published as a full entry, not an id to look up. A banner
that had to join two fields could render unnamed the moment they disagreed;
here the active entry is one of the listed ones, selected by the pass that
built them. A stamp left behind by a grant that lapsed reads as not switched —
the same answer the resolver gives the next delegated request.

Not published: the other account's avatar. Those bytes are owner-scoped, so a
URL here would resolve to a refusal and paint a broken image. Clients keep the
initials fallback.
2026-08-02 14:25:55 +02:00
Marc-André Bombeck 6a096a75eb test(sharing): admit the switch endpoint to the actor-surface list
The first entry in either frozen list. It is the way back out of a
switch: every other route refuses while one is active, so a browser that
could not reach this one would be stuck inside a record it entered. The
entry names why it may say what it says — one session row, the caller's
own, nothing of the owner's rendered and nothing granted.

The entry count moves with it, so an addition has to be made twice
rather than once.
2026-08-02 13:05:43 +02:00
Marc-André Bombeck 56447a9457 feat(sharing): invite, accept, switch, revoke, renounce
The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
2026-08-02 13:04:25 +02:00
Marc-André Bombeck e4b1718924 test(sharing): freeze the delegable and actor-surface route lists
Both resolvers land with empty allowlists. That is the correct start and
also the shape that hides a broken matcher: a scan that finds nothing
agrees with a list that holds nothing, and the two look identical from
outside. So every leg pins a count that does not start at zero. The scan
asserts it found route modules, the two frozen legs assert the matcher
still finds the declaration site, the co-occurrence leg asserts each
cookie-only helper is present in the tree it reads, and the two
out-of-band Bearer surfaces assert they still resolve a token of their
own before asserting they reach no resolver.

Membership is decided from the parsed module rather than the source text,
so a route may explain in a comment why it is not delegable without being
enrolled by saying so, and an aliased import cannot hide one. The
synthetic modules at the top prove both halves of that.

Enumerates with a recursive readdir rather than a glob: globSync skips
dot directories, so it drops the three route handlers under
src/app/.well-known and leaves the sweep looking healthy at 444 modules
instead of 447. One of the three is pinned by name.

Scans 447 route modules and 2338 source files, and finds nothing.
2026-08-02 12:10:43 +02:00
Marc-André Bombeck 3f932040ad test(sharing): hold every arm of the acting-account resolver
The unit file walks the arms with the grant module deliberately unmocked,
so the resolver consuming the one "is this grant active" predicate is a
property under test rather than a claim in a comment. Its Prisma fake
honours its own `where` clause for the same reason: a fake that answers
any query with any row turns every scoping assertion into a check that
cannot fail.

The integration file proves the two things a unit suite cannot, because
both are about which rows come back. Substitution: the owner's
measurements are returned and the delegate's own, seeded alongside, are
not, so a resolver that quietly served the caller their own record shows
up as the wrong values rather than as an empty list. Never cached: a
revocation lands between two requests inside one test and the second one
403s.

The do-no-harm arm runs against a real shipped route, and the cookie-only
helpers are asserted against a real session row carrying a real switch,
because the one edit that would break them is `getSession()` itself
substituting the owner and no mocked session can catch that.

The structural guard freezes the set of files allowed to touch the
carrier column and the selector header, and asserts a non-zero match
count before comparing against the allowlist. A matcher that finds
nothing agrees with an emptied list and reports success; this repository
has shipped several guards that did exactly that.
2026-08-02 11:32:13 +02:00
Marc-André Bombeck 33c879dd6b feat(sharing): add the account grant and its lifecycle module
An account grant is one account's standing permission to read another
account's record. The row is also the consent record for the act that
created it: there is no second receipt table, because two records of one
act drift and then nobody can say which is true.

That shapes the table. Revocation stamps revoked_at and revoked_by
instead of deleting, so the record can still answer who had access, from
when to when, and who ended it. The uniqueness constraint is therefore
partial — one live grant per (owner, delegate), with history accumulating
underneath — otherwise a re-invitation would either be refused or would
overwrite the first grant's acceptance. Both foreign keys cascade: a
grant naming a user who no longer exists is an authorization nobody can
resolve and nobody can revoke.

The state machine lives in src/lib/sharing/grants.ts and only there. The
acting-account resolver and the grant surfaces both read these rows, and
if each carried its own idea of "active" the drift would be silent in
exactly one direction: the resolver admitting a grant the panel shows as
ended. Pending confers nothing, expiry is checked against the clock of
the request that reads the row, and every transition is a conditional
update so two concurrent accepts cannot both win.

The access column carries READ or WRITE and only READ is ever written.
It exists from day one so that delegated writes become enforcement work
rather than migration work.

Three lifecycle paths, each proven against the real route:

- Delete All Data removes grants from either side. The wipe loop asks
  every model for "this account's rows", and a table relating two
  accounts has no user_id to answer with, so wipe-plan.ts now declares
  the owner columns and translates. Wiping only the grantor side would
  leave the account still holding access to other people's records.
- Account deletion removes them in both directions.
- An admin restore leaves them exactly as it found them. Grants are
  classified NOT_IN_BACKUP: rolling data back is a decision an admin
  makes, but rolling an authorization back is a decision nobody makes,
  and a grant revoked on Tuesday must not come back alive out of
  Monday's file. After a restore, sharing is off and both people
  re-consent.

The backup classification guard was reading a user relation by field
name, so a model naming its account relations grantor and grantee read
as instance-scoped and was excused from having a verdict at all. It now
reads the type. That surfaced InviteToken, classified here too.
2026-08-02 10:03:04 +02:00
Marc-André Bombeck da85e64cfa Catch the Bearer resolver the guard's matcher walked past
The Bearer guard demanded the literal `apiToken.findUnique(`.
`lib/idempotency.ts` writes the same call fluent-style, with a newline
between `prisma.apiToken` and `.findUnique({`, so the matcher never saw
it. It has been resolving a raw Bearer value to a user id since the
idempotency work landed, outside the frozen set, while the guard stayed
green on five files.

Tolerate whitespace around the member access and name the file with what
it does: it decides which user's idempotency cell a replay lands in, and
authorises nothing, because the handler still runs its own requireAuth.

Add the non-zero count assertion the other guards now carry. Narrowing
the regex back to its old form turns this test red, which is the proof
the broadened matcher is what finds the file. The mint-site matcher in
T3 was checked for the same defect and has no hidden members.
2026-08-01 22:45:04 +02:00
Marc-André Bombeck a08631e0a1 Freeze the set of surfaces that resolve a caller by any mechanism
The Bearer guard freezes one mechanism: the files that turn an
Authorization header into a user. It reads like coverage of "who can
resolve a caller" and it is not. Two other families do the same job by
other means and no guard watches either.

Fifteen files under src/app read the session cookie directly instead of
going through requireAuth: eight API routes and seven pages. Nine more
authenticate on a token carried in the URL, including the clinician
share tree, which serves one person's whole health record to an
anonymous caller holding a path segment. A sweep that greps for
getSession sees none of the second family.

Freeze both as literal lists with a reason per entry, matched by five
independent legs: the session-deriving helpers, a direct read of the
session cookie name, a dynamic route segment carrying a credential,
share-token resolution, and a query-string credential. Legs overlap, so
each asserts its own membership and its own non-zero count. A leg whose
matcher stops matching then fails instead of letting the union carry it.
2026-08-01 22:39:50 +02:00
Marc-André Bombeck c65f122017 Prove the verdict reaches a client and that nothing recomputes it
Three checks. The engine tests pin the overdue boundary from both sides:
day 42 of a stated 28-day cycle still counts, day 43 stops counting and
reports fifteen days later than usual.

The round-trip test calls the real GET handler, reads the response body as
text and parses it, so a field that both ends handle but the wire never
carries fails here. It runs the same instant twice for two accounts in
different timezones and gets two different answers, which is what the
account timezone is for. It also checks the ring's cycle day against the
phase engine the coach read uses, so the two cannot tell someone two
different days.

The guard reads the syntax tree of every file under components, hooks and
app, and fails on a grace-period constant, on an overdue verdict computed
rather than read, and on a cycle-day count compared against a sum. It
follows local bindings, so lifting the threshold into a variable above the
comparison does not hide it. A companion suite tries nine disguises against
the same detector and pins five innocent shapes it must ignore, including
an overdue medication dose.
2026-08-01 10:45:24 +02:00
Marc-André Bombeck f8074e1b07 Merge work/hero-score-panel 2026-07-31 11:48:22 +02:00
Marc-André Bombeck 2d01b9846d Merge work/coach-read-locale 2026-07-31 11:48:21 +02:00
Marc-André Bombeck 2bd964973f Record the score's delta chip in the affordance ledger
The panel picked up a green +N chip beside its label when the score moved
back into the band, and the repo-wide affordance guard counts that colour
wherever it appears outside the outcome module. The chip reports a week that
went up rather than an action that succeeded, so it is recorded here at
exactly one use rather than routed through the outcome table.
2026-07-31 11:40:26 +02:00
Marc-André Bombeck 7233845b10 Close four ways around the locale guard
The guard read a parameter as required whenever it carried no question
mark and no default, so `Locale | undefined`, `Locale | null`, a locale
widened to `any`, and a default sitting on the destructuring pattern all
walked straight past it. Each of those lets a caller say nothing and the
sentence come back in English, which is the bug the guard exists to stop.

A counter-case pins the other direction: a plainly required locale still
reads clean, so the guard stays quiet on the shape the chain is meant to
have. The correlation read now also asserts the language it was asked in;
nothing else in that file would have noticed it going back to English.
2026-07-31 11:08:06 +02:00
Marc-André Bombeck 271a49a822 Pin the Coach read strip's language, and keep the default out
Three things are held down here. The route hands the reader's language to
the strip builder, for a cookie and for a stored preference. The pattern
sentence comes back in German for a German reader, asserted word for
word, through the real chain rather than the engine on its own — the
engine could always write German, it was simply never asked, so an
engine-only test would have stayed green the whole time it was broken.
And a cached strip stays with the language it was written in.

The guard reads the declarations rather than grepping them, so renaming
the argument or moving the fallback into a variable does not slip past
it. Its counterpart runs the same detector against those disguises and
insists each one is caught, because a check that cannot fail reads like
proof and is not.
2026-07-31 09:07:50 +02:00
Marc-André Bombeck d0a42908fe Hold the due phrase to the profile clock and to whole calendar days
Moves the host zone under the function to prove the answer does not follow it, since the suite otherwise runs pinned to UTC and would not have noticed. Adds the two days a year that are not twenty-four hours long, where counting hours rounds the evening into tomorrow. The structural check that the shared body never reads the device clock was looking for the wrong shape and let a deliberate break through; it now looks for the word itself.
2026-07-31 08:00:28 +02:00
Marc-André Bombeck e1ca6d9da2 Work out the checkup due phrase on the same clock as the date beside it
The phrase read the device clock while the date printed under it read the profile clock. Set a laptop to another zone and the card could say a checkup was tomorrow above a date that was today. The zone is now something a caller has to state, and both screens state the one their dates are printed in.
2026-07-31 07:55:20 +02:00
Marc-André Bombeck c1b372de9c Stop a second copy of the checkup due line from appearing
Two screens once carried their own copy of the same date arithmetic and quietly disagreed for a year. This fails the moment a second one is written, whatever it is named, and says where the real one lives.
2026-07-31 07:48:57 +02:00
Marc-André Bombeck eb55c235c6 Rebuild the Insights health score as one pinned card
The score had two mounts: a compact face in the hero band and a
nine-block stack further down the overview. The stack also rode inside
the daily-briefing branch, so an instance with the briefing switched off
lost the whole breakdown.

Fold both into one progressive-disclosure card pinned between the
greeting and the customizable region. Collapsed it reads as the score
face: the ring, the band sentence, the weekly delta or the honest reason
there is none, Rest Mode, the tension and return-to-baseline lines, and
the coverage meter. Opening it adds a three-across pillar grid, the
named list of pillars that are not scored yet, the personal weight goal,
and the method footer with its cited standard.

The card is deliberately not a section in the insights layout registry:
an id a saved layout has never seen merges in as default-invisible,
which would have deleted the score for every account that has ever
customised the page.

A pillar the server gated stays visible by name with its reason instead
of being drawn as a zero, safety signposting leads that list, and a
pillar whose read failed gets an error row with a retry rather than
silence. The hero band goes back to the full-width greeting, the shape
it already had for an account without a score.
2026-07-30 23:20:02 +02:00
Marc-André Bombeck 17ea7bea2d fix(sw): re-anchor the fallback cache version and gate it against the release
The service worker derives its cache names from the version injected by
`sw-version.js`, and falls back to a literal compiled into `sw.js` when that
import fails. The prebuild step rewrites both from `package.json`, so both were
correct as long as a release rebuilt. One did not, and the literal trailed the
release by a version while every test stayed green.

Nothing checked it. The generated file is compared against `package.json`, and
the behavioural suite deliberately reads the literal out of the source so its
cache-name assertions cannot drift from the literal they exercise. Reasonable
apart, and together they left the literal itself unverified.

The effect is confined to the degraded path and is still worth closing: a client
that cannot load `sw-version.js` computes the previous release's cache names, so
it keeps serving the old shell and its cleanup treats the current caches as the
stale ones.

Re-anchors the literal and adds the equality as a gate, since it is a question
with an exact answer. A version bump committed without a rebuild now fails
locally instead of shipping.
2026-07-30 11:55:16 +02:00
Marc-André Bombeck 00c282d311 fix(workouts): collapse a re-sent session on every surface, not just the list
The canonical picker keyed a session on sport, start, end and duration. Only the
workout list selected the end timestamp; the Coach narration, the per-sport
baseline and the deep-link cluster passed rows without it, so those rows carried
no session identity at all and the duplicate survived. One record counted once in
the list and two or three times everywhere else, including in the averages a
person is compared against.

Drop the end timestamp from the identity. On a workout with pauses it measures
wall-clock time while the duration counts only moving time, so two re-sends of
one session can differ in that field alone and the stricter key rejected a
genuine match. It also bought nothing in exchange: nobody starts a second
workout of the same sport in the same instant with the same duration.

The deep-link cluster now selects the duration it needs. A structural guard
freezes the call-site set and records which file owns each caller's select, so a
new surface cannot join the set without fetching the identity, and the identity
cannot grow a field only some callers fetch.
2026-07-30 11:54:58 +02:00
Marc-André Bombeck 5b695f43ea fix(analytics): restore device-priority sleep selection 2026-07-29 16:08:41 +02:00
Marc-André Bombeck b41b32d472 feat(i18n): complete v1.34.1 locale reconciliation 2026-07-29 15:11:40 +02:00
Marc-André Bombeck b6dfe62c9d test(i18n): inventory v1.34.1 locale contracts 2026-07-29 15:07:05 +02:00
Marc-André Bombeck 3a813b51bd test(01-02): add red PWA cache isolation contracts
- exercise signed-in service-worker install at CacheStorage boundary
- require data-free offline navigation and immutable public assets
- require current and legacy session cache cleanup
2026-07-29 13:37:20 +02:00
Marc-André Bombeck ff8ead2db9 fix(analytics): repair route/i18n/guard fallout from health-score merge 2026-07-28 21:19:18 +02:00
Marc-André Bombeck 7b8fb98e48 feat(analytics): replace health score with reference composite 2026-07-28 21:12:05 +02:00
Marc-André Bombeck 93e1b83779 test(api): guard response field consumers 2026-07-28 21:11:56 +02:00
Marc-André Bombeck 3aff2c16db fix(insights): scope generation guard to prompt fields, clear cache on restore 2026-07-28 21:11:43 +02:00
Marc-André Bombeck c91558f18f feat(analytics): replace health score with reference composite 2026-07-28 21:10:15 +02:00