changes
All checks were successful
CI / ci (push) Successful in 19m38s

This commit is contained in:
2026-08-26 20:55:30 +02:00
parent a557c183e9
commit 23d052278a
523 changed files with 24448 additions and 2005 deletions

View File

@@ -0,0 +1,31 @@
# The day boundary is resolved from the User's timezone
Day-scoped data (DailyMetric) needs a single, unambiguous notion of "which day". We resolve it by converting an instant into the User's IANA timezone and taking the local date, rather than using the offset carried on `logged_at` or a UTC date.
## Considered Options
- **Bare local date from `logged_at`** — what `get_calendar` did originally. No server-side authority: two clients in different offsets disagree about the same instant's date.
- **UTC date** — unambiguous but wrong for most humans; evening entries in eastern offsets land on the following day and fail to line up with the health data describing them.
## Consequences
`User.timezone` stops being optional in practice. Clients must set it from the platform at register/login, and requests that need a day boundary are rejected when it is unset rather than silently defaulting — silently misfiled data produces analytics that look correct and are not. `Timezone` must therefore validate against the real IANA database, not just check for a `/`.
All day grouping in the system flows through this one rule, including `get_calendar` and streak calculation, which previously used two different and mutually inconsistent definitions.
## An imported wall clock time is the User's own wall clock
Daylio's export records `2026-08-25` and `8:00 PM` and says nothing about which zone that was. The importer originally read it as UTC, so an entry logged at 8 PM in Warsaw was stored as `20:00Z` and displayed at 10 PM — every imported entry off by the User's offset, silently, and worse the further from UTC they live.
A wall clock time with no zone is the User's own wall clock. It is now placed in their Timezone using the offset in force **on that entry's own date**, not a single offset taken from today, so a year of history spanning a clock change gets both offsets. A Warsaw import shows `+01:00` in winter and `+02:00` in summer, and every entry reads as the 8 PM it was.
This makes the Timezone a precondition for importing, exactly as it already is for the calendar and for DailyMetrics: an account with none is refused rather than filed an hour or ten out of place. That is worth an error in the one place a User can act on it.
Two edge cases the clocks create:
- **An hour that never happened.** In Warsaw the clocks jump from 02:00 to 03:00 on the last Sunday in March, so a Daylio row reading 2:30 AM that day names a time that did not exist. The entry lands an hour later rather than being dropped, with a warning — a mood that was logged is not made up, and losing it to arithmetic is worse than moving it sixty minutes.
- **An hour that happened twice.** In autumn 02:30 occurs twice; the earlier of the two is used. Either is defensible and the difference is an hour once a year, so the choice is recorded rather than agonised over.
## A second bug the same file exposed
The Daylio adapter read its columns by position, and index 7 is `note_title`, not `note`. Every imported note was silently dropped and the always-empty title kept in its place. Columns are now looked up by header name, so a column added, removed or reordered upstream cannot quietly shift the meaning of the data — and an export missing `full_date`, `time` or `mood` is refused by name rather than parsed into nonsense. Where a row has both a title and a note, both are kept.

View File

@@ -0,0 +1,18 @@
# MoodEntry is a thin root; everything optional is an independent EntryDimension
`MoodEntry` carries only its identity, one Mood, and the instant it was logged. Every optional aspect — Content, Activities, photos, voice memos, weather, location, song — is an EntryDimension owning its own type, table, port, and validation, and is composed onto the entry at read time.
A dimension is a new self-contained module owning its own storage and validation. Adding one does not change any existing dimension, repository, or use case.
It does, however, add a variant to a closed `DimensionValue` enum, mirroring `MetricValue` in ADR 0008. This is deliberate: the compiler then walks you through every site that must handle the new dimension — composer, wire dispatch, export — rather than letting it silently fail to appear. Openness was traded for exhaustiveness, on the same reasoning and for the same reason as ADR 0008.
## Considered Options
- **Adding nullable fields to `MoodEntry`** — what the codebase did. The aggregate was already at 10 fields against a stated 5-6 guideline, and the proposed data points would have taken it past 30.
- **A closed facet enum on the root** — keeps the root small but forces heterogeneous payloads into one table as JSON or sparse columns, and turns "at most one weather per entry" from structure into a runtime check.
## Consequences
The existing four dimensions were retrofitted rather than left as a parallel path, so there is exactly one answer to "what is attached to this entry". Reads batch-load per dimension across a whole page of entries rather than per entry.
Enumeration does not disappear — it concentrates in the read composer and the wire format's kind dispatch, instead of being spread across the domain.

View File

@@ -0,0 +1,26 @@
# Headless importers authenticate with long-lived scoped API tokens
Health data arrives from automations — iOS Shortcuts, Tasker, cron — that cannot participate in the session flow. Users mint named, revocable, non-expiring API tokens scoped to writing DailyMetrics only.
## Considered Options
- **Reusing the refresh token flow** — rejected because refresh tokens rotate and revoke the old token on use, so a single interrupted run locks the importer out permanently and silently, and iOS Shortcuts has no durable place to keep a rotating secret.
- **Raising the access token TTL** — rejected because it would lengthen the compromise window for every browser session to solve an automation problem.
## Consequences
The system now has two credential types with different lifetimes and different powers. An API token cannot read entries or change account settings, so a leaked one exposes less than a session token — but it does not expire, so listing and revoking tokens is a required part of the settings surface, not a nice-to-have.
## What a token is, in practice
The value is `kmood_` followed by 32 random bytes in url-safe base64 — the prefix so a secret scanner can recognise one in a repository, the bytes because 256 bits needs no stretching. Only its SHA-256 digest is stored, in a unique indexed column, so authentication is one indexed lookup and one fast hash. Argon2 would be the wrong tool twice over: it exists to slow down guessing at low-entropy human passwords, and because its output is salted per row it cannot be looked up, which would mean verifying a presented secret against every stored token on every request.
Scope is enforced by which extractor a route asks for, not by a check inside one. `AuthenticatedUser` accepts sessions only; `MetricWriter` accepts either a session or a token. A new endpoint therefore refuses tokens by default, and making one token-accessible is a visible, deliberate edit. The inverse — one extractor consulting a scope table — would make omission the permissive case.
## The name is the Provider
A token's name is a ProviderName, and its writes are attributed to it. This keeps ADR 0009's manual-wins rule intact once automations exist: an import genuinely is a Provider write, so it cannot overwrite what the User stated by hand, and a User correcting an imported day supersedes the importer. The constraint it imposes is that token names live in ProviderName's alphabet — lowercase letters, digits and hyphens — which is surfaced as a validation error rather than silently normalised.
One asymmetry follows from the same rule and is enforced separately: a Provider-attributed request may not clear a metric. Clearing is how a User says a reading should not be there, and an importer that could clear would be able to delete hand-entered data it is not allowed to overwrite.
Tokens survive clearing account data, as ProviderConnections do — a credential is not data about days. Deleting the account removes them through the foreign key.

View File

@@ -0,0 +1,60 @@
# Background work runs in a separate binary against a deliberately lossy queue
Background jobs (weather enrichment, metadata lookup, reminders) run in a dedicated worker binary, not in `tokio::spawn` calls inside the server. Correctness comes from sweeping domain state; the queue exists only for promptness and observability.
The rule that makes this safe: **no job may be enqueued unless a query over domain state can independently rediscover the same work.** "Entries with a Location and no Weather" is a valid job; anything with no trace in the data is not.
## Considered Options
- **Transactional outbox** — events written atomically with the change that caused them. Correct, but `MoodEntryCommandPort::save` and `EventPublisherPort::publish` are separate ports with no shared transaction, so this required either an aggregate-recorded event collection or a unit-of-work threaded through every command. Both are real cost for a guarantee that sweeps already provide.
- **Polling only, no queue** — matches how reminders already work, but gives no record of a failed enrichment, so nothing can be retried deliberately or inspected.
## Consequences
Saving a MoodEntry stays a single small transaction that must simply succeed. Everything downstream is best-effort with a visible trace — status, attempt count, last error — so a failure can be retried rather than disappearing.
Losing a queued job costs latency, never data. This means the queue needs no transactional guarantees, and restarts, crashes, and imports that predate the queue are all handled by the same sweep rather than by three separate mechanisms.
The cost is that every job type owes a sweeper query alongside its enqueue, and jobs that cannot be expressed as a query over state must be refused rather than accommodated.
The in-process `mpsc` event channel cannot cross a process boundary, so it is not the transport. The queue's backing store is an adapter choice behind the port — SQLite is the store of record; NATS, where available, is a relay for fan-out, never the source of truth.
`create_pool` must set a `busy_timeout`: two processes writing one SQLite file without it produces intermittent `SQLITE_BUSY` failures. Shared wiring must move out of `crates/server` so both binaries build the same object graph.
## Bounds
Sweeps are rate-bounded from configuration, not from constants. The realistic backlog is small — the Daylio importer carries no location data, so imported history never triggers weather lookups; the sweeper exists for entries stranded by an upstream outage or an offline client. The bound is a safety limit rather than a throughput design.
Where an upstream accepts a date range for one location, a backlog grouped by rounded coordinates collapses into a handful of requests rather than one per entry.
Now-playing lookup is not a job. It resolves synchronously while an entry is being composed, and a MoodEntry never acquires a Song afterwards — so there is nothing for a sweep to rediscover, and nothing belongs on the queue.
## Correcting this document on SQLITE_BUSY
This ADR said `create_pool` sets no `busy_timeout` and that two processes writing one file would therefore fail intermittently. Both halves were wrong for the sqlx version in use.
sqlx 0.9 already applies a five-second `busy_timeout` by default, so the setting was never absent. It is now set explicitly at ten seconds, because a value this operationally significant should be visible in the code rather than inherited from a dependency's default — but raising it fixed nothing, and removing it entirely breaks no test.
The real failure was different and `busy_timeout` cannot help with it. Claiming jobs was written as a SELECT followed by UPDATEs inside a deferred transaction. In WAL mode, upgrading a transaction from reading to writing fails with `SQLITE_BUSY_SNAPSHOT` (code 517) **immediately**, not after the timeout: the reader is holding a snapshot that a concurrent commit has already superseded, and no amount of waiting can reconcile it. Two writers on one file failed within milliseconds.
The fix is to claim in one statement — `UPDATE ... WHERE id IN (SELECT ...) RETURNING ...` — which takes the write lock up front and never upgrades. That is also simpler than the transaction it replaced. Any future queue operation that reads and then writes must either be a single statement or open its transaction as `BEGIN IMMEDIATE`.
## A job stored in a shape this build cannot read
Rows are hydrated defensively, as metrics are. A row whose kind, status or timestamps cannot be read is marked exhausted rather than skipped: skipping leaves it claimable, so the claim would flip it to running, fail to read it, and the stall reclaim would hand it back — a silent loop with no progress and no trace. Marking it exhausted stops the churn and leaves it visible, which is what the queue is for.
## What the worker owns
Reminder scheduling and expired-session cleanup moved out of `tokio::spawn` calls in the server and into the worker, alongside the queue loop and the sweep. The server logs that background work lives elsewhere, because a deployment that runs only the server now silently does no background work at all — `docker-compose` gains a second service, and the image carries both binaries.
Shared wiring moved to `crates/bootstrap`, which both binaries depend on, so there is one object graph rather than two that can drift.
## Two processes starting at the same moment
Both binaries run migrations on startup through the shared factory, so `docker compose up` — or a dev target that launches both — starts two processes racing to migrate one file. Each saw a migration as unapplied, each applied it, and the second `INSERT INTO schema_migrations` failed with `UNIQUE constraint failed`, killing whichever process lost. `depends_on` does not help: it waits for a container to start, not to finish migrating.
The whole migration run now happens inside one `BEGIN IMMEDIATE` transaction, so the second process waits on `busy_timeout` and then finds everything already applied. `BEGIN IMMEDIATE` rather than a plain `BEGIN` because the run reads `schema_migrations` before writing to it, and this document already records what a deferred read-to-write upgrade does in WAL mode.
Making the run safe was preferred to electing one process as the migrator. Coordination would need a startup order the compose file cannot guarantee, and a worker that must not touch the schema is a rule nothing enforces.
A test starts two pools on one file and migrates concurrently. It reliably fails with no transaction at all; it does **not** distinguish `IMMEDIATE` from deferred, because the timing in practice has one run complete before the other upgrades. The choice of `IMMEDIATE` rests on the failure mode proven elsewhere in this document rather than on that test.

View File

@@ -0,0 +1,17 @@
# Provider credentials are stored server-side, encrypted, behind one generic concept
Auto-filling what a User was listening to requires calling their music server. We store a per-User ProviderConnection — a Provider name plus a credential encrypted under a key from the environment — and the server makes the call.
## Considered Options
- **Client-side resolution, no credentials on the server** — the safer default, and preferred on security grounds: a leaked database would expose nothing new. Rejected because the SPA is a browser PWA and would hit CORS and mixed-content restrictions against a self-hosted music server, and each client would have to implement auto-fill separately.
## Consequences
Credentials are sealed with XChaCha20-Poly1305. Authenticated encryption means a tampered ciphertext is rejected rather than decrypting to rubbish, and a random 24-byte nonce stored alongside each ciphertext means the same credential never encrypts to the same bytes twice — so equal ciphertexts cannot reveal that two users share a password.
`EncryptedCredential` renders as `<redacted>` in `Debug`, so a credential cannot reach a log through a derived `Debug` on any type that holds one. The same redaction is applied to the command and request types that carry a plaintext credential in transit.
The database now holds a recoverable credential for another system. The classic Subsonic scheme computes a per-request token from the plaintext password, so the stored secret is password-equivalent. Encryption raises the bar against a stolen database file alone, but a self-hosted single-container deployment usually backs up environment and data together, in which case it buys little — this is accepted knowingly.
The domain does not know what a Provider is or how it is reached. It holds an opaque credential and a name; parsing, authenticating, and calling are entirely adapter concerns, which is what lets a second Provider with a different credential shape (OAuth, API key) be added without touching the domain.

View File

@@ -0,0 +1,60 @@
# Correlations are shown as agreement across strategies, never as ranked findings
Several CorrelationStrategies (Pearson, Spearman, Kendall, mean difference) are computed over the same data and presented side by side. Agreement among them is the headline; no single coefficient is authoritative.
Results are deliberately **not** ranked, not badged with significance, and not surfaced as an "insights" feed.
## Considered Options
- **A ranked insights list with p-values** — by far the most engaging presentation, and rejected for that reason. With roughly 25 MetricKinds tested at conventional thresholds, more than one spurious result is expected per user per run; ranking by strength selects precisely for whichever metric got lucky, and a p-value badge converts that coincidence into a claim someone may act on.
- **Strict multiple-comparison correction as the only view** — statistically the most defensible, but with this many metrics and a few months of data essentially nothing survives, so the feature reads as broken rather than rigorous and users conclude the tracking was pointless.
## Consequences
Showing several strategies is a robustness check, not a menu. A correlation that holds under all of them is worth attention; one that appears under a single strategy is reporting an outlier or a non-linearity. Presenting them as choices would let a user shop for the flattering number, which is worse than showing one.
Strategies use rank-based methods where possible because Mood is ordinal — the domain asserts that Rad is better than Good, but never that the gap from Awful to Bad equals the gap from Good to Rad, which is what Pearson assumes.
A minimum sample size gates output entirely: below it, no coefficient is returned rather than one computed from a handful of days.
Adjustment is a distinct layer over a set of results, not a strategy. Anything that cannot compute a coefficient for a single metric-mood pair on its own does not belong in the strategy set.
Adjustment is Benjamini-Hochberg, controlling false discovery rate, and it marks results rather than hiding them. Nothing is gated on surviving it. A surviving result is presented as a second axis of robustness beside cross-strategy agreement — it held up once the number of things tested was accounted for — never as a verdict or a significance claim.
FDR is the right family here rather than family-wise error control. A false positive costs a user a behaviour change that achieves nothing; a false negative costs them a real and actionable effect they never discover. In a personal exploratory tool the second is worse, and strict family-wise control needs roughly |r| >= 0.34 over ninety days, which would leave the page empty for months.
Correction applies within a CorrelationStrategy, never across strategies. Several strategies are not several hypotheses — they are several measurements of one, so correcting across them would penalise measuring carefully.
MetricKinds and Activities are corrected as separate Families. A Family is a question, and how many Activities a User chooses to keep has no bearing on whether their sleep tracks their mood — a single Family would let a large Activity catalog quietly suppress metric findings, making the guardrail's strictness depend on a number the User controls for unrelated reasons.
The q threshold and the minimum sample size are configuration, not constants.
MoonPhase is correlated like anything else and is expected to show nothing. It is included because people enjoy it, and it doubles as a control: a strategy set that reports a strong lunar effect is reporting its own false-positive rate.
## What the numbers are
Spearman is computed as Pearson over average ranks, not with the textbook `1 - 6*d^2/(n(n^2-1))` shortcut. That shortcut is only valid when no ranks are tied, and Mood has five levels, so a hundred days of entries are almost entirely ties. On a small worked example the two disagree in the third decimal, and the disagreement grows with the number of ties — it would be a quietly wrong number rather than a visibly broken one.
MoonPhase is correlated as illuminated fraction, from 0 at new to 1 at full, not as position through the cycle. A cycle position is circular: 0.99 and 0.01 are a day apart in the sky and at opposite ends of the number line, so any linear or rank correlation over it measures an artefact of where the cycle was cut. Illumination is monotonic in what a lunar-effect claim is actually about, which is how bright the night is.
A strategy returns nothing rather than a number when either series never varies. A user who logs the same mood every day has no correlation to report, and zero would assert that the metric was tested and found unrelated.
## Comparing across the strategy set
Mean difference is reported as a share of the mood scale rather than in mood points: the difference of the two means is divided by the span from Awful to Rad, so a full mood step reads as 0.25 and the number sits in the same -1..1 range as the three coefficients. Every strategy therefore returns one type with one range, which is what makes a row of them readable side by side. The cost is that "+0.25" no longer says "a quarter of a mood point"; the display has to say what a quarter of the scale means.
Agreement is the size of the largest group of returned coefficients sharing a sign, out of the number of strategies that could score the input at all. Reporting the denominator matters: an Activity can only be scored by mean difference, so it is always one of one, and presenting that as unanimity would imply corroboration that does not exist. The display says "one measure only" instead.
An Activity with no logged day inside the span is not returned. A row with a day count and no coefficient reads as "measured, nothing found"; for an activity the User never tagged, nothing was measured. Preset catalogs make this the common case rather than an edge one — a new account carries two dozen activities it has never used.
## How the adjustment gets its p-values
Benjamini-Hochberg needs a p-value per result, and this document forbids p-values reaching the response or the UI. They are therefore computed, used to decide the mark, and discarded inside the use case; the public result type has no field for one.
They come from normal approximations rather than exact distributions: Fisher's z transform for Pearson and Spearman — with the 1.06 variance inflation for the rank version — the usual large-sample z for Kendall's tau, and Welch's z for mean difference, all through one hand-rolled error function. The domain crate takes no dependencies, so the alternative was an incomplete beta function and a log-gamma, roughly triple the numerical code in exactly the class that is subtly wrong in ways mid-range tests miss. These approximations are sound from around twenty-five paired days and the minimum sample size is thirty, so the error is orders of magnitude below anything that changes a mark. Lowering that floor is the change that would make the approximation the wrong choice.
## Correcting within a group
The grouping rule lives with the Adjustment rather than at the call site: it takes results tagged with their Family and their CorrelationStrategy and corrects within each pair. Grouping by Family and Strategy is today indistinguishable from grouping by Strategy alone, because every Activity is scored by mean difference and no measurement is — the two partitions coincide. It is written and tested as both because the coincidence is an accident of the current strategy set, and a point-biserial correlation over Activity presence would end it without touching this code.
Note the direction of BH's step-up: adding results with small p-values raises the threshold every other result is judged against, so a larger set does not simply make each result work harder. That is why correcting across strategies is not merely conservative — measuring one relationship three ways would let the strongest of the three pull the other two through.

View File

@@ -0,0 +1,22 @@
# The metric set is deliberately small
Only metrics that can carry signal are stored: steps, sleep minutes, awake minutes, resting heart rate, HRV, exercise minutes, screen time, and alcoholic drinks — plus CycleStart, Weather, and MoonPhase. The originally proposed set was roughly twice this size.
Every MetricKind added costs an expected false positive across the correlation set, so a shorter list is what makes the surviving results credible.
## Rejected, and why
- **Blood oxygen, respiratory rate** — near-constant in healthy people at sea level. Most of their day-to-day movement is measurement error, and nothing can correlate against a constant.
- **Sleep stage splits (deep, REM, light)** — wrist-based sleep *staging* is weakly validated against polysomnography, unlike sleep/wake detection which is reasonable. Three low-validity metrics inflating the comparison count in the one area already covered well by sleep minutes. Awake minutes is kept because fragmentation is detected far more reliably than stage.
- **Active energy** — a device estimate derived largely from steps and exercise minutes, both already present.
- **Average heart rate** — confounded by activity; high precisely on days exercise minutes already explains.
- **Mindful minutes** — zero for most people on most days. No variance, no correlation.
- **Water intake** — self-reported, remembered badly, logged inconsistently.
- **Weight** — day-to-day movement is water, not signal. Meaningful over months, noise over days.
- **Caffeine** — dropped for a simpler reason: it carries a unit conflict with no clean answer. Providers report milligrams; the manual input is "a coffee". Converting between them is fiction, since drip, espresso, and energy drinks span roughly threefold per serving. Alcohol has no such problem — providers already report a count of standard drinks, so a tap and an import agree natively.
## Consequences
Storing and analysing are the same set; nothing is collected "just in case". Adding a MetricKind later is one enum variant, and HealthKit retains history on-device indefinitely, so a future importer can backfill years for a newly-added kind — the capability is not lost, only the convenience.
MoonPhase is kept despite having no plausible mechanism. It is a control: a strategy set reporting a strong lunar effect is reporting its own false-positive rate.

View File

@@ -0,0 +1,24 @@
# A metric's kind and value are one type, not two fields
`MetricValue` is an enum whose every variant wraps its own validated newtype — `Steps(Steps)`, `SleepMinutes(SleepMinutes)`, and so on. `MetricKind` remains as a payload-free discriminant for naming a kind without a value, but it is derived from the value and never stored alongside it.
The obvious shape — a struct holding a kind and a loose numeric value — makes invalid states constructible: a step count tagged as HRV compiles cleanly, because the kind is data and the value is untyped. Collapsing them removes that state from the language rather than from code review.
## Considered Options
- **Trait objects (`Box<dyn Metric>`)** — the more open shape, and consistent with this codebase's use of `dyn` for ports. Rejected because it gives up exhaustiveness: a new metric would compile while correlation and serialization silently failed to handle it, which is precisely the class of error the type system was brought in to catch. Persistence would need a parse-by-kind registry regardless, so the enumeration reappears at the boundary anyway.
- **`DailyMetric<M: Metric>`** — strictest typing, but mixed metrics cannot share a collection, which defeats iterating every kind to correlate it.
## Consequences
Each newtype validates its own range in `new()`, following the established value-object pattern, and units live in the type rather than in a field name — `Hrv` *is* milliseconds. Eleven near-identical bounded-integer newtypes justify a macro, as `uuid_id!` already does for identity types.
Hydration is fallible in a way entry loading is not: a row can carry a kind this build does not know, or a value a later-tightened range now rejects. Such rows are skipped and recorded in the same rejection trace that receives invalid imports, rather than failing the query.
Correlation needs a numeric projection that necessarily discards the type again. That is confined to one place.
One MetricKind carries exactly one unit. This is what forced caffeine out of the set: providers report milligrams and users report cups, and nothing converts between them honestly.
## The rejection trace, once it existed
The hydration failure this document describes now writes to the same trace that receives invalid imports, as promised. The metric query repository holds a `RejectionCommandPort` and records what it skipped: a read with a side effect, which is unusual enough to name. The alternative was returning the unreadable rows alongside the readable ones and letting a caller decide, which pushes a decision no caller has an opinion about into every one of them. A failure to write the trace is logged and swallowed — a reading that cannot be recorded as unusable must still not fail the query that found it.

View File

@@ -0,0 +1,36 @@
# A manual DailyMetric write wins by a domain rule the store obeys
`DailyMetric::supersedes` decides whether an incoming metric replaces the one already stored for a `(User, Date, MetricKind)`. A metric sourced `Manual` always supersedes; a metric sourced from a Provider supersedes anything except a `Manual` one. The SQLite store reads the existing row inside a transaction, asks the domain, and writes only on a true answer.
The precedence rule is domain knowledge, so it is stated once, in the domain, where it can be tested without a database.
## Considered Options
- **A conditional upsert in SQL** — `ON CONFLICT (user_id, date, kind) DO UPDATE ... WHERE excluded.source = 'manual' OR daily_metrics.source != 'manual'`. One atomic statement and no read. Rejected because the rule then lives in a string the domain tests cannot reach, and the in-memory store used by every application test has to reimplement it by hand — two expressions of one piece of knowledge, free to disagree. The transaction the chosen option needs is a smaller cost than that.
- **The use case compares before writing** — testable without a database, but the check spans two port calls with no transaction around them, and every future write path is free to forget it. Import (#13) is a second write path, which makes that a matter of when rather than whether.
## Consequences
`DailyMetricCommandPort::save` takes a batch and is the only way a metric reaches storage. Both the SQLite store and `InMemoryDailyMetricStore` call `supersedes`, so a fake that has drifted from the real store fails its own tests.
A refused write is not an error. A Provider importing a day the User has already stated by hand succeeds and changes nothing, because the alternative — failing the import — makes one hand-entered day poison a year of backfill.
## Hydration rejections
A stored row can carry a kind this build does not know, or a value a range tightened since it was written now rejects. Such a row is skipped and logged with its user, date and kind; the surrounding rows are returned. ADR 0008 places these in the same rejection trace that receives invalid imports, and that trace arrives with the import endpoint (#13), which is the consumer that gives it its shape. Until then the record is a log line, and the call site that emits it is the one #13 redirects.
## On the wire
A DailyMetric is read as `{ date, kind, value, provider }`, where an absent `provider` means the User stated the value. One nullable field carries the whole of Source, which keeps the wire in step with the column and leaves no way to describe a metric that is manual and from a Provider at once. A tagged `source` object was the alternative; it spends a nested shape on a distinction one field already makes, and a Provider may legitimately be named `manual`, which rules out flattening the two into a single string.
Writes never carry a source. Every metric arriving over HTTP is `Manual` by construction, because a Provider does not use this endpoint — the import path (#13) builds its own metrics and is the only thing that can produce a Provider source.
## Clearing
A metric is cleared by stating no value for its kind — `{"kind": "steps", "value": null}` on the same `PUT`. There is no delete endpoint: one write path into `daily_metrics` means clearing cannot disagree with writing about what a day holds, and the day sheet gets it for nothing, since emptying a field is already how a person says a reading should not be there.
Clearing removes the row, and a later import for that day is then free to report the kind again. The manual-wins rule therefore covers stated values but not absence: the only way to keep a Provider's reading out for good is to state one you believe.
A tombstone — a cleared kind that stays cleared until the User states a value — was the alternative, and it would close that gap. It was rejected because it needs a DailyMetric that exists with no value, which the domain deliberately cannot express: `MetricValue` is not optional, and making it optional to record an absence would put `None` into every match over every kind, for one case. The gap it leaves is small in practice, because a Provider re-reporting what it already reported is the store telling the truth about what that Provider says.
A request naming the same kind twice is refused rather than resolved by order, because "state 8412 steps and also clear steps" has no reading that is obviously right.

View File

@@ -0,0 +1,24 @@
# The cycle is correlated as progress, not as a cycle day
Cycle day is derived from the most recent preceding CycleStart and never stored, exactly as MoonPhase is derived from the calendar. What gets correlated against DayMood is progress through the cycle — 0 on its first day, 1 on its last — not the day number.
A cycle day is cyclical, so correlating it directly measures where the cycle happens to have been cut rather than anything about the person: day 1 and day 28 are neighbours in life and at opposite ends of the number line. This is the same defect that rules out correlating the moon's position through its cycle, and it is why MoonPhase is correlated as illuminated fraction instead.
## Considered Options
- **The folded transform used for MoonPhase**, `(1 - cos 2*pi*p) / 2`. Rejected here even though it is right there: it maps day 7 and day 21 to the same value, conflating the follicular and luteal halves — which is precisely the distinction anyone asking this question cares about. For the moon the fold is not a transform at all, it is the physical quantity; for a cycle it destroys the signal.
- **A sin/cos pair, correlated as two inputs.** The standard treatment of a circular variable, and it can see a symmetric effect at both ends of the cycle. Rejected for now because it doubles the comparison count in the Measurements Family for one input, and neither component means anything a User could read.
## Consequences
Progress answers "does mood drift across the cycle" and cannot answer "is mood worse at both ends". That limit is real and worth stating: a symmetric premenstrual-and-menstrual dip would show as no correlation at all. Whoever wants that question answered needs the sin/cos pair, and should know that is what it costs.
Progress needs a cycle length. A closed cycle has its own: the days until the next CycleStart. The cycle still running has none, so the median of the observed lengths is used, falling back to twenty-eight when nothing has been observed. The median rather than the mean because one missed CycleStart produces a double-length cycle, which would drag a mean and barely move a median.
Progress is clamped to 1. A cycle running late would otherwise report progress above 1, which is not a place in a cycle.
## Being off by default
The whole feature is invisible until the User turns it on, so the tracking flag gates recording, the derived cycle day on the calendar, and whether CycleProgress is a correlation input at all. Turning it off hides everything and forgets nothing — a preference is not a delete.
The flag lives on a UserPreferences record rather than as a column on User. User already carries nine fields of identity and credentials, and a display preference sitting beside the password hash is the wrong neighbourhood; the next optional feature now has somewhere to go.

View File

@@ -0,0 +1,32 @@
# Backing up and sharing are two artifacts, not one with options
A CompleteBackup carries everything an account knows. A ShareableExtract carries mood, Content and Activities as a readable markdown document, and nothing else. They are separate endpoints with separate names and separate file extensions.
A single export with checkboxes was rejected. It produces files that look like backups and are not, and the failure mode — someone sharing the wrong file — surfaces only after the disclosure has happened.
## Considered Options
- **A zip of JSON for the extract too**, matching the backup. Rejected because the extract exists to be read by a person: a therapist handed a zip of JSON has been handed nothing. Markdown also makes the distinction visible at the moment of download, where it matters.
- **Restoring through the existing importer.** Rejected for the same reason the split exists. That importer reads foreign formats — Daylio, generic CSV — and its `ImportedRow` knows only mood, date, activities and a note. Teaching it about metrics, cycle starts, reminders and preferences would make every foreign format carry empty fields for them, and would put a Daylio file and a complete backup through one path with very different expectations. Restore is its own endpoint.
## One place to register a dimension
Entry dimensions are written to and read from the backup through `DimensionPayload`, the enum in `api-types` that already maps every `DimensionValue` variant to a wire shape for the HTTP API. Adding a `DimensionValue` variant fails to compile until it is mapped there, and the backup picks it up with no further change. Registration is therefore enforced by the compiler rather than remembered from a document.
This makes an adapter depend on `api-types`, which is unusual here. It is deliberate: a backup file is a wire format, and the alternative was a second exhaustive match over every dimension that could silently fall behind the first.
## Identity across a restore
A backup stores Activity ids alongside their names, and media under their original ids, because entries reference all three by id. A restore mints new ids — media storage assigns them, and an Activity that already exists in the target account keeps the id it has — so the restore builds a translation from old id to new and rewrites each entry's Activities, Photos and VoiceMemos dimensions through it. Without that, every restored entry would reference identifiers that no longer exist and the tags would silently vanish.
An id that cannot be translated is dropped from its dimension rather than restored as a dangling reference.
## What a backup deliberately omits
ApiTokens are absent. They are credentials, not data about days, and restoring one would resurrect a secret the User may have revoked deliberately.
RejectedMetrics are absent. The trace records readings that were never stored; carrying a record of absence into a restore has nothing to restore.
## Restore adds, it never replaces
A restore writes into the account as it stands. This is the honest behaviour for the merge the code performs, and it means restoring twice duplicates entries — the importer's `(logged_at, mood)` dedup does not apply on this path. The alternative, clearing the account first, is a destructive act hidden inside an action a User reaches for when something has already gone wrong.

View File

@@ -0,0 +1,29 @@
# Weather is observed after the entry is saved, in the domain's own vocabulary
Weather is resolved server-side from an entry's Location, after the entry is written, and stored as a Condition drawn from a closed vocabulary plus a temperature in celsius. It is always attributed to the Provider that observed it.
Logging a mood is the most latency-sensitive thing the app does, so it cannot wait on a third party. The lookup is an `ObserveWeather` job, swept from "entries with a Location and no Weather" — which satisfies ADR 0004's rule, so losing a queued job costs promptness and never data.
## Considered Options
- **Client-supplied weather.** Three clients would each pick a provider, a vocabulary and a unit, and correlating across inconsistent labels is meaningless. Resolving on the server gives one implementation and one vocabulary.
- **OpenWeatherMap.** Better known, but historical data is a paid tier, so the sweeper could only enrich recent entries — and every self-hoster would need to register a key before weather worked at all. Open-Meteo needs no key, serves history by coordinate and date, and publishes WMO condition codes as a documented integer scale, so the mapping to our vocabulary is a table rather than a guess.
- **Storing the provider's own description.** Rejected: "light intensity shower rain" is not comparable with anything.
## What the vocabulary carries
Condition and temperature. Both plausibly move mood, both are comparable across any two entries, and both correlate — condition as a category, temperature as a continuous input in the Measurements Family. Daylight was considered and left out: it is derivable from coordinates and time with no provider at all, and it is a fact about the calendar and latitude rather than about weather, so it belongs beside MoonPhase if it is wanted.
The vocabulary is deliberately coarse. Open-Meteo distinguishes slight, moderate and dense drizzle; all three are `drizzle` here. A finer vocabulary would multiply the categories without adding signal, and every Provider draws those lines differently.
## Weather cannot be edited away
Every other dimension repository deletes its row when a save arrives without that dimension — that is how a User clears a note or removes a photo. Weather does not. The worker writes it after the fact, and no client ever sends it back, so honouring absence as deletion would mean any edit silently erased it. Absence from a user-supplied dimension list means "not mentioned", and for an observed dimension that is not a request to delete.
## The switch is a switch, not a filter
`worker.look_up_weather = false` means no coordinates leave the machine. It is enforced by not constructing the lookup at all, so there is nothing that could make a request. It also stops the weather sweep entirely: an early version left the sweep running, which enqueued jobs that failed on every attempt until they exhausted — churn and noise for someone who deliberately turned the feature off. If weather cannot be observed there is no weather backlog. Turning it back on catches up the whole backlog on the next sweep.
## What is not built
The issue asks for backlogged lookups to be grouped by rounded coordinates and date range so a backlog collapses into few requests. It is not implemented: each job resolves one entry with one request. Open-Meteo accepts a date range for one coordinate, so the grouping is possible, but it needs a batch shape the job queue does not have — one job would have to stand for many entries, which breaks the one-job-one-subject rule the queue and its sweep are built on. The realistic backlog is small, as this ADR's own reasoning notes: imported history carries no coordinates, so nothing but an outage or an offline client produces one. The sweep's configured bound is the guard until that stops being true.

View File

@@ -0,0 +1,37 @@
# Configuration arrives in three layers: defaults, then the file, then the environment
Every setting has a compiled-in default. `config.toml` overrides the defaults it names. Environment variables prefixed `KMOOD_` override both. The layering is one expression in `crates/config/src/loader.rs`, so a new setting is a new struct field and nothing else.
A single underscore stays inside a field name, a double underscore descends a section: `KMOOD_AUTH__JWT_SECRET` reaches `auth.jwt_secret`, and `KMOOD_SERVER__CORS__ALLOW_ANY_ORIGIN` reaches two levels down. Splitting on a single underscore would have been ambiguous the moment a field was called `data_dir`.
## Why the environment had to reach everything
CODE_STYLE has always said secrets live in environment variables and tunables live in the file. Until this change nothing in the workspace read an environment variable at all, so `auth.jwt_secret`, `push.vapid_private_key`, `provider.encryption_key` and the S3 keys were only settable by writing them into a file next to the binary — the arrangement the rule exists to prevent.
Stopping at secrets would have honoured the letter of that rule and left the useful half undone. A container that cannot be told its own port or data directory needs a bind-mounted file to differ from any other container running the same image, which puts deployment shape back into a committed artifact. The rule is a floor: secrets *must* be settable from the environment. It is not a ceiling.
## Considered Options
- **A hand-rolled overlay.** Read each variable in `bootstrap` and assign the field. No new dependency and completely transparent, but roughly a hundred and fifty lines that must be edited every time a setting is added — a second place to update for one logical change, which is the thing "single call, cascading changes" exists to forbid. Rejected: the cost is paid forever, by whoever adds the next field.
- **`config-rs`.** Does the same layering, but the crate is named `config` and so is ours. Every import in the crate would need renaming to stay readable. Rejected on the name alone.
- **`figment`.** Layering is its whole purpose, it reads the `serde` derives already on these structs, and it needed no change to any of the ten config types — not even a `Serialize` derive, because container-level `#[serde(default)]` already supplies the base layer. Chosen.
## Why loading moved out of bootstrap
The loader used to live in `bootstrap`, which the testing table in CODE_STYLE marks as untested by design. Precedence between three layers is exactly the logic that needs pinning, and it cannot be pinned where tests are not written. So `load()` now sits in `crates/config` beside the types it populates, and `bootstrap` re-exports it so the binaries are unchanged.
This also puts the defaults and the means of overriding them in one crate. `AppConfig::default()` and the layering that overrides it were previously two crates apart, and only one of them was reachable from a test.
## Refusal is better than a silent default
A malformed `config.toml`, or a variable that cannot be parsed as its field's type, fails startup. figment names the offending key — `invalid type: found string "not-a-port", expected u16 for key "SERVER.PORT"` — which is worth more than a server that comes up on a port nobody chose.
A missing `config.toml` is not an error: the defaults are a complete configuration and running with no file is a supported way to run. But a file *named* by `KMOOD_CONFIG_FILE` and not found is an error, because naming it is a statement that it exists, and silently ignoring the typo would start the server with settings the operator did not choose.
## What is logged
The file that was read, and the *names* of the `KMOOD_` variables that took effect. Never their values: four of the settings reachable this way are secrets, and a log line is the wrong place to learn one.
## Lists are arrays, not delimited strings
`KMOOD_SERVER__CORS__ALLOWED_ORIGINS=["https://a.example","https://b.example"]`. A comma-separated form would read more naturally in a shell, but it would be a second syntax for the same data, parsed differently from the file. One syntax, one parser.