Add the health-score record table

The score is computed live from today's rows, so once the readings behind
a past day change the number the person actually saw that day is gone.
Nothing wrote it down. This adds the table that does: one row per account
per local day, holding the composite, its band, the algorithm version, the
composition that produced it, every counted pillar's own score, and a
fingerprint over exactly what those depend on.

Append-only by construction. A unique index on account plus local day gives
the writer something to conflict against, and there is no updated_at,
because a later computation of the same day must leave the earlier row
exactly as it stood.
This commit is contained in:
Marc-André Bombeck committed 2026-07-31 15:02:01 +02:00
1 parent 63b54587a8
commit cb75986eb5
2 files changed
+138

No files matched your search

@@ -0,0 +1,56 @@
-- v1.35.0 — the health score as it was shown, one row per account per local day.
--
-- Nothing can rebuild this. The live computation always answers for today from
-- today's rows, so once the readings behind a past day have been corrected,
-- re-synced or simply grown, the number the person saw on that day is gone
-- unless it was written down. This table is the writing down.
--
-- Two properties are enforced here rather than left to the application:
--
-- * one row per account per local day, so a second computation on the same
-- day cannot mint a rival record of the same day, and the writer's
-- ON CONFLICT DO NOTHING has something to conflict against;
-- * a closed set of bands, so a row can never carry a band no surface knows
-- how to render.
--
-- There is deliberately no updated_at column. The row is written once and
-- never rewritten: a later computation under different readings, or under a
-- different configuration, leaves the earlier day exactly as it stood.
CREATE TABLE "health_score_records" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"day_key" VARCHAR(10) NOT NULL,
"timezone" TEXT NOT NULL,
"composite" INTEGER NOT NULL,
"band" VARCHAR(8) NOT NULL,
"score_version" INTEGER NOT NULL,
"composition" TEXT[] NOT NULL,
"pillar_scores" JSONB NOT NULL,
"input_fingerprint" VARCHAR(64) NOT NULL,
"config_version" INTEGER,
"config_changed_at" TIMESTAMPTZ(3),
"computed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "health_score_records_pkey" PRIMARY KEY ("id"),
CONSTRAINT "health_score_records_band_check"
CHECK ("band" IN ('green', 'yellow', 'red')),
CONSTRAINT "health_score_records_composite_range_check"
CHECK ("composite" >= 0 AND "composite" <= 100),
CONSTRAINT "health_score_records_day_key_check"
CHECK ("day_key" ~ '^\d{4}-\d{2}-\d{2}$'),
CONSTRAINT "health_score_records_composition_not_empty_check"
CHECK (array_length("composition", 1) >= 1)
);
CREATE UNIQUE INDEX "health_score_records_user_id_day_key_key"
ON "health_score_records" ("user_id", "day_key");
CREATE INDEX "health_score_records_user_id_day_key_desc_idx"
ON "health_score_records" ("user_id", "day_key" DESC);
ALTER TABLE "health_score_records"
ADD CONSTRAINT "health_score_records_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+82
View File
@@ -846,6 +846,10 @@ model User {
customMetrics CustomMetric[]
customMetricEntries CustomMetricEntry[]
correlationPatterns CorrelationPattern[]
// v1.35.0 — one row per local day holding the health score as it was shown
// that day. Append-only: a later recomputation of the same day never
// rewrites the row it finds.
healthScoreRecords HealthScoreRecord[]
// v1.19.0 — illness / condition journal (born-gated by the module system).
// IllnessEpisode is the soft-deleted episode row; IllnessDayLog is the
// per-day timeline mirroring the CycleDayLog shape.
@@ -5050,6 +5054,84 @@ model UserAchievement {
@@map("user_achievements")
}
// ─── Health Score record ──────
/// The health score as it was SHOWN to the account on one local day.
///
/// Not a cache and not a derived tier: nothing can rebuild it. The live
/// computation always answers for today from today's rows, so once the rows
/// behind an old day have been edited, re-synced, corrected or simply grown,
/// the number the person actually saw that day is gone unless it was written
/// down. That is what this table is, and it is why the write is append-only:
/// the FIRST score computed on a local day is the one that is kept, and a
/// later computation of the same day leaves the row exactly as it found it.
///
/// It never overrides the live computation either. Every surface keeps
/// reading the live score; this table is read only where a past day is the
/// question.
model HealthScoreRecord {
id String @id @default(cuid())
userId String @map("user_id")
/// The LOCAL calendar day (`YYYY-MM-DD`) the score was computed for, cut in
/// `timezone`. Lexicographically ordered, so a trailing-window read is a
/// range scan.
dayKey String @map("day_key") @db.VarChar(10)
/// The IANA zone the day was cut in. An account that moves zones leaves
/// behind days cut on the old clock; carrying the zone is what lets a
/// reader see that rather than silently compare days of different lengths.
timezone String
/// The composite 0-100 as shown. Integer, matching the composite's own
/// rounding.
composite Int
/// The band as shown (`green` / `yellow` / `red`). Stored rather than
/// re-derived from `composite`, because the worst pillar can pull the band
/// below the one the mean alone would give, so the number does not
/// determine the band.
band String @db.VarChar(8)
/// Algorithm identity the row was computed under.
scoreVersion Int @map("score_version")
/// Registry-ordered pillar ids that produced the composite. Part of the
/// number's identity: the same value under a different composition is a
/// different measurement.
composition String[]
/// Each counted pillar's own 0-100 score, keyed by pillar id. The composite
/// breaks across a composition change; these stay comparable through one,
/// which is the whole reason they are stored beside it rather than folded
/// away.
pillarScores Json @map("pillar_scores")
/// SHA-256 over exactly what the stored composite and band depend on: the
/// score version, the composition, and every counted pillar's score with
/// its scoring-mode identity. Two rows with the same fingerprint were
/// computed from the same standing; two rows that differ, were not.
inputFingerprint String @db.VarChar(64) @map("input_fingerprint")
/// The account's health-score configuration version in force when the row
/// was written, and when that configuration last changed. Written by
/// whoever computes the score; today nothing supplies them, because the
/// per-user configuration itself lands beside this table and its delta
/// guard lands after it. They are here now so that guard does not need a
/// second migration over a table that will already hold rows.
/// @pending-consumer(score delta guard) suppresses the week-over-week delta
/// across a configuration change instead of narrating a settings action as
/// a health event.
configVersion Int? @map("config_version")
/// @pending-consumer(score delta guard) dates the series break on the first
/// record written after a configuration change.
configChangedAt DateTime? @map("config_changed_at") @db.Timestamptz(3)
/// When the score was computed. Distinct from `dayKey`: the day says which
/// day was scored, this says when the scoring ran.
computedAt DateTime @default(now()) @map("computed_at") @db.Timestamptz(3)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, dayKey])
@@index([userId, dayKey(sort: Desc)])
@@map("health_score_records")
}
// ─── Host Metrics (admin system-status host-load chart) ──────
/// Per-minute snapshot of host-process load + memory captured by the