mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
ba4e34f09ecdd3f2be60b8da8d4ff1fededfd55a
20
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a74dc289a8 |
Carry the archived schedule eras, chain and all
The register said a restored medication keeps today's schedule and loses the record of when the dose or the timing moved. Measured, it costs more than a record: the band minter reads each past day against the schedule that was live THEN, so an account that came back without its eras had its own compliance history rewritten against a plan it was never on. The hard part was supersededByRevisionId, the pointer from a corrected ARCHIVED era to the MANUAL row that replaced it. Three things about it had to be settled. It is a bare id column with no relation, so nothing in the database refuses a value that points at nothing. That makes losing it quieter and worse than a broken foreign key would be: every era consumer SKIPS a row that carries the pointer, so a corrected era whose link came back null goes live again beside its own correction, and the timeline shows one window twice with past days minted against whichever the query read first. A portable restore mints fresh ids, so the pointer cannot travel as one. It travels as a POSITION in the drug's own ordered era list. That list is the only address space both ends share: the builder sorts it totally (validFrom, then createdAt, then id) so two eras sharing an instant cannot swap places between two exports of the same account, and the pointer never crosses a drug, because every route that writes it scopes the update to one medication. The same mechanism serves both purposes rather than one carrying ids and the other positions. Writing it needs two passes, because the row a link addresses is as often written after the row addressing it as before. Pass one creates every era with the link null; pass two patches the links once every position has an id. A hand-edited file that names a position out of range, or an era's own position, gets the era and not the pointer, and the position is reported as scheduleRevisionLink with the JSON path into the file. Refusing the file would cost an operator every era of every drug over one bad integer, and writing the link would point a correction at whatever row happened to be there. A row that supersedes itself is treated the same way: every consumer would skip it, so the era would vanish from the timeline while still sitting in the table. The round trip seeds three eras with one supersede among them and asserts the shape of the chain, then asserts that exactly two are live. Three rows with every link null satisfies any count and has thrown away the only thing the pointer says. |
||
|
|
768320f3d3 |
Restore what each medication was supposed to move
The register said the drug comes back with no statement of what it was for, which is exactly what a pinned efficacy target is: a row exists ONLY where the person overrode the derived ATC / name resolution, so it is a decision and never a computation. Nothing recomputes it after a restore because there is nothing to recompute it from. The lab arm travels as a biomarker NAME rather than an id, the way a lab result's cross-reference already does. A portable restore mints a fresh catalogue id, and `Biomarker` is unique on `(userId, name)`, so the name is the only end of the reference that survives the trip. The obstacle was ordering, not machinery: the remap map already existed, but the drugs are rebuilt several hundred lines before the analytes are. The targets are collected against the id each drug actually got and written in a second pass once the biomarkers exist. The alternative was hoisting the biomarker restore above the medications, and it loses on two counts. `biomarkerByName` and `restoredBiomarkerIds` are read by the lab-result loop immediately below the block, so moving it would separate a map from its only other consumer and leave a later edit free to re-order them back with nothing noticing. And the dependency would read backwards: a pinned target is a leaf of the medication tree, and letting a leaf dictate where the clinical record gets rebuilt inverts what the file is about. The second pass states the constraint where it applies instead of hiding it in a line order. A named analyte the restore did not put back is dropped and reported as medicationTarget rather than written. `biomarkerId` is a real foreign key, so a dangling value would fail the constraint and cost the whole account over one override. Dropping beats nulling here because the override IS the reference: a row with neither arm set is what the resolver already reads as "no override", so keeping it would restore something that means nothing while still holding the primary slot. The round trip asserts the restored target points at the biomarker the restore itself wrote, resolved from the database rather than remembered from the fixture. Counting would have passed on a row whose biomarkerId came back null, and nothing else would have complained: the resolver falls back to deriving a target and the page still renders. |
||
|
|
14638a6660 |
Bring the ECG strips back from a restore
The register said the originating device may still hold them. That holds for about as long as the phone beside the watch does, and not at all once a Withings connection is revoked: the signal id stops resolving with it. So a self-hoster who exported and wiped had a file with no traces in it and a restore that reported success. Both ends live in src/lib/export/ecg-backup.ts, the arrangement the reminders, visits and Coach sections already use. Three things needed deciding. The waveform follows the note contract: ciphertext verbatim in a disaster-recovery payload, the micro-volt samples decrypted in a portable one. There is no placeholder for a strip that will not decrypt the way the Coach has one for a turn, because waveformEncrypted is a required column. Such a recording is left out of a portable file with a warning on the wide event, so the number of strips in the file is the number of strips that can be shown. measurementId is a REAL foreign key against the event row a strip was filed with, unlike the Coach references next door. A value that will not resolve does not dangle quietly here, it violates the constraint and rolls the whole restore back over one cross-reference. A portable export omits soft-deleted measurements, so that case is reachable rather than theoretical. The reference is resolved against the measurements the restore wrote, nulled when it cannot be, and reported as ecgReference. The strips are wiped and rewritten by the section itself. The wipe at the top of the restore never reached them: a recording hangs off the account, not off a measurement, so deleting the measurements only nulled the pointer and left the row behind. The round trip asserts the row column by column rather than counting it. A recording is three things at once and each is the whole value of the row on its own: a strip with no rhythm verdict is a squiggle nobody has read, a strip with no recordedAt belongs to no day, and a strip with no samples is a claim that an ECG exists which nothing can draw. |
||
|
|
f95fb95295 |
Carry the bests, the badges and the environmental history through a restore
Four models leave the coverage-pending register with both ends and a round-trip proof: PersonalRecord, UserAchievement, EnvironmentContext and EnvironmentTravelLocation. Two new section modules hold reader and restore beside each other, the arrangement the visits, vaccinations, reminders and Coach sections already use. The register understated two of them. The bests are "recomputable in principle", which is true and does not help: nothing recomputes them, and a recomputation over a restored history would find different rows, because sample series and GPS routes never travel, a portable file omits deleted readings, and a record set in a reading that has since been corrected cannot be found twice. The badge's unlock date is worse. The evaluator prefers a persisted date over a derived one, and several of the dates it can still derive come from counters no backup carries, so a dropped row does not relock the badge, it re-earns it today. PersonalRecord.sourceMeasurementId is the reference that needed care, and it is the dangerous kind. The schema declares no relation for it; migration 0054 created the column as a real foreign key against measurements. Writing it blind is not a dangling pointer, it is a constraint violation that rolls the whole restore back and hands the operator an empty account. Measured, with the resolution removed: "Foreign key constraint violated on the constraint: personal_records_source_measurement_id_fkey", HTTP 500, nothing restored. So the restore runs after the measurements, resolves the pointer against the rows it actually wrote, nulls what it cannot resolve and reports it under a new personalRecordReference skip kind. An unlocked badge names a definition in the code catalogue, which drifts between the release that writes a file and the one that reads it. The id is carried and written back verbatim rather than judged, the same answer the vaccination restore gives an antigen slug it cannot resolve. The readings and the location periods travel as a pair because carrying the readings alone is worse than losing them. The environment resolver reads the periods live, so with them gone the nightly seven-day lookback re-resolves every trip day to the home location and upserts over the row: an account would come back with a fortnight abroad and then have it quietly rewritten as a fortnight at home, days after a restore that reported success. Day keys stay strings end to end, and fetchedAt is restored verbatim so a two-year-old provisional reading does not claim it was confirmed today. The round trip seeds bests that share a metric type and an instant and differ only in the sport slot, two badges earned years apart (one of them under an id this build no longer defines), and a trip day beside a home day, then reads each back column by column. Counting alone passes every one of the failures worth fearing here: slots dropped so a 5 km best is presented as a 10 km best, unlock dates stamped at restore time, a trip day re-attributed to the home city, a location period whose bounds no longer cover the reading it explains. Each of those was applied on purpose and each failed on its own assertion. Two schema comments claimed no read path selects a column that this backup now selects; both say what reads it instead of staying wrong. |
||
|
|
d5dde3eb1c |
Carry the vault's filing and its staged facts through backup and restore
`DocumentConditionLink` and `ExtractedFact` were classified as backed up and came back from no restore. The register said a restored vault is unsorted and that the facts are re-derivable by re-running the extraction. The first is right; the second understates it. What a re-run cannot produce is the REVIEW DECISION on each fact, and every column holding one defaults to "nobody has looked at this yet". `POST /api/documents/inbound/[id]/confirm` acts only on a PENDING fact and commits it into the structured store, so a restore that let those columns default would offer an already-committed reading for approval a second time and write a second lab result behind it. Both ends live in `src/lib/export/document-filing-backup.ts`, beside each other like the Coach, reminder and vaccination sections. `status`, `needsReview`, `confidence` and the commitment pair are carried and restored verbatim, and the wire schema requires the first three rather than defaulting them. Ordering: the section runs after the documents, the condition episodes, the lab results and the medications. The first two are real foreign keys, unlike the Coach's bare id columns, so an unresolvable value there does not cost one edge, it aborts the transaction and the operator gets none of the account back. The last two are what `committedRecordId` resolves against. The builder therefore carries a filing or a fact only when both of its ends are carried, and the restore still checks and drops what a hand-edited file leaves unresolvable. `committedRecordId` is nulled together with its type, because a type with no id claims a commitment the row cannot name. The round trip seeds a second condition and files the page under it, so a restore that kept the count and lost the pairing fails rather than handing back a lab report filed under a head cold. A third case breaks both references in a saved file to prove the drop path runs and still answers 200. Three new skip catalogues arrive with it, and with them labels for the three that had none: a dropped Coach attachment had been reaching the operator's screen labelled "mood factor" since it was added. The label chain is exhaustive now, so the next catalogue without one stops the build. |
||
|
|
f4fe08c73e |
Carry the screener history and the consent record, disaster recovery only
Two models come off the coverage register together, and neither is an ordering or an id-remap problem. Both hang off the account and nothing else. What kept them off was a question the code never wrote down, so this change writes it down. The screeners hold completed WHO-5, PHQ and GAD administrations, and for PHQ-9 the encrypted blob contains item 9, the question about thoughts of being better off dead. The application already draws a line around that blob: it keeps a coarse item9Flagged boolean so the history view can re-surface crisis resources without decrypting anything. The intent here was to follow that line and carry the administration while leaving the answers encrypted. The schema settles it more firmly. responsesEncrypted is Bytes and NOT NULL, so there is no half-row to write and the choice is the whole thing or none of it. A plaintext export in a downloads folder is the wrong home for that answer, so the row rides in a disaster-recovery file only. The consent receipts are not sensitive in that way. Their problem is that a consent is given to an operator. A receipt restored onto a different instance asserts an agreement that operator never obtained, with a signature timestamp to make it look settled. A disaster-recovery file goes back to the same instance, so it travels there and not in a portable one. In both cases the portable file SAYS so in its own manifest rather than reporting an account that has neither. The manifest is merged rather than added beside the records one, because two keys of the same name would shadow each other depending on spread order. item9Flagged is restored from the file rather than recomputed. Recomputing would mean decrypting every administration during a restore, which is the handling this data is kept away from, and the stored flag is what the history view has been reading all along. Verified by breaking it: dropping revokedAt on the way back fails the round trip with a withdrawn consent coming back as an active one. |
||
|
|
16b10c7855 |
Carry per-drug reminder phase timings, and stop claiming the notification preferences could travel
Two entries sat side by side on the coverage register, described as "the same shape of loss". They are not the same shape at all, and the difference is worth writing down. ReminderPhaseConfig hangs off a medication, one row per drug, nested inside something that already travels. It needed nothing new and it travels now. The round trip tunes all four thresholds away from their defaults and puts one of them in PERCENT rather than MINUTES, because a restore that quietly fell back to the schema defaults would hand back four plausible numbers and change when the drug turns orange. NotificationPreference cannot travel, and the register was wrong to imply it was merely unbuilt. A preference addresses ONE notification channel by a hard foreign key. The channel model is deliberately excluded from every backup because it carries channel secrets, and the restore deletes every channel it finds. A carried preference would point at a row that cannot exist: the same constraint violation the custom mood categories were causing, waiting for somebody to implement it in good faith. So it moves to the deliberate exclusions with a reason that names the dependency instead of the symptom. Re-applying somebody's tuning to a channel they add after a restore is a feature about channel types, not a backup contract about rows, and it should be argued for on its own terms rather than smuggled in as debt repayment. |
||
|
|
d596f8c66d |
Restore an account that grouped its own mood factors
An account that ever created its own mood category could not be restored at all. Not partially, not with a gap: the restore violated mood_tags_category_id_fkey and rolled the whole file back, so the operator got a 500 naming a constraint and an empty account. The coverage register described this as custom tags landing in a grouping that no longer exists, which reads like a cosmetic loss. Measured, it is the same shape the retired-catalogue-key work already removed once: one row nobody thought about taking down everything else with it. MoodTag.categoryId is a real foreign key. Seeded categories belong to the instance and are deliberately still not carried, but a category the person made is theirs, cascades away with them, and has to travel. It now does, and it is written before the tags that point at it. The hidden set travels by key rather than id, because what people hide is almost always a seeded tag whose id differs on every instance. A key this instance no longer knows costs one hidden row and is reported: a silent drop would un-hide a factor somebody had deliberately taken out of their mood editor, and they would have no way to know why it came back. Found by experiment rather than by reading: seeding a user-owned category into the round trip turned it red with the constraint name, and the same test is green now. |
||
|
|
86231ed23e |
Carry medication stock and its ledger through backup and restore
A restored account reported no supply at all. Every pack was gone, and with it every low-stock reminder, on a feature that became first-class when medication moved to one stock per drug. The two travel together because the register itself paired them: without the events the count "cannot be rebuilt even in principle", and without the packs the events are a list of deltas against nothing. The count comes back exactly as recorded, not recalculated from the ledger. It is the value the server had resolved and the person was shown, and every reminder already sent was computed from it. The fixture makes the difference visible on purpose: thirty in, eight consumed, and a remaining count of twenty-one, because somebody dropped one without recording it. A restore that "corrected" that to twenty-two would disagree with the account's own history and look like a bug fix while doing it. Both decimal columns cross the wire as strings. Half tablets are a real prescription and a JSON number would round them. The note follows the contract the side effects and dose changes beside it already use, and the packs nest inside their drug, so a portable restore minting fresh ids needs no remap. Verified by breaking it: recomputing the remaining count from the ledger fails the round trip with twenty-one against twenty-two. |
||
|
|
1fbd3b628b |
Carry the Coach's memory through backup and restore
The transcript came back in the previous change; what the Coach keeps BETWEEN threads did not. A restored account had every word of every conversation and a Coach that has to be told its own history again — the register said as much for all three: "a restore makes the account re-tell its history", "keeps the talk and loses the commitment", "a restored account silently stops sending them". Tombstones ride only in a disaster-recovery file. All three soft-delete, and a deleted fact is hidden from injection while kept for audit — a distinction only the owning instance can act on. A portable file carrying them would hand a person rows their own app refuses to show them, and a restore that resurrected one would put a fact back into the Coach's mouth that the person had deliberately taken out of it. Neither reference here is a foreign key. sourceConversationId, on all three, and relatedPlanId on the reminder are bare id columns, so nothing in the database refuses a value pointing at nothing. That makes them more dangerous than a real key rather than less: a dangling reference costs no error, it just quietly stops meaning anything while the row goes on looking complete. The restore resolves both against what it actually wrote, nulls what it cannot resolve, and reports the count. Nulling rather than dropping is the point. A live reminder must not be lost because the plan it mentions was tombstoned out of a portable file. The reminder is still the person's and it still fires; what it loses is a pointer that was already going nowhere. Order follows from that: threads, then plans, then reminders. Running the memory before the transcript would cut every fact loose from the conversation it came out of and still report success. Verified by breaking it: making the conversation lookup always return null fails the round trip on the fact's own source field. |
||
|
|
bcb7716ffa |
Carry the Coach transcript through backup and restore
A restored account got a Coach that had never spoken to it. The conversations, every turn in them, and the record of which vault documents a thread was grounded in were all classified as backed up and travelled with nothing. The register's own entry for the messages called them the largest volume of free text an account owns after its documents. The three travel together because two of them mean nothing alone: a turn without its thread has nowhere to hang, and an attachment names a pairing rather than a thing. documentScoped is the field that makes this more than lost history. It is a permanent fence — set when a conversation is created through the fenced endpoint or gets its first document, and lowered by no code path anywhere, not by detaching every document and not by deleting one. A restore that let it default to false would hand the tool loop back to precisely the conversations the fence exists to keep away from it, and nothing in the interface would look wrong afterwards. So it is carried verbatim, restored verbatim, and asserted per thread rather than in aggregate: a restore that wrote false everywhere would still return both threads and every turn and satisfy every count. Prose follows the contract the rest of the payload already uses. A disaster-recovery file carries the stored ciphertext; a portable file carries the text, because a portable export exists to be readable by the person who owns it, exactly as it already decrypts medication notes, mood notes and document summaries. A single row under a key the instance no longer holds is named rather than thrown, so one unreadable turn cannot cost someone the rest of the transcript. Ids ride in both modes, as the reminders do, so an attachment resolves and the facts, plans and reminders still owed have something real to point at. The restore runs after the documents for the same reason, and an attachment naming a document the file does not carry is dropped and reported instead of failing the whole file over one link. Verified by breaking it: forcing documentScoped to false on the way back fails the round trip on that field by name. |
||
|
|
5e0994aab9 |
Carry medication dose changes through backup and restore
The titration history was classified as backed up and travelled with nothing. An account restored from a backup showed the current dose and no record of how it got there: the ramp, the reductions, the note the prescriber gave for each step. For a long-running drug that history is often the more clinically interesting half. Export writes the rows nested inside their drug, ordered by effectiveFrom, so a portable restore that mints fresh ids needs no remap. The note follows the contract the side effects already use: a portable export decrypts it for a human reader, a disaster-recovery export carries the ciphertext verbatim, and a restore never writes it into the legacy plaintext column. Order is the content here, not decoration. Two rows on the same drug in the same unit, 5 mg then 10 mg, describe a ramp; the same two rows returned in the other order describe a taper. Counting them back cannot tell those apart, so the round-trip test asserts the sequence and the values, not the count. MedicationDoseChange moves from the coverage-pending register into the two-ended list, which makes the round-trip registry incomplete until a row is seeded and counted. That is a compile error, not a review note. |
||
|
|
48f4cba3aa |
Carry medication pause eras through backup and restore
`MedicationPauseEra` was classified as backed up and travelled nowhere. The spans where a drug was deliberately paused are the difference between "stopped taking it" and "was told to stop taking it", and nothing else in the payload can reconstruct them: an era is a decision, not an observation, and no intake row records its own absence. Without them the compliance recomputation reads a deliberate pause as missed doses, so a restored account looks non-adherent for a stretch during which it did exactly what it was told. Both ends land together. The eras ride nested inside their medication, like the schedules and side effects beside them, so a portable file that mints fresh medication ids needs no remap — Prisma binds each child to whatever id the parent actually got. ## The open era is the point `resumedAt` is nullable end to end and the writer always emits it. An open era means the drug is paused right now, and readers run it to the present. The restore passes the null through rather than closing it at restore time: a pause that was still running when the backup was taken is still running afterwards, and closing it would silently turn an ongoing decision into a historical one. Counting alone cannot catch that — a restore that closed the era would still hand back two rows. So the round-trip test seeds one closed era and one open one and asserts both field by field. ## What is proven and what is not Moving the model out of `COVERAGE_PENDING` and onto `TWO_ENDED_MODELS` makes the round-trip test's registry incomplete, which is a compile error. Verified by watching `tsc` fail with TS2741 before the count-back was written: the coupling the plan's comment describes does hold. The round-trip test itself needs Postgres via testcontainers and has not been run here. Typecheck, lint, format and the 21458-test unit suite are green; the integration proof comes from CI. |
||
|
|
b1be67bfa7 |
Carry the reminder schedule and its ledger through backup and restore
The reminder cadences were the oldest entry on the coverage-pending register: carried in name, restored never — an account came back silent until every reminder was rebuilt by hand. Both models are two-ended now, in the house shape (both ends in one file, reminders-backup.ts): every column travels, nextDueAt verbatim rather than recomputed, the completion ledger beside it, tombstones in disaster recovery only, and a portable export omits a tombstoned reminder together with its ledger rows. Encounter.reminderId and VaccinationRecord.reminderId stop being dropped on restore: both remap against the restored reminders and fall to NULL with a skip report only when the file genuinely lacks the row. The wipe plan names the ledger child-before-parent; the classification guard, completeness guards and the integration round trip all demand the new coverage (proven failing with the section removed). Refs #223 |
||
|
|
6926caa11d |
feat(vaccinations): carry the immunization log through export and restore
- a new backup section reads the dose and its document link through their own delegates and writes both ends in one file, the arrangement the visit section already uses - the practitioner and the visit remap to the rows the same restore put back; the booster reminder never travels, so its reference resolves to null and the drop is reported rather than swallowed - the antigen slug restores verbatim: a slug the catalogue has since dropped still says what a person was given - the round trip seeds a dose with every optional column filled and reads each one back, so a returning row count cannot stand in for a returning record |
||
|
|
a83be074ca | Merge branch 'main' into feat/encounter-phase-1 | ||
|
|
7b1a44e799 |
chore(mood): account for MoodContext in the four whole-schema guards
- the wipe plan names it before its parent, so the confirmation screen's count is the number of rows that existed rather than zero - the backup plan classifies it as carried and two-ended, and the round-trip registry seeds one, which is what makes that claim provable - the sharing surface admits the linked-day read with the reason it may answer - CHANGELOG for the release |
||
|
|
24425e9aff |
Add the visit record and the carriers that keep it
A person's whole 'which doctor' history was one free-text column on a reminder: they typed the practice name once and could reach nothing else that happened there. This turns that string into a record. Five tables in migration 0320, all additive: an encounter that happened or is booked, the account's own address book of practitioners, and three links from a visit to the documents, lab results and conditions it produced. The encounter is slim on purpose — the diagnosis stays on the linked condition, the address on the linked practitioner, and the reason each field was kept or cut is written at the model. No module gate, and the schema says why rather than leaving the absence to be read as an oversight. Every carrier lands with the tables rather than after them. All five are classified as travelling both ways and are proven doing it: a seeded row of each goes out through the real export builder, the account is deleted so the schema's cascade decides what survives, and the rows are counted back through the real restore route. Two references deliberately do not survive and are named instead of dropped quietly — the reminder a visit points at is not in the backup, and a link whose far side a portable export does not carry cannot resolve. The wipe plan deletes the links before the lab results and documents they hang off, or the audit row would report zero for rows that existed. The three encrypted columns are registered for key rotation, without which dropping a legacy key would make those notes permanently unreadable. Widening the reminder origin enum is a type error at the reminder DTO, which is the right outcome: the mapper now refuses an appointment row outright rather than relabelling it as a checkup. |
||
|
|
75644bc3d2 |
Carry recorded side effects through the backup and back
MedicationSideEffect was classified as backed up and was not carried: the export wrote nothing and the restore had nothing to read, so a restored account kept the drug and lost the reason someone may have stopped taking it. Delegated write access sharpens that — a side effect is one of the things a second person can contribute, and it was the contribution a restore dropped. The rows ride inside their medication, like the schedules beside them. A top-level array would have to carry `medicationId` and survive a restore that mints a fresh id for a portable file, which is the remap the intake events already work around by drug name; a nested create has no id to remap, because Prisma binds the child to whatever id the parent row actually got. The note follows the measurement contract for the same dual-column arrangement: a portable export carries the decrypted note and no ciphertext, a disaster-recovery payload carries the ciphertext verbatim, and the restore re-encrypts a legacy plaintext row rather than writing plaintext back into the column. Move the model from COVERAGE_PENDING to TWO_ENDED_MODELS, and settle the claim where a written verdict cannot: the round trip seeds a side effect, exports through the real builder, empties the account and reads the row back out of the real restore route with its category, entry, severity and note intact. Both halves were excised to check the test can fail — the restore branch turns the count red, the payload turns the field values red. |
||
|
|
cb9867e379 |
Prove a backed-up model travels by making it travel
The plan says which models a backup carries and a source-text check says each one has a reader and a restore branch written for it. Neither can tell a restore branch that runs from one that only exists: wrap the nutrient restore in `if (false && …)` and every check stays green, which is the correct-green-and-inert shape this project already shipped once. So settle it end to end. Seed one row of every two-ended model, export through the real payload builder, delete the account so the cascade empties what a hand-listed sweep would miss, restore through the real admin route, and count each row back out. A model that never travels comes back at zero and says which one it was. The two-ended list is a literal tuple now, so the seed registry can be keyed by it: adding a name without seeding and counting a row for it is a compile error rather than an unproven claim. Also names `src/lib/illness/dto.ts` as a backup writer. The illness day-log's symptom include is a shared constant living there, not a literal in the records writer — the same "the caller delegates" shape that already caught the measurement pager. |