diff --git a/README.md b/README.md
index db0fadc..d52bfc1 100644
--- a/README.md
+++ b/README.md
@@ -316,8 +316,8 @@ Available tools:
| Tool | Description |
|---|---|
-| `get_health_briefing` | Daily health briefing: composite readiness score (z-score based), sleep analysis, activity, insights, and alerts. Supports `lang` (en/ru/sr). |
-| `get_readiness_history` | Composite readiness scores (0-100) for the last N days. |
+| `get_health_briefing` | Daily health briefing with readiness score, server-owned `readiness_serving` freshness/confidence metadata, sleep analysis, activity, insights, and alerts. Supports `lang` (en/ru/sr). |
+| `get_readiness_history` | Composite readiness scores (0-100) and server-derived bands for the last N days. Use `get_health_briefing` for today's freshness/confidence contract. |
| `list_metrics` | List all available metrics with record counts and date ranges. |
| `get_dashboard` | Today's summary with trend vs yesterday. |
| `get_metric_data` | Time series for a single metric with minute/hour/day buckets. |
@@ -333,6 +333,32 @@ Available tools:
| `workout_stats` | Aggregate counters for workouts in a range: count, total duration, distance, energy, avg/max HR, total time-in-HR-zone. |
| `sql_query` | Run any read-only SQL SELECT on the PostgreSQL database. |
+### Readiness serving contract
+
+`/api/health-briefing` and MCP `get_health_briefing` expose `readiness_serving` as the canonical contract for clients that need to render score freshness or uncertainty. Prefer this grouped object over recomputing state from dates or metric counts client-side.
+
+```json
+{
+ "readiness_serving": {
+ "status": "fresh | missing | stale | data_accruing | low_coverage | capped",
+ "confidence": "final | provisional | low",
+ "reason": "missing_same_day_evidence",
+ "components": [
+ {
+ "metric": "heart_rate_variability",
+ "present": true,
+ "freshness": "ok | missing | stale",
+ "confidence": "final | provisional | low",
+ "sample_count": 4,
+ "missing_reason": "missing_same_day_value"
+ }
+ ]
+ }
+}
+```
+
+The legacy top-level `readiness_confidence`, `readiness_cap_reason`, and `readiness_components` fields remain for compatibility. `readiness_serving.status` is the display-oriented summary: `missing` means a core same-day input is absent, `data_accruing` means same-day data exists but is still too sparse for final confidence, `low_coverage` means a quality/coverage gate fired, and `capped` means a safety or coherence cap changed the displayed score without implying missing data.
+
## Telegram Reports
When `TELEGRAM_TOKEN` and `TELEGRAM_CHAT_ID` are set, the server sends two daily reports:
diff --git a/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html b/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html
new file mode 100644
index 0000000..0d7f2e7
--- /dev/null
+++ b/docs/ai-plans/2026-06-06-issue-166-readiness-serving-freshness-contract.html
@@ -0,0 +1,180 @@
+
+
+
+
+ Implementation Plan: Issue #166 Readiness Serving/Freshness Contract
+
+
+
+
+
+ Implementation Plan: Issue #166 Readiness Serving/Freshness Contract
+ Approved and implemented
+
+
+ Task Summary
+
+ Define and implement the stable readiness freshness/confidence contract that
+ dashboard, iOS, MCP, and later confidence UI can consume without duplicating
+ backend logic. This issue is broader than the already-executed
+ 2026-05-28-readiness-serving-freshness-closure.html plan: that
+ plan closed bounded restamp/backfill wiring, while this one should formalize
+ the API shape and consumer semantics.
+
+
+
+
+ Current Behavior
+
+ internal/health/types.go already exposes readiness_confidence, readiness_cap_reason, readiness_components, and per-component freshness, confidence, sample_count, and missing_reason on BriefingResponse.
+ internal/storage/briefing.go builds date-aligned readiness evidence from daily cache plus same-day raw metric fallback, including missing and stale states.
+ /api/health-briefing returns the full BriefingResponse; /api/readiness-history returns score and band only.
+ internal/mcpserver/tools.go has get_health_briefing and get_readiness_history, but tool descriptions still describe the older readiness formula and do not call out freshness/confidence metadata.
+ - Dashboard page rendering currently reads confidence and cap reason into the page data, but confidence UI is intentionally not the goal of this issue.
+
+
+
+
+ Desired Behavior
+
+ - One documented server-owned readiness state vocabulary: fresh, stale, missing, data-accruing, low-coverage, and capped/provisional states.
+ - API consumers can decide display state from response fields only; no client should infer freshness from dates or recompute coverage.
+ - Existing score values remain behavior-compatible unless tests expose a mismatch between score and metadata.
+ - Dashboard and MCP continue to work, with richer metadata available and documented for future confidence UI.
+
+
+
+
+ Assumptions And Unknowns
+
+ - Assumption: the primary contract should be attached to
/api/health-briefing first because it already owns BriefingResponse.
+ - Assumption:
/api/readiness-history should stay lightweight unless the confidence UI needs historical confidence states.
+ - Unknown: whether
data_accruing should be represented as a new readiness freshness enum or mapped through confidence=provisional plus reason.
+ - Unknown: whether MCP needs a separate concise readiness contract tool or simply better descriptions for existing tools.
+
+
+
+
+ Files Likely To Change
+
+ internal/health/types.go - add or tighten response structs/enums only if the current fields cannot express the contract cleanly.
+ internal/storage/briefing.go - normalize evidence mapping and reasons if stale/missing/low-coverage cases are inconsistent.
+ internal/storage/readiness_evidence_test.go or related tests - cover fresh, stale, missing, low-coverage, and data-accruing/provisional cases.
+ internal/ui/handler.go and UI tests - only if response wrapping or dashboard API shape needs explicit metadata preservation.
+ internal/mcpserver/tools.go - update readiness tool descriptions and, if approved, expose concise contract metadata.
+ README.md, docs/ARCHITECTURE.md, or a small new API doc - document the contract for dashboard and MCP/debug consumers.
+
+
+
+
+ Implementation Steps
+
+ - Write a short contract table before code edits: fields, allowed values, producer, and intended consumer behavior.
+ - Audit current readiness evidence outputs against that table for fresh, stale, missing same-day value, low HRV sample count, missing sleep-stage details, and illness/stress cap scenarios.
+ - Add focused tests for the current mapping. Prefer tests around pure or near-pure evidence builders before touching handlers.
+ - If the current fields cannot express
data_accruing cleanly, add a small explicit reason/status field rather than changing existing score semantics.
+ - Update MCP descriptions and docs so the contract is discoverable.
+ - Run focused tests first, then full Go test/vet before PR.
+
+
+
+
+ Impact
+
+ - API: additive metadata only; no removal or rename of existing JSON fields.
+ - Data/migration: no schema migration expected.
+ - Auth/permissions: no change.
+ - UX: no new confidence UI in this issue; it prepares that work.
+ - Deployment: normal app deploy after tests if code changes are needed.
+
+
+
+
+ Test Plan
+
+ go test ./internal/health ./internal/storage -run "Test.*Readiness.*Evidence|Test.*Readiness.*Confidence" -count=1
+ go test ./internal/ui ./internal/mcpserver -count=1 if handlers or MCP descriptions are touched.
+ go test ./...
+ go vet ./...
+ - Optional direct DB smoke with
~/.health-db: inspect one current /api/health-briefing-equivalent row and confirm metadata matches actual same-day availability.
+
+
+
+
+ Risks And Edge Cases
+
+ - Changing numeric readiness behavior under a metadata issue would blur scope; keep score changes out unless a contract test reveals a bug.
+ - Adding too many enums could make client logic harder; prefer a small stable vocabulary plus reason strings.
+ - Historical readiness points may not have enough evidence to expose detailed metadata without extra queries.
+
+
+
+
+ Rollback Plan
+ Revert additive metadata and documentation changes. No database rollback should be required. Existing consumers should continue to work throughout because the plan avoids removing current fields.
+
+
+
+ Open Questions
+
+ - Should
/api/readiness-history gain historical confidence metadata now, or should that wait for the confidence UI issue?
+ - Should
data_accruing be a freshness enum, a cap reason, or a separate serving state?
+ - Which doc should be canonical for API consumers: README, architecture doc, or a dedicated API contract section?
+
+
+
+
+ Implementation Result
+
+ Implemented on 2026-06-06. The change is additive: existing
+ readiness_confidence, readiness_cap_reason, and
+ readiness_components fields remain in place, and clients can
+ now prefer the grouped readiness_serving object.
+
+
+ - Added
ReadinessServingState with status, confidence, reason, and component evidence.
+ - Added serving status values:
fresh, missing, stale, data_accruing, low_coverage, and capped.
+ - Kept readiness score behavior unchanged; the new object summarizes existing evidence/cap metadata for consumers.
+ - Kept serving state synchronized when coherence or illness safety caps update readiness metadata after initial scoring.
+ - Updated MCP descriptions and README documentation so consumers know to use
readiness_serving instead of guessing.
+ - Added tests for missing evidence priority, sparse-HRV data-accruing status, illness cap status, low-coverage status, and
ComputeBriefing response exposure.
+
+
+
+
+ Verification Run
+
+ go test ./internal/health ./internal/storage -run "Test.*Readiness.*Evidence|Test.*Readiness.*Serving|TestComputeBriefing_ExposesReadinessServingContract" -count=1 passed.
+ go test ./internal/ui ./internal/mcpserver -count=1 passed; internal/mcpserver has no test files but compiled.
+ go test ./internal/health ./internal/storage -count=1 passed.
+ go test ./... passed.
+ go vet ./... passed.
+
+
+
+
+ Known Limitations After Implementation
+
+ /api/readiness-history still returns lightweight score and band data only; historical confidence metadata remains a future confidence UI decision.
+ - No database migration or historical backfill was added; this is a response-contract change over current evidence.
+ - No new visual confidence UI was added in this issue.
+
+
+
+
+ Approval Gate
+ Approved and implemented. Further confidence UI or historical readiness metadata should use a separate follow-up plan.
+
+
+
+
diff --git a/internal/health/headline.go b/internal/health/headline.go
index 8a355bc..c7fd47a 100644
--- a/internal/health/headline.go
+++ b/internal/health/headline.go
@@ -343,4 +343,5 @@ func applyReadinessResponseCap(resp *BriefingResponse, maxScore int, confidence,
if readinessCapReasonRank(reason) > readinessCapReasonRank(resp.ReadinessCapReason) {
resp.ReadinessCapReason = reason
}
+ updateReadinessServing(resp)
}
diff --git a/internal/health/readiness.go b/internal/health/readiness.go
index 8a572e1..ce96715 100644
--- a/internal/health/readiness.go
+++ b/internal/health/readiness.go
@@ -269,6 +269,7 @@ type readinessComputation struct {
Confidence string
CapReason string
Components []ReadinessComponentSummary
+ Serving *ReadinessServingState
}
func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
@@ -292,6 +293,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
Confidence: ReadinessConfidenceFinal,
}
if e == nil {
+ out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components)
return out
}
out.Components = readinessComponentSummaries(*e)
@@ -323,6 +325,7 @@ func computeReadinessWithEvidence(d RawMetrics) readinessComputation {
case IllnessConfidenceModerate:
out.cap(readinessFairCap, ReadinessConfidenceProvisional, "illness_suspicion_moderate")
}
+ out.Serving = readinessServingState(out.Confidence, out.CapReason, out.Components)
return out
}
@@ -364,6 +367,52 @@ func readinessCapReasonRank(reason string) int {
}
}
+func updateReadinessServing(resp *BriefingResponse) {
+ if resp == nil {
+ return
+ }
+ resp.ReadinessServing = readinessServingState(resp.ReadinessConfidence, resp.ReadinessCapReason, resp.ReadinessComponents)
+}
+
+func readinessServingState(confidence, reason string, components []ReadinessComponentSummary) *ReadinessServingState {
+ if confidence == "" {
+ confidence = ReadinessConfidenceFinal
+ }
+ status := ReadinessServingFresh
+ if len(components) == 0 && reason == "" {
+ status = ReadinessServingMissing
+ } else if hasComponentFreshness(components, ReadinessFreshnessMissing) || reason == "missing_same_day_evidence" {
+ status = ReadinessServingMissing
+ } else if hasComponentFreshness(components, ReadinessFreshnessStale) {
+ status = ReadinessServingStale
+ } else if reason == "hrv_provisional" || reason == "hrv_sparse" {
+ status = ReadinessServingDataAccruing
+ } else if reason == "sleep_quality_low" {
+ status = ReadinessServingLowCoverage
+ } else if reason != "" || confidence == ReadinessConfidenceProvisional {
+ status = ReadinessServingCapped
+ }
+ copied := append([]ReadinessComponentSummary(nil), components...)
+ return &ReadinessServingState{
+ Status: status,
+ Confidence: confidence,
+ Reason: reason,
+ Components: copied,
+ }
+}
+
+func hasComponentFreshness(components []ReadinessComponentSummary, freshness string) bool {
+ for _, c := range components {
+ switch c.Metric {
+ case "heart_rate_variability", "resting_heart_rate", "sleep_total":
+ if c.Freshness == freshness {
+ return true
+ }
+ }
+ }
+ return false
+}
+
func readinessComponentSummaries(e ReadinessEvidenceInput) []ReadinessComponentSummary {
components := []ReadinessComponentEvidence{
e.HRV,
diff --git a/internal/health/readiness_evidence_test.go b/internal/health/readiness_evidence_test.go
index 3cfdd4e..4c8c48c 100644
--- a/internal/health/readiness_evidence_test.go
+++ b/internal/health/readiness_evidence_test.go
@@ -51,6 +51,9 @@ func TestReadinessEvidence_SparseHRVCapsConfidence(t *testing.T) {
if got.CapReason != "hrv_provisional" {
t.Fatalf("cap reason = %q", got.CapReason)
}
+ if got.Serving == nil || got.Serving.Status != ReadinessServingDataAccruing {
+ t.Fatalf("serving = %+v, want data_accruing", got.Serving)
+ }
}
func TestReadinessEvidence_IllnessCapsDisplayNotRaw(t *testing.T) {
@@ -80,6 +83,9 @@ func TestReadinessEvidence_IllnessCapsDisplayNotRaw(t *testing.T) {
if got.CapReason != "illness_suspicion_high" {
t.Fatalf("cap reason = %q", got.CapReason)
}
+ if got.Serving == nil || got.Serving.Status != ReadinessServingCapped {
+ t.Fatalf("serving = %+v, want capped", got.Serving)
+ }
}
func TestApplyIllnessSafetyCap_UpdatesMetadataAtBoundary(t *testing.T) {
@@ -104,6 +110,9 @@ func TestApplyIllnessSafetyCap_UpdatesMetadataAtBoundary(t *testing.T) {
if resp.ReadinessCapReason != "illness_suspicion_moderate" {
t.Fatalf("cap reason = %q", resp.ReadinessCapReason)
}
+ if resp.ReadinessServing == nil || resp.ReadinessServing.Status != ReadinessServingCapped {
+ t.Fatalf("serving = %+v, want capped", resp.ReadinessServing)
+ }
}
func TestApplyIllnessSafetyCap_HighIllnessReasonWinsOverSleepQuality(t *testing.T) {
@@ -150,6 +159,60 @@ func TestReadinessEvidence_LowSleepQualityPenalizesAndCaps(t *testing.T) {
if got.CapReason != "sleep_quality_low" {
t.Fatalf("cap reason = %q", got.CapReason)
}
+ if got.Serving == nil || got.Serving.Status != ReadinessServingLowCoverage {
+ t.Fatalf("serving = %+v, want low_coverage", got.Serving)
+ }
+}
+
+func TestReadinessServingState_MissingCoreComponentWins(t *testing.T) {
+ state := readinessServingState(ReadinessConfidenceProvisional, "missing_same_day_evidence", []ReadinessComponentSummary{
+ {Metric: "heart_rate_variability", Present: true, Freshness: ReadinessFreshnessOK, Confidence: ReadinessConfidenceFinal},
+ {Metric: "resting_heart_rate", Present: false, Freshness: ReadinessFreshnessMissing, Confidence: ReadinessConfidenceFinal, MissingReason: "missing_same_day_value"},
+ {Metric: "sleep_total", Present: true, Freshness: ReadinessFreshnessOK, Confidence: ReadinessConfidenceFinal},
+ })
+ if state.Status != ReadinessServingMissing {
+ t.Fatalf("status = %q, want missing", state.Status)
+ }
+ if state.Confidence != ReadinessConfidenceProvisional || state.Reason != "missing_same_day_evidence" {
+ t.Fatalf("state = %+v, want provisional missing_same_day_evidence", state)
+ }
+}
+
+func TestReadinessServingState_EvidenceFreeInputIsMissing(t *testing.T) {
+ state := readinessServingState(ReadinessConfidenceFinal, "", nil)
+ if state.Status != ReadinessServingMissing {
+ t.Fatalf("status = %q, want missing", state.Status)
+ }
+ if state.Confidence != ReadinessConfidenceFinal {
+ t.Fatalf("confidence = %q, want final", state.Confidence)
+ }
+}
+
+func TestComputeBriefing_ExposesReadinessServingContract(t *testing.T) {
+ d := RawMetrics{
+ LastDate: "2026-06-04",
+ HRV: append([]float64{90}, repeatFloat(40, 20)...),
+ RHR: repeatFloat(55, 21),
+ Sleep: repeatFloat(7.5, 21),
+ ReadinessEvidence: &ReadinessEvidenceInput{
+ Date: "2026-06-04",
+ HRV: presentReadinessComponent("heart_rate_variability", 90, 2),
+ RHR: presentReadinessComponent("resting_heart_rate", 55, 1),
+ SleepDuration: presentReadinessComponent("sleep_total", 7.5, 0),
+ },
+ }
+ d.ReadinessEvidence.HRV.Confidence = ReadinessConfidenceProvisional
+
+ resp := ComputeBriefing(d, "en")
+ if resp.ReadinessServing == nil {
+ t.Fatal("readiness serving contract missing")
+ }
+ if resp.ReadinessServing.Status != ReadinessServingDataAccruing {
+ t.Fatalf("serving status = %q, want data_accruing", resp.ReadinessServing.Status)
+ }
+ if len(resp.ReadinessServing.Components) == 0 {
+ t.Fatal("serving components missing")
+ }
}
func presentReadinessComponent(metric string, value float64, samples int) ReadinessComponentEvidence {
diff --git a/internal/health/scoring.go b/internal/health/scoring.go
index 2c674c6..1153274 100644
--- a/internal/health/scoring.go
+++ b/internal/health/scoring.go
@@ -47,6 +47,7 @@ func ComputeBriefing(d RawMetrics, lang string) *BriefingResponse {
ReadinessConfidence: readiness.Confidence,
ReadinessCapReason: readiness.CapReason,
ReadinessComponents: readiness.Components,
+ ReadinessServing: readiness.Serving,
RecoverySource: ReadinessRecoverySourceLegacyAlias,
RecoveryPct: readinessScore,
ReadinessToday: readinessScore,
@@ -64,6 +65,7 @@ func ComputeBriefing(d RawMetrics, lang string) *BriefingResponse {
// "all good" in section cards (converging-evidence rule, Meeusen 2013).
applyCoherencePass(resp, ls)
resp.Overall = overallStatus(resp.Sections) // re-derive after coherence
+ updateReadinessServing(resp)
// Energy Bank runs *after* the coherence pass so a stress-capped readiness
// flows in as morning capacity, and so the verdict can downgrade
diff --git a/internal/health/types.go b/internal/health/types.go
index 24ff019..84f27f5 100644
--- a/internal/health/types.go
+++ b/internal/health/types.go
@@ -443,9 +443,15 @@ type BriefingResponse struct {
ReadinessConfidence string `json:"readiness_confidence,omitempty"`
ReadinessCapReason string `json:"readiness_cap_reason,omitempty"`
ReadinessComponents []ReadinessComponentSummary `json:"readiness_components,omitempty"`
- RecoverySource string `json:"recovery_source,omitempty"`
- RecoveryPct int `json:"recovery_pct"`
- ReadinessToday int `json:"readiness_today"` // today only vs baseline
+ // ReadinessServing is the canonical serving/freshness contract for
+ // dashboards, MCP clients, and future confidence UI. The older
+ // top-level readiness_confidence/readiness_cap_reason/components fields
+ // remain for compatibility, but clients should prefer this grouped
+ // object when they need to render freshness or uncertainty states.
+ ReadinessServing *ReadinessServingState `json:"readiness_serving,omitempty"`
+ RecoverySource string `json:"recovery_source,omitempty"`
+ RecoveryPct int `json:"recovery_pct"`
+ ReadinessToday int `json:"readiness_today"` // today only vs baseline
// ReadinessTodayBand mirrors ReadinessBand for the today-only score.
ReadinessTodayBand string `json:"readiness_today_band"`
ReadinessTodayLabel string `json:"readiness_today_label"`
@@ -477,6 +483,13 @@ const (
ReadinessFreshnessMissing = "missing"
ReadinessFreshnessStale = "stale"
+ ReadinessServingFresh = "fresh"
+ ReadinessServingMissing = "missing"
+ ReadinessServingStale = "stale"
+ ReadinessServingDataAccruing = "data_accruing"
+ ReadinessServingLowCoverage = "low_coverage"
+ ReadinessServingCapped = "capped"
+
ReadinessRecoverySourceLegacyAlias = "readiness_legacy_alias"
)
@@ -516,6 +529,13 @@ type ReadinessComponentSummary struct {
MissingReason string `json:"missing_reason,omitempty"`
}
+type ReadinessServingState struct {
+ Status string `json:"status"`
+ Confidence string `json:"confidence"`
+ Reason string `json:"reason,omitempty"`
+ Components []ReadinessComponentSummary `json:"components,omitempty"`
+}
+
// SubjectiveCheckinSummary is the read-shape rendered into the
// dashboard hero. nil when no check-in row exists for today.
type SubjectiveCheckinSummary struct {
diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go
index f120084..944ca48 100644
--- a/internal/mcpserver/tools.go
+++ b/internal/mcpserver/tools.go
@@ -15,7 +15,7 @@ import (
func registerMetricTools(s *server.MCPServer, _ DBResolver) {
s.AddTool(mcp.NewTool("get_health_briefing",
- mcp.WithDescription("Get a full daily health briefing: composite readiness score (z-score based: today 60% + 7-day trend 40%, components HRV 40% + RHR 25% + Sleep 35% vs 30-day personal baseline; 70 = your norm, 85 = good, 55 = rough day), sleep analysis with per-source breakdown, recovery, activity, cardio sections, insights, and health alerts. Best starting point."),
+ mcp.WithDescription("Get a full daily health briefing with readiness score, server-owned readiness_serving freshness/confidence metadata, sleep analysis, recovery, activity, cardio sections, EnergyBank, insights, and health alerts. Best starting point for consumers that must not guess missing/stale/low-coverage states."),
mcp.WithString("lang", mcp.Description("Response language: en, ru, sr (default: en)")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
lang := req.GetString("lang", "en")
@@ -41,7 +41,7 @@ func registerMetricTools(s *server.MCPServer, _ DBResolver) {
})
s.AddTool(mcp.NewTool("get_readiness_history",
- mcp.WithDescription("Get daily composite readiness scores (0–100) for the last N days. Z-score based: today 60% + 7-day trend 40%. Components: HRV 40%, RHR 25%, Sleep 35% (duration + consistency). Score 70 = personal baseline, 85 = 1 SD above, 55 = 1 SD below."),
+ mcp.WithDescription("Get daily composite readiness scores (0–100) and server-derived readiness bands for the last N days. For today's freshness/confidence contract, use get_health_briefing and read readiness_serving."),
mcp.WithNumber("days", mcp.Description("Number of recent days (default: 30)")),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {
days := req.GetInt("days", 30)
@@ -52,7 +52,6 @@ func registerMetricTools(s *server.MCPServer, _ DBResolver) {
return jsonResult(map[string]any{"days": days, "points": pts})
})
-
s.AddTool(mcp.NewTool("list_metrics",
mcp.WithDescription("List all available health metrics with record counts and date ranges. Call this first to discover what data is available."),
), func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) {