{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://raw.githubusercontent.com/DrBradStanfield/roadmap/main/docs/health-roadmap-file.schema.json", "title": "Health Roadmap record file", "description": "The complete health record for one user: a single JSON document named health-roadmap.json, stored in the user's own cloud (Google Drive, Dropbox, GitHub, WebDAV) or in browser localStorage. Never on our server. Derived from packages/health-core/src/roadmap-file.ts at schemaVersion 2. Unknown properties are preserved on read and write (forward-compatibility rule 1 in migrate.ts), so every object here allows extra keys. Timestamps are ISO 8601 strings. `meta.createdAt`/`meta.updatedAt` are shape-checked on read (a malformed value is replaced with the load time); row timestamps are compared as strings and clamped, not format-validated — so this schema does not require a format. Agents: see docs/agent-access.md for the read/write rules.", "type": "object", "required": [ "schemaVersion", "meta", "profile", "measurements", "medications", "medicationHistory", "supplements", "supplementHistory", "screenings", "labValues", "documents", "reminderPreferences", "recommendationSnapshots" ], "properties": { "schemaVersion": { "type": "integer", "enum": [1, 2], "description": "On-disk shape version. A file with a HIGHER version than the reading app understands must not be rewritten — migrate.ts throws SchemaTooNewError rather than downgrade it. 2 marks a file that carries `erasedRows`; every other file is 1 (US-09 AC23)." }, "meta": { "$ref": "#/$defs/RoadmapFileMeta" }, "profile": { "$ref": "#/$defs/RoadmapProfile" }, "measurements": { "type": "array", "description": "Append-only. Core metric values in SI canonical units.", "items": { "$ref": "#/$defs/FileMeasurement" } }, "medications": { "type": "array", "description": "Current state, one row per medicationKey.", "items": { "$ref": "#/$defs/FileMedication" } }, "medicationHistory": { "type": "array", "description": "Append-only log; rows carry changeType.", "items": { "$ref": "#/$defs/FileMedication" } }, "supplements": { "type": "array", "description": "Current state, one row per supplementKey.", "items": { "$ref": "#/$defs/FileSupplement" } }, "supplementHistory": { "type": "array", "description": "Append-only log; rows carry changeType.", "items": { "$ref": "#/$defs/FileSupplement" } }, "screenings": { "$ref": "#/$defs/FileScreenings" }, "labValues": { "type": "array", "description": "Append-only. Lab tests beyond the core metrics. A test in packages/health-core/src/lab-catalog.ts is stored in that catalogue's canonical SI unit; a test the catalogue does not know keeps the unit it was reported in.", "items": { "$ref": "#/$defs/FileLabValue" } }, "documents": { "type": "array", "description": "Metadata for uploaded reports. The bytes live beside this file, at fileRef.", "items": { "$ref": "#/$defs/FileDocument" } }, "reminderPreferences": { "type": "array", "description": "Current state, one row per reminder category.", "items": { "$ref": "#/$defs/FileReminderPreference" } }, "recommendationSnapshots": { "type": "array", "description": "Point-in-time record of what the tool advised, deduped by date.", "items": { "$ref": "#/$defs/RecommendationSnapshot" } }, "reminderOptIn": { "$ref": "#/$defs/FileReminderOptIn" }, "erasedRows": { "$ref": "#/$defs/ErasedRows" } }, "if": { "required": ["erasedRows"] }, "then": { "properties": { "schemaVersion": { "const": 2 } } }, "$defs": { "RoadmapFileMeta": { "type": "object", "description": "Top-level sync state for the whole file. An agent writing the file sets `updatedAt` and nothing else here — `lamport`, `eraseEpoch`, `erasedAt` and `lastDeviceId` belong to the sync engine. Unrecognised keys added here are dropped by mergeFiles, which rebuilds meta from these six fields. `updatedAt` also anchors the clock clamp on read — a stale value rewinds rows written after it.", "required": ["createdAt", "updatedAt", "lastDeviceId", "lamport"], "properties": { "createdAt": { "type": "string", "description": "ISO 8601 — earliest creation across devices." }, "updatedAt": { "type": "string", "description": "ISO 8601 — last write to the file." }, "lastDeviceId": { "type": "string", "description": "Device that produced this revision." }, "lamport": { "type": "number", "description": "File-level logical clock, monotonic per device." }, "eraseEpoch": { "type": "number", "description": "\"Delete All My Data\" counter. A file with a higher epoch wins a merge; the other copy keeps the rows no erase listed in `erasedRows`, or, past an erase that listed nothing, only the measurements, lab values and documents it created at or after the winner's `erasedAt`. Absent reads as 0." }, "erasedAt": { "type": "string", "description": "ISO 8601 — when the latest erase happened, stamped with every `eraseEpoch` bump. Read only past an erase that listed nothing in `erasedRows`. Absent on a record erased before it existed: then a losing copy keeps no rows." } } }, "ErasedRows": { "type": "object", "description": "Which rows the erases removed (US-09 AC23). Sync bookkeeping: never write or edit it. Every reader drops a row whose root id is listed (the id without any `#dup-…` or `#si` suffix), from every array that carries ids, so an erased row never comes back. A malformed list is dropped whole on read. A file that carries it is schemaVersion 2.", "required": ["fromEpoch", "ids"], "properties": { "fromEpoch": { "type": "integer", "minimum": 1, "description": "Every erase from this epoch up to `meta.eraseEpoch` listed its ids; `eraseEpoch + 1` claims nothing. Read only if at most `eraseEpoch + 1`." }, "ids": { "type": "array", "items": { "type": "string" }, "description": "The root id of every row an erase removed, sorted and unique." } } }, "FieldStamps": { "type": "object", "description": "When each field of this object was last written, keyed by field name: the merge takes each field's newest write, not the newest whole object. These are the sync engine's clocks, so never write them; the app and the MCP tools stamp their own writes. A file written before 2026-09-25 has none, and every field it holds then carries the object's own stamp; a field it neither holds nor stamps was never written, so clear a field by setting it to null, never by removing its key. A writer that moves the object's own stamp past every one of these (an older app, or a hand edit under agent-access.md) is read as a write to every field it holds, as before field stamps.", "additionalProperties": { "type": "object", "required": ["updatedAt"], "properties": { "updatedAt": { "type": "string" }, "lamport": { "type": "number" } } } }, "RoadmapProfile": { "type": "object", "description": "Demographics and display preferences. A singleton merged field by field: each field is last-write-wins (see fieldStamps). Any field may be null, meaning cleared; the clocks (updatedAt, lamport, fieldStamps) never are.", "required": ["updatedAt"], "properties": { "updatedAt": { "type": "string", "description": "When any field last changed." }, "lamport": { "type": "number" }, "fieldStamps": { "$ref": "#/$defs/FieldStamps" }, "sex": { "enum": ["male", "female", null] }, "birthYear": { "type": ["number", "null"] }, "birthMonth": { "type": ["number", "null"], "description": "1–12." }, "heightCm": { "type": ["number", "null"], "description": "Centimetres." }, "unitSystem": { "enum": ["si", "conventional", null], "description": "Display units only; stored values are always SI." }, "unitOverrides": { "type": ["object", "null"], "description": "Per-metric display-unit overrides, keyed by metric type.", "additionalProperties": { "enum": ["si", "conventional"] } }, "lpaUnit": { "enum": ["nmol/L", "mg/dL", "mg/L", null], "description": "The unit the app's Lp(a) field shows and reads typed values in. Display only; stored Lp(a) values are always nmol/L. Absent: US units give mg/dL, any other nmol/L." }, "reportEmailCaptured": { "type": ["boolean", "null"], "description": "Monotonic: set true once the user submits an email for their plan. Never set back to false." } } }, "MetricType": { "enum": [ "weight", "waist", "hba1c", "ldl", "total_cholesterol", "hdl", "triglycerides", "systolic_bp", "diastolic_bp", "apob", "creatinine", "psa", "lpa" ], "description": "The core metrics, from METRIC_TYPES in packages/health-core/src/validation.ts. Anything outside this list belongs in labValues. Height is not here — it lives in profile.heightCm, not as a measurement row." }, "MeasurementSource": { "enum": [ "manual", "apple_health", "fitbit", "lab_import", "lab_import_edited", "manual_correction" ], "description": "Where the value came from. From MEASUREMENT_SOURCES in packages/health-core/src/validation.ts." }, "MeasurementStatus": { "enum": ["active", "entered-in-error"], "description": "FHIR-style audit state. One-way: active may become entered-in-error, never the reverse." }, "FileMeasurement": { "type": "object", "description": "One immutable measurement row. Never edit or delete a row — append a correction instead.", "required": [ "id", "metricType", "value", "recordedAt", "createdAt", "source", "status", "correctsId", "externalId" ], "properties": { "id": { "type": "string", "description": "Stable unique id. Write a fresh UUID; never reuse one. Read ids as opaque strings: an id containing '#dup-' is a quarantined duplicate the merge created after an id was reused with different content — never write one yourself." }, "metricType": { "$ref": "#/$defs/MetricType" }, "value": { "type": "number", "description": "SI canonical unit: kg, cm, mmHg, mmol/mol (HbA1c), mmol/L (lipids), g/L (ApoB), µmol/L (creatinine), ng/mL (PSA), nmol/L (Lp(a))." }, "recordedAt": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}", "description": "ISO 8601. Only the calendar day matters: (metricType, day) is the slot key, and at most one active row exists per slot. Both 'YYYY-MM-DD' and a full timestamp occur in real files." }, "createdAt": { "type": "string", "description": "ISO 8601 write time. Within a slot, the newest createdAt is the active row (id breaks ties)." }, "source": { "$ref": "#/$defs/MeasurementSource" }, "status": { "$ref": "#/$defs/MeasurementStatus" }, "correctsId": { "type": ["string", "null"], "description": "Id of the row this one replaces, or null." }, "externalId": { "type": ["string", "null"], "description": "Id in the originating system (e.g. a HealthKit sample UUID), used for dedup." } } }, "FileLabValue": { "type": "object", "description": "One immutable lab row for a test outside the core metrics. Same append-only rules as a measurement.", "required": [ "id", "metricName", "value", "unit", "referenceLow", "referenceHigh", "recordedAt", "createdAt", "source", "status", "correctsId" ], "properties": { "id": { "type": "string", "description": "Stable unique id. Write a fresh UUID; never reuse one. An id containing '#dup-' is a merge-quarantined duplicate, and an id ending '#si' is the app's own unit conversion of the row it corrects — never write either one yourself." }, "metricName": { "type": "string", "description": "Prefer a catalogue key from packages/health-core/src/lab-catalog.ts (e.g. 'ferritin'). Free text otherwise. Slot key is (metricName, day of recordedAt)." }, "value": { "type": "number", "description": "The value in the unit this row is STORED in — the catalogue's canonical SI unit for a catalogued test, converted from what the lab printed; as reported for a test the catalogue does not know." }, "unit": { "type": "string", "description": "The unit the value is STORED in. For a catalogued test this is the catalogue's canonical SI unit, not necessarily the one the lab printed; the conversion factors are in health_roadmap_algorithm.md section 2. A spelling the catalogue does not know for that test is refused on write, never rescaled by guess." }, "referenceLow": { "type": ["number", "null"], "description": "Lab reference range low, in the same unit as value (converted by the same factor), or null." }, "referenceHigh": { "type": ["number", "null"], "description": "Lab reference range high, in the same unit as value (converted by the same factor), or null." }, "recordedAt": { "type": "string", "pattern": "^\\d{4}-\\d{2}-\\d{2}" }, "createdAt": { "type": "string" }, "source": { "$ref": "#/$defs/MeasurementSource" }, "status": { "$ref": "#/$defs/MeasurementStatus" }, "correctsId": { "type": ["string", "null"] } } }, "HistoryChangeType": { "enum": ["started", "stopped", "dose_changed", "switched"], "description": "How a history row differs from the state before it. Recorded at write time, present only on medicationHistory / supplementHistory rows." }, "FileMedication": { "type": "object", "description": "One medication slot. In `medications` this is current state; in `medicationHistory` it is an appended log row.", "required": ["updatedAt", "id", "medicationKey", "drugName", "doseValue", "doseUnit"], "properties": { "updatedAt": { "type": "string" }, "lamport": { "type": "number" }, "id": { "type": "string", "description": "Stable unique id. Write a fresh UUID; never reuse one. An id containing '#dup-' is a merge-quarantined duplicate — never write one yourself." }, "medicationKey": { "enum": [ "statin", "ezetimibe", "statin_escalation", "pcsk9i", "bempedoic_acid", "glp1", "glp1_escalation", "sglt2i", "metformin" ], "description": "MEDICATION_KEYS in validation.ts. The merge key for the current-state list." }, "drugName": { "type": "string", "description": "Drug or sentinel value, e.g. 'atorvastatin', 'yes', 'not_yet', 'not_tolerated'." }, "doseValue": { "type": ["number", "null"] }, "doseUnit": { "type": ["string", "null"] }, "changeType": { "$ref": "#/$defs/HistoryChangeType" } } }, "FileSupplement": { "type": "object", "description": "One supplement. In `supplements` this is current state; in `supplementHistory` it is an appended log row.", "required": [ "updatedAt", "id", "supplementKey", "supplementName", "doseValue", "doseUnit", "status", "startedAt" ], "properties": { "updatedAt": { "type": "string" }, "lamport": { "type": "number" }, "id": { "type": "string", "description": "Stable unique id. Write a fresh UUID; never reuse one. An id containing '#dup-' is a merge-quarantined duplicate — never write one yourself." }, "supplementKey": { "type": "string", "description": "Lowercase alphanumeric with underscores. The merge key for the current-state list." }, "supplementName": { "type": "string" }, "doseValue": { "type": ["number", "null"] }, "doseUnit": { "type": ["string", "null"] }, "status": { "enum": ["active", "stopped"], "description": "Stopping a supplement flips this; it does not delete the row." }, "startedAt": { "type": "string" }, "changeType": { "$ref": "#/$defs/HistoryChangeType" } } }, "ScreeningResult": { "enum": ["normal", "abnormal", "awaiting"] }, "ScreeningFollowupStatus": { "enum": ["not_organized", "scheduled", "completed"] }, "FileScreenings": { "type": "object", "description": "The cancer- and bone-screening workflow, as one singleton merged field by field: each answer is last-write-wins (see fieldStamps). Every field is optional, and any field may be null, meaning cleared (the app writes null for a blank numeric answer); the clocks (updatedAt, lamport, fieldStamps) never are. Date fields use 'YYYY-MM' (month precision).", "required": ["updatedAt"], "properties": { "updatedAt": { "type": "string", "description": "When any answer last changed." }, "lamport": { "type": "number" }, "fieldStamps": { "$ref": "#/$defs/FieldStamps" }, "colorectalMethod": { "enum": ["fit_annual", "colonoscopy_10yr", "other", "not_yet_started", null] }, "colorectalLastDate": { "type": ["string", "null"] }, "colorectalResult": { "anyOf": [{ "$ref": "#/$defs/ScreeningResult" }, { "type": "null" }] }, "colorectalFollowupStatus": { "anyOf": [{ "$ref": "#/$defs/ScreeningFollowupStatus" }, { "type": "null" }] }, "colorectalFollowupDate": { "type": ["string", "null"] }, "breastFrequency": { "enum": ["annual", "biennial", "not_yet_started", null] }, "breastLastDate": { "type": ["string", "null"] }, "breastResult": { "anyOf": [{ "$ref": "#/$defs/ScreeningResult" }, { "type": "null" }] }, "breastFollowupStatus": { "anyOf": [{ "$ref": "#/$defs/ScreeningFollowupStatus" }, { "type": "null" }] }, "breastFollowupDate": { "type": ["string", "null"] }, "cervicalMethod": { "enum": ["hpv_every_5yr", "pap_every_3yr", "other", "not_yet_started", null] }, "cervicalLastDate": { "type": ["string", "null"] }, "cervicalResult": { "anyOf": [{ "$ref": "#/$defs/ScreeningResult" }, { "type": "null" }] }, "cervicalFollowupStatus": { "anyOf": [{ "$ref": "#/$defs/ScreeningFollowupStatus" }, { "type": "null" }] }, "cervicalFollowupDate": { "type": ["string", "null"] }, "lungSmokingHistory": { "enum": ["never_smoked", "former_smoker", "current_smoker", null] }, "lungPackYears": { "type": ["number", "null"] }, "lungScreening": { "enum": ["annual_ldct", "not_yet_started", null] }, "lungLastDate": { "type": ["string", "null"] }, "lungResult": { "anyOf": [{ "$ref": "#/$defs/ScreeningResult" }, { "type": "null" }] }, "lungFollowupStatus": { "anyOf": [{ "$ref": "#/$defs/ScreeningFollowupStatus" }, { "type": "null" }] }, "lungFollowupDate": { "type": ["string", "null"] }, "prostateDiscussion": { "enum": ["not_yet", "elected_not_to", "will_screen", null] }, "prostatePsaValue": { "type": ["number", "null"], "description": "ng/mL." }, "prostateLastDate": { "type": ["string", "null"] }, "endometrialDiscussion": { "enum": ["not_yet", "discussed", null] }, "endometrialAbnormalBleeding": { "enum": ["no", "yes_reported", "yes_need_to_report", null] }, "dexaScreening": { "enum": ["dexa_scan", "not_yet_started", null] }, "dexaLastDate": { "type": ["string", "null"] }, "dexaResult": { "enum": ["normal", "osteopenia", "osteoporosis", "awaiting", null] }, "dexaFollowupStatus": { "anyOf": [{ "$ref": "#/$defs/ScreeningFollowupStatus" }, { "type": "null" }] }, "dexaFollowupDate": { "type": ["string", "null"] } } }, "FileReminderPreference": { "type": "object", "description": "One reminder category the user has switched on or off. Merge key is `category`.", "required": ["updatedAt", "category", "enabled"], "properties": { "updatedAt": { "type": "string" }, "lamport": { "type": "number" }, "category": { "type": "string" }, "enabled": { "type": "boolean" } } }, "FileReminderOptIn": { "type": "object", "description": "The user's email-reminder opt-in. Cancelling flips status; the object is never removed, because an absent field cannot win a merge.", "required": ["updatedAt", "status", "token", "email", "provider"], "properties": { "updatedAt": { "type": "string" }, "lamport": { "type": "number" }, "status": { "enum": ["active", "cancelled"] }, "token": { "type": "string", "description": "Opaque server-issued capability token. It manages ONLY the reminder schedule. Treat it as a secret: never copy it elsewhere, never send it anywhere." }, "email": { "type": "string", "description": "Provider-verified address, kept for display and change detection." }, "provider": { "enum": ["google-drive", "dropbox", "github"] } } }, "FileDocument": { "type": "object", "description": "Metadata for one uploaded report or letter. Deletion sets `deleted: true`; the row itself stays forever, or the union merge would resurrect it.", "required": [ "id", "title", "type", "date", "fileRef", "contentHash", "mimeType", "extractedText", "addedAt" ], "properties": { "id": { "type": "string", "description": "Stable unique id. Write a fresh UUID; never reuse one. An id containing '#dup-' is a merge-quarantined duplicate — never write one yourself." }, "title": { "type": "string" }, "type": { "enum": [ "scan_result", "clinic_letter", "discharge_summary", "pathology_report", "vaccination_record", "other" ], "description": "DOCUMENT_TYPES in validation.ts. It decides the archive folder (document-path.ts)." }, "date": { "type": ["string", "null"], "description": "The document's own date, 'YYYY-MM-DD', or null." }, "fileRef": { "type": "string", "description": "Path of the stored original relative to the record folder, e.g. 'Lab results/2024-05-10 Lipid panel.pdf'. Empty string when the document is text-only." }, "contentHash": { "type": "string", "description": "'sha256-…' over the stored bytes. Empty string when text-only." }, "mimeType": { "type": "string", "description": "Empty string when text-only." }, "extractedText": { "type": "string", "description": "Markdown text of the document, so search works without the original." }, "addedAt": { "type": "string" }, "metadata": { "type": "object", "description": "Free-form extraction metadata." }, "sourceFileName": { "type": ["string", "null"], "description": "Original upload filename. It is the dedup key for re-uploads — stable, unlike any generated title." }, "deleted": { "type": "boolean", "description": "Tombstone. Merge ORs it, so a delete is never undone." } } }, "Suggestion": { "type": "object", "description": "One piece of advice the tool produced. Generated output — agents read it, they do not author it.", "required": ["id", "category", "priority", "title", "description"], "properties": { "id": { "type": "string" }, "category": { "enum": [ "nutrition", "exercise", "bloodwork", "blood_pressure", "general", "sleep", "medication", "screening", "supplements", "skin" ] }, "priority": { "enum": ["info", "attention", "urgent"] }, "title": { "type": "string" }, "description": { "type": "string" }, "ingredients": { "type": "array", "items": { "type": "string" } }, "link": { "type": "string" }, "reason": { "type": "string" }, "guidelines": { "type": "array", "items": { "type": "string" } }, "references": { "type": "array", "items": { "type": "object", "required": ["label", "url"], "properties": { "label": { "type": "string" }, "url": { "type": "string" } } } } } }, "RecommendationSnapshot": { "type": "object", "description": "What the user was advised on one day. Deduped by date.", "required": ["date", "suggestions"], "properties": { "date": { "type": "string", "description": "'YYYY-MM-DD'." }, "suggestions": { "type": "array", "items": { "$ref": "#/$defs/Suggestion" } } } } } }