From 7af439597f99a8d50ce61bee3b7fdaea27d445a3 Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: Wed, 1 Jul 2026 09:40:28 -0400 Subject: [PATCH 1/5] docs(adr,design): ADRs 050-057 + reconcile ADR-015/021, index 045-057 - ADR-050 design system, 051 adaptive shell, 052 proof-trace UX, 053 witness-chained provenance, 054 realtime WebGL twin (supersedes 015 render), 055 external literature verifier (extends 006/008), 056 Cognitum Seed KB, 057 privacy-tiered model routing (Connected vs Sealed, local-only egress gate) - README index now covers 045-049 + 050-057; documents the 037-044 gap - 4 UI direction mockups + chosen hybrid + design README --- docs/adr/ADR-015-visual-3d-digital-twin.md | 9 +- docs/adr/ADR-021-genome-ingestion-rvdna.md | 9 + .../ADR-050-design-system-visual-language.md | 119 ++ .../adr/ADR-051-adaptive-application-shell.md | 107 ++ docs/adr/ADR-052-proof-reasoning-trace-ux.md | 119 ++ ...R-053-witness-chained-answer-provenance.md | 129 ++ .../ADR-054-realtime-webgl-digital-twin.md | 121 ++ ...55-external-medical-literature-verifier.md | 131 ++ ...gnitum-seed-offline-knowledge-substrate.md | 136 ++ .../ADR-057-privacy-tiered-model-routing.md | 393 +++++ docs/adr/README.md | 32 +- docs/design/mockups/README.md | 51 + .../mockups/direction-a-clinical-aurora.html | 975 ++++++++++++ .../mockups/direction-b-bio-instrument.html | 1203 ++++++++++++++ .../mockups/direction-c-living-twin.html | 945 +++++++++++ .../mockups/direction-final-hybrid.html | 1375 +++++++++++++++++ 16 files changed, 5849 insertions(+), 5 deletions(-) create mode 100644 docs/adr/ADR-050-design-system-visual-language.md create mode 100644 docs/adr/ADR-051-adaptive-application-shell.md create mode 100644 docs/adr/ADR-052-proof-reasoning-trace-ux.md create mode 100644 docs/adr/ADR-053-witness-chained-answer-provenance.md create mode 100644 docs/adr/ADR-054-realtime-webgl-digital-twin.md create mode 100644 docs/adr/ADR-055-external-medical-literature-verifier.md create mode 100644 docs/adr/ADR-056-cognitum-seed-offline-knowledge-substrate.md create mode 100644 docs/adr/ADR-057-privacy-tiered-model-routing.md create mode 100644 docs/design/mockups/README.md create mode 100644 docs/design/mockups/direction-a-clinical-aurora.html create mode 100644 docs/design/mockups/direction-b-bio-instrument.html create mode 100644 docs/design/mockups/direction-c-living-twin.html create mode 100644 docs/design/mockups/direction-final-hybrid.html diff --git a/docs/adr/ADR-015-visual-3d-digital-twin.md b/docs/adr/ADR-015-visual-3d-digital-twin.md index bcf81f9..93805b9 100644 --- a/docs/adr/ADR-015-visual-3d-digital-twin.md +++ b/docs/adr/ADR-015-visual-3d-digital-twin.md @@ -1,12 +1,19 @@ # ADR-015: Visual Health-Intelligence Layer (3D Anatomical Digital Twin) -**Status**: Proposed +**Status**: Proposed — **superseded in part by ADR-054** (2026-07-01) **Date**: 2026-06-25 **Project**: Helix — Personal Health Intelligence (PHI) **Prepared by**: ISO Vision LLC **Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin **Related**: ADR-001, ADR-003, ADR-005, ADR-006, ADR-009, ADR-010, ADR-014, ADR-016 +> **2026-07-01 note**: ADR-054 supersedes this ADR's render behavior — live +> metric→region data binding and runtime-adaptive LOD, in place of the static +> render/static LOD described below. This ADR's stack choice (React Three +> Fiber/WebGL), the four hard visualization-safety constraints (V-1–V-4), the +> tap-to-reveal information architecture, and the full alternatives analysis remain +> in force unchanged — see ADR-054 for what changed and why. + --- ## Context diff --git a/docs/adr/ADR-021-genome-ingestion-rvdna.md b/docs/adr/ADR-021-genome-ingestion-rvdna.md index 8461d41..8d9af51 100644 --- a/docs/adr/ADR-021-genome-ingestion-rvdna.md +++ b/docs/adr/ADR-021-genome-ingestion-rvdna.md @@ -96,3 +96,12 @@ pharmacogenomic rule set (not Darwin-mutable). - Helix ADR-001 / §7.4 (the 23andMe lesson), ADR-005, ADR-010, ADR-011. **[A]** > Architectural/product guidance, not legal or medical advice. Genomic risk/pharmacogenomics is decision-support; engage clinical and legal counsel. + +--- + +## Amendment (2026-07-01, ref. ADR-052) + +Pharmacogenomic advisories (Decision 5) surface inside the Proof/Reasoning-Trace panel +(ADR-052) alongside the medication they flag — not as a separate genome screen — so a +non-normal metabolizer phenotype appears exactly where the user is already looking at +"why," using the same source/tier/verification layout as every other claim. diff --git a/docs/adr/ADR-050-design-system-visual-language.md b/docs/adr/ADR-050-design-system-visual-language.md new file mode 100644 index 0000000..1c35a99 --- /dev/null +++ b/docs/adr/ADR-050-design-system-visual-language.md @@ -0,0 +1,119 @@ +# ADR-050: Design System & Visual Language + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-015 (digital twin palette/accessibility origin), ADR-016 (health score), ADR-031 (timeline), ADR-032 (focus areas), ADR-033 (dashboard recommendations), ADR-010 (SaMD copy constraints), ADR-051 (adaptive shell), ADR-052 (proof/reasoning-trace UX), ADR-054 (digital twin) + +--- + +## Context + +Five separate ADRs (015, 016, 031, 032, 033) each specify visual rules for their own +surface — ADR-015 defines a teal/amber/warm-orange palette, dark base (`#0B1322`), +color-blind-safety, and WCAG AA for the 3D twin only; ADR-016's score, ADR-031's +timeline, and ADR-032/033's dashboard cards each inherit that intent informally but have +no single source of truth to inherit *from*. Left alone, this drifts: each feature +reinvents hex codes, type scales, and motion rules, and the "world-class UI" push (a +distinct-feeling, professional, dark aesthetic — the "two-strand"/double-helix motif) has +nowhere to live. This ADR promotes ADR-015's palette/accessibility rules to an +app-wide design system and adds the tokens ADR-015 never needed to specify (typography, +spacing, motion, a brand motif) so every other UI-facing ADR consumes one contract. + +--- + +## Decision + +**One versioned design-token registry is the single source of visual truth; every +surface (twin, score, timeline, dashboard, proof panel, shell) consumes it — none +define their own colors, type, or motion.** + +1. **Color tokens.** Inherit ADR-015's triad unchanged: dark base `#0B1322`, teal + (good/in-range), amber (attention/approaching boundary), warm orange-red + (out-of-range) — pure red reserved exclusively for the Escalation Guardian + (ADR-009), never for routine status, app-wide. Neutral gray is the only valid + "no data" state anywhere in the app, not just the twin. +2. **Typography scale.** A fixed scale (display / heading / body / caption) at a + 10th-grade plain-language reading level for body text (extending ADR-015 Decision 3 + app-wide), with line-height and size tuned for WCAG AA contrast at every step. +3. **Two-strand (double-helix) brand motif.** A recurring paired-intertwined-strand + graphic used for structural/navigational chrome (loading states, section dividers, + the app's visual "spine") — brand grammar only. It never substitutes for or + overlaps a grounded data visualization (ADR-015's V-1/V-2 constraints apply: the + motif is decorative chrome, not a data-bearing or anatomical element). +4. **Motion language.** Purposeful, restrained transitions; `prefers-reduced-motion` + honored everywhere (ADR-015 V-4 generalized app-wide, not twin-only). Motion is + never used to imply urgency outside Escalation Guardian output — keeps ADR-015's + Constraint V-3 (non-alarming) intact as a whole-app rule. +5. **Accessibility as a release gate, app-wide.** WCAG 2.1 AA, color-blind-safe + palette (Okabe-Ito derived, per ADR-015 reference 8), redundant icon+color+label + encoding, and reduced-motion support are release-blocking for every screen, not + just the twin. +6. **One shared component library.** Buttons, cards, the evidence-tier chip (ADR-006), + the citation chip (ADR-005), and gap-notice styling (ADR-006) are implemented once + and consumed by both the ADR-051 shell (desktop + mobile) and any future surface — + no per-platform restyle. +7. **Token governance.** Tokens live in one versioned file (design-tokens + JSON/CSS custom properties). Any new status color or type-scale step requires a + registry change reviewed against this ADR, not an ad hoc hex code in feature code. + +--- + +## Alternatives Considered + +- **Leave each ADR (015/016/031/032/033) to its own styling (status quo).** Rejected: + already producing drift (ADR-015's palette is not consistently referenced elsewhere); + undermines the "one trust gesture" goal ADR-052 depends on. +- **Adopt an off-the-shelf design system unmodified (e.g., Material Design).** + Rejected as the *visual language*: generic systems have no concept of an + evidence-tier chip or a non-alarming health palette. Acceptable only as an + engineering primitive layer underneath Helix's own tokens, not as the source of + Helix's look. +- **Design-only (Figma), no enforced token contract in code.** Rejected: unenforceable; + design and implementation drift within a release cycle without a code-level registry. + +--- + +## Consequences + +### Positive +- Consistent, accessible, trustworthy visual language across every ADR-owned surface. +- Single accessibility audit surface instead of five per-feature audits. +- Less engineering rework — one component library, many callers. + +### Negative +- Upfront design-system investment before feature work can consume it. +- Retrofitting ADR-015/016/031/032/033 surfaces that already have informal styling. +- Ongoing token-registry governance overhead as new statuses/components are proposed. + +### Mitigations +| Risk | Mitigation | +|---|---| +| Feature drift back to ad hoc styling | Token registry change required for any new color/type value; reviewed against this ADR | +| Retrofit cost | Migrate call sites incrementally; new surfaces (ADR-051/052/054) consume tokens from day one | +| Motif overreach into data territory | V-1/V-2 constraints (ADR-015) explicitly bound the two-strand motif to chrome, not data | + +--- + +## Open Questions + +1. Token tooling: raw CSS custom properties vs. a build-time token pipeline (e.g. + Style Dictionary) for cross-platform (web/Tauri/PWA) consistency. +2. Does the two-strand motif need medical-illustration review like ADR-015's + explanatory library? Current view: no — it is abstract/brand, not anatomical. +3. Dark-mode-only for MVP, or does a light-mode variant need its own token pass? + +--- + +## References + +- ADR-015 §Decision 2, 5 — palette, accessibility rules generalized here **[A]** +- W3C, "Web Content Accessibility Guidelines 2.1" **[A]** +- Okabe & Ito, "Color Universal Design" (2002) — color-blind-safe palette basis **[A]** + +--- + +> Architectural/product guidance, not legal or medical advice. Visual design choices +> do not alter the clinical-safety or evidence-tiering rules of ADR-006/009/010. diff --git a/docs/adr/ADR-051-adaptive-application-shell.md b/docs/adr/ADR-051-adaptive-application-shell.md new file mode 100644 index 0000000..6f59fe3 --- /dev/null +++ b/docs/adr/ADR-051-adaptive-application-shell.md @@ -0,0 +1,107 @@ +# ADR-051: Adaptive Application Shell (Tauri v2 Desktop + PWA Mobile over One WASM Core) + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-001 (local-first vault), ADR-013 (on-device inference), ADR-026 (on-device LLM analyst), ADR-047 (single-tenant local-first topology), ADR-049 (scheduled pulls), ADR-050 (design system) + +--- + +## Context + +Helix already has one real, shared analytic core: the `helix-wasm` crate in this +workspace compiles the full pipeline — provenance (ADR-005), numeric (ADR-007), +evidence (ADR-006), escalation (ADR-009), ontology (ADR-004), score (ADR-016), sensing +(ADR-014/020), genome (ADR-021), OCR (ADR-022), bioage (ADR-034), and more — to WASM +for the web/mobile UI. A web UI (`ui/`) and an installable mobile PWA (`mobile/`, +already shipping a `manifest.webmanifest` and a service worker) already consume it. What +does **not** yet exist is a formal desktop shell, and no ADR has decided the +shell strategy that keeps "one codebase, adapts desktop↔mobile" true as the product +grows rather than forking into parallel native codebases that re-implement the same +pipeline logic already expressed once in Rust. + +## Decision + +**A single shared Rust→WASM core (`helix-wasm`) is wrapped by two thin shells: Tauri v2 +for desktop, the existing installable PWA for mobile. Neither shell re-implements +business logic; both are chrome around the same core.** + +1. **One core, two shells.** `helix-wasm` remains the only place pipeline logic lives. + Desktop and mobile shells differ only in windowing/chrome/notification plumbing, + not in analysis, scoring, or grounding logic. +2. **Desktop: Tauri v2**, chosen over Electron because (a) Tauri ships a system webview + instead of bundling Chromium — materially smaller binaries and lower idle memory, + which matters for an always-on, local-first health app that should not compete for + the user's machine; (b) Tauri's backend is Rust, matching every existing Helix + crate — no bundled Node.js server process is needed, keeping the single-tenant + local-first topology (ADR-047) intact; (c) Tauri v2's mobile targets are evaluated + but not adopted here — Helix's mobile path is the PWA (below), kept independent for + app-store/sideload flexibility. +3. **Mobile: the existing installable PWA**, offline-first via its service worker and + the same WASM core running in the mobile browser engine. No native app-store + dependency is required for MVP — consistent with the low-friction, "nothing to + charge" ethos already established for ambient sensing (ADR-014). +4. **Offline-first everywhere.** Both shells operate against the local encrypted vault + (ADR-001) and on-device inference (ADR-013, ADR-026) with no required network round + trip for core analysis. Scheduled pulls (ADR-049) run independently of whether a + shell is open. +5. **Shared UI layer.** Both shells render the same component library and tokens + (ADR-050); platform-specific chrome (window controls vs. mobile navigation) is a + thin adapter, never a fork of business logic or restyle of components. +6. **Update path respects local-first.** Desktop updates via Tauri's signed updater; + mobile via service-worker cache-busting. Neither requires a cloud dependency to + apply a logic update, only to *distribute* one. + +## Alternatives Considered + +- **Electron.** Rejected: bundles a full Chromium + Node runtime alongside the + already-existing Rust core — a second heavyweight runtime, larger binaries, higher + idle RAM, poor fit for a local-first always-on app. +- **Parallel native codebases (Swift/Kotlin).** Rejected for MVP: doubles engineering + surface tracking logic already expressed once in `helix-wasm`. Revisit only if a + specific feature (e.g., deep OS integration, or ADR-054's AR question) demands it — + mirroring ADR-015's own deferral of native rendering to Phase 3+. +- **React Native + native modules.** Rejected: introduces a second non-Rust runtime + bridging back into the WASM core, adding a translation layer with no clear benefit + over a PWA that already runs the same WASM directly in the mobile browser. + +## Consequences + +**Positive.** One core, two thin shells; smaller/lighter desktop footprint than +Electron; fully consistent with the existing all-Rust workspace; offline-first by +construction, not by retrofit. + +**Negative.** System-webview rendering varies slightly by OS (WebView2 / WebKit / +WebKitGTK), adding a cross-platform QA matrix; the PWA path inherits iOS Safari's PWA +limitations (background sync, push-notification restrictions), which may lag native +for Escalation Guardian (ADR-009) delivery latency. + +**Mitigations.** Pin minimum webview versions and test across the matrix; use the Web +Notifications API where available on mobile, falling back to in-app on-open checks on +iOS Safari PWA until/unless a native wrapper is separately justified. + +## Open Questions + +1. Should the Tauri desktop shell call `helix-wasm` through the WASM runtime (asset/UI + consistency) or link the native Rust crates directly (performance)? Needs a + benchmark before Phase 1 ships. +2. How much does iOS PWA push-notification limitation degrade Escalation Guardian + (ADR-009) delivery latency in practice, and is that acceptable for red-flag copy? +3. App-store distribution vs. sideload-only for a wellness-positioned app (ties to + ADR-010's app-store-metadata review requirement). + +## References + +- Tauri v2 documentation — architecture, webview model, updater **[A — vendor docs, standard reference]** +- `crates/helix-wasm/Cargo.toml` (this repo) — confirms the existing shared core and + its 17 dependent Helix crates **[A — verified in-repo]** +- `ui/`, `mobile/manifest.webmanifest`, `mobile/sw.js` (this repo) — confirms the web + UI and installable PWA already exist and are the basis this ADR formalizes **[A — verified in-repo]** +- Helix ADR-001, ADR-013, ADR-026, ADR-047, ADR-049, ADR-050 + +--- + +> Architectural/product guidance, not legal or medical advice. Shell choice does not +> alter any grounding, evidence-tiering, or clinical-safety decision made elsewhere. diff --git a/docs/adr/ADR-052-proof-reasoning-trace-ux.md b/docs/adr/ADR-052-proof-reasoning-trace-ux.md new file mode 100644 index 0000000..313a227 --- /dev/null +++ b/docs/adr/ADR-052-proof-reasoning-trace-ux.md @@ -0,0 +1,119 @@ +# ADR-052: Proof / Reasoning-Trace UX — Making Grounding Legible + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-005 (grounded answering/citation chips), ADR-006 (evidence tiering/abstention), ADR-007 (deterministic numerics), ADR-008 (verifier/critic), ADR-009 (escalation), ADR-010 (SaMD copy), ADR-015 (twin tap-to-reveal panel), ADR-033 (dashboard recs), ADR-050 (design system), ADR-053 (witness-chained provenance), ADR-021 (genome — pharmacogenomic flags, amendment) + +--- + +## Context + +ADR-005 through ADR-008 built a rigorous backend pipeline — retrieval with provenance +(005), evidence tiering and abstention (006), deterministic numeric computation (007), +and independent cross-family verification (008) — but its legibility to the user is +currently scattered: ADR-005's inline citation chips, ADR-006's evidence-tier chips, +ADR-015's twin tap-to-reveal panel, and ADR-033's dashboard citations are each +independently specified, in different words, for different surfaces. No single ADR +owns "what does the user actually see when they ask *why*." The world-class UI push +wants one canonical "show your work" surface everywhere a claim appears. This ADR is a +**UX/consumption decision** — it does not add grounding capability, it makes the +capability ADR-005/006/007/008 already built visible and consistent. + +## Decision + +**Every answer or nudge that makes a factual claim carries a "show your work" +affordance that opens one standard Proof Panel — implemented once, called everywhere.** + +1. **Universal affordance.** Any surface producing a grounded claim (chat answer, + dashboard recommendation ADR-033, twin tap-to-reveal ADR-015, non-emergency + escalation copy ADR-009) exposes the same collapse/expand gesture. True red-flag + emergency copy stays terse per ADR-010 Gate 5 and is exempt. +2. **Fixed panel sections, in order:** + - **Sources** — the ProvRecord(s) consulted (ADR-005): source system, date, + provenance hash. + - **Evidence tier** — the tier chip(s) (ADR-006) with the standard tooltip. + - **Numeric derivation** — the deterministic trend/computation trail (ADR-007), + stated as arithmetic over stored points, explicitly "not LLM-computed." + - **Verification state** — a simple badge (Verified / Reformulated / + Dropped-and-replaced) from the Verifier (ADR-008) — not the full internal + verdict set, preserving ADR-008's existing answer to "should every verification + event be shown" (no — see ADR-008 Open Question 4). + - **Abstention state** — if part of the response abstained, the gap notice and its + reason (ADR-006) appears inline, not hidden. +3. **One component, every caller.** The panel is built once in the ADR-050 component + library. ADR-005's citation chip, ADR-006's tier chip, ADR-015's tap-to-reveal + panel, and ADR-033's dashboard citations all open *this* component rather than each + maintaining a bespoke variant. +4. **Abstention gets a proof affordance too.** A gap notice (ADR-006) opens a lighter + version of the same panel showing exactly why (missing / stale / low-confidence / + conflicting), reinforcing "abstention is honesty, not failure" as a felt, not just + stated, product property. +5. **Progressive disclosure.** Collapsed by default — a short summary line (echoing + ADR-008's "cross-checking sources..." language, e.g. "checked · 3 sources") — + expandable on demand. Depth is available; it is never forced on the user. +6. **Accessible and on-brand.** Built from ADR-050 tokens/typography; provides a + text-equivalent list (not a graphics-only view) meeting WCAG AA, consistent with + ADR-015 Constraint V-4 generalized app-wide by ADR-050. +7. **Extensible for pharmacogenomics and literature.** Genomic advisories (ADR-021 + amendment, this pass) and literature-grounded Tier-3 citations (ADR-055) surface + inside this same panel — the panel is the one legibility surface, not a new one per + data type. + +## Alternatives Considered + +- **Leave the current scattered per-feature approach as-is.** Rejected: users learn a + different "trust gesture" per feature, undermining the consistency the grounding + architecture exists to build trust through. +- **Expose the full raw Verifier `ClaimVerdict` by default.** Rejected: ADR-008 already + decided (Open Question 4) that surfacing every verification event is noisy. The + panel is a curated distillation for the user; the raw verdict set stays in the audit + log (ADR-002/008). +- **Replace the structured panel with a single confidence number.** Rejected: this + collapses exactly the tier conflation ADR-006's Context section names as the core + failure mode Helix exists to avoid. + +## Consequences + +### Positive +- One consistent, learnable trust gesture across the entire app. +- Makes the anti-hallucination architecture a felt experience, not just a backend + property — the actual differentiator becomes visible. +- Reduces engineering duplication: one component, many callers. + +### Negative +- Retrofitting existing bespoke surfaces (ADR-015 tap-to-reveal, ADR-033 feed) to the + canonical shape is real rework. +- Risk of the panel becoming an unreviewed dumping ground for every new data type if + not curated. +- Adds interaction depth some users may never open (mitigated by collapse-by-default). + +### Mitigations +| Risk | Mitigation | +|---|---| +| Retrofit cost | Componentize once (ADR-050); migrate call sites incrementally | +| Panel scope creep | New sections require an ADR-052 update, not ad hoc addition | +| Unused depth | Collapsed-by-default with a short trust-signal summary line | +| Privacy of usage analytics | Local-only open-rate counters, no export absent consent (ADR-001/011) | + +## Open Questions + +1. Exact copy for the collapsed summary line, and its localization. +2. Does the Verification-state badge need a 4th, power-user disclosure level showing + more of the `ClaimVerdict`? +3. How does ADR-053's witness-chain "verify this trail" affordance nest inside vs. + beside this panel? + +## References + +- Helix ADR-005 §Decision (citation format), ADR-006 §Decision (tiers/abstention), + ADR-007, ADR-008 §Decision (verdict schema, Open Question 4), ADR-015 §Decision 3 + (tap-to-reveal precedent), ADR-033, ADR-050 **[A]** + +--- + +> Architectural/product guidance, not legal or medical advice. This ADR changes how +> existing grounding guarantees are surfaced; it does not change what is required to +> be grounded, tiered, or verified. diff --git a/docs/adr/ADR-053-witness-chained-answer-provenance.md b/docs/adr/ADR-053-witness-chained-answer-provenance.md new file mode 100644 index 0000000..be4c209 --- /dev/null +++ b/docs/adr/ADR-053-witness-chained-answer-provenance.md @@ -0,0 +1,129 @@ +# ADR-053: Witness-Chained Answer Provenance + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-005 (ProvRecord/`provenance_hash` — extended here), ADR-001 (vault/key material), ADR-008 (verifier verdicts, "prep for my appointment" export), ADR-052 (proof panel — the UI consumer), ADR-056 (Cognitum Seed KB — shares the same witness-chain substrate) + +--- + +## Context + +ADR-005 gives every measurement a `provenance_hash` — a SHA-256 fingerprint over key +fields, so a user can confirm a value was not silently mutated after ingestion. That is +a **per-record fingerprint**, not a **chain**: it proves one value wasn't altered, not +that the whole derivation trail behind a displayed claim — from raw import, through +retrieval, numeric computation, and Verifier pass — is tamper-evident end to end. That +stronger property matters most for the "prep for my appointment" export (ADR-008): a +document a clinician or the user's future self may want to independently verify was not +edited after the fact, without trusting a Helix server (there isn't one, ADR-001/047). + +**Grounding (mandatory check, via the ruvnet brain):** the RVF ecosystem already ships a +real, working primitive for exactly this. +- The `rulake-witness` plugin computes a **SHAKE-256(32)** hash over stable bundle + fields and refuses on mismatch (`WITNESS_MATCH` / `WITNESS_MISMATCH_REFUSED`), + independently recomputable offline with no server. **[A — working code + CLI, `rulake/plugins/rulake-witness`]** +- RuVector's ADR-155 (ruLake, **Status: Accepted**, implemented) lists "Witness chains + (SHAKE-256, Ed25519, ML-DSA-65 PQ)" among the RVF crates' already-shipped + capabilities. **[A — accepted/implemented ADR in-repo]** +- QuDAG confirms **ML-DSA-65 (Dilithium3)** as a real, implemented post-quantum + signature primitive in the same ecosystem. **[A]** +- The Cognitum Seed's own mesh sync already carries "vectors and witness chains via + delta sync" between paired devices, and a cross-device provenance patent describes a + hash-chain + immutable provenance-tag design for this exact purpose. **[B — patent + application, design intent, novelty/approval scored, not a granted patent or a + confirmed shipped feature of that specific patent claim]** + +**What is not grounded:** Helix itself has not wired any of this into its own +ProvRecord or answer pipeline. This ADR proposes applying a real, existing, +cross-project primitive to a new (Helix-specific) target — it is not describing an +existing Helix capability. + +## Decision + +**Extend ADR-005's provenance schema with a chained, signed witness, reusing the +existing RVF witness-chain construction rather than inventing a new one.** + +1. **Extend, don't replace.** Add a `witness_chain` field alongside ADR-005's + `provenance_hash`: a SHAKE-256(32) hash over the ordered set of (ProvRecord hashes + + query manifest + Verifier verdict set) for a given answer, chained to the prior + witness — so tampering with *history*, not just one record, is detectable. Reuse + the `rulake` `compute_witness` construction; do not reinvent hashing. +2. **Sign it.** Ed25519 (Helix's existing device-identity primitive, ADR-001/014) for + standard integrity; offer ML-DSA-65 as an opt-in post-quantum tier for long-lived + "prep for my appointment" exports (ADR-008) users may want verifiable years later. +3. **Surface via the Proof Panel (ADR-052), not a new UI.** A "Verify this trail" + action inside the existing panel's Verification-state section, showing match/ + mismatch only — never raw hashes by default, mirroring ADR-005 Open Question 1's + "hidden by default, view-source on demand" answer. +4. **Export-time verification.** The "prep for my appointment" export embeds the + witness chain so a clinician or the user can independently recompute and confirm it + later using the same public construction — no Helix server required, consistent + with ADR-001/047's local-first posture. +5. **Scope.** Applies only to the "clinically meaningful" answer set already defined + by ADR-008's consensus-required threshold — not every navigational response, to + avoid needless signing overhead. + +## Alternatives Considered + +- **Fold this into ADR-052.** Considered, rejected as a merge: ADR-052 is a UX/ + consumption decision (what the user sees); this ADR extends the underlying data + model ADR-005 defined (what is cryptographically true). Keeping them separate lets + the crypto primitive (e.g., a future PQ-algorithm migration) be revisited without + touching the UX ADR. +- **Roll a bespoke hash-chain instead of reusing rulake's construction.** Rejected: + duplicates an already-implemented, accepted primitive and forfeits interoperability + with the rest of the RVF ecosystem (the Cognitum Seed mesh already speaks this + format — see ADR-056). +- **Sign every record at ingestion instead of chaining at answer-time.** Rejected for + MVP: multiplies signing operations without adding chain-of-custody value across + derivations; answer-level chaining captures the unit a user might actually dispute. + +## Consequences + +### Positive +- Tamper-evident provenance for the highest-stakes exports, built on a real, + already-accepted primitive rather than a bespoke one. +- Strengthens the "prep for my appointment" audit trail with independent, offline + verifiability — no trust in a Helix server required. +- PQ-signature option is future-proofed against algorithm migration. + +### Negative +- Adds chain-maintenance and key-management complexity (Ed25519/ML-DSA on-device). +- Overclaiming risk: marketing must not call this "blockchain-verified" — the nova- + medicina stub (ADR-055) is a cautionary example of a named pattern outrunning its + actual implementation. +- ML-DSA-65 signatures are larger than Ed25519, adding export-size overhead. + +### Mitigations +| Risk | Mitigation | +|---|---| +| Key management surface | Key material lives in the existing on-device vault (ADR-001); no new surface | +| Overclaiming in copy | One reviewed phrase only — "cryptographically tamper-evident, locally verifiable" — gated with ADR-010 copy review | +| Export size | ML-DSA offered opt-in for long-lived exports only, not the default | + +## Open Questions + +1. Periodic self-check vs. purely on-demand verification at export/view time. +2. Chain-amendment convention for corrected records (ADR-004 de-duplication, ADR-006 + conflicting-data abstention) — a correction needs a documented amendment, not a + silent chain rewrite. +3. Whether the patent-pending cross-device provenance design should be treated as a + dependency, or whether Helix should implement directly against the open + `rulake-witness` construction to avoid patent entanglement — flag for legal review. + +## References + +- `rulake/plugins/rulake-witness/README.md`, `.../commands/rulake-verify.md` — SHAKE-256(32) witness, working CLI **[A]** +- RuVector ADR-155 (ruLake, Accepted) — "Witness chains (SHAKE-256, Ed25519, ML-DSA-65 PQ)" **[A]** +- QuDAG — ML-DSA-65 (Dilithium3) post-quantum signatures **[A]** +- `cognitum-platform-docs/patents/appliance/115-witness-provenance-crossdevice.md` — Patent Application I-005, cross-device witness provenance **[B — design intent, unimplemented/unwon patent, not confirmed shipped]** +- Helix ADR-005, ADR-001, ADR-008, ADR-052, ADR-056 + +--- + +> Architectural/product guidance, not legal or medical advice. Cryptographic +> tamper-evidence strengthens auditability; it is not a claim of diagnostic or clinical +> validity, and does not alter ADR-010's wellness/SaMD boundary. diff --git a/docs/adr/ADR-054-realtime-webgl-digital-twin.md b/docs/adr/ADR-054-realtime-webgl-digital-twin.md new file mode 100644 index 0000000..584c2d0 --- /dev/null +++ b/docs/adr/ADR-054-realtime-webgl-digital-twin.md @@ -0,0 +1,121 @@ +# ADR-054: Real-Time WebGL Digital Twin — Live Binding & Adaptive LOD + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Supersedes**: ADR-015 (Visual Health-Intelligence Layer — 3D Anatomical Digital Twin) +**Related**: ADR-005 (grounding), ADR-006 (evidence tiers), ADR-007 (deterministic numerics), ADR-009 (escalation), ADR-014/ADR-020 (ambient sensing sources), ADR-033 (dashboard diffs), ADR-050 (design system), ADR-051 (adaptive shell) + +--- + +## Context + +ADR-015 made the foundational decision for the 3D digital twin: the React Three +Fiber/WebGL stack, the schematic-not-counterfeit anatomical model, the four hard +visualization-safety constraints (V-1 grounded-only, V-2 schematic not counterfeit, V-3 +non-alarming, V-4 accessible), the tap-to-reveal information architecture, and a +phased rollout (WebGL now, native evaluated later). **That decision stands unchanged.** +What the "world-class UI + proactive daily specialist" push adds is the concrete +real-time engineering shape ADR-015 named as future work but did not fully specify: + +1. **Live data binding.** ADR-015 defines `SystemColorState` as a function of the + user's data, computed by the Trend/Numeric agent (ADR-007) — but described it as a + render input, not a subscribable stream. A proactive product needs the twin to + reflect a new grounded value (an ambient-sensing reading landing overnight, + ADR-014/020; a dashboard "what changed" diff, ADR-033) without requiring the user + to manually refresh. +2. **Adaptive LOD for mobile.** ADR-015 Decision 1 names three static mesh tiers + (full/medium/low) chosen once at install. Real device variance (older phones + running the ADR-051 PWA, thermal throttling during long sessions) needs runtime + adaptation, not a single install-time choice. + +This ADR supersedes ADR-015 **only insofar as it replaces "static render, static LOD" +with "live-bound render, adaptive LOD."** ADR-015's stack choice, safety constraints, +information architecture, and full alternatives analysis are retained by reference and +not re-litigated here; ADR-015 remains intact as the origin decision. + +## Decision + +**The twin becomes a live-bound, adaptively-rendered surface: React Three Fiber/WebGL +unchanged, running inside the ADR-051 shell, with `SystemColorState` treated as a +subscribable stream and mesh LOD selected and adjusted at runtime.** + +1. **Stack unchanged.** React Three Fiber/WebGL (ADR-015 Decision 1) remains the + production stack, now running inside the ADR-051 adaptive shell (Tauri desktop + webview + PWA mobile) so the same scene graph and GLTF assets run unmodified on + both. +2. **Four hard constraints inherited unchanged.** V-1 through V-4 (ADR-015 Decision 5) + are not reopened by this ADR; this ADR specifies *how* they are delivered at + runtime, not whether they hold. +3. **Live metric→region binding.** The Trend/Numeric agent's (ADR-007) + `SystemColorState` output is a subscribable stream: the twin re-renders a region's + color/trend-arrow the moment a new grounded value crosses the versioned + data-binding contract (ADR-015 Decision 2) — including ambient-sensing updates + (ADR-014/020) and dashboard diffs (ADR-033) — so the twin has the same freshness as + the rest of the app, not a lagging snapshot. +4. **Adaptive LOD policy.** At scene load, detect device class (GPU tier via WebGL + renderer string / a short frame-time probe) and select automatically from the + existing full/medium/low tiers (ADR-015 Decision 1). If frame time drops below a + 30 fps floor for 3+ consecutive seconds (e.g., mobile thermal throttling), downgrade + one LOD tier at runtime rather than freezing. +5. **Data-binding contract remains the single source of truth.** No new binding logic + is introduced; this ADR makes the existing versioned contract (ADR-015 Decision 2) + drive a live, adaptive render loop instead of a one-time render call. + +## Alternatives Considered + +*ADR-015's full alternatives analysis (text-only interface, 2D-chart-only dashboard, +AI-generated "personalized" imagery) stands and is incorporated by reference, not +repeated here.* + +- **Static, install-time-only LOD selection (no runtime adaptation).** Rejected: real + mobile hardware variance and thermal throttling during extended sessions make a + single install-time choice brittle; runtime adaptation is a small addition given the + LOD tiers already exist. +- **Polling refresh instead of a subscribable stream.** Rejected: polling adds latency + between a grounded update landing (e.g., after an ADR-049 scheduled pull) and the + twin reflecting it — undermining the "organic flow" freshness goal ADR-049 exists to + serve. + +## Consequences + +### Positive +- The twin becomes a live reflection of the whole grounded pipeline, not a snapshot — + reinforcing the "body as navigation metaphor" (ADR-015) with real freshness. +- Adaptive LOD extends usable device range without a second engineering track. + +### Negative +- A live-streaming render loop adds state-management complexity (subscription + lifecycle, avoiding re-render storms on rapid or bursty updates). +- Runtime LOD downgrade adds a QA matrix dimension (device × thermal state). +- Still inherits ADR-015's anatomical/explanatory-library licensing and clinical- + governance costs, unchanged. + +### Mitigations +| Risk | Mitigation | +|---|---| +| Re-render storms | Debounce/coalesce region updates landing within a short window rather than per-metric re-render | +| LOD downgrade thrash | Tune the 30 fps/3-second threshold against the target device matrix before release | +| Licensing/governance cost | Reuse ADR-015's existing licensed-asset and clinical-review plan unchanged | + +## Open Questions + +1. Exact debounce window for coalescing rapid region updates. +2. Is WebGL-renderer-string GPU-tier detection reliable across Tauri's system + webviews (WebView2/WebKit/WebKitGTK, ADR-051), or is a frame-time-only fallback + needed? +3. ADR-015's Open Question 3 (AR overlay) remains open; this ADR does not resolve it. + +## References + +- ADR-015 (superseded decision; retained by reference for stack choice, safety + constraints, information architecture, full alternatives, and citation list) **[A]** +- Helix ADR-007, ADR-014, ADR-020, ADR-033, ADR-050, ADR-051 + +--- + +> Architectural/product guidance, not legal or medical advice. This ADR does not +> change any visualization-safety constraint (V-1–V-4) or the wellness/SaMD boundary +> (ADR-010) established by ADR-015. diff --git a/docs/adr/ADR-055-external-medical-literature-verifier.md b/docs/adr/ADR-055-external-medical-literature-verifier.md new file mode 100644 index 0000000..d11c8ac --- /dev/null +++ b/docs/adr/ADR-055-external-medical-literature-verifier.md @@ -0,0 +1,131 @@ +# ADR-055: External Medical-Literature Verifier (Live Citation Grounding) + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Extends**: ADR-006 (evidence tiering — Tier-3 definition unchanged), ADR-008 (verifier/critic — Pass 2 evidence-tier check) +**Related**: ADR-005 (grounding), ADR-033 (dashboard recommendations), ADR-052 (proof panel — surfaces the check) + +--- + +## Context + +ADR-006 defines Tier 3 as "peer-reviewed literature," cited with author/journal/year/ +DOI. ADR-008's Verifier Pass 2 checks that a Tier-3 claim *cites* a publication and +that the tier label is structurally appropriate — but it does not itself independently +retrieve and confirm that the cited work actually exists, says what the claim says, and +has not been retracted. An LLM can format a fake citation perfectly; structural +well-formedness is not existence. The "proactive daily specialist" push wants +recommendations to cite living literature, not just structurally-valid-looking +citations. + +**Grounding check (mandatory, via the ruvnet brain):** a named pattern for exactly this +already exists in the ruvnet ecosystem — `agentic-flow/examples/nova-medicina` +(author: ruv/ruvnet), described as "AI-powered medical analysis system with +anti-hallucination safeguards," whose `Verifier` class targets multi-source validation +against `pubmed`, `cochrane`, `uptodate`, and whose `Analyzer` documents "cross- +reference with PubMed, Cochrane, UpToDate." + +**However**, on inspection every method across `verifier.js`, `analyzer.js`, and +`provider-search.js` is an unimplemented stub: `// TODO: Implement multi-source +verification`, `// TODO: Query PubMed, Cochrane Library`, returning +`{ verified: false, confidence: 0.0, sources: [], citations: [] }` unconditionally. +**[C] inferred / design-intent only** — nova-medicina is a named pattern and scaffold, +not a working verification system. Helix cannot adopt its code; it can, honestly, +adopt the *shape* (named-source multi-source check, confidence threshold, +contradiction-detection hook) as a naming and design reference. + +A real, separately-grounded mechanism for live literature retrieval does exist in this +environment: the PubMed E-utilities API (reachable via a standard MCP PubMed +connector) provides live search, article metadata, and full-text retrieval — this, +not nova-medicina's stub, is the actually-workable integration point. + +## Decision + +**Add a live literature-grounding sub-step to the Verifier's existing Pass 2 (ADR-008): +Tier-3 claims must be checked against a real bibliographic source before being marked +Verified, not merely structurally validated.** + +1. **Additive to ADR-006/008, not a new tier.** Tier 3's definition is unchanged; this + ADR specifies *how* the Verifier confirms a Tier-3 citation is real rather than + model-fabricated. +2. **Reference integration: PubMed E-utilities**, as the MVP live lookup — confirms + the cited work exists, is not retracted, and its abstract is directionally + consistent with the claim. Cochrane/UpToDate-class sources are later additions, + pending licensing review (Open Questions). +3. **Name the pattern honestly.** Internal references to "the nova-medicina pattern" + describe the *shape* adopted from the named ruvnet example — never a dependency on + or reuse of its (stub) code. Helix's existing `helix-verifier` crate implements + this fresh. +4. **Currency re-check.** A previously literature-grounded Tier-3 claim whose source + is later retracted or superseded is caught on a periodic re-verification pass, the + same staleness pattern ADR-006 already applies to Tier-1 data, applied here to + literature currency. +5. **Failure is abstention, never silent downgrade.** If a citation cannot be + confirmed (network unavailable, DOI not found, source contradicts the claim), the + claim is dropped or downgraded to Tier-4-with-disclosure per ADR-006's existing + abstention design — never presented as a passed Tier-3 check that didn't actually + run. +6. **Surfaced in the Proof Panel (ADR-052).** A Tier-3 citation that passed live + literature-grounding shows a distinct "checked against PubMed" affirmation, visibly + different from a citation that only passed structural formatting checks. + +## Alternatives Considered + +- **Trust the model's parametric knowledge of the literature, no live retrieval.** + Rejected: this is exactly the "Correctness is not Faithfulness" failure mode ADR-005 + cites — a model can cite a plausible but non-existent or misremembered study with + high confidence. +- **Adopt nova-medicina's code directly as the verification backend.** Rejected: it is + a confirmed unimplemented stub; shipping it would mean a component that always + returns `verified: false, confidence: 0.0` — worse than no feature, because it looks + functional while silently failing every check. +- **Build a full custom biomedical literature index in-house before launch.** + Deferred, not rejected: a live PubMed lookup is a lower-effort, real, immediately + gradeable MVP. A curated in-house Tier-2/3 knowledge base (ADR-006 Open Question 3) + remains a future upgrade, not a blocker for this decision. + +## Consequences + +### Positive +- Tier-3 citations become independently checkable rather than trusted on formatting + or model memory alone — closes a real, previously-open gap. +- Honestly names a known ruvnet pattern without overclaiming its (unimplemented) code. + +### Negative +- Adds a network dependency (PubMed API) to Tier-3 verification, in tension with + Helix's local-first/on-device posture (ADR-013) — one of the few places Helix must + reach an external service. +- Adds latency to Tier-3-claim verification; retraction re-checks require a + background job, not just point-in-time verification. + +### Mitigations +| Risk | Mitigation | +|---|---| +| Network dependency vs. local-first | Scoped narrowly to citation verification only, not general retrieval — ADR-005's "retrieval from the user's own vault" rule stays intact for the primary answering path | +| Latency | Cache verified citations locally with a re-check TTL; don't re-hit the network per repeat citation | +| Lookup failure | Treated as abstention (degrade to Tier-4-with-disclosure), never a blocked response | + +## Open Questions + +1. Which sources beyond PubMed (Cochrane, UpToDate licensing) are viable at Helix's + budget/scale? +2. Re-verification cadence for previously-checked citations. +3. Does "checked against PubMed" expire alongside the citation's own currency window, + or is it permanent once confirmed? +4. Legal review of citing a licensed clinical reference (UpToDate) vs. open + bibliographic sources only for MVP. + +## References + +- `agentic-flow/examples/nova-medicina/src/{verifier,analyzer,index,provider-search}.js` + — confirmed named pattern, confirmed unimplemented stub **[C — design intent only]** +- Helix ADR-005, ADR-006, ADR-008, ADR-033, ADR-052 + +--- + +> Architectural/product guidance, not legal or medical advice. Literature-grounding +> strengthens citation integrity; it does not make Helix a source of clinical guidance +> and does not alter ADR-010's wellness/SaMD boundary. diff --git a/docs/adr/ADR-056-cognitum-seed-offline-knowledge-substrate.md b/docs/adr/ADR-056-cognitum-seed-offline-knowledge-substrate.md new file mode 100644 index 0000000..6890b93 --- /dev/null +++ b/docs/adr/ADR-056-cognitum-seed-offline-knowledge-substrate.md @@ -0,0 +1,136 @@ +# ADR-056: Cognitum Seed as Personal Offline Knowledge Substrate + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-014 (ambient sensing — orthogonal), ADR-020 (WiFi-CSI sensing — orthogonal), ADR-001 (vault), ADR-013 (on-device inference), ADR-011 (federation exclusions), ADR-026 (on-device LLM analyst), ADR-053 (witness-chained provenance) + +--- + +## Context + +ADR-014 and ADR-020 already decided the Cognitum Seed's role as an ambient *sensing* +device (mmWave + WiFi-CSI vitals). This ADR addresses a **different, orthogonal** role +of the same physical/software platform: the Seed as the user's private, offline +**knowledge substrate** — a place Helix's own dossier data (not sensor readings) can +be mirrored to and queried from, independent of whether ambient sensing is enabled at +all. A user who never opts into sensing (ADR-014/020 are opt-in) could still pair a +Seed purely as an offline KB appliance — for example, for a household member without a +phone, or as a local, never-cloud backup target. + +**Grounding check (mandatory, via the ruvnet brain) — confirmed [A], real and working, +not design-only:** +- **REST + MCP surface.** The Seed exposes a real HTTPS REST API (confirmed at + `https://:8443`, `https://cognitum.local:8443`, self-signed Device CA with + TOFU TLS pinning implemented in the `cognitum-seed-client` Rust crate — "Typed REST + + MCP client for Cognitum seed devices") alongside an MCP JSON-RPC tool surface wired + for direct use from an MCP-aware client. **[A]** +- **RVF store with real export/import.** The Seed persists an on-device RVF vector + store; `GET /api/v1/store/import/formats` and import endpoints exist, with a + dimension mismatch returning a real `409` — confirming RVF import/export is a + checked, working format on-device, not aspirational. **[A]** +- **Ed25519 identity.** `GET /api/v1/identity` returns a real per-device identity + (used for the TLS cert SAN and mesh pairing); paired Seeds "share vectors and witness + chains via delta sync" in a mesh — confirming both the identity primitive and that + witness-chained sync (the mechanism ADR-053 proposes for Helix's own answers) already + runs device-to-device on this platform. **[A]** +- **Genuinely offline-capable.** The Seed's own operator guide documents fully local + operation (USB/loopback trust-bypass, no internet required to pair or use the local + API; a dedicated `/api/v1/network/internet-check` endpoint exists precisely because + offline is a first-class supported state). **[A]** + +**What is not grounded:** Helix has no code today wiring its own dossier into a paired +Seed. This ADR proposes applying a real, working device capability to a new target — it +is not describing an existing Helix feature. + +## Decision + +**A paired Cognitum Seed is an optional, user-owned offline mirror/KB appliance for the +Helix dossier — a second storage tier, not a replacement for the primary vault, and +fully independent of the ambient-sensing decision.** + +1. **Primary vault stays authoritative.** The phone/desktop vault (ADR-001) is always + the source of truth; a paired Seed is an opt-in, eventually-consistent mirror. +2. **Export path.** The dossier (ProvRecords, evidence-tiered facts, the score + decomposition — never more raw data than the user already owns) exports to the + Seed's RVF store via the existing store-import REST endpoint, using the Seed's own + dimension/format check as a data-integrity gate — the confirmed 409-on-mismatch + behavior becomes Helix's protection against importing wrong-shaped data. +3. **Query path.** The Seed's MCP surface lets Helix's on-device LLM analyst + (ADR-026) — or, for advanced users, any MCP-aware client — query the offline KB + entirely on the local network, no cloud round trip, consistent with ADR-013's + on-device-first posture. +4. **No new auth model.** Identity and pairing follow the Seed's existing model + unchanged (Ed25519 device identity, USB/local-network pairing, bearer-token-per- + Seed) — Helix is a client of the existing `cognitum-seed-client` contract, not the + author of a parallel one. +5. **Explicitly orthogonal to ADR-014/020.** A user may adopt this KB role with + ambient sensing fully disabled, and vice versa. UI copy must never conflate the two + toggles — enabling one must not imply or require the other. +6. **Witness-chain carryover.** Because the Seed's mesh sync already carries witness + chains device-to-device, a Seed-hosted mirror is a natural, low-additional-effort + target for ADR-053's tamper-evident export trail — the same primitive, reused. + +## Alternatives Considered + +- **Sensing-only; don't use the Seed for KB purposes.** Rejected: leaves a real, + already-available hardware capability unused and forecloses the no-phone-household- + member use case that is cheaply available on the existing platform. +- **Build a separate, Helix-specific offline-KB appliance.** Rejected: duplicates + hardware/firmware investment the Cognitum Seed platform already provides. +- **Bundle the KB role with enabling ambient sensing.** Rejected: conflates two + genuinely separate decisions and would force a sensing opt-in on a user who only + wants offline storage — undermining the voluntariness of the sensing consent model + (ADR-014 Decision 7). + +## Consequences + +### Positive +- Reuses real, already-implemented device capability (REST+MCP, RVF import/export, + Ed25519 identity, offline operation) for a genuinely useful, orthogonal purpose. +- Strengthens local-first positioning: a Helix household can run entirely without any + cloud dependency, even for backup. +- Dovetails with ADR-053's witness-chain work at near-zero additional cost. + +### Negative +- Adds a second storage tier to reason about (mirror vs. primary vault + authoritativeness during conflicts). +- Risk of the exact sensing/KB conflation this ADR explicitly rejects, if UI copy is + careless. +- The Seed's firmware/OTA update cadence (ADR-014 Open Question 5) now also affects KB + availability, not just sensing accuracy. + +### Mitigations +| Risk | Mitigation | +|---|---| +| Authoritativeness conflicts | Primary vault (ADR-001) always wins; Seed mirror documented as best-effort/eventually-consistent only | +| Sensing/KB conflation | Two independent settings toggles, named and described separately in the UI | +| OTA availability risk | Reuse ADR-014's required staged-rollout/rollback policy; it now also covers KB uptime | + +## Open Questions + +1. Conflict-resolution policy when the Seed mirror and primary vault diverge. +2. Can multiple household members' dossiers coexist on one Seed's RVF store, or does + each person need a separate Seed? +3. How this interacts with ADR-011's federation exclusions — genomic/PII-sensitive + records should stay off any mesh-synced Seed by the same default that excludes them + from federation. + +## References + +- `cognitum-v0-appliance/crates/cognitum-seed-client/Cargo.toml` — "Typed REST + MCP + client for Cognitum seed devices" **[A]** +- `cognitum-seed/src/cognitum-agent/src/guide.html` — REST API at `:8443`, Ed25519 + identity (`/api/v1/identity`), RVF store import with 409-on-dimension-mismatch, mesh + vector + witness-chain delta sync, offline-first operation **[A]** +- `cognitum-v0-appliance/docs/adr/ADR-212-api-management-page.md` — confirms the MCP + tool surface and its JSON-RPC `tools/list`/`tools/call` contract **[A]** +- Helix ADR-001, ADR-013, ADR-014, ADR-020, ADR-011, ADR-053 + +--- + +> Architectural/product guidance, not legal or medical advice. This ADR decides a +> storage/knowledge topology; it does not decide or expand ambient sensing, which +> remains governed exclusively by ADR-014/020. diff --git a/docs/adr/ADR-057-privacy-tiered-model-routing.md b/docs/adr/ADR-057-privacy-tiered-model-routing.md new file mode 100644 index 0000000..4e225ef --- /dev/null +++ b/docs/adr/ADR-057-privacy-tiered-model-routing.md @@ -0,0 +1,393 @@ +# ADR-057: Privacy-Tiered Model Routing (Connected vs Sealed) + +**Status**: Proposed +**Date**: 2026-07-01 +**Project**: Helix — Personal Health Intelligence (PHI) +**Prepared by**: ISO Vision LLC +**Substrate**: Ruflo + RuVector + Cognitum Seed + MetaHarness/Darwin +**Related**: ADR-013, ADR-019, ADR-026, ADR-048, ADR-056 + +--- + +## Context + +### One seam, not two products + +Helix already has three pieces that this ADR must unify rather than duplicate: + +- **ADR-005/006/007** establish that the analytical work — retrieval, evidence tiering, + reference-range and trend math, red-flag detection — is deterministic and grounded, + independent of any model. A model never reasons or retrieves; it narrates. +- **ADR-026** shipped `helix-llm`: an on-device narrator behind an `LlmBackend` trait, + with a **number-guard** that rejects any output introducing a value not present in the + input facts, falling back to a deterministic template on rejection. +- **ADR-019** already designed a three-tier, learned (Tiny Dancer FastGRNN) cost/privacy + router with a non-negotiable quality-bar floor and consent-gated cloud escalation. + **ADR-013** already established on-device-preferred inference with the same escalation + pattern. + +What is missing is the **concrete hardware ladder** underneath ADR-019's Tier 1/2/3 +abstraction, and a **single user-facing switch** — not three technical tiers exposed to +the user, but two modes people can actually reason about: whether their data leaves the +device at all. This ADR names that ladder, decides the switch, and states a hard, +testable invariant for the "data never leaves" mode. + +### Grounding check for the Rung 0 (bare Seed) hardware ceiling + +**Confirmed, [A], from direct inspection of the Cognitum Seed platform:** + +- The Seed is a **Pi Zero 2 W**, 512 MB LPDDR2 RAM, Cortex-A53 @ 1 GHz — confirmed in + `cognitum-seed/docs/seed/ADR-094-pi-zero-sparse-llm-cog.md` and + `cognitum-seed/docs/cognitum-seed-specs.md`. The Cog Store's own promotional copy states + the operating envelope for its 90 edge apps: **max 3 concurrent cogs**, **~388 KB average + binary**, **~1.7 MB RAM per cog**, 100% local, written in Rust + (`cognitum-seed/scripts/cognitum/promo-video-script.md`, "KEY STATS FOR LOWER THIRDS"). +- **A general-purpose chat-LLM does not fit this envelope, and this has been measured, not + assumed.** ADR-094 evaluated model candidates for Pi Zero 2 W: `Phi-3-mini 3.8B Q4` and + `Llama-3.2-1B Q4` both **OOM**; only ultra-narrow sparse-attention micro-models + (`SmolLM2-135M Q4`, `Qwen2.5-0.5B Q4`) are viable, and only as a single dedicated cog, not + alongside anything else. **ADR-096 measured the result on live hardware**: a real + `SmolLM2-135M-Instruct-Q4_K_M.gguf` loaded and generated coherent tokens, at + **~0.08–0.14 tok/s** baseline (a 12-token completion took **83 seconds**); the + optimization target after two tranches of work is only **3–10 tok/s**. Even at the + stretch target, a several-sentence grounded narration would take tens of seconds to + minutes, from a 135M-parameter model whose fluency is far below what ADR-026's narrator + role needs. **[A]** +- Conclusion, stated honestly: it is not strictly true that *no* transformer of any size + can execute on Rung 0 — a single-purpose, non-conversational 135M–0.5B sparse-attention + model can, per ADR-094/096. It *is* true that no general-purpose, responsive **chat-LLM + narrator** is viable there. Rung 0 therefore runs the deterministic pipeline plus a + **classification-only** micro-model, not a generative one. +- **The micro-model that does fit** is a **FastGRNN classifier** (Tiny Dancer) — already + real and wired on the Seed platform, not proposed: `cognitum-seed/src/cognitum-agent/src/ + neural_router.rs` implements "Tiny Dancer FastGRNN routing integration" and is used today + to decide whether an incoming task/signal warrants further processing. **[A]** for + FastGRNN/Tiny Dancer specifically. The `ruv-fann` crate name used loosely for "the + ruvnet micro-net family" is **not** independently confirmed as a Seed dependency in this + scan — grade **[C]** for that specific crate name; the decision below routes on FastGRNN, + which is confirmed. + +### Grounding check for Rung 1 (local node) and its honest quality gap + +**[B, Proposed]** `agent-harness-generator/docs/adrs/ADR-150-tailscale-local-frontier- +concurrent-benchmarks.md` establishes the exact pattern Rung 1 needs: a bigger local box +the user owns (Mac Studio/mini, or a Pi-5-class appliance) runs `ruvllm serve` / Ollama / +llama.cpp, bound to a Tailscale (or LAN) address, exposing an OpenAI-compatible +`/v1/chat/completions` endpoint — "over Tailscale it looks exactly like the OpenAI API... +but free and air-gapped." A `Qwen2.5-Coder-32B` GGUF fits in 48 GB unified memory alongside +other workloads with Metal acceleration. + +**The same ADR is the source of the honest caveat this decision must carry**: measured +results (SWE-bench code-repair, not a health benchmark) show local 7B and 14B models +resolving at **~¼–⅓ of the hosted-frontier rate** — `qwen2.5-coder:14b` + repair scored +**6.7%** against hosted `deepseek` at **29.3%** on the same harness and corpus (full-300 +SWE-bench Lite); an earlier stratified sample put local 7B at "**~⅓–¼**" of the hosted +rate. ADR-150's own conclusion: **"the model is the binding constraint,"** not the harness. +**[B]** — this is a coding-benchmark measurement, not a health-narration measurement, and +is cited here as a directional signal about the local-vs-frontier capability gap, not as a +Helix-specific quality number. It is the honest reason Rung 1 is *capability-limited* +relative to Rung 2, even though it is architecturally equivalent (same OpenAI-compatible +seam, zero third-party egress either way). + +### Grounding check for the routing seam itself + +- **Helix side, [A], directly inspected**: `crates/helix-llm/src/lib.rs` ships a tested + `LlmBackend` trait, a `LocalLlmBackend` with `ruvllm()` (default, + `http://127.0.0.1:8080/v1`, `Qwen/Qwen2.5-3B-Instruct`) and `ollama()` + (`http://127.0.0.1:11434/v1`) presets, and a passing number-guard test suite (5 unit + tests, `cargo test -p helix-llm`). **No non-local backend exists in code today, and a + workspace-wide grep confirms zero callers outside the crate itself** — `helix-llm` is + built and tested in isolation; nothing in the Helix pipeline invokes `compose()` yet. + This ADR is **design-intent**, not a description of a wired feature — graded honestly, + matching the discipline ADR-150 itself models ("no number is claimed until a real served + run exists"). +- **ADR-019 (this repo)** already specifies the abstraction this ADR's ladder slots under: + a three-tier FastGRNN router (Tiny Dancer) with a hardcoded, Darwin-immutable floor — + `quality_bar` cannot be lowered below the grounding minimum, and consent + AIDefence PII + gating wrap every cloud call. +- **[B, Proposed]** `agentic-flow/docs/adr/ADR-073-metaharness-router-cost-optimal-model- + routing.md`: `agentic-flow`'s `ModelRouter` (`src/router/router.ts`) already picks + providers/models across **anthropic / openrouter / onnx / gemini / ollama** by static + config rules today, and ADR-073 proposes layering a cost-optimal, quality-bar-gated mode + on top via `@metaharness/router` / `@ruvector/tiny-dancer` (already a dependency). +- **[A], shipped and read directly**: `open-claude-code/v2/src/optimize/router.mjs` is a + live, zero-dependency cost-cascade router already in production use elsewhere in the + stack. Its `DEFAULT_LADDER` (`claude-haiku-4-5` → `claude-sonnet-4-6` → `claude-opus-4-6`, + cheapest → most capable) and its deterministic `estimateComplexity()` heuristic are the + concrete precedent for "cheapest model that clears a quality bar, escalate on failure" — + the same thesis ADR-019 already adopted for Helix via Tiny Dancer. +- **ADR-056 (this repo)** confirms a paired Cognitum Seed exposes a real REST+MCP surface + (`https://cognitum.local:8443`) reachable over the local network with no cloud round + trip — a plausible auto-discovery target for detecting a Rung-1 local node, reusing an + already-implemented capability rather than inventing new pairing/discovery machinery. +- **ADR-048 (this repo)** already requires AIMDS/AIDefence at every inbound/outbound LLM + surface. Any Connected-mode (Rung 2) call is a cloud escalation in ADR-013/019's sense + and must pass through the same gates: consent, PII-strip, audit log. + +--- + +## Decision + +**Helix exposes exactly two user-facing modes — Connected and Sealed — over one +model-routing seam. In both modes, all analytical work (retrieval, evidence tiering, +reference ranges, trend math, red-flag escalation) stays deterministic and local per +ADR-005/006/007; the model's only job, in every mode, is to narrate already-grounded +facts, bounded by helix-llm's number-guard (ADR-026).** + +Underneath the two user-facing modes is a three-rung **privacy-tiered ladder**. Capability +rises and privacy falls as the ladder climbs; a hard egress gate protects the bottom two +rungs. + +### The ladder + +| Rung | Name | What runs | Egress | Narrator quality | +|---|---|---|---|---| +| **0** | Sealed / bare Seed | Deterministic engine + FastGRNN (Tiny Dancer) classification-only micro-model. **No chat-LLM.** | **Zero.** Pi Zero 2 W (512 MB RAM, max 3 concurrent cogs) cannot host a responsive general-purpose transformer — measured, not assumed (ADR-094/096: best-case micro-LLM candidate manages ~0.1–10 tok/s). | Grounded, cited, structured findings + templated language only. | +| **1** | Sealed / local node | A real local LLM (7–32B GGUF via Ollama / ruvLLM / llama.cpp) on hardware the user owns — a Pi-5-class appliance or their own Mac — exposed OpenAI-compatibly over LAN/Tailscale. | **Zero third-party egress.** Traffic never leaves the user's own network/tailnet. | Real generative narration; honestly ~¼–⅓ of frontier quality on current 7–14B local models per the measured coding-benchmark signal (ADR-150) — a directional caveat, not a health-specific number. | +| **2** | Connected | A frontier model (e.g. Claude Opus 4.8) via provider API, for maximum capability. | The grounded prompt leaves the device. | Highest quality narration/reasoning-adjacent phrasing available. | + +**User-facing surface is exactly two switches, not three:** + +- **Sealed** — auto-selects the best rung the user's *available* hardware can host: Rung 1 + if a local LLM node is detected (paired Cognitum Seed running a local model, or a + user-declared local endpoint), Rung 0 otherwise. The user never manually picks 0 vs 1; + Helix probes for a local node and falls back gracefully. +- **Connected** — Rung 2. Requires the same consent + PII-minimization + AIDefence gate + already specified in ADR-013/019/048 before any prompt leaves the device. + +Exposing the three technical rungs directly to the user was considered and rejected (see +Alternatives) — the ladder is an internal routing concept; the product surface is binary. + +### The hard Sealed-mode egress invariant + +**In Sealed mode, the model endpoint MUST resolve to a local address. Any non-local +endpoint is refused.** This is the privacy guarantee, and it must be testable, not +advisory: + +- The backend selected while in Sealed mode is checked against an allowlist before every + call: `127.0.0.1` / `localhost`, RFC 1918 private ranges, the Tailscale CGNAT range + (`100.64.0.0/10`), and mDNS `.local` names resolved and re-checked against the same + ranges (guards against DNS rebinding to a public IP under a `.local`-looking name). +- A configured base URL that does not resolve into this allowlist is rejected at + construction time — Sealed mode does not silently fall through to Connected; it refuses + and falls back further down the ladder (to Rung 0's deterministic template) rather than + ever calling out. +- This mirrors ADR-013's framing exactly: an architectural guarantee, not a contractual + one. "Sealed" must mean the code cannot dial out, the same way ADR-013 argues a BAA is a + weaker guarantee than a system that structurally cannot transmit. +- The check lives in the routing seam (below), not in each caller — one enforcement point, + testable in isolation (unit tests can assert `is_local_endpoint("https://api.anthropic. + com/v1")` → `false`, `is_local_endpoint("http://127.0.0.1:8080/v1")` → `true`, + `is_local_endpoint("http://100.x.x.x:11434/v1")` → `true`). + +### The routing seam + +The seam is `helix-llm`'s `LlmBackend` trait (ADR-026), extended — not replaced — with a +mode-aware selector, backed by the same primitives ADR-019 already named: + +1. **`SealedBackend`** — wraps today's `LocalLlmBackend` (already shipped, tested), adding + the egress allowlist check described above. Auto-detection between Rung 0 and Rung 1: + probe for a reachable local OpenAI-compatible endpoint (a paired Cognitum Seed's + `cognitum.local:8443` MCP surface per ADR-056, or a user-configured local URL); on + success, narrate via Rung 1; on failure/timeout, fall back to Rung 0 — deterministic + template + FastGRNN-classified structured findings, no generative call at all. +2. **`ConnectedBackend`** — a new implementation of the same `LlmBackend` trait, routed + through ADR-019's Tier 3 path: explicit per-call-type consent, AIDefence PII-strip + (ADR-048), audit log entry, then the frontier provider call. +3. **Mode selection** picks which backend implementation `compose()` (ADR-026) receives. + The number-guard, the deterministic-facts-in contract, and the system prompt are + **identical across all three rungs and both modes** — only the backend changes. This is + what makes it one seam: nothing about grounding, retrieval, or the anti-hallucination + pipeline varies by rung. +4. **ADR-019's learned FastGRNN router** governs *within* Connected mode which cloud tier + (Tier 2 small model vs Tier 3 frontier) a given task escalates to; this ADR does not + change that policy — it adds the Sealed/Connected product-level switch and the Rung 0/1 + hardware-detection layer beneath ADR-019's Tier 1. + +### Configuration sketch (illustrative, not yet implemented) + +```json +{ + "user_mode": "sealed", + "sealed_allowlist": ["127.0.0.1", "localhost", "10.0.0.0/8", "172.16.0.0/12", + "192.168.0.0/16", "100.64.0.0/10", "*.local"], + "sealed_rung1_probe": { "endpoints": ["cognitum.local:8443", "user_configured"], + "timeout_ms": 500 }, + "connected_requires": ["consent_per_call_type", "aidefence_pii_gate", "audit_log"], + "rung0_model": "fastgrnn-classifier (tiny-dancer)", + "rung1_models": ["qwen2.5-coder:14b", "qwen2.5-coder-32b (Mac, 48GB unified)"], + "rung2_model": "claude-opus-4-8" +} +``` + +--- + +## Alternatives Considered + +### Alternative 1: Two separate products/codebases (air-gapped SKU, cloud SKU) + +Ship a fully air-gapped build and a separate cloud-enabled build. Rejected: doubles +engineering and QA surface for no privacy benefit over a single seam with a hard mode +switch; defeats the "one codebase, one seam" simplicity ADR-019/026 already established. +A single binary with a testable invariant is a stronger, cheaper guarantee than two +binaries a user has to trust were built correctly. + +### Alternative 2: Expose all three rungs to the user directly + +Let the user manually pick Rung 0/1/2 instead of Sealed/Connected. Rejected: the rung a +device can reach is a hardware fact, not a preference — most users cannot usefully reason +about "does my Seed have a paired local LLM node." Sealed auto-selecting the best rung +available already matches ADR-013's "profile the device, auto-select the best model +variant" pattern. Power users retain visibility (the active rung is always shown in the +UI), but the *decision* is automatic. + +### Alternative 3: Soft/advisory local-only preference in Sealed mode (no hard gate) + +Treat "Sealed" as a UI preference that biases routing toward local models without a code- +level refusal of non-local endpoints. Rejected: this is exactly the "contractual, not +architectural" guarantee ADR-013 argues is weaker. A misconfiguration (wrong base URL, +a Rung-1 probe that returns a public address) would silently leak health context with no +error. The hard allowlist check makes the guarantee testable and fails closed. + +### Alternative 4: Sealed mode never detects Rung 1; always Rung 0 only + +Simplify by making Sealed mean only the bare-Seed rung, requiring a separate manual step +to use a local LLM node. Rejected: this throws away real, zero-additional-privacy-cost +capability that a user's own hardware (Mac, Pi-5 appliance) can provide. The entire point +of a three-rung ladder under a two-mode switch is that Sealed should get the best +available quality without the user manually managing hardware detection. + +--- + +## Consequences + +### Positive + +- **One seam, testable privacy.** A single `LlmBackend` extension point (ADR-026) serves + all three rungs and both user modes; the privacy guarantee is a unit-testable allowlist + check, not a design intention scattered across callers. +- **Never worse than deterministic.** Rung 0's total absence of a chat-LLM does not + degrade safety — the deterministic pipeline (ADR-005/006/007) and the number-guard + (ADR-026) already make the model an optional narrator, not a required reasoner. A user + on a bare Seed still gets grounded, cited, structured findings. +- **Honest capability ladder.** Naming the Rung 1 quality gap explicitly (¼–⅓ of frontier, + from a real measurement, even if from a different benchmark domain) prevents Helix from + overselling "local LLM = same quality as cloud." +- **Reuses real, already-built capability.** Cognitum Seed's REST/MCP discovery (ADR-056), + the Tiny Dancer FastGRNN router (ADR-019, and confirmed live on the Seed via + `neural_router.rs`), and `helix-llm`'s existing trait/number-guard are all real + components this decision arranges rather than reinvents. +- **Consistent user mental model.** "Sealed" and "Connected" map directly to a single + question — does health data ever leave this device — which is the question users + actually have, rather than a three-way technical choice they cannot evaluate. + +### Negative + +- **helix-llm has no cloud backend and no callers yet.** This ADR is design-intent: the + `ConnectedBackend` implementation, the mode-selection layer, the egress allowlist check, + and the Rung-1 auto-probe are all unbuilt. Until then, helix-llm remains an isolated, + tested crate that nothing in the pipeline invokes. +- **Rung 0 has zero generative narration.** Users on a bare Seed with no phone or local + node get templated language only — a real capability gap versus Rung 1/2, not just a + privacy tradeoff. This must be communicated honestly in-product, not glossed over. +- **Rung 1 quality gap is real and only loosely characterized for health tasks.** The + ¼–⅓ figure comes from a code-repair benchmark, not health narration. Helix's own + eval set (once it exists, per ADR-018/019) is needed before this ladder can carry a + health-specific quality number instead of a borrowed directional one. +- **Auto-detection adds a failure surface.** Probing for a Rung-1 local node (timeout, + false negative on a slow-to-respond Seed, false positive on a stale cached address) is + new logic that did not exist before; a broken probe could silently strand a user on + Rung 0 when a local node is actually available, or (worse, if the allowlist check has a + bug) treat a non-local address as local. +- **Egress allowlist correctness is safety-critical.** A bug in the allowlist (missing a + CGNAT range, mishandling a `.local` name that actually resolves publicly, a DNS-rebinding + gap) directly breaks the one invariant this ADR exists to guarantee. + +### Mitigations + +| Risk | Mitigation | +|---|---| +| No cloud backend / no callers yet | Track as explicit implementation debt; this ADR gates on ADR-019's existing consent/PII/audit machinery being wired before `ConnectedBackend` ships | +| Rung 0 has no generative narration | UI states plainly which rung is active and what that means ("structured findings only — no local LLM node detected") | +| Rung 1 quality gap unmeasured for health tasks | Do not claim health-specific quality parity; carry the coding-benchmark figure as a caveat only until a Helix eval set exists (ADR-018/019 dependency) | +| Rung-1 probe failure surface | Fail closed to Rung 0 (never to Connected) on any probe ambiguity; log probe outcome for diagnosis | +| Egress allowlist bugs | Allowlist logic isolated in one function with its own unit test suite (loopback, RFC1918, CGNAT, `.local`, and known-bad public addresses as explicit negative cases); code review requirement before any change to the allowlist | + +--- + +## Open Questions + +1. **Egress-gate implementation specifics.** Exact allowlist ranges, `.local`/mDNS + resolution-then-recheck order, and DNS-rebinding defenses need a concrete design before + `SealedBackend` ships — this ADR states the invariant, not the final regex/parser. + +2. **Rung-1 auto-detection protocol.** Probe order and timeout between a paired Cognitum + Seed's MCP surface (ADR-056) and a user-declared local endpoint; how false negatives + are surfaced to the user versus silently retried. + +3. **Minimum viable Rung-1 model.** Given ADR-150's honest ¼–⅓ gap on 7–14B local models, + is there a floor model size/quant below which Rung 1 should not be offered at all + (falling back to Rung 0 instead of shipping a narration quality users will distrust)? + +4. **Connected-mode consent granularity.** Per-call-type versus standing consent for Rung 2 + is ADR-019 Open Question 4's territory; this ADR should inherit whatever that resolves + to rather than deciding it independently. + +5. **`ruv-fann` vs. Tiny Dancer FastGRNN naming.** This ADR routes Rung 0's micro-model + decision through the confirmed FastGRNN/Tiny Dancer integration (`neural_router.rs`); + whether a distinct `ruv-fann` crate is also intended for Rung 0 was not confirmed in + this scan and is graded **[C]** pending verification — do not build against that name + without re-checking. + +6. **Helix-specific quality benchmark.** When Helix's own health-narration eval set exists + (ADR-018 gating condition), replace the borrowed coding-benchmark quality-gap figure + with a measured Helix number for Rung 1 vs Rung 2. + +--- + +## References + +- `crates/helix-llm/src/lib.rs`, `crates/helix-llm/Cargo.toml` — `LlmBackend` trait, + `LocalLlmBackend` (ruvLLM default / Ollama fallback), number-guard, 5 passing unit + tests; confirmed zero external callers in the workspace. **[A]** +- Helix ADR-005, ADR-006, ADR-007 — deterministic, grounded pipeline the model never + bypasses. **[A]** (local ADR) +- Helix ADR-013 — on-device-preferred inference, consent-gated cloud escalation, + architectural (not contractual) privacy guarantee. **[A]** (local ADR) +- Helix ADR-019 — three-tier learned (Tiny Dancer FastGRNN) cost/privacy router, + Darwin-immutable quality-bar floor, consent + PII-gate for cloud escalation. **[A]** + (local ADR) +- Helix ADR-026 — `helix-llm` on-device narrator, number-guard, LocalLlmBackend + ruvLLM/ollama presets. **[A]** (local ADR) +- Helix ADR-048 — AIMDS/AIDefence at every inbound/outbound LLM surface. **[A]** (local ADR) +- Helix ADR-056 — Cognitum Seed REST+MCP surface (`cognitum.local:8443`), confirmed real + and offline-capable; candidate Rung-1 discovery target. **[A]** (local ADR) +- `cognitum-seed/scripts/cognitum/promo-video-script.md` ("KEY STATS FOR LOWER THIRDS") — + Pi Zero 2 W, max 3 concurrent cogs, ~388 KB avg binary, ~1.7 MB RAM per cog, 100% local, + Rust. **[A]** +- `cognitum-seed/docs/seed/ADR-094-pi-zero-sparse-llm-cog.md` — Pi Zero 2 W hardware + envelope (512 MB RAM), model-viability matrix (Phi-3-mini 3.8B Q4 / Llama-3.2-1B Q4 OOM; + SmolLM2-135M / Qwen2.5-0.5B Q4 marginally viable as a single dedicated cog). **[A]** +- `cognitum-seed/docs/seed/ADR-096-pi-zero-sparse-llm-throughput-optimization.md` — + measured live-hardware result: SmolLM2-135M Q4_K_M at ~0.08–0.14 tok/s baseline, + 3–10 tok/s optimization target. **[A]** +- `cognitum-seed/src/cognitum-agent/src/neural_router.rs` — Tiny Dancer FastGRNN routing, + confirmed real and wired on the Seed agent. **[A]** +- `agent-harness-generator/docs/adrs/ADR-150-tailscale-local-frontier-concurrent- + benchmarks.md` — local-frontier-over-Tailscale pattern (OpenAI-compatible, zero egress); + measured local 7B/14B resolve-rate at ~¼–⅓ of hosted frontier on SWE-bench (coding + benchmark, cited here as a directional signal, not a health-specific number). **[B, + Proposed]** +- `agentic-flow/docs/adr/ADR-073-metaharness-router-cost-optimal-model-routing.md` — + `ModelRouter` across anthropic/openrouter/onnx/gemini/ollama; proposed cost-optimal, + quality-bar-gated layer via `@metaharness/router`/`@ruvector/tiny-dancer`. **[B, + Proposed]** +- `open-claude-code/v2/src/optimize/router.mjs` — shipped, zero-dependency cost-cascade + router; `DEFAULT_LADDER` (haiku-4-5 → sonnet-4-6 → opus-4-6), deterministic complexity + heuristic. **[A]** + +--- + +*This document provides architectural guidance, not legal, regulatory, or medical advice. +Helix is a decision-support tool, not a diagnostic authority. The model, at every rung of +this ladder, narrates already-grounded facts; it never diagnoses, recommends, or invents.* diff --git a/docs/adr/README.md b/docs/adr/README.md index 386b1cb..64d5ad2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,7 +35,7 @@ The product spec these decisions implement is [`../Helix-PHI-ADR-Product-Spec.md | [012](ADR-012-connector-abstraction-graceful-degradation.md) | Connector Abstraction with Graceful Degradation | Platform / ingestion | | [013](ADR-013-on-device-inference.md) | On-Device Inference Where Feasible | Privacy / compute | | [014](ADR-014-ambient-sensing-cognitum-seed.md) | Ambient Passive Sensing via the Cognitum Seed (mmWave) | Sensing | -| [015](ADR-015-visual-3d-digital-twin.md) | Visual Health-Intelligence Layer (3D Anatomical Digital Twin) | Visual | +| [015](ADR-015-visual-3d-digital-twin.md) | Visual Health-Intelligence Layer (3D Anatomical Digital Twin) | Visual — superseded in part by ADR-054 | | [016](ADR-016-composite-health-score.md) | Composite 0–100 Health Score — Transparent, Decomposable | Visual | | [017](ADR-017-mint-branded-harness-metaharness.md) | Mint Helix as a Branded Harness via MetaHarness | Self-optimization | | [018](ADR-018-darwin-mode-faithfulness-fitness.md) | Darwin Mode Self-Optimization with Faithfulness Fitness | Self-optimization | @@ -57,9 +57,33 @@ The product spec these decisions implement is [`../Helix-PHI-ADR-Product-Spec.md | [034](ADR-034-biological-age-estimate.md) | Biological / Medical Age Estimate from Routine Labs | Dashboard / biomarkers | | [035](ADR-035-darwin-parameter-evolution.md) | Darwin-Style Parameter Evolution (safety-frozen) | Self-optimization / safety | | [036](ADR-036-scale-invariant-trend-band.md) | Scale-Invariant (Reference-Range-Relative) Trend Dead-Band | Accuracy / numerics | +| [045](ADR-045-encrypted-credential-vault-zero-knowledge.md) | Encrypted Credential Vault (Zero-Knowledge) | Security / credentials | +| [046](ADR-046-agentic-browser-data-acquisition.md) | Agentic-Browser Data Acquisition for No-API Sources | Ingestion / integration | +| [047](ADR-047-single-tenant-local-first-topology.md) | Single-Tenant, Local-First Product Topology | Platform / topology | +| [048](ADR-048-aimds-guardrails-pull-analyst-surface.md) | AIMDS/AIDefence Guardrails on the Pull + Analyst Surface | Security / anti-hallucination | +| [049](ADR-049-scheduled-per-source-pull-cadences.md) | Scheduled Per-Source Pull Cadences with Auto-Refresh | Ingestion / scheduling | +| [050](ADR-050-design-system-visual-language.md) | Design System & Visual Language | Visual / design system | +| [051](ADR-051-adaptive-application-shell.md) | Adaptive Application Shell (Tauri v2 Desktop + PWA Mobile over One WASM Core) | Platform / shell | +| [052](ADR-052-proof-reasoning-trace-ux.md) | Proof / Reasoning-Trace UX | UX / anti-hallucination | +| [053](ADR-053-witness-chained-answer-provenance.md) | Witness-Chained Answer Provenance | Provenance / security | +| [054](ADR-054-realtime-webgl-digital-twin.md) | Real-Time WebGL Digital Twin — Live Binding & Adaptive LOD | Visual — supersedes ADR-015 | +| [055](ADR-055-external-medical-literature-verifier.md) | External Medical-Literature Verifier (Live Citation Grounding) | Anti-hallucination / literature | +| [056](ADR-056-cognitum-seed-offline-knowledge-substrate.md) | Cognitum Seed as Personal Offline Knowledge Substrate | Knowledge substrate / integration | +| [057](ADR-057-privacy-tiered-model-routing.md) | Privacy-Tiered Model Routing (Connected vs Sealed) | Model routing / privacy | ## Status -35 Proposed + ADR-036 Accepted (v1.0.0). They are derived from the v1.0.0 product spec and -grounded by multi-source research; they have not yet been ratified against an -implementation or reviewed by regulatory counsel / a clinical advisory board. +48 Proposed + ADR-036 Accepted (v1.0.0). ADR-015 is proposed and intact, with its render +behavior partially superseded by ADR-054 (see the note at the top of ADR-015). They are +derived from the v1.0.0 product spec and grounded by multi-source research; they have not +yet been ratified against an implementation or reviewed by regulatory counsel / a +clinical advisory board. + +**Numbering gap (037–044).** ADR-037 through ADR-044 are referenced by later ADRs (e.g. +ADR-045 → ADR-037 "persistent vault"; ADR-046 → ADR-041 "connector clients") and are +described in [`../Helix-Build-Spec.md`](../Helix-Build-Spec.md) (the persistent +encrypted vault, the holistic `ProvRecord` event-map/graph, the Lose It connector, the +proactive daily-briefing engine, and local-embeddings-via-RVF) — but have not yet been +filed as standalone ADR files in this directory. The gap is intentional and documented +here rather than filled with placeholder files; do not fabricate ADR-037–044 documents +without doing the underlying design work first. diff --git a/docs/design/mockups/README.md b/docs/design/mockups/README.md new file mode 100644 index 0000000..3ce3690 --- /dev/null +++ b/docs/design/mockups/README.md @@ -0,0 +1,51 @@ +# Helix UI — Design Directions & Chosen Visual Language + +Concept exploration for Helix's world-class interface. These mockups implement the +decisions in [ADR-050 (Design System & Visual Language)](../../adr/ADR-050-design-system-visual-language.md), +[ADR-052 (Proof / Reasoning-Trace UX)](../../adr/ADR-052-proof-reasoning-trace-ux.md), +and [ADR-054 (Real-Time WebGL Digital Twin)](../../adr/ADR-054-realtime-webgl-digital-twin.md). + +Each file is a **single self-contained HTML** (inline CSS/JS; CDNs only for fonts + +three.js). Open any of them directly in a browser — no build step. + +> **Synthetic data only.** Every mockup renders the same fictional persona **"Alex Rivera, 45"** +> (composite score 74/100). There is **no real PHI** in this directory — it is safe for the +> public repo. Real health data never leaves the user's device (ADR-001, ADR-047). + +## Three explorations + +| File | Direction | Type pairing | Character | +|---|---|---|---| +| [`direction-a-clinical-aurora.html`](direction-a-clinical-aurora.html) | **Clinical Aurora** | Fraunces + Schibsted Grotesk | Refined luxury-medical — obsidian stage lit by a bioluminescent teal→green helix aurora, frosted glass, generous negative space. Premium and trustworthy. | +| [`direction-b-bio-instrument.html`](direction-b-bio-instrument.html) | **Bio-Instrument** | JetBrains Mono + Chakra Petch | Industrial diagnostic console — near-black, telemetry grid, oscilloscope vitals, amber/lime signal accents. The most distinctive/memorable; reads technical. | +| [`direction-c-living-twin.html`](direction-c-living-twin.html) | **Living Twin** | Bricolage Grotesque + Hanken Grotesk | Warm-dark organic wellness — a breathing helix, tactile rounded cards, encouraging tone. The most human. | + +All three share: a real **three.js double-helix twin** (amber strand = your *data*, green/lime = +the *intelligence*) with an SVG/Canvas fallback and `prefers-reduced-motion` support; an +animated **composite Health Score ring** decomposed into six sub-scores; **nudge cards** whose +core feature is an expandable **proof trail** (own-data source → observed value → evidence tier +→ cited reference → non-diagnostic action); and a **90-day holistic event map**. + +## Chosen direction — the Hybrid + +**[`direction-final-hybrid.html`](direction-final-hybrid.html)** is the canonical reference the +production UI is built from. + +- **Frame / shell = Clinical Aurora** — the premium, credible obsidian stage, aurora mesh, and + glowing three.js twin. Establishes trust for a health-intelligence product. +- **Coaching / nudge surfaces = Living Twin warmth** — the daily "what to do today" moments use + warmer, softer, encouraging cards so the everyday companion feels human, not clinical. +- **Bio-Instrument density = optional "deep-dive" toggle** — the dense telemetry view is + available for power users but is *not* the default; the everyday view stays calm. + +**Why this hybrid:** Helix must be *both* a trustworthy functional-medicine specialist *and* a +warm daily companion "anybody" can use. Aurora carries the credibility; Twin carries the daily +encouragement; the Instrument view serves the quantified-self power user without imposing its +density on everyone. In every view, the **proof trail stays the centerpiece** — the +anti-hallucination promise (ADR-005/006/052) made visible. + +## Status + +Concept mockups, **not yet wired to live data**. Next step: integrate the hybrid into the real +app shell (`ui/`, ADR-051) driven by the `helix-wasm` exports and the synthetic demo dossier, +then the same shell adapts to Tauri (desktop) and PWA (mobile). diff --git a/docs/design/mockups/direction-a-clinical-aurora.html b/docs/design/mockups/direction-a-clinical-aurora.html new file mode 100644 index 0000000..c8307dd --- /dev/null +++ b/docs/design/mockups/direction-a-clinical-aurora.html @@ -0,0 +1,975 @@ + + + + + +Helix — Personal Health Intelligence · Clinical Aurora + + + + + + + + + + + + + + +
+ + Sample · Synthetic demo data — public-repo illustration, not real PHI + +
+ +
+
+ +
+
+ +
+ +
+
+
+ Two strands · one story +

Good morning,
Alex.

+

Your data and Helix's intelligence, intertwined. Every signal below shows its work and cites its source — nothing asserted, everything traced.

+
+
+ Biological age + 47 · chrono 45 · improving +
+
+ Composite today + 74 · ▲ 6 vs. last +
+
+ Data sources + 6 · all on-device +
+
+
+ +
+ + + + Live twin +
+ Your digital twin +
+ Data strand + Intelligence strand +
+
+
+
+
+ + +
+
+
+
+ Composite health score +

A single number, fully decomposable.

+
+

Tap any domain to project it onto the ring. The composite is a weighted blend — never a black box.

+
+ +
+ + +
+ + + Six domains · weighted blend · updated from your own sources + +
+
+
+
+
+ + +
+
+
+
+ Today's signals +

Four nudges, each with a proof trail.

+
+

Open Why? on any card to see the exact data, the evidence tier, and the reference behind it.

+
+
+
+
+ + +
+
+
+
+ Holistic event map +

Ninety days, every strand aligned.

+
+

+ + Labs · Supplements · Sleep · Body · Workouts — merged locally +

+
+ +
+
+ +
+ 90-day timeline + Apr → Jul 2026 +
+
+ +
AprMayJunJul
+
+
+
+ Lab draw + Supplement + Sleep quality + Body composition + Workout +
+
+
+
+
+ + +
+
+
+
+ Private by design +

Helix runs on your device — and shows its work.

+

Your records never leave your machine unless you choose. There is no cloud account, no data broker, no silent training. Every insight is traceable to a source you can inspect, and Helix will decline to answer rather than guess.

+
+
+
On-device — Tauri desktop + PWA mobile, encrypted at rest
+
Anti-hallucination — no citation, no claim
+
Non-diagnostic — wellness guidance, always your call
+
+
+
+ + + Helix — ISO Vision LLC + + Concept mockup · Direction A — “Clinical Aurora” · Sample data only +
+
+
+ + + + + diff --git a/docs/design/mockups/direction-b-bio-instrument.html b/docs/design/mockups/direction-b-bio-instrument.html new file mode 100644 index 0000000..28b9768 --- /dev/null +++ b/docs/design/mockups/direction-b-bio-instrument.html @@ -0,0 +1,1203 @@ + + + + + + +HELIX — Bio-Instrument Console // SAMPLE + + + + + + + + + + +
+
+ + +
+
+
HELIX
+
bio-instrument // personal health intelligence · iso vision llc
+
+
+
SCANNING 000%ON-DEVICE · NO CLOUD LINK
+
+
+ + +
+ + +
+ + SAMPLE // SYNTHETIC DATA + · + PUBLIC-REPO DEMO — NOT REAL PHI + · + PERSONA: ALEX RIVERA / 45 + --:--:-- UTC +
+ + +
+
+ +
HELIXPHI · v0.1.2
+
+ +
+
VaultSEALED
+
Modelhelix-local · 4B
+
SessionAR-0000
+
+
+ + +
+
+ SYS nominal · ▲ composite +6 (30d) · 1,284 own-data points indexed · deep-sleep −18% (7d) · ferritin recovering 22→29 ng/mL · LDL 135→128 mg/dL · body-fat 26.0→23.5% · bio-age 47 (chrono 45) · inference on-device · cloud link SEVERED · proof-trails signed ✓ ·  +
+
+ + +
+ + +
+
[HERO/01] DIGITAL TWIN · DOUBLE-HELIX RENDER DATA ⟂ INTELLIGENCE
+
+
+ +
+ + +
+
WIREFRAME · POINT-CLOUD · 60FPS
+
+ RENDER THREE.js
PARTICLES 2,048
ROT 0.14 rad/s +
+
+ STRAND-A ■ YOUR DATA — 1,284 signals
+ STRAND-B ■ INTELLIGENCE — cited inferences
+ // intertwined · every claim shows its work +
+
+
+
+
HR · bpm61
+
+
+
+
HRV · ms58
+
+
+
+
SpO₂ · %98
+
+
+
+
GLU · mg/dL92
+
+
+
+
+
+ + +
+
[SCORE/02] COMPOSITE HEALTH INDEX
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
74/100
+
▲ +6 · 30D TREND
+
weighted · 6 channels
+
+
+ +
+
Bio-Age47
+
◀ IMPROVING ▶
gap −2 → 0
+
Chrono45
+
+ +
+
+
+ + +
+
[SIGNAL/03] DAILY NUDGES · PROOF-TRAIL LOG ANTI-HALLUCINATION
+
+
+
+
+ + +
+
[TELE/04] 90-DAY EVENT MAP
+
+
+ WINDOW −90D → TODAY +
+ LABS + MEDS + SLEEP + WEIGHT + WORKOUT +
+
+
+
+
+
+
+
+ +
+
+ + +
+
LOCAL · ON-DEVICE · NO CLOUD
+
VAULT AES-256-GCM · SEALED
+
SYNC OFF
+
EGRESS 0 B / SESSION
+
+
INFERENCE helix-local · 4B
+
LIVE
+
+
+ + + + + + + + + diff --git a/docs/design/mockups/direction-c-living-twin.html b/docs/design/mockups/direction-c-living-twin.html new file mode 100644 index 0000000..1c8ff5f --- /dev/null +++ b/docs/design/mockups/direction-c-living-twin.html @@ -0,0 +1,945 @@ + + + + + +Helix — Living Twin · Personal Health Intelligence + + + + + + + + + + + + + + +
+ SAMPLE · SYNTHETIC DATA + + Public-repo demo · not real PHI · persona “Alex Rivera” is fictional +
+ + + + +
+
+ + +
+
+
+ Good morning, Alex +

Two strands,
one living twin.

+

+ Overnight your twin wove together everything you’ve measured with what it means. + Every word below shows its work and cites its source — gently, never a diagnosis. +

+
+ + +
+
+
74/100
Composite score · ▲ 6
+
47yr
Biological age · improving
+
12
day movement streak
+
+
+ +
+
+ Living twin + + Data Intelligence +
+
+
+
+ + +
+
+
Composite Health Score
+

Where you are today — and what makes it up

+

One number, honestly decomposable. Tap any strand to see how your twin weighs it.

+
+ +
+ +
+
+ +
+
74
+
of 100
+
▲ 6 this month
+
+
+
+

Trending upward

+

Up from 68. Activity and body composition are carrying you; sleep is the soft spot.

+
+
47
Biological age
+
45
Chronological
+
+
+
+ + +
+

Six strands of the composite

+

Each contributes to the 74. Greener is stronger; amber wants attention.

+ +
Activity88
+
Body composition78
+
Metabolic71
+
Nutrition70
+
Cardiovascular66
+
Sleep62
+
+
+
+ + +
+
+
Today’s gentle nudges
+

Four small things, each with its receipts

+

Wellness guidance, never a medical claim. Open any “why” to see your own data, the evidence tier, and the citation.

+
+ +
+ + +
+ +

Your deep sleep dipped this week — let’s protect it.

+

Deep sleep averaged 42 min/night and slipped 18% over the last 7 nights. A steady wind-down tonight is the kindest lever.

+
Own · Apple WatchTier BRef
+ +
+
+
Your data
+
Apple Watch sleep stages, last 7 nights · deep-sleep avg 42 min (target 90+), down 18% vs prior week.
+
+ + 51 → 42 min +
+
+
+
Evidence tier · B
+
Observational / mechanistic support. Consistent association, not a controlled result for you personally.
+
+
+
Reference
+
Slow-wave sleep & glucose regulation. Deep sleep supports overnight metabolic recovery. (deep-sleep ↔ glucose regulation)
+
+
+
Suggested action · non-diagnostic
+
A consistent wind-down — same time, dim light, screens down 45 min before bed.
+
+
+
+ + +
+ +

Your ferritin is climbing back up.

+

Three draws in a row moving the right way — 22 → 26 → 29 ng/mL. Whatever you’re doing with iron-rich meals, it’s landing.

+
Own · Quest labsTier ARef
+ +
+
+
Your data
+
Quest serum ferritin, 3 draws · 22 → 26 → 29 ng/mL (low-normal, improving).
+
+ + +7 ng/mL +
+
+
+
Evidence tier · A
+
Direct measurement of your own labs, trended over time — the strongest signal your twin can cite.
+
+
+
Reference
+
Iron status & fatigue / endurance. Rising ferritin tracks with restored energy and aerobic capacity. (iron ↔ fatigue/endurance)
+
+
+
Suggested action · non-diagnostic
+
Keep the iron-rich meals going; a recheck in ~8 weeks would confirm the trend.
+
+
+
+ + +
+ +

Your LDL is heading the right way.

+

Down from 135 to 128 mg/dL — still borderline, but the direction is good, and your saturated-fat intake is trending down too.

+
Own · Labs + Lose ItTier ARef
+ +
+
+
Your data
+
Lipid panel 135 → 128 mg/dL · Lose It saturated-fat log trending ↓ over the same window.
+
+ + −7 mg/dL +
+
+
+
Evidence tier · A
+
Two of your own sources agreeing — lab result plus dietary log — reinforcing one another.
+
+
+
Reference
+
Lipids, fiber & activity. Higher fiber and regular movement associate with lower LDL. (lipids ↔ fiber/activity)
+
+
+
Suggested action · non-diagnostic
+
Keep the fiber and the movement going — you’re already doing the thing that’s working.
+
+
+
+ + +
+ +

Body composition is trending leaner.

+

Body fat moved 26% → 23.5% alongside a 12-day movement streak. Steady, sustainable, and yours.

+
Own · RENPHOTier BStreak
+ +
+
+
Your data
+
RENPHO body-fat 26% → 23.5% · movement 12-day streak logged in parallel.
+
+ + −2.5% BF +
+
+
+
Evidence tier · B
+
Consumer scale bio-impedance — great for trend, less so for a single-day absolute. Read the slope, not the point.
+
+
+
Reference
+
Consistency & body composition. Sustained activity streaks associate with gradual fat-mass reduction. (activity ↔ body composition)
+
+
+
Suggested action · non-diagnostic
+
Protect the streak — showing up beats intensity. Same time each day helps it stick.
+
+
+
+ +
+
+ + +
+
+
90-day journey
+

Your holistic event map

+

Labs, meds, sleep, weight and workouts on one flowing timeline. Hover a marker to read the moment.

+
+ +
+
+ Last 90 days + ◀ scroll · today at right ▶ +
+
+
+ +
AprMayJunToday
+ + +
+
Labs
+
+
Lipid panel · LDL 135
+
Ferritin 22 ng/mL
+
Ferritin 26 ng/mL
+
Ferritin 29 · LDL 128
+
+
+ +
+
Meds & supp.
+
+
Started gentle iron
+
Added Vitamin D
+
+
+ +
+
Sleep
+
+
Deep sleep 55 min avg
+
Steady stretch — 51 min
+
Deep sleep dip −18% · 42 min
+
+
+ +
+
Weight / body
+
+
Body fat 26.0%
+
Body fat 24.8%
+
Body fat 23.5%
+
+
+ +
+
Workouts
+
+
3×/week rhythm begins
+
Strength block added
+
12-day movement streak
+
+
+
+
+
+
+ + +
+
+ +
+

Your data stays with you.

+

Helix runs on your device. Your labs, wearables and logs are woven into your twin locally — nothing is uploaded, sold, or seen by anyone else unless you explicitly choose to share it. The intelligence comes to your data, never the other way around.

+
+ Local-first + On-device processing + No PHI leaves + You hold the keys +
+
+
+
+ +
+
+ + +
+
+
+
Helix.
+

Personal Health Intelligence Platform · ISO Vision LLC. Wellness guidance, not medical advice. Data shown is synthetic sample data for demonstration.

+
+ +
+
+ + + + + + + + + + diff --git a/docs/design/mockups/direction-final-hybrid.html b/docs/design/mockups/direction-final-hybrid.html new file mode 100644 index 0000000..e688701 --- /dev/null +++ b/docs/design/mockups/direction-final-hybrid.html @@ -0,0 +1,1375 @@ + + + + + +Helix — Personal Health Intelligence · Final (Hybrid) + + + + + + + + + + + + + + +
+ + Sample · Synthetic demo data — public-repo illustration, persona “Alex Rivera” is fictional, not real PHI + +
+ +
+
+ +
+
+ +
+ +
+
+
+ Two strands · one story +

Good morning,
Alex.

+

Overnight your twin wove together everything you’ve measured with what it means. Your data and Helix’s intelligence, intertwined — and every word below shows its work and cites its source. Gently, never a diagnosis.

+
+
+ Biological age + 47 · chrono 45 · improving +
+
+ Composite today + 74 · ▲ 6 vs. last +
+
+ Data sources + 6 · all on-device +
+
+
+ +
+ + + + Live twin +
+ Your digital twin +
+ Data strand + Intelligence strand +
+
+
+
+
+ + +
+
+
+
+ Composite health score +

A single number, fully decomposable.

+
+

Tap any domain to project it onto the ring. The composite is a weighted blend — never a black box.

+
+ +
+ + +
+ + + Six domains · weighted blend · updated from your own sources · up from 68 + +
+
+
+
+
+ + +
+
+
+
+ Today’s gentle nudges +

Four small things, each with its receipts.

+
+

Open Why? on any card to see your own data, the evidence tier, and the reference behind it.

+
+
+
+
+ + +
+
+
+
+ For power users +

Deep dive — the instrument view.

+
+ +
+

The daily companion above stays calm and premium. Open the console for live oscilloscope vitals, channel readouts, and the raw signal stream — full diagnostic density, on demand, off by default.

+ + +
+
+ + +
+
+
+
+ Holistic event map +

Ninety days, every strand aligned.

+
+

+ + Labs · Supplements · Sleep · Body · Workouts — merged locally +

+
+ +
+
+ +
+ 90-day timeline + Apr → Jul 2026 +
+
+ +
AprMayJunJul
+
+
+
+ Lab draw + Supplement + Sleep quality + Body composition + Workout +
+
+
+
+
+ + +
+
+
+
+ Private by design +

Helix runs on your device — and shows its work.

+

Your records never leave your machine unless you choose. There is no cloud account, no data broker, no silent training. Every insight is traceable to a source you can inspect, and Helix will decline to answer rather than guess.

+
+ Private + On-device + Local-first + You hold the keys +
+
+
+
On-device — Tauri desktop + PWA mobile, encrypted at rest
+
Anti-hallucination — no citation, no claim
+
Non-diagnostic — wellness guidance, always your call
+
+
+
+ + + Helix — ISO Vision LLC + + Concept mockup · Final direction — “Aurora Companion” (A + C + B hybrid) · Sample data only +
+
+
+ + + + + From 7b1de69bb7e159856c55ad789c782233918c835f Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: Wed, 1 Jul 2026 09:40:29 -0400 Subject: [PATCH 2/5] feat(helix-ingest): native vault-sealing ingest CLI Parses FHIR/Apple via tested importers -> seals ProvRecords into the encrypted PersistentVaultStore -> proves ciphertext-at-rest -> emits a gitignored dossier.json for the UI. Passphrase via env/prompt only. 5/5 tests pass under --features persist; clippy -D warnings clean. --- Cargo.lock | 148 ++++++++++++++++ Cargo.toml | 1 + crates/helix-ingest/Cargo.toml | 37 ++++ crates/helix-ingest/src/dossier.rs | 74 ++++++++ crates/helix-ingest/src/lib.rs | 131 ++++++++++++++ crates/helix-ingest/src/main.rs | 121 +++++++++++++ crates/helix-ingest/src/parse.rs | 61 +++++++ crates/helix-ingest/src/vault.rs | 192 +++++++++++++++++++++ crates/helix-ingest/tests/ingest.rs | 254 ++++++++++++++++++++++++++++ 9 files changed, 1019 insertions(+) create mode 100644 crates/helix-ingest/Cargo.toml create mode 100644 crates/helix-ingest/src/dossier.rs create mode 100644 crates/helix-ingest/src/lib.rs create mode 100644 crates/helix-ingest/src/main.rs create mode 100644 crates/helix-ingest/src/parse.rs create mode 100644 crates/helix-ingest/src/vault.rs create mode 100644 crates/helix-ingest/tests/ingest.rs diff --git a/Cargo.lock b/Cargo.lock index 42da6ef..1b552c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -33,12 +33,62 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + [[package]] name = "anstyle" version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + [[package]] name = "argon2" version = "0.5.3" @@ -228,6 +278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -236,8 +287,22 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ + "anstream", "anstyle", "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -246,6 +311,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -478,6 +549,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "helix-bioage" version = "0.1.2" @@ -602,6 +679,23 @@ dependencies = [ "thiserror", ] +[[package]] +name = "helix-ingest" +version = "0.1.2" +dependencies = [ + "anyhow", + "argon2", + "clap", + "getrandom 0.2.17", + "helix-connect", + "helix-provenance", + "helix-vault", + "rpassword", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "helix-llm" version = "0.1.2" @@ -946,6 +1040,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itertools" version = "0.10.5" @@ -1063,6 +1163,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "oorandom" version = "11.1.5" @@ -1300,6 +1406,27 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1448,6 +1575,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" @@ -1597,6 +1730,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -1773,6 +1912,15 @@ dependencies = [ "windows-targets", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 2cc6867..46ddf99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ members = [ "crates/helix-refranges", "crates/helix-demo", "crates/helix-wasm", + "crates/helix-ingest", ] [workspace.package] diff --git a/crates/helix-ingest/Cargo.toml b/crates/helix-ingest/Cargo.toml new file mode 100644 index 0000000..5ec5863 --- /dev/null +++ b/crates/helix-ingest/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "helix-ingest" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "ADR-029/001: native ingest CLI — parse real health files through the tested importers, seal every record into the encrypted-at-rest vault, prove the round-trip and ciphertext-at-rest, and emit a local (gitignored) dossier.json for the UI." + +[[bin]] +name = "helix-ingest" +path = "src/main.rs" + +[dependencies] +serde.workspace = true +serde_json.workspace = true +helix-provenance = { path = "../helix-provenance", version = "0.1.0" } +helix-connect = { path = "../helix-connect", version = "0.1.0" } +# persist = redb + Argon2id + XChaCha20-Poly1305 encrypted-at-rest store (ADR-001). +helix-vault = { path = "../helix-vault", version = "0.1.0", features = ["persist"] } +clap = { version = "4", features = ["derive"] } +anyhow = "1" +# Interactive passphrase entry only — never a CLI flag, never echoed. +rpassword = "7" +# Passphrase KDF (mirrors helix-vault's private credential-vault KDF). +argon2 = "0.5" +getrandom = "0.2" + +[dev-dependencies] +tempfile = "3" + +[features] +# `persist` is a no-op passthrough kept so `cargo test -p helix-ingest --features +# persist` is accepted; the disk-backed vault (helix-vault/persist) is always on +# for this binary because sealing to disk is its whole purpose. +persist = [] diff --git a/crates/helix-ingest/src/dossier.rs b/crates/helix-ingest/src/dossier.rs new file mode 100644 index 0000000..6c2163c --- /dev/null +++ b/crates/helix-ingest/src/dossier.rs @@ -0,0 +1,74 @@ +//! Emit the decrypted corpus as `dossier.json` in the exact shape the web UI +//! loads (`ui/app.js` `applyDossier`). +//! +//! The UI needs `now` + `records`; every other collection defaults to empty. Each +//! element of `records` is a serialized [`ProvRecord`], which is byte-for-byte the +//! record shape `ui/app.js` pushes via `addImportedRecords` (`method` snake_case, +//! `confidence` a bare float, `reference_range` `{low,high}` or `null`). +//! +//! `meta` is deliberately OMITTED: the UI renders a "SAMPLE DATA — not a real +//! person" banner whenever `meta` is present, which would be a dangerous mislabel +//! for a real person's decrypted PHI. Provenance for humans/tools lives under the +//! non-UI `_helix_ingest` key, which `applyDossier` ignores. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use helix_provenance::ProvRecord; +use serde_json::{json, Value}; + +/// Build the dossier JSON value from the decrypted records and a `now` timestamp. +pub fn build(records: &[ProvRecord], now_ms: i64) -> Result { + let by_source = source_counts(records); + let records_json: Value = + serde_json::to_value(records).context("serializing records for dossier")?; + + Ok(json!({ + "now": now_ms, + "records": records_json, + // Present-but-empty so the schema is complete; the UI defaults these anyway. + "medications": [], + "notes": [], + "timeline": [], + "questions": [], + "subsystems": [], + // Non-UI provenance block (ignored by applyDossier). + "_helix_ingest": { + "generated_by": format!("helix-ingest {}", env!("CARGO_PKG_VERSION")), + "record_count": records.len(), + "by_source": by_source, + "encryption_at_rest": "proven", + "note": "Decrypted from the local encrypted vault by helix-ingest. \ + Contains PHI — keep under a gitignored path; never commit." + } + })) +} + +/// Count records per source (e.g. `{"FHIR": 3, "Apple Health": 4}`). +pub fn source_counts(records: &[ProvRecord]) -> BTreeMap { + let mut m = BTreeMap::new(); + for r in records { + *m.entry(r.source.clone()).or_insert(0) += 1; + } + m +} + +/// Write the dossier to `out` (creating the parent dir), owner-only on Unix. +pub fn write(value: &Value, out: &Path) -> Result<()> { + if let Some(parent) = out.parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent) + .with_context(|| format!("creating dossier dir {}", parent.display()))?; + } + } + let text = serde_json::to_string_pretty(value).context("encoding dossier json")?; + fs::write(out, text).with_context(|| format!("writing dossier {}", out.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(out, fs::Permissions::from_mode(0o600)); + } + Ok(()) +} diff --git a/crates/helix-ingest/src/lib.rs b/crates/helix-ingest/src/lib.rs new file mode 100644 index 0000000..8ef12b7 --- /dev/null +++ b/crates/helix-ingest/src/lib.rs @@ -0,0 +1,131 @@ +//! # helix-ingest — the piece that finally *uses* the vault. +//! +//! Parse real health-data files through the already-tested `helix-connect` +//! importers → seal every [`helix_provenance::ProvRecord`] into the encrypted +//! `helix-vault` store → RE-OPEN the store fresh and decrypt them back (proving +//! the round-trip) → PROVE encryption-at-rest by asserting sealed payload markers +//! are absent from the raw file → emit a local, gitignored `dossier.json` in the +//! exact shape the web UI consumes. +//! +//! This is also the schedulable unit for future "ongoing" pulls: one run = parse, +//! seal, verify, emit. +//! +//! The passphrase is supplied by the caller ([`RunArgs::passphrase`]); the binary +//! sources it ONLY from `HELIX_VAULT_PASSPHRASE` or an interactive `rpassword` +//! prompt — never a CLI flag, never logged. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use helix_provenance::ProvRecord; + +pub mod dossier; +pub mod parse; +pub mod vault; + +pub use parse::{APPLE_SOURCE, FHIR_SOURCE}; + +/// Inputs to one ingest run. Both sources are optional but at least one is required. +pub struct RunArgs<'a> { + pub fhir: Option<&'a Path>, + pub apple: Option<&'a Path>, + pub vault_dir: &'a Path, + pub out: &'a Path, + pub passphrase: &'a str, + /// "Now" (epoch millis) written into the dossier for the UI's trend math. + pub now_ms: i64, +} + +/// What a run produced — counts and metadata only. Deliberately carries **no** +/// record values, so a caller can print it without leaking PHI. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunReport { + pub record_count: usize, + pub queued_for_review: usize, + pub by_source: BTreeMap, + pub markers_checked: usize, + pub vault_records_path: PathBuf, + pub out_path: PathBuf, + pub encryption_at_rest_proven: bool, +} + +/// Execute the full ingest pipeline. Returns a PHI-free [`RunReport`]. +pub fn run(args: RunArgs) -> Result { + if args.fhir.is_none() && args.apple.is_none() { + bail!("no source given: pass --fhir and/or --apple "); + } + if args.passphrase.is_empty() { + bail!("empty passphrase"); + } + + // 1. Parse file(s) → records via the tested importers. + let mut records = Vec::new(); + let mut queued_for_review = 0usize; + if let Some(path) = args.fhir { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading FHIR file {}", path.display()))?; + let parsed = parse::parse_fhir_bundle(&text, FHIR_SOURCE)?; + queued_for_review += parsed.queued_for_review; + records.extend(parsed.records); + } + if let Some(path) = args.apple { + let text = std::fs::read_to_string(path) + .with_context(|| format!("reading Apple export {}", path.display()))?; + let parsed = parse::parse_apple_export(&text, APPLE_SOURCE); + queued_for_review += parsed.queued_for_review; + records.extend(parsed.records); + } + if records.is_empty() { + bail!("parsed 0 usable records ({queued_for_review} held for review); nothing to seal"); + } + + // Plaintext markers for the at-rest proof: the concept labels and units that + // live ONLY inside the sealed payload (NOT the LOINC code, which is in the id). + let markers = payload_markers(&records); + + // 2. Derive the key from the passphrase and seal every record. + let key = vault::prepare_key(args.vault_dir, args.passphrase)?; + vault::seal_records(args.vault_dir, &key, &records)?; + + // 3. RE-OPEN fresh and decrypt back — proves the round-trip across a close. + let reopened = vault::reopen_records(args.vault_dir, &key)?; + if reopened.len() < records.len() { + bail!( + "round-trip lost records: sealed {}, recovered {}", + records.len(), + reopened.len() + ); + } + + // 4. PROVE ciphertext-at-rest against the raw file bytes. + let markers_checked = vault::prove_ciphertext_at_rest(args.vault_dir, &markers)?; + + // 5. Emit the decrypted records as dossier.json in the UI schema. + let value = dossier::build(&reopened, args.now_ms)?; + dossier::write(&value, args.out)?; + + Ok(RunReport { + record_count: reopened.len(), + queued_for_review, + by_source: dossier::source_counts(&reopened), + markers_checked, + vault_records_path: vault::records_db_path(args.vault_dir), + out_path: args.out.to_path_buf(), + encryption_at_rest_proven: markers_checked > 0, + }) +} + +/// Distinct concept labels + units from the records — the sealed-payload strings +/// used to prove nothing leaked. Deduplicated; empties/too-short dropped downstream. +fn payload_markers(records: &[ProvRecord]) -> Vec { + use std::collections::BTreeSet; + let mut set = BTreeSet::new(); + for r in records { + set.insert(r.concept.clone()); + if !r.unit.is_empty() { + set.insert(r.unit.clone()); + } + } + set.into_iter().collect() +} diff --git a/crates/helix-ingest/src/main.rs b/crates/helix-ingest/src/main.rs new file mode 100644 index 0000000..c019e6f --- /dev/null +++ b/crates/helix-ingest/src/main.rs @@ -0,0 +1,121 @@ +//! `helix-ingest` CLI (ADR-029/001). +//! +//! ```text +//! helix-ingest --fhir --apple --vault --out +//! ``` +//! +//! Both sources are optional; at least one is required. The passphrase comes ONLY +//! from `HELIX_VAULT_PASSPHRASE` or an interactive prompt — never a flag, never +//! logged. The summary prints counts/metadata only; record values never touch a log. + +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Result}; +use clap::Parser; +use helix_ingest::{run, vault, RunArgs}; + +/// Parse health-data files through the tested importers, seal them into the +/// encrypted vault, prove the round-trip + encryption-at-rest, and emit a local +/// (gitignored) dossier.json for the UI. +#[derive(Parser, Debug)] +#[command(name = "helix-ingest", version, about)] +struct Cli { + /// FHIR R4 bundle (or bare Observation) JSON to import. + #[arg(long, value_name = "path.json")] + fhir: Option, + + /// Apple Health `export.xml` to import. + #[arg(long, value_name = "export.xml")] + apple: Option, + + /// Vault directory (holds the encrypted redb store + salt). Created if absent. + #[arg(long, value_name = "dir")] + vault: PathBuf, + + /// Where to write the decrypted dossier.json. Default is under the gitignored + /// `./private/` path so PHI never lands in a tracked file. + #[arg(long, value_name = "dossier.json", default_value = "./private/dossier.json")] + out: PathBuf, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + if cli.fhir.is_none() && cli.apple.is_none() { + bail!("provide at least one of --fhir or --apple "); + } + + // Decide whether we are initializing a fresh vault BEFORE touching anything, + // so a first-time passphrase can be confirmed. + let creating = !vault::is_initialized(&cli.vault); + let passphrase = read_passphrase(creating)?; + + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0); + + let report = run(RunArgs { + fhir: cli.fhir.as_deref(), + apple: cli.apple.as_deref(), + vault_dir: &cli.vault, + out: &cli.out, + passphrase: &passphrase, + now_ms, + })?; + + print_summary(&report); + Ok(()) +} + +/// Read the passphrase from the environment or an interactive prompt. NEVER from +/// a CLI flag; never echoed. On first-time vault creation, confirm it twice. +fn read_passphrase(creating: bool) -> Result { + if let Ok(p) = std::env::var("HELIX_VAULT_PASSPHRASE") { + if p.is_empty() { + bail!("HELIX_VAULT_PASSPHRASE is set but empty"); + } + return Ok(p); + } + let p = rpassword::prompt_password("Vault passphrase: ")?; + if p.is_empty() { + bail!("empty passphrase"); + } + if creating { + let confirm = rpassword::prompt_password("Confirm passphrase: ")?; + if confirm != p { + bail!("passphrases do not match"); + } + } + Ok(p) +} + +/// Print counts/metadata only — no record values, ever. +fn print_summary(report: &helix_ingest::RunReport) { + println!("helix-ingest: sealed and verified {} record(s)", report.record_count); + if report.queued_for_review > 0 { + println!(" held for review: {}", report.queued_for_review); + } + println!(" by source:"); + for (source, count) in &report.by_source { + println!(" {source}: {count}"); + } + println!(" vault: {}", report.vault_records_path.display()); + println!(" dossier: {}", report.out_path.display()); + println!( + " round-trip: OK (re-opened fresh, decrypted {} back)", + report.record_count + ); + println!( + " ciphertext-at-rest markers checked (all absent from raw file): {}", + report.markers_checked + ); + println!( + "encryption-at-rest: {}", + if report.encryption_at_rest_proven { + "PROVEN" + } else { + "NOT PROVEN" + } + ); +} diff --git a/crates/helix-ingest/src/parse.rs b/crates/helix-ingest/src/parse.rs new file mode 100644 index 0000000..112ce4a --- /dev/null +++ b/crates/helix-ingest/src/parse.rs @@ -0,0 +1,61 @@ +//! File → `Vec` via the already-tested `helix-connect` importers. +//! +//! No new parsing logic lives here: FHIR bundles go through +//! [`helix_connect::parse_observation`] (same iteration as `helix-wasm`'s +//! `fhir_import_json`) and Apple exports through +//! [`helix_connect::parse_apple_health`]. Un-parseable resources are counted into +//! a review queue (ADR-012), never silently dropped. + +use anyhow::{Context, Result}; +use helix_provenance::ProvRecord; +use serde_json::Value; + +/// Source label stamped onto records imported from a FHIR bundle. +pub const FHIR_SOURCE: &str = "FHIR"; +/// Source label stamped onto records imported from an Apple Health export. +pub const APPLE_SOURCE: &str = "Apple Health"; +/// Upper bound on Apple records parsed in one run (mirrors `helix-wasm`). +const APPLE_MAX_RECORDS: usize = 100_000; + +/// Outcome of parsing one file: the accepted records plus how many resources were +/// held for review (unmapped type, no LOINC, non-finite value, …). +pub struct Parsed { + pub records: Vec, + pub queued_for_review: usize, +} + +/// Parse a FHIR R4 `Bundle` (or a bare `Observation`) string into records. +/// Mirrors `helix_wasm::fhir_import_json` exactly so native and wasm agree. +pub fn parse_fhir_bundle(json: &str, source: &str) -> Result { + let v: Value = serde_json::from_str(json).context("FHIR file is not valid JSON")?; + let entries = v["entry"].as_array().cloned().unwrap_or_default(); + let candidates: Vec = if entries.is_empty() && v["resourceType"] == "Observation" { + vec![v.clone()] + } else { + entries.iter().map(|e| e["resource"].clone()).collect() + }; + + let mut records = Vec::new(); + let mut queued_for_review = 0usize; + for r in &candidates { + match helix_connect::parse_observation(r, source) { + Ok(rec) => records.push(rec), + Err(_) => queued_for_review += 1, // → human-review queue (ADR-012/004) + } + } + Ok(Parsed { + records, + queued_for_review, + }) +} + +/// Parse an Apple Health `export.xml` string into records via the tested scanner. +pub fn parse_apple_export(xml: &str, source: &str) -> Parsed { + let records = helix_connect::parse_apple_health(xml, source, APPLE_MAX_RECORDS); + // The importer already skips unknown/invalid records internally; those are not + // recoverable to a count here, so review-queue is 0 for the Apple path. + Parsed { + records, + queued_for_review: 0, + } +} diff --git a/crates/helix-ingest/src/vault.rs b/crates/helix-ingest/src/vault.rs new file mode 100644 index 0000000..7283ee1 --- /dev/null +++ b/crates/helix-ingest/src/vault.rs @@ -0,0 +1,192 @@ +//! Sealing records into the encrypted-at-rest vault, and PROVING it. +//! +//! The corpus store is [`helix_vault::PersistentVaultStore`] (redb + +//! XChaCha20-Poly1305). It holds *only* [`helix_vault::SealedRecord`] ciphertext +//! and has no key of its own, so the sealing key is derived here from the user's +//! passphrase (Argon2id, mirroring `helix-vault`'s credential-vault KDF) with a +//! per-vault random salt persisted alongside the store. +//! +//! On-disk layout under the `--vault` directory: +//! * `records.redb` — sealed [`ProvRecord`]s, keyed by record id, plus one +//! sealed verifier record that makes a wrong passphrase fail cleanly instead +//! of silently sealing under the wrong key. +//! * `salt` — the 16-byte Argon2id salt (NOT secret; it must persist so +//! the same passphrase re-derives the same key on re-open). +//! +//! IMPORTANT at-rest note: redb *keys* are the record ids in plaintext. Our ids +//! embed source + LOINC code + timestamp, so those leak as metadata. The sealed +//! *value* — concept label, numeric value, unit, reference range — does not. The +//! ciphertext-at-rest proof therefore asserts the concept/unit are absent from +//! the raw file (asserting the LOINC code would be wrong: it is in the key). + +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, bail, Context, Result}; +use argon2::{Algorithm, Argon2, Params, Version}; +use helix_provenance::{ProvRecord, RecordId}; +use helix_vault::{open, seal, PersistentVaultStore, SealKey}; + +const RECORDS_DB: &str = "records.redb"; +const SALT_FILE: &str = "salt"; +const SALT_LEN: usize = 16; +/// Reserved id prefix for internal (non-corpus) records; excluded from the dossier. +const RESERVED_PREFIX: &str = "__helix_ingest"; +const VERIFIER_ID: &str = "__helix_ingest_verifier__"; +const VERIFIER_PLAINTEXT: &[u8] = b"helix-ingest-verifier-v1"; + +/// Path of the encrypted redb store inside a vault directory. +pub fn records_db_path(dir: &Path) -> PathBuf { + dir.join(RECORDS_DB) +} + +fn salt_path(dir: &Path) -> PathBuf { + dir.join(SALT_FILE) +} + +/// Whether this vault directory has already been initialized (salt present). +/// Used by the CLI to decide whether to confirm a freshly chosen passphrase. +pub fn is_initialized(dir: &Path) -> bool { + salt_path(dir).is_file() +} + +/// Best-effort tighten to owner-only (0600) on Unix; no-op elsewhere. +fn restrict(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + let _ = path; +} + +/// Load the per-vault salt, generating and persisting one on first use. +fn load_or_create_salt(dir: &Path) -> Result> { + fs::create_dir_all(dir).with_context(|| format!("creating vault dir {}", dir.display()))?; + let path = salt_path(dir); + if path.is_file() { + let salt = fs::read(&path).with_context(|| format!("reading salt {}", path.display()))?; + if salt.len() != SALT_LEN { + bail!("corrupt vault salt at {} (unexpected length)", path.display()); + } + Ok(salt) + } else { + let mut salt = [0u8; SALT_LEN]; + getrandom::getrandom(&mut salt).map_err(|_| anyhow!("OS RNG failed generating salt"))?; + fs::write(&path, salt).with_context(|| format!("writing salt {}", path.display()))?; + restrict(&path); + Ok(salt.to_vec()) + } +} + +/// Derive the 256-bit sealing key from a passphrase + salt via Argon2id. Mirrors +/// `helix-vault`'s private credential-vault KDF (same algorithm/params) so the +/// two agree on key material. The key zeroizes on drop (see [`SealKey`]). +pub fn derive_key(passphrase: &str, salt: &[u8]) -> Result { + let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, Params::default()); + let mut key = [0u8; 32]; + argon2 + .hash_password_into(passphrase.as_bytes(), salt, &mut key) + .map_err(|e| anyhow!("argon2 key derivation failed: {e}"))?; + Ok(SealKey::from_bytes(key)) +} + +/// Prepare the key for a vault directory: load/create salt, then derive. +pub fn prepare_key(dir: &Path, passphrase: &str) -> Result { + let salt = load_or_create_salt(dir)?; + derive_key(passphrase, &salt) +} + +/// Open the store and enforce the passphrase against the sealed verifier. +/// * existing verifier → must open to the known plaintext, else the passphrase is +/// wrong (or the file was tampered) → hard error, nothing is written. +/// * no verifier + `init` → seal and store one now (fresh vault). +fn open_verified(dir: &Path, key: &SealKey, init: bool) -> Result { + let store = PersistentVaultStore::open(records_db_path(dir)) + .with_context(|| format!("opening vault store in {}", dir.display()))?; + let vid = RecordId::from(VERIFIER_ID); + match store.get(&vid).context("reading vault verifier")? { + Some(sealed) => { + let pt = open(key, &sealed) + .map_err(|_| anyhow!("wrong passphrase (vault verifier failed to open)"))?; + if pt != VERIFIER_PLAINTEXT { + bail!("vault verifier mismatch — refusing to touch this vault"); + } + } + None if init => { + let sealed = seal(key, VERIFIER_PLAINTEXT).context("sealing verifier")?; + store.put(&vid, &sealed).context("writing verifier")?; + } + None => bail!("vault is not initialized (missing verifier)"), + } + Ok(store) +} + +/// Seal every record and persist it under its own id. Verifies (or, for a fresh +/// vault, establishes) the passphrase first, so a mistyped passphrase on an +/// existing vault can never mix keys and corrupt the corpus. +pub fn seal_records(dir: &Path, key: &SealKey, records: &[ProvRecord]) -> Result<()> { + let store = open_verified(dir, key, true)?; + for rec in records { + let plaintext = serde_json::to_vec(rec).context("serializing record before sealing")?; + let sealed = seal(key, &plaintext).context("sealing record")?; + store.put(&rec.id, &sealed).context("storing sealed record")?; + } + restrict(&records_db_path(dir)); // best-effort; harmless if the file is open + Ok(()) +} + +/// Re-open the store FRESH (proving durability across a close) and decrypt every +/// corpus record back out. Internal/reserved records are skipped. +pub fn reopen_records(dir: &Path, key: &SealKey) -> Result> { + let store = open_verified(dir, key, false)?; + let mut out = Vec::new(); + for id in store.ids().context("listing vault ids")? { + if id.0.starts_with(RESERVED_PREFIX) { + continue; + } + let sealed = store + .get(&id) + .context("reading sealed record")? + .ok_or_else(|| anyhow!("record {:?} vanished between list and read", id))?; + let plaintext = open(key, &sealed) + .map_err(|_| anyhow!("failed to decrypt record {:?} (wrong key?)", id))?; + let rec: ProvRecord = + serde_json::from_slice(&plaintext).context("deserializing decrypted record")?; + out.push(rec); + } + Ok(out) +} + +/// True iff `needle` occurs as a contiguous byte run in `haystack`. +fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || needle.len() > haystack.len() { + return false; + } + haystack.windows(needle.len()).any(|w| w == needle) +} + +/// PROVE encryption-at-rest: read the raw store bytes and assert that not one of +/// the plaintext `markers` (concept labels / unit strings drawn from the sealed +/// payloads) appears. Any hit means a payload leaked in the clear → hard error. +/// Returns the number of markers checked. +pub fn prove_ciphertext_at_rest(dir: &Path, markers: &[String]) -> Result { + let path = records_db_path(dir); + let bytes = fs::read(&path).with_context(|| format!("reading raw vault {}", path.display()))?; + let mut checked = 0usize; + for m in markers { + let m = m.trim(); + if m.len() < 3 { + continue; // too short to be a meaningful marker + } + if contains_bytes(&bytes, m.as_bytes()) { + bail!( + "ENCRYPTION-AT-REST VIOLATION: a plaintext payload marker was found \ + in the raw vault file. Refusing to certify." + ); + } + checked += 1; + } + Ok(checked) +} diff --git a/crates/helix-ingest/tests/ingest.rs b/crates/helix-ingest/tests/ingest.rs new file mode 100644 index 0000000..adb58b0 --- /dev/null +++ b/crates/helix-ingest/tests/ingest.rs @@ -0,0 +1,254 @@ +//! Integration tests for helix-ingest, on SYNTHETIC data only (never real PHI). +//! +//! They prove the four load-bearing properties: +//! 1. parse → seal → RE-OPEN fresh → get: records round-trip through the vault. +//! 2. ciphertext-at-rest: sealed-payload markers (concept label + unit) are +//! ABSENT from the raw redb file, while the plaintext key is present +//! (positive control) — so the "absent" assertions are meaningful. +//! 3. dossier.json is emitted and re-parses into the UI's record shape +//! (== serialized ProvRecord), with no misleading `meta` banner block. +//! 4. a wrong passphrase on an existing vault is rejected (no key mixing). + +use std::fs; +use std::path::Path; + +use helix_ingest::{run, vault, RunArgs}; +use helix_provenance::ProvRecord; + +const PASS: &str = "correct-horse-battery-staple"; +const NOW_MS: i64 = 1_780_000_000_000; + +// --- synthetic fixtures ----------------------------------------------------- + +/// 3 parseable Observations (Ferritin, HDL, TSH) + 1 unparseable (no LOINC). +const FHIR_BUNDLE: &str = r#"{ + "resourceType": "Bundle", + "entry": [ + { "resource": { "resourceType": "Observation", "id": "ferritin1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2276-4", "display": "Ferritin" }] }, + "valueQuantity": { "value": 28.0, "unit": "ng/mL" }, "effectiveDateTime": "2026-06-19T10:00:00Z", + "referenceRange": [{ "low": { "value": 30.0 }, "high": { "value": 400.0 } }] } }, + { "resource": { "resourceType": "Observation", "id": "hdl1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2085-9", "display": "HDL Cholesterol" }] }, + "valueQuantity": { "value": 58.0, "unit": "mg/dL" }, "effectiveDateTime": "2026-06-10" } }, + { "resource": { "resourceType": "Observation", "id": "tsh1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "3016-3", "display": "TSH" }] }, + "valueQuantity": { "value": 2.1, "unit": "mIU/L" }, "effectiveDateTime": "2026-06-10" } }, + { "resource": { "resourceType": "Observation", "id": "bad1", + "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1" }] }, + "valueQuantity": { "value": 1.0, "unit": "x" }, "effectiveDateTime": "2026-01-01" } } + ] +}"#; + +/// 3 known HealthKit records (HR, RHR, BodyMass) + 1 unmapped (skipped). +const APPLE_XML: &str = r#" + + + + + +"#; + +/// Write both fixtures into `dir` and return their paths. +fn write_fixtures(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) { + let fhir = dir.join("bundle.json"); + let apple = dir.join("export.xml"); + fs::write(&fhir, FHIR_BUNDLE).unwrap(); + fs::write(&apple, APPLE_XML).unwrap(); + (fhir, apple) +} + +fn run_full(base: &Path) -> helix_ingest::RunReport { + let (fhir, apple) = write_fixtures(base); + let vault_dir = base.join("vault"); + let out = base.join("out").join("dossier.json"); + run(RunArgs { + fhir: Some(&fhir), + apple: Some(&apple), + vault_dir: &vault_dir, + out: &out, + passphrase: PASS, + now_ms: NOW_MS, + }) + .expect("ingest run should succeed") +} + +// --- 1. round-trip ---------------------------------------------------------- + +#[test] +fn parse_seal_reopen_roundtrip() { + let tmp = tempfile::tempdir().unwrap(); + let report = run_full(tmp.path()); + + // 3 FHIR + 3 Apple = 6 sealed; the 4th FHIR entry was held for review. + assert_eq!(report.record_count, 6, "expected 6 sealed records"); + assert_eq!(report.queued_for_review, 1, "1 unparseable FHIR resource"); + assert_eq!(report.by_source.get("FHIR"), Some(&3)); + assert_eq!(report.by_source.get("Apple Health"), Some(&3)); + + // Independently RE-OPEN the vault fresh and decrypt — the values survive. + let vault_dir = tmp.path().join("vault"); + let key = vault::prepare_key(&vault_dir, PASS).unwrap(); + let recovered: Vec = vault::reopen_records(&vault_dir, &key).unwrap(); + assert_eq!(recovered.len(), 6); + + let ferritin = recovered + .iter() + .find(|r| r.concept == "Ferritin") + .expect("ferritin should round-trip"); + assert_eq!(ferritin.value, 28.0); + assert_eq!(ferritin.unit, "ng/mL"); + assert_eq!(ferritin.code.as_deref(), Some("2276-4")); + + let hr = recovered + .iter() + .find(|r| r.concept == "Heart rate") + .expect("heart rate should round-trip"); + assert_eq!(hr.value, 62.0); +} + +// --- 2. ciphertext-at-rest -------------------------------------------------- + +#[test] +fn ciphertext_at_rest_payload_absent_keys_present() { + let tmp = tempfile::tempdir().unwrap(); + let _ = run_full(tmp.path()); + + let db_path = vault::records_db_path(&tmp.path().join("vault")); + let raw = fs::read(&db_path).expect("raw vault file readable"); + + // Sealed PAYLOAD strings must NOT appear in the clear. + for marker in [ + "Ferritin", + "HDL Cholesterol", + "Heart rate", + "Resting heart rate", + "ng/mL", + "count/min", + ] { + assert!( + !contains(&raw, marker.as_bytes()), + "plaintext payload marker {marker:?} leaked into the raw vault file" + ); + } + + // Positive control: the redb KEY (record id) IS plaintext, proving the file + // is populated with our data and the "absent" checks above are meaningful. + // (This is the honest at-rest metadata leak: ids embed source + LOINC code.) + assert!( + contains(&raw, b"fhir-FHIR-ferritin1"), + "expected the plaintext record-id key in the raw file" + ); +} + +// --- 3. dossier emitted + re-parseable into the UI record shape ------------- + +#[test] +fn dossier_emitted_and_reparses_into_ui_record_shape() { + let tmp = tempfile::tempdir().unwrap(); + let report = run_full(tmp.path()); + + let text = fs::read_to_string(&report.out_path).expect("dossier written"); + let doc: serde_json::Value = serde_json::from_str(&text).expect("dossier is valid JSON"); + + // UI requires `now` + `records`. + assert_eq!(doc["now"].as_i64(), Some(NOW_MS)); + let records = doc["records"].as_array().expect("records is an array"); + assert_eq!(records.len(), 6); + + // Every element must deserialize back into a ProvRecord (i.e. the emitted + // shape IS the UI record shape == serialized ProvRecord). + let parsed: Vec = + serde_json::from_value(doc["records"].clone()).expect("records parse as Vec"); + assert_eq!(parsed.len(), 6); + + // Spot-check the exact UI field shape on one record. + let r0 = &records[0]; + for field in [ + "id", + "source", + "measured_at", + "method", + "code", + "concept", + "value", + "unit", + "reference_range", + "confidence", + ] { + assert!(r0.get(field).is_some(), "record missing UI field {field:?}"); + } + // method is snake_case; confidence is a bare float (not an object). + assert!(r0["method"].is_string()); + assert!(r0["confidence"].is_number()); + + // No `meta` block — that would trigger the UI's "SAMPLE DATA" banner on real + // PHI. Provenance lives under the non-UI `_helix_ingest` key instead. + assert!(doc.get("meta").is_none(), "dossier must not carry a meta banner"); + assert_eq!(doc["_helix_ingest"]["record_count"].as_u64(), Some(6)); + assert_eq!(doc["_helix_ingest"]["encryption_at_rest"], "proven"); +} + +// --- 4. wrong passphrase rejected ------------------------------------------- + +#[test] +fn wrong_passphrase_is_rejected() { + let tmp = tempfile::tempdir().unwrap(); + let (fhir, apple) = write_fixtures(tmp.path()); + let vault_dir = tmp.path().join("vault"); + let out = tmp.path().join("dossier.json"); + + // First run creates the vault under the correct passphrase. + run(RunArgs { + fhir: Some(&fhir), + apple: Some(&apple), + vault_dir: &vault_dir, + out: &out, + passphrase: PASS, + now_ms: NOW_MS, + }) + .expect("first run succeeds"); + + // Second run on the SAME vault with a wrong passphrase must fail cleanly, + // before any records are (mis)sealed under the wrong key. + let err = run(RunArgs { + fhir: Some(&fhir), + apple: Some(&apple), + vault_dir: &vault_dir, + out: &out, + passphrase: "wrong-passphrase", + now_ms: NOW_MS, + }) + .expect_err("wrong passphrase must be rejected"); + let msg = format!("{err:#}").to_lowercase(); + assert!( + msg.contains("wrong passphrase") || msg.contains("verifier"), + "unexpected error: {err:#}" + ); +} + +// --- 5. at least one source required ---------------------------------------- + +#[test] +fn requires_at_least_one_source() { + let tmp = tempfile::tempdir().unwrap(); + let vault_dir = tmp.path().join("vault"); + let out = tmp.path().join("dossier.json"); + let err = run(RunArgs { + fhir: None, + apple: None, + vault_dir: &vault_dir, + out: &out, + passphrase: PASS, + now_ms: NOW_MS, + }) + .expect_err("no source must error"); + assert!(format!("{err:#}").contains("no source")); +} + +/// True iff `needle` occurs contiguously in `haystack`. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + !needle.is_empty() + && needle.len() <= haystack.len() + && haystack.windows(needle.len()).any(|w| w == needle) +} From bd44743fe469b09dff8d2677d80c1ce0aed47262 Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: Wed, 1 Jul 2026 09:40:29 -0400 Subject: [PATCH 3/5] feat(ui): wire hybrid UI to live WASM + per-concept grounded answers - hybrid.html/js/css driven by compose_score_json/bioage_json/timeline_json/ focus_json/analyze_json over a dossier (synthetic default; ./private or ?dossier= for real) - new per-concept loop: enumerate codes -> resolve range (own -> NHANES fallback -> abstain) -> analyze_json, rendering grounded proof-trail cards over an arbitrary panel - Sealed/private banner + on-device marker; gitignore ui/private/ --- .gitignore | 4 + ui/hybrid.css | 58 ++++ ui/hybrid.html | 835 ++++++++++++++++++++++++++++++++++++++++++++++++ ui/hybrid.js | 839 +++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1736 insertions(+) create mode 100644 ui/hybrid.css create mode 100644 ui/hybrid.html create mode 100644 ui/hybrid.js diff --git a/.gitignore b/.gitignore index 4a620d2..3b82504 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ claw_swarm_memory.db* ros3-agentdb.db # Convention for any future private data — put it under private/ /private/ +# Real (PHI) dossier drop-in for the local UI — never commit; only the +# synthetic demo-dossier.json is tracked. hybrid.html auto-loads this if present. +/ui/private/ +ui/private/ diff --git a/ui/hybrid.css b/ui/hybrid.css new file mode 100644 index 0000000..729c8a9 --- /dev/null +++ b/ui/hybrid.css @@ -0,0 +1,58 @@ +/* ============================================================ + hybrid.css — data-wiring additions for ui/hybrid.html + The visual language is UNCHANGED: every rule here reuses the + mockup's existing design tokens (--teal / --warm / --living …) + and its card/proof-trail vocabulary. Nothing is redesigned; + these classes only style the NEW data-driven pieces + (private-dossier marker, compact per-concept cards, the + held-for-abstention strip) so they match the aurora hybrid. + ============================================================ */ + +/* ---- Private-dossier marker (swapped in when a REAL dossier loads) ---- */ +.sample-banner.private{ + color:var(--text-hi); + background:linear-gradient(90deg, rgba(23,183,171,.16), rgba(63,212,137,.12)); + border-bottom:1px solid var(--glass-brd); +} +.sample-banner.private .dot{ background:var(--teal); opacity:.9; } +.sample-banner.private b{ color:var(--teal); } +.sample-banner.private svg{ width:13px; height:13px; color:var(--teal); } + +/* ---- Compact per-concept grounded cards (reuse the warm .nudge look) ---- */ +.nudge.compact{ padding:18px 18px 15px; gap:9px; } +.nudge.compact h3{ font-size:1.05rem; line-height:1.14; margin:0; } +.nudge.compact .n-body{ font-size:.86rem; } + +/* value line + range / trend chips on grounded cards */ +.gv{ display:flex; align-items:baseline; gap:.55em; flex-wrap:wrap; margin:1px 0 1px; } +.gv .num{ font-family:"Fraunces",serif; font-variation-settings:"opsz" 40; font-size:1.55rem; font-weight:420; color:var(--text-hi); line-height:1; } +.gv .unit{ font-size:.72rem; color:var(--text-low); } +.gv .range-chip{ font-size:.58rem; letter-spacing:.1em; text-transform:uppercase; font-weight:700; padding:3px 9px; border-radius:100px; } +.range-chip.inr{ color:var(--living); border:1px solid rgba(131,230,166,.42); } +.range-chip.out{ color:var(--warm); border:1px solid rgba(246,189,124,.48); } +.trend-tag{ font-size:.74rem; color:var(--text-low); display:inline-flex; gap:.4em; align-items:center; font-family:"Hanken Grotesk",sans-serif; } +.trend-tag i{ font-style:normal; } +.trend-tag.up i{ color:var(--living); } .trend-tag.down i{ color:var(--warm); } + +/* honesty note on the Reference row when a population fallback is used */ +.pt-note{ display:block; margin-top:3px; font-size:.62rem; color:var(--text-faint); letter-spacing:.01em; font-family:"Schibsted Grotesk",sans-serif; } + +/* escalation (red-flag) variant — rare, but never fabricate around it */ +.nudge.escalate::before{ background:linear-gradient(180deg,#fb7185,#e0566c); box-shadow:0 0 14px rgba(251,113,133,.4); } +.nudge.escalate h3{ color:#ffd7dd; } + +/* ---- Held-for-abstention disclosure ---- */ +.abstain-wrap{ margin-top:22px; border-top:1px solid var(--line-soft); padding-top:16px; } +.abstain-wrap summary{ cursor:pointer; list-style:none; user-select:none; + font-size:.75rem; font-weight:600; letter-spacing:.02em; color:var(--text-low); + display:flex; align-items:center; gap:.55em; font-family:"Hanken Grotesk",sans-serif; } +.abstain-wrap summary::-webkit-details-marker{ display:none; } +.abstain-wrap summary::before{ content:"›"; color:var(--text-faint); display:inline-block; transition:transform .3s var(--ease); } +.abstain-wrap[open] summary::before{ transform:rotate(90deg); } +.abstain-strip{ display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; } +.abstain-chip{ display:inline-flex; align-items:center; gap:.5em; font-size:.7rem; color:var(--text-mid); + border:1px dashed var(--line); background:rgba(255,255,255,.015); padding:5px 11px; border-radius:100px; } +.abstain-chip .d{ width:5px; height:5px; border-radius:50%; background:var(--text-faint); } + +/* keep the report-empty helper the mockup's JS never needed but ours does */ +.report-empty{ color:var(--text-low); font-size:.9rem; padding:8px 0; } diff --git a/ui/hybrid.html b/ui/hybrid.html new file mode 100644 index 0000000..6951a6b --- /dev/null +++ b/ui/hybrid.html @@ -0,0 +1,835 @@ + + + + + +Helix — Personal Health Intelligence · Final (Hybrid) + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ +
+ +
+
+
+ Two strands · one story +

Good morning,
Alex.

+

Overnight your twin wove together everything you’ve measured with what it means. Your data and Helix’s intelligence, intertwined — and every word below shows its work and cites its source. Gently, never a diagnosis.

+
+
+ Biological age + · chrono — · +
+
+ Composite today + · +
+
+ Data sources + · all on-device +
+
+
+ +
+ + + + Live twin +
+ Your digital twin +
+ Data strand + Intelligence strand +
+
+
+
+
+ + +
+
+
+
+ Composite health score +

A single number, fully decomposable.

+
+

Tap any domain to project it onto the ring. The composite is a weighted blend — never a black box.

+
+ +
+ + +
+ + + Weighted blend · updated from your own sources + +
+
+
+
+
+ + +
+
+
+
+ Today’s gentle nudges +

Four small things, each with its receipts.

+
+

Open Why? on any card to see your own data, the evidence tier, and the reference behind it.

+
+
+
+
+ + +
+
+
+
+ Every marker, checked +

One grounded answer per concept — or an honest abstention.

+
+

Each card is computed from your own records against a reference range. No range on file and none in the population fallback → Helix declines rather than guesses.

+
+
+ +
+
+ + +
+
+
+
+ For power users +

Deep dive — the instrument view.

+
+ +
+

The daily companion above stays calm and premium. Open the console for live oscilloscope vitals, channel readouts, and the raw signal stream — full diagnostic density, on demand, off by default.

+ + +
+
+ + +
+
+
+
+ Holistic event map +

Ninety days, every strand aligned.

+
+

+ + Labs · Supplements · Sleep · Body · Workouts — merged locally +

+
+ +
+
+ +
+ 90-day timeline + +
+
+ +
+
+
+
+ Lab draw + Supplement + Sleep quality + Body composition + Workout +
+
+
+
+
+ + +
+
+
+
+ Private by design +

Helix runs on your device — and shows its work.

+

Your records never leave your machine unless you choose. There is no cloud account, no data broker, no silent training. Every insight is traceable to a source you can inspect, and Helix will decline to answer rather than guess.

+
+ Private + On-device + Local-first + You hold the keys +
+
+
+
On-device — Tauri desktop + PWA mobile, encrypted at rest
+
Anti-hallucination — no citation, no claim
+
Non-diagnostic — wellness guidance, always your call
+
+
+
+ + + Helix — ISO Vision LLC + + Concept mockup · Final direction — “Aurora Companion” (A + C + B hybrid) · Sample data only +
+
+
+ + + + + diff --git a/ui/hybrid.js b/ui/hybrid.js new file mode 100644 index 0000000..2cb8d2e --- /dev/null +++ b/ui/hybrid.js @@ -0,0 +1,839 @@ +// ============================================================================ +// hybrid.js — wires ui/hybrid.html (the "Aurora Companion" hybrid mockup) to +// the REAL Helix anti-hallucination pipeline compiled to WebAssembly (ui/pkg). +// +// Nothing here re-implements analytic logic in JS. Every number, trend, tier, +// citation and abstention shown on the page is produced by the audited Rust +// core (helix-score / helix-bioage / helix-timeline / helix-focus / +// helix-pipeline / helix-refranges) over the imported dossier records. The +// mockup's aesthetic (aurora frame, warm proof-trail cards, three.js twin, +// score ring, event map, deep-dive instrument) is preserved verbatim; only the +// data feeding those components changed from hardcoded to live. +// ============================================================================ + +import init, { + compose_score_json, + bioage_json, + timeline_json, + focus_json, + analyze_json, + population_range_json, + version, + redflag_registry_version, +} from "./pkg/helix.js"; + +const DAY = 86_400_000; +const reduce = window.matchMedia && matchMedia("(prefers-reduced-motion: reduce)").matches; + +// ---- dossier state --------------------------------------------------------- +let NOW = Date.UTC(2026, 5, 1); +let records = []; +let MEDS = []; +let NOTES = []; +let META = null; +let SUBSYSTEMS = []; +let TIMELINE = []; +let BIOAGE = null; +let isPrivate = false; +let byCode = new Map(); + +// Minimal synthetic fallback so the page still renders if the fetch fails. +const FALLBACK = { + now: NOW, + records: [ + { id: "f1", source: "Quest Diagnostics", measured_at: NOW - 85 * DAY, method: "lab_feed", code: "2276-4", concept: "Ferritin", value: 22, unit: "ng/mL", reference_range: { low: 30, high: 400 }, confidence: 1 }, + { id: "f2", source: "Quest Diagnostics", measured_at: NOW - 40 * DAY, method: "lab_feed", code: "2276-4", concept: "Ferritin", value: 26, unit: "ng/mL", reference_range: { low: 30, high: 400 }, confidence: 1 }, + { id: "f3", source: "Quest Diagnostics", measured_at: NOW - 8 * DAY, method: "lab_feed", code: "2276-4", concept: "Ferritin", value: 29, unit: "ng/mL", reference_range: { low: 30, high: 400 }, confidence: 1 }, + { id: "l1", source: "Quest Diagnostics", measured_at: NOW - 40 * DAY, method: "lab_feed", code: "13457-7", concept: "LDL cholesterol (calc)", value: 135, unit: "mg/dL", reference_range: { low: null, high: 100 }, confidence: 1 }, + { id: "l2", source: "Quest Diagnostics", measured_at: NOW - 8 * DAY, method: "lab_feed", code: "13457-7", concept: "LDL cholesterol (calc)", value: 128, unit: "mg/dL", reference_range: { low: null, high: 100 }, confidence: 1 }, + ], + medications: [], notes: [], timeline: [{ days_before: 88, value: 68 }, { days_before: 0, value: 75 }], + bioage_inputs: { albumin_g_l: 47, creatinine_umol_l: 82, glucose_mmol_l: 5, crp_mg_dl: 0.11, lymphocyte_pct: 33, mcv_fl: 86, rdw_pct: 13.8, alk_phosphatase_u_l: 70, wbc_1000_ul: 5.8, age_years: 45 }, + subsystems: [ + { subsystem: "cardiometabolic", value: 72, weight: 0.35, confidence: 0.85, trend: "improving", driver: "Lipids" }, + { subsystem: "sleep", value: 76, weight: 0.25, confidence: 0.8, trend: "improving", driver: "Deep sleep, HRV" }, + { subsystem: "inflammation", value: 80, weight: 0.2, confidence: 0.85, trend: "stable", driver: "hs-CRP, ferritin" }, + { subsystem: "fitness", value: 74, weight: 0.2, confidence: 0.7, trend: "improving", driver: "Resting HR, body fat" }, + ], + meta: { persona: { name: "Alex Rivera" }, synthetic: true }, +}; + +// ---- utils ----------------------------------------------------------------- +const cap = (s) => String(s).charAt(0).toUpperCase() + String(s).slice(1); +const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c])); +function fmtNum(n) { if (n == null || !isFinite(n)) return "—"; const r = Math.round(n * 10) / 10; return Number.isInteger(r) ? String(r) : r.toFixed(1); } +function fmtDate(ms) { const d = new Date(ms); return d.toLocaleString("en", { month: "short" }) + " " + d.getDate(); } +function clk() { const d = new Date(), p = (n) => String(n).padStart(2, "0"); return p(d.getHours()) + ":" + p(d.getMinutes()) + ":" + p(d.getSeconds()); } + +function applyDossier(d) { + NOW = d.now ?? NOW; + records = d.records || []; + MEDS = d.medications || []; + NOTES = d.notes || []; + META = d.meta || null; + SUBSYSTEMS = d.subsystems || []; + TIMELINE = d.timeline || []; + BIOAGE = d.bioage_inputs || null; + byCode = new Map(); + for (const r of records) { + if (!r.code) continue; + let a = byCode.get(r.code); + if (!a) { a = []; byCode.set(r.code, a); } + a.push(r); + } + for (const [, a] of byCode) a.sort((x, y) => x.measured_at - y.measured_at); +} + +// Resolve a reference range for a concept: the record's OWN range first +// (a lab's stated interval), else the NHANES population fallback, else none. +// Never invents a range — a null result drives an honest abstention. +function resolveRange(recs) { + for (let i = recs.length - 1; i >= 0; i--) { + const rr = recs[i].reference_range; + if (rr && (rr.low != null || rr.high != null)) return { lo: rr.low ?? null, hi: rr.high ?? null, src: "own" }; + } + const code = recs[0] && recs[0].code; + if (code) { + const pop = JSON.parse(population_range_json(code)); + if (pop) return { lo: pop.low ?? null, hi: pop.high ?? null, src: "population", name: pop.name }; + } + return { lo: null, hi: null, src: null }; +} + +function analyzeConcept(code, recs, rng) { + return JSON.parse(analyze_json(JSON.stringify({ + concept_code: code, records: recs, now: NOW, + staleness_window_days: 365, confidence_floor: 0.5, + reference_low: rng.lo, reference_high: rng.hi, + flat_band_per_day: 0.02, flat_band_frac: 0, + }))); +} + +const isLab = (recs) => recs[recs.length - 1] && recs[recs.length - 1].method === "lab_feed"; +function tierFor(recs) { + const m = recs[recs.length - 1] && recs[recs.length - 1].method; + if (m === "lab_feed") return { letter: "A", word: "Strong — direct lab measurement", cls: "a" }; + if (m === "manual_entry") return { letter: "B", word: "Moderate — self-logged intake", cls: "b" }; + return { letter: "B", word: "Moderate — device / wearable-derived", cls: "b" }; +} + +// ---- dossier selection ----------------------------------------------------- +// Order: explicit ?dossier= override, then a private drop-in, then the synthetic +// demo. Only same-origin relative paths are fetched (no external data). +async function pickDossier() { + const q = new URLSearchParams(location.search).get("dossier"); + const tries = []; + if (q) tries.push({ url: q, priv: true }); + tries.push({ url: "./private/dossier.json", priv: true }); + tries.push({ url: "./demo-dossier.json", priv: false }); + for (const t of tries) { + try { + const r = await fetch(t.url, { cache: "no-cache" }); + if (r.ok) return { data: await r.json(), priv: t.priv, url: t.url }; + } catch (_) { /* optional source absent — keep looking */ } + } + return { data: FALLBACK, priv: false, url: "(fallback)" }; +} + +function renderBanner() { + const el = document.getElementById("banner"); + if (!el) return; + const synthetic = !isPrivate && (META?.synthetic !== false); + if (synthetic) { + const name = META?.persona?.name || "Alex Rivera"; + const n = (records.length + MEDS.length).toLocaleString(); + el.className = "sample-banner"; + el.innerHTML = + '' + + `Sample · Synthetic demo data — persona “${esc(name)}” is fictional (${n} on-device records); not real PHI` + + ''; + } else { + el.className = "sample-banner private"; + const lock = ''; + el.innerHTML = + '' + lock + + "Private · your data — decrypted locally, analysed on-device · 0 B egress" + + ''; + } +} + +// ---- composite score: ring + sub-rows (compose_score_json) ----------------- +function computeScore() { + const subs = SUBSYSTEMS.map((s) => ({ + subsystem: s.subsystem, value: s.value, weight: s.weight, confidence: s.confidence, trend: s.trend, + drivers: [{ concept: s.driver || s.subsystem, points: s.value, trend: s.trend, source_record: "rec" }], + })); + return JSON.parse(compose_score_json(JSON.stringify(subs))); +} + +function renderScore(score) { + const prog = document.getElementById("ringProg"); + const numEl = document.getElementById("ringNum"); + const lblEl = document.getElementById("ringLbl"); + const C = 2 * Math.PI * 92; + prog.style.strokeDasharray = C; + prog.style.strokeDashoffset = C; + let shown = 0; + function animateNum(to) { + if (reduce) { numEl.textContent = Math.round(to); shown = to; return; } + const from = shown, start = performance.now(), dur = 950; + (function step(t) { + const k = Math.min(1, (t - start) / dur), e = 1 - Math.pow(1 - k, 3); + numEl.textContent = Math.round(from + (to - from) * e); + if (k < 1) requestAnimationFrame(step); else shown = to; + })(performance.now()); + } + function setRing(value, label) { prog.style.strokeDashoffset = C * (1 - value / 100); lblEl.textContent = label; animateNum(value); } + + const comp = { value: Math.round(score.value), label: "Composite" }; + const subList = document.getElementById("subList"); + subList.innerHTML = ""; + (score.subscores || []).forEach((s) => { + const name = cap(s.subsystem), val = Math.round(s.value); + const row = document.createElement("div"); + row.className = "subrow"; + row.setAttribute("role", "button"); row.setAttribute("tabindex", "0"); + row.setAttribute("aria-label", `${name} score ${val} of 100`); + row.innerHTML = `${esc(name)}${val}`; + const activate = () => { + document.querySelectorAll(".subrow").forEach((r) => r.classList.remove("active")); + row.classList.add("active"); setRing(val, name); + }; + row.addEventListener("click", activate); + row.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); activate(); } }); + subList.appendChild(row); + }); + const holder = document.querySelector(".ring-holder"); + holder.addEventListener("click", () => { + document.querySelectorAll(".subrow").forEach((r) => r.classList.remove("active")); + setRing(comp.value, comp.label); + }); + + // kick-off animation (matches the mockup's timing/easing) + document.querySelectorAll(".subs .bar>i").forEach((el, i) => { + if (reduce) { el.style.transition = "none"; el.style.width = el.getAttribute("data-w") + "%"; } + else setTimeout(() => { el.style.width = el.getAttribute("data-w") + "%"; }, 350 + i * 90); + }); + if (reduce) { prog.style.transition = "none"; setRing(comp.value, comp.label); } + else setTimeout(() => setRing(comp.value, comp.label), 500); +} + +// ---- bio-age (bioage_json) ------------------------------------------------- +function bioageInputs() { + return BIOAGE || { albumin_g_l: 47, creatinine_umol_l: 82, glucose_mmol_l: 5, crp_mg_dl: 0.11, lymphocyte_pct: 33, mcv_fl: 86, rdw_pct: 13.8, alk_phosphatase_u_l: 70, wbc_1000_ul: 5.8, age_years: 45 }; +} + +// ---- hero stat pills ------------------------------------------------------- +function renderHero(score, ba, delta, firstComposite) { + const first = (META?.persona?.name || "").trim().split(/\s+/)[0]; + document.getElementById("heroName").textContent = (first || "there") + "."; + + const byr = Math.round(ba.phenoage_years); + const chrono = Math.round(ba.chronological_years); + const dy = ba.phenoage_years - ba.chronological_years; + const younger = dy < 0; + document.getElementById("heroBioage").innerHTML = + `${byr} · chrono ${chrono} · ${Math.abs(dy).toFixed(1)} yrs ${younger ? "younger" : "older"}`; + + const comp = Math.round(score.value); + const arrow = delta > 0 ? `▲ ${delta}` : delta < 0 ? `▼ ${Math.abs(delta)}` : "→ 0"; + document.getElementById("heroComposite").innerHTML = + `${comp} · ${arrow} vs 90 days ago`; + + const sources = new Set(); + records.forEach((r) => sources.add(r.source)); + MEDS.forEach((m) => sources.add(m.source)); + NOTES.forEach((n) => sources.add(n.author || n.source)); + document.getElementById("heroSources").innerHTML = `${sources.size} · all on-device`; + + document.getElementById("scoreHint").textContent = + `${(score.subscores || []).length} domains · weighted blend · confidence ${Math.round(score.confidence * 100)}% · up from ${firstComposite}`; + const rd = document.getElementById("ringDelta"); if (rd) rd.textContent = arrow; + const holder = document.querySelector(".ring-holder"); + if (holder) holder.setAttribute("aria-label", `Composite health score ${comp} of 100, ${arrow} vs 90 days ago. Click to reset to composite.`); + const nav = document.getElementById("navDate"); + if (nav) { const d = new Date(NOW); nav.textContent = d.toLocaleDateString("en", { weekday: "short", day: "2-digit", month: "short", year: "numeric" }).replace(",", " ·"); } +} + +// ---- proof trail (shared by nudge + grounded cards) ------------------------ +function seriesFlow(out, unit) { + const ev = out?.claims?.[0]?.evidence || []; + if (ev.length >= 2) return `${ev.map((e, i) => (i ? '' : "") + "" + fmtNum(e.value) + "").join("")} ${esc(unit)}`; + if (ev.length === 1) return `${fmtNum(ev[0].value)} ${esc(unit)}`; + return "—"; +} +function fmtRange(lo, hi, unit) { + const u = unit ? " " + esc(unit) : ""; + if (lo != null && hi != null) return `${fmtNum(lo)}–${fmtNum(hi)}${u}`; + if (hi != null) return `≤ ${fmtNum(hi)}${u}`; + if (lo != null) return `≥ ${fmtNum(lo)}${u}`; + return "—"; +} +function buildProof(p) { + const ref = fmtRange(p.lo, p.hi, p.unit); + const refNote = p.rangeSrc === "population" + ? 'Population fallback (NHANES) — not your lab\'s own range' + : (p.rangeSrc === "own" ? 'From your lab\'s stated reference interval' : ""); + const cite = p.lastEv + ? `${esc(p.lastEv.source)} · ${esc(fmtDate(p.lastEv.measured_at))}` + : ""; + return ( + '
' + + "" + + '' + + "Why? See the proof trail" + + 'Anti-hallucination' + + "" + + '
' + + `
Own data${p.source}
` + + `
Observed${p.obs}
` + + `
EvidenceTier ${p.tier.letter} — ${p.tier.word}
` + + `
Reference${ref}${refNote}
${cite}
` + + `
Action${p.action} · non-diagnostic
` + + "
" + + "
" + ); +} + +const ICONS = { + sleep: '', + iron: '', + heart: '', + body: '', +}; +function iconFor(concept) { + const c = concept.toLowerCase(); + if (/ferritin|iron|transferrin|hemoglob|hematocrit|rbc|red cell|mcv|mch/.test(c)) return "iron"; + if (/ldl|hdl|cholesterol|apolipo|triglyc|lipid|crp|heart|cardio/.test(c)) return "heart"; + if (/sleep|hrv|rem/.test(c)) return "sleep"; + if (/body|fat|weight|muscle|bmi|visceral/.test(c)) return "body"; + return "heart"; +} + +// ---- nudge cards (focus_json, enriched with analyze_json) ------------------ +function renderNudges() { + const grid = document.getElementById("nudgeGrid"); + grid.innerHTML = ""; + const focus = JSON.parse(focus_json(JSON.stringify({ records, now: NOW }))); + if (!focus.length) { grid.innerHTML = '

Nothing needs your attention right now — everything on file is inside its reference range.

'; return; } + + focus.forEach((f, i) => { + const cited = f.cites.map((id) => records.find((r) => r.id === id)).filter(Boolean); + const code = cited.length ? cited[cited.length - 1].code : null; + const recs = (code && byCode.get(code)) || cited; + const rng = resolveRange(recs.length ? recs : cited); + let out = null; + try { if (code) out = analyzeConcept(code, recs, rng); } catch (e) { console.warn("analyze failed for", code, e); } + + const unit = recs[recs.length - 1]?.unit || ""; + const latest = out?.trend?.latest_value ?? (cited.length ? cited[cited.length - 1].value : null); + const below = rng.lo != null && latest != null && latest < rng.lo; + const above = rng.hi != null && latest != null && latest > rng.hi; + const dir = out?.trend?.direction; + const improving = (below && dir === "rising") || (above && dir === "falling"); + const escalate = out?.escalation?.level === "critical"; + const tone = escalate ? "warm" : improving ? "cool" : "warm"; + const tier = tierFor(recs); + + const statusWord = below ? "below its reference range" : above ? "above its reference range" : "within range"; + const trendWord = dir === "rising" ? "trending up" : dir === "falling" ? "trending down" : "holding steady"; + const title = escalate + ? `${esc(f.concept)} needs a clinician’s eyes.` + : `Your ${esc(f.concept)} is ${statusWord}${improving ? " — and " + trendWord : ""}.`; + const body = escalate ? esc(out.escalation.message) : esc(out?.claims?.[0]?.text || f.message); + const action = escalate + ? "Bring this to a clinician now — optimisation tips are suppressed on a red-flag value." + : esc(out?.recommendation?.text || "Worth a mention at your next visit."); + const ownSrc = cited[0]?.source || recs[0]?.source || "your records"; + + const proof = buildProof({ + source: `${esc(ownSrc)} — ${esc(f.concept)} ×${recs.length} (own records)`, + obs: `${seriesFlow(out, unit)} (${statusWord}, ${trendWord})`, + tier, lo: rng.lo, hi: rng.hi, unit, rangeSrc: rng.src, + lastEv: out?.claims?.[0]?.evidence?.slice(-1)[0] || cited[cited.length - 1], + action, + }); + + const card = document.createElement("article"); + card.className = `nudge reveal ${tone === "cool" ? "cool" : ""}${escalate ? " escalate" : ""}`; + card.style.setProperty("--i", i + 1); + const stroke = tone === "cool" ? "#83e6a6" : "#f6bd7c"; + card.innerHTML = + '
' + + `` + + `Tier ${tier.letter}` + + "
" + + `

${title}

` + + `

${body}

` + + `Own · ${esc(ownSrc)}` + + `

${escalate ? "Escalated" : "Next"} · ${action}

` + + proof; + grid.appendChild(card); + }); +} + +// ---- per-concept grounded answers (THE generalised capability) ------------- +// Enumerates every DISTINCT concept code in the dossier, resolves a reference +// range (own → population fallback → none), runs analyze_json, and renders a +// proof-trail card per grounded concept. Concepts with no available range are +// shown as honest abstentions ("insufficient reference data"), never guessed. +function renderGrounded() { + const grid = document.getElementById("groundedGrid"); + grid.innerHTML = ""; + const cards = []; + const abstain = []; + + for (const [code, recs] of byCode) { + const rng = resolveRange(recs); + if (rng.lo == null && rng.hi == null) { abstain.push(recs[0].concept); continue; } + let out; + try { out = analyzeConcept(code, recs, rng); } catch (e) { abstain.push(recs[0].concept); continue; } + if (out.outcome !== "answered") { abstain.push(recs[0].concept); continue; } + const lv = out.trend?.latest_value ?? recs[recs.length - 1].value; + const outOfRange = (rng.lo != null && lv < rng.lo) || (rng.hi != null && lv > rng.hi); + cards.push({ code, recs, out, rng, lv, outOfRange, lab: isLab(recs) }); + } + + // most interesting first: out-of-range, then direct-lab, then alphabetical + cards.sort((a, b) => (b.outOfRange - a.outOfRange) || (b.lab - a.lab) || a.recs[0].concept.localeCompare(b.recs[0].concept)); + cards.forEach((c, i) => grid.appendChild(groundedCard(c, i))); + + document.getElementById("groundedIntro").innerHTML = + `${cards.length} of your ${byCode.size} tracked concepts have a reference to check against and returned a grounded answer; ` + + `${abstain.length} are held for insufficient reference data — Helix declines rather than guesses.`; + + if (abstain.length) { + const wrap = document.getElementById("abstainWrap"); + wrap.hidden = false; + document.getElementById("abstainSummary").textContent = `Held for insufficient reference data · ${abstain.length}`; + document.getElementById("abstainStrip").innerHTML = abstain + .sort((a, b) => a.localeCompare(b)) + .map((n) => `${esc(n)}`).join(""); + } +} + +function groundedCard(c, i) { + const { out, recs, rng, lv, outOfRange } = c; + const concept = recs[0].concept; + const unit = recs[recs.length - 1].unit || ""; + const dir = out.trend?.direction; + const tier = tierFor(recs); + const tone = outOfRange ? "warm" : "cool"; + const below = rng.lo != null && lv < rng.lo; + const rangeLbl = outOfRange ? (below ? "below range" : "above range") : "in range"; + const arrow = dir === "rising" ? "▲" : dir === "falling" ? "▼" : "→"; + const trendCls = dir === "rising" ? "up" : dir === "falling" ? "down" : ""; + const trendWord = dir === "rising" ? "trending up" : dir === "falling" ? "trending down" : "steady"; + const statusWord = below ? "below its reference range" : outOfRange ? "above its reference range" : "within range"; + + const proof = buildProof({ + source: `${esc(recs[0].source)} — ${esc(concept)} ×${recs.length} (own records)`, + obs: `${seriesFlow(out, unit)} (${statusWord}, ${trendWord})`, + tier, lo: rng.lo, hi: rng.hi, unit, rangeSrc: rng.src, + lastEv: out.claims?.[0]?.evidence?.slice(-1)[0], + action: esc(out.recommendation?.text || "Track on your next panel to confirm the trend."), + }); + + const card = document.createElement("article"); + card.className = `nudge compact reveal ${tone === "cool" ? "cool" : ""}`; + card.style.setProperty("--i", (i % 8) + 1); + const stroke = tone === "cool" ? "#83e6a6" : "#f6bd7c"; + card.innerHTML = + '
' + + `` + + `Tier ${tier.letter}` + + "
" + + `

${esc(concept)}

` + + `
${fmtNum(lv)}${esc(unit)}` + + `${rangeLbl}` + + `${arrow} ${trendWord}
` + + proof; + return card; +} + +// ---- timeline: composite trajectory + sparklines + 90-day event map -------- +function buildTimeline(score) { + const src = (Array.isArray(TIMELINE) && TIMELINE.length) ? TIMELINE : [{ days_before: 0, value: Math.round(score.value) }]; + // NOTE: Snapshot.subscores[].subsystem is a fixed Rust enum (cardiometabolic | + // sleep | inflammation | fitness). We only need a single carrier for the + // already-composited daily value, so we use "sleep" with weight 1 — the + // timeline value equals the composite we pass in (same approach as app.js). + const snaps = src.map((p) => ({ + at: NOW - p.days_before * DAY, + subscores: [{ subsystem: "sleep", value: p.value, weight: 1, confidence: 0.9, drivers: [{ concept: "composite", points: p.value, trend: "stable", source_record: "r" }], trend: "stable" }], + })); + return JSON.parse(timeline_json(JSON.stringify({ snapshots: snaps, flat_band: 0.001 }))); +} + +function sparkSVG(data, color, id) { + const w = 100, h = 34, pad = 4, min = Math.min(...data), max = Math.max(...data), rng = (max - min) || 1; + const pts = data.map((v, i) => [(i / (data.length - 1)) * (w - pad * 2) + pad, h - pad - ((v - min) / rng) * (h - pad * 2)]); + const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" "); + const area = d + " L " + pts[pts.length - 1][0].toFixed(1) + " " + h + " L " + pts[0][0].toFixed(1) + " " + h + " Z"; + const last = pts[pts.length - 1]; + return ``; +} + +function renderTimeline(score, tl) { + const sparkRow = document.getElementById("sparkRow"); + sparkRow.innerHTML = ""; + const sparks = []; + // #1 — composite trajectory straight from timeline_json + sparks.push({ + name: "Composite", data: tl.points.map((p) => p.value), val: Math.round(tl.points[tl.points.length - 1].value), unit: "/100", + dir: tl.direction, color: "#8bf3a4", tag: (tl.direction === "rising" ? "improving" : tl.direction === "falling" ? "slipping" : "steady") + (tl.change_point_at ? " · change-point" : ""), + cls: tl.direction === "falling" ? "watch" : "good", + }); + // #2..N — a few real concept series that exist in this dossier + const wanted = [["2276-4", "#8bf3a4"], ["13457-7", "#37e6d0"], ["RENPHO-BFP", "#9fe9df"], ["HK-SLEEP-DEEP", "#f2c778"]]; + for (const [code, color] of wanted) { + const recs = byCode.get(code); + if (!recs || recs.length < 2) continue; + const data = recs.map((r) => r.value); + const d = data[data.length - 1] - data[0]; + const dir = Math.abs(d) < 1e-9 ? "flat" : d > 0 ? "rising" : "falling"; + const rng = resolveRange(recs); + const lv = data[data.length - 1]; + const out = (rng.lo != null && lv < rng.lo) || (rng.hi != null && lv > rng.hi); + sparks.push({ name: recs[0].concept, data, val: fmtNum(lv), unit: recs[0].unit, dir, color, tag: out ? "outside range" : "in range", cls: out ? "watch" : "good" }); + if (sparks.length >= 4) break; + } + sparks.forEach((s, i) => { + const arrow = s.dir === "rising" ? "▲" : s.dir === "falling" ? "▼" : "→"; + const el = document.createElement("div"); + el.className = "spark"; + el.innerHTML = + `
${esc(s.name)}${s.val} ${esc(s.unit)}
` + + sparkSVG(s.data, s.color, "sp" + i) + + `${arrow} ${esc(s.tag)}`; + sparkRow.appendChild(el); + }); + + buildEventMap(); +} + +function buildEventMap() { + const laneWrap = document.getElementById("laneWrap"); + laneWrap.innerHTML = ""; + const start = NOW - 90 * DAY; + const pct = (t) => Math.max(0, Math.min(100, ((t - start) / (90 * DAY)) * 100)); + const inWin = (t) => t >= start - DAY && t <= NOW + DAY; + + // month tick labels + range header driven by the REAL 90-day window + const mon = (t) => new Date(t).toLocaleString("en", { month: "short" }); + const ticksEl = document.getElementById("mapTicks"); + if (ticksEl) ticksEl.innerHTML = [0, 1 / 3, 2 / 3, 1].map((f) => `${mon(start + f * 90 * DAY)}`).join(""); + const rangeEl = document.getElementById("mapRange"); + if (rangeEl) rangeEl.textContent = `${mon(start)} → ${mon(NOW)} ${new Date(NOW).getFullYear()}`; + + const lane = (name, sw, inner) => { + const el = document.createElement("div"); + el.className = "lane-a"; + el.innerHTML = `${esc(name)}
${inner}
`; + laneWrap.appendChild(el); + }; + const dot = (p, color, label) => ``; + + // Labs — one dot per distinct draw date (lab_feed records) + const labDays = [...new Set(records.filter((r) => r.method === "lab_feed" && inWin(r.measured_at)).map((r) => new Date(r.measured_at).toISOString().slice(0, 10)))].sort(); + lane("Labs", "#37e6d0", labDays.map((d) => { const t = Date.parse(d + "T00:00:00Z"); return dot(pct(t), "#37e6d0", "Lab panel · " + fmtDate(t)); }).join("") || ""); + + // Supplements — medication start → running bar to today + if (MEDS.length) { + const m = MEDS.slice().sort((a, b) => a.measured_at - b.measured_at)[0]; + const p = pct(m.measured_at); + const shortName = String(m.concept).split(" ")[0]; + lane("Supplements", "#3fd489", + `` + + `` + + dot(p, "#8bf3a4", `${shortName} started · ${fmtDate(m.measured_at)}`)); + } + + // Sleep — nightly deep-sleep bars, height scaled to value + const deep = (byCode.get("HK-SLEEP-DEEP") || []).filter((r) => inWin(r.measured_at)); + if (deep.length) { + const vals = deep.map((r) => r.value), mn = Math.min(...vals), mx = Math.max(...vals), rng = (mx - mn) || 1; + const bars = deep.map((r) => { + const h = 6 + ((r.value - mn) / rng) * 16; + const p = pct(r.measured_at); + const low = r.value < mn + rng * 0.35; + return ``; + }).join(""); + lane("Sleep", "#f2c778", bars); + } + + // Body — body-fat trend line + latest dot + const bfp = (byCode.get("RENPHO-BFP") || []).filter((r) => inWin(r.measured_at)); + if (bfp.length >= 2) { + const vals = bfp.map((r) => r.value), mn = Math.min(...vals), mx = Math.max(...vals), rng = (mx - mn) || 1; + const pts = bfp.map((r) => [pct(r.measured_at), 22 - ((r.value - mn) / rng) * 14]); + const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(" "); + const svg = ``; + const lastV = bfp[bfp.length - 1].value; + lane("Body", "#9fe9df", svg + dot(pct(bfp[bfp.length - 1].measured_at), "#9fe9df", `${fmtNum(lastV)}% body fat · today`)); + } + + // Workouts — a tick per session + const wk = (byCode.get("HK-WORKOUT") || []).filter((r) => inWin(r.measured_at)); + if (wk.length) { + const now14 = NOW - 14 * DAY; + const recent = wk.filter((r) => r.measured_at >= now14).length; + const ticks = wk.map((r) => { + const p = pct(r.measured_at), hot = r.measured_at >= now14; + return ``; + }).join(""); + lane("Workouts", "#17b7ab", ticks + `${recent} in last 14d`); + } +} + +// ---- deep-dive instrument (opt-in) ----------------------------------------- +function wireDeepDive(score, ba) { + const btn = document.getElementById("instToggle"); + const panel = document.getElementById("instPanel"); + let ready = false; + btn.addEventListener("click", () => { + const open = panel.hasAttribute("hidden"); + const label = btn.querySelector(".inst-label"); + if (open) { + panel.removeAttribute("hidden"); panel.classList.add("reveal-in"); + btn.setAttribute("aria-expanded", "true"); label.textContent = "Close instrument view"; + if (!ready) { ready = true; requestAnimationFrame(() => initInstrument(score, ba)); } + } else { + panel.setAttribute("hidden", ""); panel.classList.remove("reveal-in"); + btn.setAttribute("aria-expanded", "false"); label.textContent = "Open instrument view"; + } + }); +} + +function setReadout(id, code) { + const el = document.getElementById(id); + const recs = byCode.get(code); + if (!el || !recs || !recs.length) return; + const v = fmtNum(recs[recs.length - 1].value); + if (el.firstChild && el.firstChild.nodeType === 3) el.firstChild.nodeValue = v; else el.textContent = v; +} + +function initInstrument(score, ba) { + // channel readouts ← the real weighted sub-scores + const chWrap = document.getElementById("channels"); + chWrap.innerHTML = (score.subscores || []).map((s) => { + const v = Math.round(s.value), cls = v >= 80 ? "g-good" : v >= 65 ? "g-mid" : "g-low"; + return `
${esc(cap(s.subsystem))}${v}
`; + }).join(""); + const meters = chWrap.querySelectorAll(".meter i"); + if (reduce) meters.forEach((el) => { el.style.transition = "none"; el.style.width = el.getAttribute("data-w") + "%"; }); + else meters.forEach((el, i) => setTimeout(() => { el.style.width = el.getAttribute("data-w") + "%"; }, 120 * i + 120)); + const chSub = document.getElementById("chSub"); + if (chSub) chSub.textContent = `Channel readouts · ${(score.subscores || []).length} weighted sub-scores`; + + // bio-age readout ← bioage_json + const dy = ba.phenoage_years - ba.chronological_years; + document.getElementById("conBioAge").textContent = ba.phenoage_years.toFixed(1); + document.getElementById("conChrono").textContent = Math.round(ba.chronological_years); + document.getElementById("conBioGap").innerHTML = + `${dy < 0 ? "◀ younger" : dy > 0 ? "older ▶" : "on par"}
gap ${dy >= 0 ? "+" : ""}${dy.toFixed(1)} yrs`; + + // oscilloscope numeric readouts ← real latest device/lab values (no jitter, no fabrication) + setReadout("v-hr", "HK-RHR"); + setReadout("v-hrv", "HK-HRV"); + setReadout("v-spo", "59408-5"); + setReadout("v-glu", "2345-7"); + + buildStream(score); + initScopes(); +} + +function buildStream(score) { + const log = document.getElementById("streamLog"); + if (!log) return; + log.innerHTML = ""; + const lines = []; + const ferr = byCode.get("2276-4"); + if (ferr?.length) lines.push(["quest", `ferritin ${ferr.map((r) => fmtNum(r.value)).join("→")} ng/mL · own records`]); + const ldl = byCode.get("13457-7"); + if (ldl?.length) lines.push(["quest", `LDL ${ldl.map((r) => fmtNum(r.value)).join("→")} mg/dL`]); + const bfp = byCode.get("RENPHO-BFP"); + if (bfp?.length) lines.push(["renpho", `body-fat ${fmtNum(bfp[0].value)}→${fmtNum(bfp[bfp.length - 1].value)}%`]); + const deep = byCode.get("HK-SLEEP-DEEP"); + if (deep?.length) { const last7 = deep.slice(-7); const avg = Math.round(last7.reduce((s, r) => s + r.value, 0) / last7.length); lines.push(["apple-watch", `deep-sleep 7-night avg ${avg} min`]); } + lines.push(["helix-local", `composite index ${Math.round(score.value)}/100 · ${esc(score.methodology_version)}`]); + lines.push(["vault", `${records.length.toLocaleString()} own-data points indexed · sealed`]); + lines.push(["proof-engine", `red-flag registry ${esc(redflag_registry_version())} · engine v${esc(version())}`]); + lines.push(["net", 'cloud uplink severed · 0 B egress']); + + lines.forEach((l, i) => { + const add = () => { + const d = document.createElement("div"); + d.className = "ln"; + d.innerHTML = `${clk()}${l[0]}${l[1]}`; + log.appendChild(d); log.scrollTop = log.scrollHeight; + }; + if (reduce) add(); else setTimeout(add, i * 110); + }); +} + +// Decorative oscilloscope traces (abstract waveforms — no fabricated values are +// labelled). Ported verbatim from the mockup to keep the instrument's look. +function initScopes() { + const scopes = []; + document.querySelectorAll("#instPanel .scope").forEach((cv) => { + const ctx = cv.getContext("2d"); + const color = cv.dataset.color, kind = cv.dataset.kind, buf = []; + function size() { + const rct = cv.parentElement.getBoundingClientRect(), dpr = Math.min(window.devicePixelRatio || 1, 2); + cv.width = Math.max(1, rct.width * dpr); cv.height = Math.max(1, rct.height * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); cv._w = rct.width; cv._h = rct.height; + } + size(); window.addEventListener("resize", size); + scopes.push({ cv, ctx, color, kind, buf, get w() { return cv._w; }, get h() { return cv._h; } }); + }); + function sample(kind, t) { + switch (kind) { + case "ecg": { const p = t % 2.2; let v = 0; v += 0.08 * Math.sin(p * 3.14 / 0.2) * (p < 0.2 ? 1 : 0); + if (p > 0.5 && p < 0.62) { const q = (p - 0.5) / 0.12; v = q < 0.35 ? -0.18 * (q / 0.35) : q < 0.55 ? -0.18 + (q - 0.35) / 0.2 * 1.15 : 1.0 - (q - 0.55) / 0.45 * 1.18; } + if (p > 0.7 && p < 0.95) v += 0.22 * Math.sin((p - 0.7) / 0.25 * 3.14); return v * 0.7; } + case "hrv": return (Math.sin(t * 1.1) + Math.sin(t * 2.7 + 1) * 0.4 + Math.sin(t * 0.6) * 0.3) * 0.28; + case "resp": return Math.sin(t * 0.9) * 0.5 + Math.sin(t * 1.8) * 0.04; + case "glu": return Math.sin(t * 0.35) * 0.4 + Math.sin(t * 0.11) * 0.2 + (Math.random() - 0.5) * 0.03; + default: return 0; + } + } + function renderScope(s) { + const ctx = s.ctx, w = s.w, h = s.h, color = s.color; + ctx.clearRect(0, 0, w, h); + ctx.strokeStyle = "rgba(255,255,255,0.03)"; ctx.lineWidth = 1; + for (let x = 0; x < w; x += 16) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); } + ctx.beginPath(); + const n = s.buf.length; + for (let i = 0; i < n; i++) { const xx = i / (n - 1) * w, yy = h / 2 - s.buf[i] * h * 0.42; i ? ctx.lineTo(xx, yy) : ctx.moveTo(xx, yy); } + ctx.strokeStyle = color; ctx.lineWidth = 1.6; ctx.shadowColor = color; ctx.shadowBlur = 6; ctx.stroke(); ctx.shadowBlur = 0; + if (n) { const lx = w, ly = h / 2 - s.buf[n - 1] * h * 0.42; ctx.fillStyle = color; ctx.beginPath(); ctx.arc(lx - 1, ly, 2, 0, 7); ctx.fill(); } + } + if (reduce) { + scopes.forEach((s) => { const cap = Math.max(60, Math.floor(s.w)); for (let x = 0; x < cap; x++) s.buf.push(sample(s.kind, x * 0.08)); renderScope(s); }); + } else { + let t = 0; + (function draw() { + t += 0.055; + scopes.forEach((s) => { const cap = Math.max(60, Math.floor(s.w)); s.buf.push(sample(s.kind, t)); while (s.buf.length > cap) s.buf.shift(); renderScope(s); }); + requestAnimationFrame(draw); + })(); + } +} + +// ---- three.js digital twin (kept as-is from the mockup) -------------------- +function initTwin() { + const canvas = document.getElementById("twin"); + const stage = canvas.parentElement; + if (!window.THREE) { stage.classList.add("twin-fallback"); return; } + let renderer; + try { renderer = new THREE.WebGLRenderer({ canvas, alpha: true, antialias: true }); } + catch (e) { stage.classList.add("twin-fallback"); return; } + + const dims = () => ({ w: stage.clientWidth || 420, h: stage.clientHeight || 420 }); + const d0 = dims(); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.setSize(d0.w, d0.h, false); + renderer.setClearColor(0x000000, 0); + + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x04060b, 0.045); + const camera = new THREE.PerspectiveCamera(40, d0.w / d0.h, 0.1, 100); + camera.position.set(0, 0, 15.5); + const group = new THREE.Group(); + scene.add(group); + const TEAL = 0x37e6d0, GREEN = 0x8bf3a4; + + function makeGlow() { + const c = document.createElement("canvas"); c.width = c.height = 128; + const g = c.getContext("2d"); + const grd = g.createRadialGradient(64, 64, 0, 64, 64, 64); + grd.addColorStop(0, "rgba(255,255,255,1)"); grd.addColorStop(0.22, "rgba(190,255,240,0.75)"); grd.addColorStop(1, "rgba(255,255,255,0)"); + g.fillStyle = grd; g.fillRect(0, 0, 128, 128); + return new THREE.CanvasTexture(c); + } + const glowTex = makeGlow(); + + const N = 200, turns = 3, R = 2.15, H = 9.4, p1 = [], p2 = []; + for (let i = 0; i <= N; i++) { + const f = i / N, t = f * turns * Math.PI * 2, y = (f - 0.5) * H; + p1.push(new THREE.Vector3(Math.cos(t) * R, y, Math.sin(t) * R)); + p2.push(new THREE.Vector3(Math.cos(t + Math.PI) * R, y, Math.sin(t + Math.PI) * R)); + } + const tube1 = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(p1), 380, 0.072, 10, false), new THREE.MeshBasicMaterial({ color: TEAL, transparent: true, opacity: 0.95 })); + const tube2 = new THREE.Mesh(new THREE.TubeGeometry(new THREE.CatmullRomCurve3(p2), 380, 0.072, 10, false), new THREE.MeshBasicMaterial({ color: GREEN, transparent: true, opacity: 0.92 })); + group.add(tube1, tube2); + + const rungMat = new THREE.MeshBasicMaterial({ color: 0xbfeee6, transparent: true, opacity: 0.32 }); + for (let r = 6; r < N; r += 9) { + const a = p1[r], b = p2[r], mid = a.clone().add(b).multiplyScalar(0.5), dir = b.clone().sub(a), len = dir.length(); + const cyl = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.018, len, 6), rungMat); + cyl.position.copy(mid); cyl.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), dir.clone().normalize()); + group.add(cyl); + } + function addNodes(pts, coreHex, glowHex) { + const nodeMat = new THREE.MeshBasicMaterial({ color: coreHex }); + for (let i = 0; i <= N; i += 10) { + const s = new THREE.Mesh(new THREE.SphereGeometry(0.11, 12, 12), nodeMat); + s.position.copy(pts[i]); group.add(s); + const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: glowHex, transparent: true, opacity: 0.85, blending: THREE.AdditiveBlending, depthWrite: false })); + sp.scale.set(1.5, 1.5, 1); sp.position.copy(pts[i]); group.add(sp); + } + } + addNodes(p1, 0xdffff8, TEAL); addNodes(p2, 0xeafff0, GREEN); + + const bloom = new THREE.Sprite(new THREE.SpriteMaterial({ map: glowTex, color: 0x1fc9bb, transparent: true, opacity: 0.42, blending: THREE.AdditiveBlending, depthWrite: false })); + bloom.scale.set(12, 14, 1); scene.add(bloom); + + const dustGeo = new THREE.BufferGeometry(), cnt = 150, pos = new Float32Array(cnt * 3); + for (let k = 0; k < cnt; k++) { pos[k * 3] = (Math.random() - 0.5) * 16; pos[k * 3 + 1] = (Math.random() - 0.5) * 14; pos[k * 3 + 2] = (Math.random() - 0.5) * 8; } + dustGeo.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + const dust = new THREE.Points(dustGeo, new THREE.PointsMaterial({ color: 0x8fe9df, size: 0.05, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false })); + scene.add(dust); + + group.rotation.x = 0.16; + let mx = 0, my = 0, tx = 0, ty = 0; + window.addEventListener("pointermove", (e) => { tx = (e.clientX / window.innerWidth - 0.5); ty = (e.clientY / window.innerHeight - 0.5); }); + + let t0 = performance.now(); + (function loop(now) { + const dt = (now - t0) / 1000; t0 = now; + if (!reduce) group.rotation.y += dt * 0.26; + mx += (tx - mx) * 0.045; my += (ty - my) * 0.045; + group.rotation.x = 0.16 + my * 0.45; group.position.x = mx * 0.9; + dust.rotation.y -= dt * 0.04; + renderer.render(scene, camera); + requestAnimationFrame(loop); + })(performance.now()); + + function onResize() { const s = dims(); camera.aspect = s.w / s.h; camera.updateProjectionMatrix(); renderer.setSize(s.w, s.h, false); } + window.addEventListener("resize", onResize); setTimeout(onResize, 60); +} + +// ---- boot ------------------------------------------------------------------ +(async function boot() { + try { await init(); } + catch (e) { console.error("Helix WASM failed to initialise:", e); const gi = document.getElementById("groundedIntro"); if (gi) gi.textContent = "On-device engine failed to load."; return; } + + const picked = await pickDossier(); + applyDossier(picked.data); + isPrivate = picked.priv || (META?.synthetic === false); + renderBanner(); + + let score; + try { score = computeScore(); } + catch (e) { console.error("compose_score failed:", e); score = { value: 0, confidence: 0, subscores: [], methodology_version: "n/a" }; } + const ba = JSON.parse(bioage_json(JSON.stringify(bioageInputs()))).bioage; + const tl = buildTimeline(score); + const firstComposite = Math.round(tl.points[0].value); + const lastComposite = Math.round(tl.points[tl.points.length - 1].value); + const delta = lastComposite - firstComposite; + + renderScore(score); + renderHero(score, ba, delta, firstComposite); + renderNudges(); + renderGrounded(); + renderTimeline(score, tl); + wireDeepDive(score, ba); + initTwin(); +})(); From 652162eb5867f342713576df0972ee330ead0f1d Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: Wed, 1 Jul 2026 14:39:01 -0400 Subject: [PATCH 4/5] feat(helix-ingest): companion server + adaptive multi-format ingest + CSRF hardening - helix-ingest serve: loopback-only companion (static UI + vault/import/connector API) - adaptive ingest auto-detects FHIR / CLAW-envelope / Apple; unknown fails loudly (422) - CLAW-envelope adapter reaches into nested fhirData, unique wrapper-derived ids - id-collision fix in helix-connect (unique fallback id: code+measured_at) - CSRF / DNS-rebinding guard: Host must be loopback + Origin must be local, pre-routing - Apple Health local-push connector (POST /health/ingest, HAE subset) - tests green under --features persist; clippy -D warnings clean --- Cargo.lock | 32 ++ crates/helix-connect/src/lib.rs | 41 ++- crates/helix-ingest/Cargo.toml | 7 + crates/helix-ingest/src/lib.rs | 1 + crates/helix-ingest/src/main.rs | 64 +++- crates/helix-ingest/src/parse.rs | 214 +++++++++++- crates/helix-ingest/src/serve/api.rs | 296 ++++++++++++++++ crates/helix-ingest/src/serve/connectors.rs | 148 ++++++++ crates/helix-ingest/src/serve/hae.rs | 180 ++++++++++ crates/helix-ingest/src/serve/mod.rs | 337 ++++++++++++++++++ crates/helix-ingest/src/vault.rs | 28 ++ crates/helix-ingest/tests/serve.rs | 363 ++++++++++++++++++++ 12 files changed, 1699 insertions(+), 12 deletions(-) create mode 100644 crates/helix-ingest/src/serve/api.rs create mode 100644 crates/helix-ingest/src/serve/connectors.rs create mode 100644 crates/helix-ingest/src/serve/hae.rs create mode 100644 crates/helix-ingest/src/serve/mod.rs create mode 100644 crates/helix-ingest/tests/serve.rs diff --git a/Cargo.lock b/Cargo.lock index 1b552c3..3a193b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,6 +101,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-trait" version = "0.1.89" @@ -233,6 +239,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "ciborium" version = "0.2.2" @@ -685,6 +697,7 @@ version = "0.1.2" dependencies = [ "anyhow", "argon2", + "base64", "clap", "getrandom 0.2.17", "helix-connect", @@ -694,6 +707,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "tiny_http", ] [[package]] @@ -904,6 +918,12 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "icu_collections" version = "2.2.0" @@ -1642,6 +1662,18 @@ dependencies = [ "syn", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.3" diff --git a/crates/helix-connect/src/lib.rs b/crates/helix-connect/src/lib.rs index 34ae6df..7aa0402 100644 --- a/crates/helix-connect/src/lib.rs +++ b/crates/helix-connect/src/lib.rs @@ -95,7 +95,10 @@ pub fn parse_observation( if obs["resourceType"].as_str() != Some("Observation") { return Err(ConnectError::Parse("not an Observation".into())); } - let id = obs["id"].as_str().unwrap_or("obs").to_string(); + // Explicit id if present; otherwise synthesized below from code + measured_at + // (the `id` binding near the ProvRecord). Deferring lets us fall back to a + // UNIQUE key instead of the literal "obs", which used to collide. + let explicit_id = obs["id"].as_str(); // LOINC coding. let coding = obs["code"]["coding"] @@ -137,8 +140,15 @@ pub fn parse_observation( let reference_range = rr.map(|r| ReferenceRange::new(r["low"]["value"].as_f64(), r["high"]["value"].as_f64())); + // With an explicit id, keep the stable `fhir-{source}-{id}`. Without one, + // synthesize a UNIQUE id from code + measured_at (mirrors the Apple scheme) + // so distinct id-less observations on the same date never collide. + let id = match explicit_id { + Some(existing) => format!("fhir-{source}-{existing}"), + None => format!("fhir-{source}-{code}-{measured_at}"), + }; Ok(ProvRecord { - id: RecordId::from(format!("fhir-{source}-{id}")), + id: RecordId::from(id), source: source.to_string(), measured_at, method: MeasurementMethod::LabFeed, @@ -338,6 +348,33 @@ mod tests { assert!(parse_observation(&no_loinc, "s").is_err()); } + #[test] + fn idless_same_date_distinct_codes_get_distinct_ids() { + // Three id-less Observations, SAME date, DISTINCT LOINC codes. Before the + // fix they all collapsed to id "obs" and overwrote each other; now each + // gets a unique code+date id. + let mk = |code: &str| { + serde_json::json!({ + "resourceType": "Observation", + "code": { "coding": [{ "system": "http://loinc.org", "code": code, "display": "x" }] }, + "valueQuantity": { "value": 1.0, "unit": "u" }, + "effectiveDateTime": "2026-06-19" + }) + }; + let a = parse_observation(&mk("2276-4"), "s").unwrap(); + let b = parse_observation(&mk("2085-9"), "s").unwrap(); + let c = parse_observation(&mk("3016-3"), "s").unwrap(); + let ids: std::collections::BTreeSet = [a.id, b.id, c.id].into_iter().collect(); + assert_eq!(ids.len(), 3, "id-less same-date distinct-code obs must yield 3 distinct ids"); + } + + #[test] + fn explicit_id_path_unchanged() { + let obs = observation("2276-4", "Ferritin", 28.0, "ng/mL", "2026-06-19"); + let r = parse_observation(&obs, "MyChart").unwrap(); + assert_eq!(r.id, RecordId::from("fhir-MyChart-o1"), "with-id path is stable"); + } + #[test] fn connector_imports_and_queues_unparseable() { let good = observation("2276-4", "Ferritin", 28.0, "ng/mL", "2026-06-19"); diff --git a/crates/helix-ingest/Cargo.toml b/crates/helix-ingest/Cargo.toml index 5ec5863..1a70d85 100644 --- a/crates/helix-ingest/Cargo.toml +++ b/crates/helix-ingest/Cargo.toml @@ -26,9 +26,16 @@ rpassword = "7" # Passphrase KDF (mirrors helix-vault's private credential-vault KDF). argon2 = "0.5" getrandom = "0.2" +# `serve` companion server: a tiny, synchronous, loopback-only HTTP server (no +# async runtime, no outbound capability). ADR-057 Sealed-mode bind invariant. +tiny_http = "0.12" +# Decode uploaded files carried as base64 (or data-URL) inside the import JSON. +base64 = "0.22" [dev-dependencies] tempfile = "3" +# Used by the serve integration tests to exercise the base64/data-URL upload path. +base64 = "0.22" [features] # `persist` is a no-op passthrough kept so `cargo test -p helix-ingest --features diff --git a/crates/helix-ingest/src/lib.rs b/crates/helix-ingest/src/lib.rs index 8ef12b7..f5d4d73 100644 --- a/crates/helix-ingest/src/lib.rs +++ b/crates/helix-ingest/src/lib.rs @@ -22,6 +22,7 @@ use helix_provenance::ProvRecord; pub mod dossier; pub mod parse; +pub mod serve; pub mod vault; pub use parse::{APPLE_SOURCE, FHIR_SOURCE}; diff --git a/crates/helix-ingest/src/main.rs b/crates/helix-ingest/src/main.rs index c019e6f..be39fc5 100644 --- a/crates/helix-ingest/src/main.rs +++ b/crates/helix-ingest/src/main.rs @@ -1,26 +1,36 @@ //! `helix-ingest` CLI (ADR-029/001). //! +//! Two modes: //! ```text +//! # one-shot ingest (unchanged): file → sealed vault → gitignored dossier //! helix-ingest --fhir --apple --vault --out +//! +//! # NEW: local companion server (loopback only) powering guided onboarding +//! helix-ingest serve [--port 8799] [--vault ] [--ui ] //! ``` //! -//! Both sources are optional; at least one is required. The passphrase comes ONLY -//! from `HELIX_VAULT_PASSPHRASE` or an interactive prompt — never a flag, never -//! logged. The summary prints counts/metadata only; record values never touch a log. +//! For the ingest mode, both sources are optional but at least one is required. +//! The passphrase comes ONLY from `HELIX_VAULT_PASSPHRASE` or an interactive +//! prompt — never a flag, never logged. The summary prints counts/metadata only; +//! record values never touch a log. The `serve` mode binds `127.0.0.1` ONLY. use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{bail, Result}; -use clap::Parser; -use helix_ingest::{run, vault, RunArgs}; +use clap::{Args, Parser, Subcommand}; +use helix_ingest::{run, serve, vault, RunArgs}; /// Parse health-data files through the tested importers, seal them into the /// encrypted vault, prove the round-trip + encryption-at-rest, and emit a local -/// (gitignored) dossier.json for the UI. +/// (gitignored) dossier.json for the UI — or run the local companion server. #[derive(Parser, Debug)] #[command(name = "helix-ingest", version, about)] struct Cli { + #[command(subcommand)] + command: Option, + + // --- one-shot ingest flags (used when no subcommand is given) --- /// FHIR R4 bundle (or bare Observation) JSON to import. #[arg(long, value_name = "path.json")] fhir: Option, @@ -31,7 +41,7 @@ struct Cli { /// Vault directory (holds the encrypted redb store + salt). Created if absent. #[arg(long, value_name = "dir")] - vault: PathBuf, + vault: Option, /// Where to write the decrypted dossier.json. Default is under the gitignored /// `./private/` path so PHI never lands in a tracked file. @@ -39,15 +49,51 @@ struct Cli { out: PathBuf, } +#[derive(Subcommand, Debug)] +enum Command { + /// Run the localhost companion server (127.0.0.1 only) for guided onboarding. + Serve(ServeArgs), +} + +#[derive(Args, Debug)] +struct ServeArgs { + /// Port to bind on 127.0.0.1 (the host is fixed to loopback; ADR-057). + #[arg(long, default_value_t = 8799)] + port: u16, + + /// Vault directory (created on first unlock). + #[arg(long, value_name = "dir", default_value = "./private/vault")] + vault: PathBuf, + + /// UI root to serve; `/` loads `hybrid.html` from here. + #[arg(long, value_name = "dir", default_value = "./ui")] + ui: PathBuf, +} + fn main() -> Result<()> { let cli = Cli::parse(); + match cli.command { + Some(Command::Serve(a)) => serve::serve(serve::ServeConfig { + port: a.port, + vault_dir: a.vault, + ui_dir: a.ui, + }), + None => run_ingest(cli), + } +} + +/// The original one-shot ingest flow (unchanged behavior). +fn run_ingest(cli: Cli) -> Result<()> { + let Some(vault_dir) = cli.vault else { + bail!("provide --vault (or use `helix-ingest serve`)"); + }; if cli.fhir.is_none() && cli.apple.is_none() { bail!("provide at least one of --fhir or --apple "); } // Decide whether we are initializing a fresh vault BEFORE touching anything, // so a first-time passphrase can be confirmed. - let creating = !vault::is_initialized(&cli.vault); + let creating = !vault::is_initialized(&vault_dir); let passphrase = read_passphrase(creating)?; let now_ms = SystemTime::now() @@ -58,7 +104,7 @@ fn main() -> Result<()> { let report = run(RunArgs { fhir: cli.fhir.as_deref(), apple: cli.apple.as_deref(), - vault_dir: &cli.vault, + vault_dir: &vault_dir, out: &cli.out, passphrase: &passphrase, now_ms, diff --git a/crates/helix-ingest/src/parse.rs b/crates/helix-ingest/src/parse.rs index 112ce4a..2bc4973 100644 --- a/crates/helix-ingest/src/parse.rs +++ b/crates/helix-ingest/src/parse.rs @@ -7,13 +7,16 @@ //! a review queue (ADR-012), never silently dropped. use anyhow::{Context, Result}; -use helix_provenance::ProvRecord; +use helix_provenance::{ProvRecord, RecordId}; use serde_json::Value; /// Source label stamped onto records imported from a FHIR bundle. pub const FHIR_SOURCE: &str = "FHIR"; /// Source label stamped onto records imported from an Apple Health export. pub const APPLE_SOURCE: &str = "Apple Health"; +/// Source label stamped onto records imported from a consolidated "CLAW envelope" +/// export (a dict of `lab_results`/`clinical_vitals`/… arrays wrapping `fhirData`). +pub const CLAW_SOURCE: &str = "Health Export"; /// Upper bound on Apple records parsed in one run (mirrors `helix-wasm`). const APPLE_MAX_RECORDS: usize = 100_000; @@ -59,3 +62,212 @@ pub fn parse_apple_export(xml: &str, source: &str) -> Parsed { queued_for_review: 0, } } + +/// The detected on-the-wire shape of an uploaded file (drives the `auto` path). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// A FHIR R4 `Bundle` or a bare `Observation`. + Fhir, + /// The consolidated "CLAW envelope": a dict of `*_results`-style arrays whose + /// items wrap a real `fhirData` resource. + ClawEnvelope, + /// An Apple Health `export.xml` (XML, so detected at the text layer, not here). + AppleXml, + /// Nothing we recognize — the caller MUST fail loudly, never import 0 silently. + Unknown, +} + +/// Classify already-parsed JSON into a [`Format`]. Apple exports are XML (never a +/// `Value`) and are detected by [`looks_like_apple_xml`]; a JSON-wrapped +/// `HealthData` object is still recognized here for completeness. +pub fn detect_format(v: &Value) -> Format { + if let Some(obj) = v.as_object() { + // CLAW envelope: an explicit `lab_results` key, any `*_results` key, or + // any array whose first element carries a nested `fhirData` resource. + let is_claw = obj.contains_key("lab_results") + || obj.keys().any(|k| k.ends_with("_results")) + || obj.values().any(|val| { + val.as_array() + .and_then(|a| a.first()) + .is_some_and(|first| first.get("fhirData").is_some()) + }); + if is_claw { + return Format::ClawEnvelope; + } + if obj.contains_key("HealthData") { + return Format::AppleXml; + } + } + match v["resourceType"].as_str() { + Some("Bundle") | Some("Observation") => Format::Fhir, + _ => Format::Unknown, + } +} + +/// Cheap text-level check for an Apple Health `export.xml` (which is not JSON, so +/// the `auto` path must sniff it before attempting a JSON parse). +pub fn looks_like_apple_xml(text: &str) -> bool { + text.contains(" Vec { + v.as_object() + .map(|o| o.keys().cloned().collect()) + .unwrap_or_default() +} + +/// Parse a consolidated "CLAW envelope" export into records. +/// +/// Iterates the `lab_results` and `clinical_vitals` arrays; each item is +/// `{ id, dateAdded, displayName, fhirData: }`. Every +/// `fhirData` goes through the tested [`helix_connect::parse_observation`], then +/// the record id is OVERRIDDEN with `claw-{item.id}` so two items sharing a +/// date/code never collide. When `fhirData` lacks `effectiveDateTime`, the +/// wrapper's `dateAdded` is used. Non-parseable items are counted into the review +/// queue (never dropped silently). +/// +/// `medications`/`conditions`/`procedures`/etc. are out of scope for records this +/// pass (they are not FHIR Observations) — they are left untouched, not +/// force-mapped into records. +pub fn parse_claw_envelope(json: &str, source: &str) -> Result { + let v: Value = serde_json::from_str(json).context("CLAW export is not valid JSON")?; + let mut records = Vec::new(); + let mut queued_for_review = 0usize; + + for section in ["lab_results", "clinical_vitals"] { + let Some(items) = v[section].as_array() else { + continue; + }; + for item in items { + // Fall back to the wrapper `dateAdded` when the resource carries no + // effectiveDateTime, so otherwise-valid items still parse. + let mut fhir = item["fhirData"].clone(); + let has_dt = fhir + .get("effectiveDateTime") + .and_then(Value::as_str) + .is_some(); + if !has_dt { + if let Some(added) = item["dateAdded"].as_str() { + fhir["effectiveDateTime"] = Value::String(added.to_string()); + } + } + match helix_connect::parse_observation(&fhir, source) { + Ok(mut rec) => { + // Unique id from the wrapper id → no cross-record collisions. + if let Some(wid) = item["id"].as_str() { + rec.id = RecordId::from(format!("claw-{wid}")); + } + records.push(rec); + } + Err(_) => queued_for_review += 1, // → human-review queue (ADR-012/004) + } + } + } + Ok(Parsed { + records, + queued_for_review, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::collections::BTreeSet; + + /// One CLAW item wrapping a FHIR Observation. `wid` is the unique wrapper id; + /// the inner resource deliberately REUSES the same `"obs"` id across items so + /// the tests prove the wrapper-id override is what prevents collisions. + fn item(wid: &str, code: &str, name: &str, date: Option<&str>) -> Value { + let mut fhir = json!({ + "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": code, "display": name }] }, + "valueQuantity": { "value": 1.0, "unit": "u" } + }); + if let Some(d) = date { + fhir["effectiveDateTime"] = json!(d); + } + json!({ "id": wid, "dateAdded": "2026-06-15", "displayName": name, "fhirData": fhir }) + } + + #[test] + fn claw_same_date_distinct_wrappers_no_collision() { + // 4 labs + 1 vital, ALL the same date, inner resources share id "obs". + // `medications` is present but out of scope and must be ignored. + let doc = json!({ + "lab_results": [ + item("w-ferr", "2276-4", "Ferritin", Some("2026-06-15")), + item("w-hdl", "2085-9", "HDL", Some("2026-06-15")), + item("w-tsh", "3016-3", "TSH", Some("2026-06-15")), + item("w-glu", "2345-7", "Glucose", None), // no effectiveDateTime + ], + "clinical_vitals": [ item("w-hr", "8867-4", "Heart rate", Some("2026-06-15")) ], + "medications": [ { "id": "m1" } ] + }); + let p = parse_claw_envelope(&doc.to_string(), CLAW_SOURCE).unwrap(); + assert_eq!(p.records.len(), 5, "5 items in → 5 records (no id collision)"); + assert_eq!(p.queued_for_review, 0); + let ids: BTreeSet = p.records.iter().map(|r| r.id.clone()).collect(); + assert_eq!(ids.len(), 5, "every stored record id is distinct"); + assert!(ids.contains(&RecordId::from("claw-w-glu"))); + // dateAdded fallback stamped the date-less Glucose item. + let glu = p + .records + .iter() + .find(|r| r.id == RecordId::from("claw-w-glu")) + .unwrap(); + assert!(glu.measured_at > 0, "dateAdded fallback stamped a timestamp"); + } + + #[test] + fn claw_unparseable_item_is_queued_not_dropped() { + let doc = json!({ + "lab_results": [ + item("w-ok", "2276-4", "Ferritin", Some("2026-06-15")), + { "id": "w-bad", "dateAdded": "2026-06-15", "fhirData": { + "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1" }] }, + "valueQuantity": { "value": 1.0, "unit": "x" }, + "effectiveDateTime": "2026-06-15" } } + ] + }); + let p = parse_claw_envelope(&doc.to_string(), CLAW_SOURCE).unwrap(); + assert_eq!(p.records.len(), 1); + assert_eq!(p.queued_for_review, 1, "bad item queued, never silently dropped"); + } + + #[test] + fn detect_classifies_shapes() { + assert_eq!( + detect_format(&json!({ "lab_results": [], "medications": [] })), + Format::ClawEnvelope + ); + // Recognized purely by a nested `fhirData` array, with no `*_results` key. + assert_eq!( + detect_format(&json!({ "whatever": [ { "fhirData": { "resourceType": "Observation" } } ] })), + Format::ClawEnvelope + ); + assert_eq!(detect_format(&json!({ "resourceType": "Bundle" })), Format::Fhir); + assert_eq!(detect_format(&json!({ "resourceType": "Observation" })), Format::Fhir); + assert_eq!(detect_format(&json!({ "HealthData": {} })), Format::AppleXml); + assert_eq!(detect_format(&json!({ "foo": 1, "bar": 2 })), Format::Unknown); + } + + #[test] + fn apple_xml_sniff_and_top_level_keys() { + assert!(looks_like_apple_xml( + "" + )); + assert!(!looks_like_apple_xml("{\"lab_results\":[]}")); + let keys: BTreeSet = top_level_keys(&json!({ "b": 1, "a": 2 })) + .into_iter() + .collect(); + assert_eq!( + keys, + BTreeSet::from(["a".to_string(), "b".to_string()]), + "top-level keys are surfaced for the loud unknown-format error" + ); + } +} diff --git a/crates/helix-ingest/src/serve/api.rs b/crates/helix-ingest/src/serve/api.rs new file mode 100644 index 0000000..967ca93 --- /dev/null +++ b/crates/helix-ingest/src/serve/api.rs @@ -0,0 +1,296 @@ +//! The JSON API handlers and the in-memory session state. +//! +//! The unlock passphrase is turned into a [`SealKey`] and held ONLY in +//! [`ServeState::key`] for the process lifetime — never written to disk, never +//! logged, never echoed back in a response. Every response carries counts and +//! metadata only; decrypted record values never enter a log line. All ingest +//! paths seal into the encrypted vault first, THEN emit the decrypted dossier to +//! the gitignored `/private/dossier.json` the UI reads. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::Result; +use base64::Engine as _; +use helix_provenance::ProvRecord; +use helix_vault::SealKey; +use serde::Deserialize; +use serde_json::{json, Value}; + +use super::connectors::{self, Connector}; +use super::hae; +use super::Http; +use crate::{dossier, parse, vault}; + +/// Live session state, guarded by a `Mutex` at the server layer. +pub struct ServeState { + pub vault_dir: PathBuf, + pub ui_dir: PathBuf, + /// The derived sealing key, present only while unlocked. Zeroizes on drop. + key: Option, + connectors: Vec, +} + +impl ServeState { + /// Build state for a vault dir and the UI root to serve. Loads the persisted + /// connector registry (cadence config) if present. + pub fn new(vault_dir: PathBuf, ui_dir: PathBuf) -> Result { + let connectors = connectors::load(&vault_dir); + Ok(Self { + vault_dir, + ui_dir, + key: None, + connectors, + }) + } + + pub fn ui_dir(&self) -> &Path { + &self.ui_dir + } +} + +/// Epoch millis now (the only wall-clock read; used to stamp the dossier). +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn to_val(t: &T) -> Value { + serde_json::to_value(t).unwrap_or(Value::Null) +} + +// --- GET /api/status -------------------------------------------------------- + +pub fn status(s: &ServeState) -> Http { + let vault_exists = vault::is_initialized(&s.vault_dir); + let unlocked = s.key.is_some(); + let (record_count, by_source): (usize, BTreeMap) = match &s.key { + Some(k) => match vault::reopen_records(&s.vault_dir, k) { + Ok(recs) => (recs.len(), dossier::source_counts(&recs)), + Err(_) => (0, BTreeMap::new()), + }, + None => (vault::count_records(&s.vault_dir).unwrap_or(0), BTreeMap::new()), + }; + Http::json(json!({ + "vault_exists": vault_exists, + "unlocked": unlocked, + "record_count": record_count, + "by_source": to_val(&by_source), + "connectors": to_val(&s.connectors), + "mode": "sealed", + })) +} + +// --- GET /api/connectors ---------------------------------------------------- + +pub fn connectors(s: &ServeState) -> Http { + Http::json(json!({ + "connectors": to_val(&s.connectors), + "live": connectors::LIVE_ID, + "supported_health_metrics": to_val(&hae::supported_metrics()), + })) +} + +// --- POST /api/vault/unlock ------------------------------------------------- + +#[derive(Deserialize)] +struct UnlockReq { + passphrase: String, + #[serde(default)] + first_time: Option, +} + +pub fn unlock(s: &mut ServeState, body: &[u8]) -> Http { + let Ok(req) = serde_json::from_slice::(body) else { + return Http::json_status(400, json!({ "ok": false, "error": "invalid request body" })); + }; + if req.passphrase.is_empty() { + return Http::json_status(400, json!({ "ok": false, "error": "empty passphrase" })); + } + // First-time iff the caller says so OR the vault does not exist yet. + let creating = req.first_time.unwrap_or(false) || !vault::is_initialized(&s.vault_dir); + let key = match vault::prepare_key(&s.vault_dir, &req.passphrase) { + Ok(k) => k, + Err(_) => { + return Http::json_status(500, json!({ "ok": false, "error": "key derivation failed" })) + } + }; + match vault::unlock(&s.vault_dir, &key, creating) { + Ok(()) => { + s.key = Some(key); // held in memory only + Http::json(json!({ "ok": true, "first_time": creating })) + } + // Wrong passphrase on an existing vault — key is NOT retained. + Err(_) => Http::json_status(401, json!({ "ok": false, "error": "wrong passphrase" })), + } +} + +// --- POST /api/import ------------------------------------------------------- + +#[derive(Deserialize)] +struct ImportReq { + kind: String, + #[serde(default)] + content: Option, + #[serde(default)] + data_base64: Option, +} + +pub fn import(s: &mut ServeState, body: &[u8]) -> Http { + if s.key.is_none() { + return Http::json_status(401, json!({ "error": "vault locked; unlock first" })); + } + let Ok(req) = serde_json::from_slice::(body) else { + return Http::json_status(400, json!({ "error": "invalid request body" })); + }; + let text = match decode_payload(&req) { + Ok(t) => t, + Err(msg) => return Http::json_status(400, json!({ "error": msg })), + }; + let records = match req.kind.as_str() { + "fhir" => match parse::parse_fhir_bundle(&text, parse::FHIR_SOURCE) { + Ok(p) => p.records, + Err(e) => return Http::json_status(400, json!({ "error": format!("{e}") })), + }, + "apple" => parse::parse_apple_export(&text, parse::APPLE_SOURCE).records, + "claw" => match parse::parse_claw_envelope(&text, parse::CLAW_SOURCE) { + Ok(p) => p.records, + Err(e) => return Http::json_status(400, json!({ "error": format!("{e}") })), + }, + "auto" => match import_auto(&text) { + Ok(recs) => recs, + Err(resp) => return resp, + }, + other => { + return Http::json_status( + 400, + json!({ "error": format!( + "unknown kind {other:?}; expected \"auto\", \"fhir\", \"apple\", or \"claw\"" + ) }), + ) + } + }; + if records.is_empty() { + return Http::json_status(422, json!({ "error": "parsed 0 usable records" })); + } + let imported = records.len(); + let key = s.key.as_ref().unwrap(); + match seal_and_emit(&s.vault_dir, &s.ui_dir, key, &records) { + Ok((_total, by_source)) => Http::json(json!({ + "imported": imported, + "by_source": to_val(&by_source), + "sealed": true, + })), + Err(_) => Http::json_status(500, json!({ "error": "seal/emit failed" })), + } +} + +/// Auto-detect an uploaded file and route it to the right importer. Apple exports +/// are XML (sniffed first at the text layer); everything else is parsed as JSON +/// and classified by [`parse::detect_format`]. An unrecognized shape returns a +/// LOUD error carrying the top-level keys — NEVER a silent 0-record success. +fn import_auto(text: &str) -> Result, Http> { + if parse::looks_like_apple_xml(text) { + return Ok(parse::parse_apple_export(text, parse::APPLE_SOURCE).records); + } + let Ok(v) = serde_json::from_str::(text) else { + // Neither Apple XML nor valid JSON — nothing to key off of. + return Err(Http::json_status( + 422, + json!({ "error": "unrecognized format", "top_level_keys": [] }), + )); + }; + let map_err = |e: anyhow::Error| Http::json_status(400, json!({ "error": format!("{e}") })); + match parse::detect_format(&v) { + parse::Format::ClawEnvelope => parse::parse_claw_envelope(text, parse::CLAW_SOURCE) + .map(|p| p.records) + .map_err(map_err), + parse::Format::Fhir => parse::parse_fhir_bundle(text, parse::FHIR_SOURCE) + .map(|p| p.records) + .map_err(map_err), + parse::Format::AppleXml => Ok(parse::parse_apple_export(text, parse::APPLE_SOURCE).records), + parse::Format::Unknown => Err(Http::json_status( + 422, + json!({ "error": "unrecognized format", "top_level_keys": parse::top_level_keys(&v) }), + )), + } +} + +// --- POST /health/ingest (the one LIVE ongoing connector) ------------------- + +pub fn health_ingest(s: &mut ServeState, body: &[u8]) -> Http { + if s.key.is_none() { + return Http::json_status(401, json!({ "error": "vault locked; unlock first" })); + } + let Ok(payload) = serde_json::from_slice::(body) else { + return Http::json_status(400, json!({ "error": "invalid Health Auto Export JSON" })); + }; + let parsed = hae::parse(&payload); + if parsed.records.is_empty() { + return Http::json_status( + 422, + json!({ + "imported": 0, + "mapped": parsed.mapped, + "skipped": parsed.skipped, + "error": "no supported metrics in payload", + }), + ); + } + let imported = parsed.records.len(); + let key = s.key.as_ref().unwrap(); + let emitted = seal_and_emit(&s.vault_dir, &s.ui_dir, key, &parsed.records); + match emitted { + Ok((_total, by_source)) => { + // Live connector: record the successful pull watermark and persist it. + connectors::mark_pull(&mut s.connectors, connectors::LIVE_ID, now_ms()); + connectors::save(&s.vault_dir, &s.connectors); + Http::json(json!({ + "imported": imported, + "mapped": parsed.mapped, + "skipped": parsed.skipped, + "by_source": to_val(&by_source), + "sealed": true, + })) + } + Err(_) => Http::json_status(500, json!({ "error": "seal/emit failed" })), + } +} + +// --- shared ingest tail ----------------------------------------------------- + +/// Seal `records` into the vault, RE-OPEN the whole corpus, and write the +/// decrypted dossier to `/private/dossier.json`. Returns `(total, by_source)`. +fn seal_and_emit( + vault_dir: &Path, + ui_dir: &Path, + key: &SealKey, + records: &[ProvRecord], +) -> Result<(usize, BTreeMap)> { + vault::seal_records(vault_dir, key, records)?; + let all = vault::reopen_records(vault_dir, key)?; + let value = dossier::build(&all, now_ms())?; + let out = ui_dir.join("private").join("dossier.json"); + dossier::write(&value, &out)?; + Ok((all.len(), dossier::source_counts(&all))) +} + +/// Extract the uploaded file text: `data_base64` (raw base64 or a `data:` URL) +/// takes precedence, else `content` verbatim. +fn decode_payload(req: &ImportReq) -> Result { + if let Some(b64) = &req.data_base64 { + // Strip a `data:;base64,` prefix if the browser sent a data URL. + let raw = b64.rsplit_once(',').map(|(_, d)| d).unwrap_or(b64.as_str()); + let bytes = base64::engine::general_purpose::STANDARD + .decode(raw.trim()) + .map_err(|_| "data_base64 is not valid base64".to_string())?; + return String::from_utf8(bytes).map_err(|_| "decoded file is not UTF-8".to_string()); + } + if let Some(text) = &req.content { + return Ok(text.clone()); + } + Err("provide `data_base64` or `content`".to_string()) +} diff --git a/crates/helix-ingest/src/serve/connectors.rs b/crates/helix-ingest/src/serve/connectors.rs new file mode 100644 index 0000000..36ef1d2 --- /dev/null +++ b/crates/helix-ingest/src/serve/connectors.rs @@ -0,0 +1,148 @@ +//! The connector registry — honest status metadata, no fake "connected". +//! +//! Exactly one connector is **live** today: Apple Health, via the local push +//! endpoint `POST /health/ingest` (a Health Auto Export JSON payload). Every other +//! source is a real, named entry marked `coming_soon` — the UI can show the full +//! roadmap without ever implying a source is wired when it is not. +//! +//! Cadence defaults come straight from ADR-049 (scheduled per-source pull +//! cadences). A scheduler is NOT built here (ADR-049's launchd/cron loop is future +//! work); this module only persists the cadence config + last-pull watermark so +//! the choice survives a restart. Persisted state can only overlay `cadence` and +//! `last_pull` — `status`/`name`/`mechanism` always come from the defaults below, +//! so a tampered `connectors.json` can never forge a source to `live`. + +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +const CONNECTORS_FILE: &str = "connectors.json"; + +/// Honest wiring state. `live` = data actually flows today; `coming_soon` = a real +/// planned source that is not yet connected. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectorStatus { + Live, + ComingSoon, +} + +/// One source in the registry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Connector { + /// Stable machine id, e.g. `"apple_health"`. + pub id: String, + /// Human label, e.g. `"Apple Health"`. + pub name: String, + pub status: ConnectorStatus, + /// ADR-049 default pull cadence (`daily` / `biweekly` / `monthly` / `one_time`). + pub cadence: String, + /// How data arrives (ADR-049 "Mechanism" column) — informational. + pub mechanism: String, + /// Epoch-millis of the last successful pull, or `None` if never pulled. + pub last_pull: Option, +} + +/// Id of the one live connector (Apple Health local push). +pub const LIVE_ID: &str = "apple_health"; + +/// The canonical registry (ADR-049 cadence defaults). Source of truth for +/// `status`/`name`/`mechanism`; persisted config may only overlay cadence/last_pull. +pub fn defaults() -> Vec { + let c = |id: &str, name: &str, status, cadence: &str, mech: &str| Connector { + id: id.to_string(), + name: name.to_string(), + status, + cadence: cadence.to_string(), + mechanism: mech.to_string(), + last_pull: None, + }; + use ConnectorStatus::{ComingSoon, Live}; + vec![ + c( + LIVE_ID, + "Apple Health", + Live, + "daily", + "Local push → on-device ingest (POST /health/ingest)", + ), + c( + "renpho", + "RENPHO", + ComingSoon, + "daily", + "Vaulted-credential pull (ADR-045/046)", + ), + c( + "quest_fhir", + "Quest / FHIR API", + ComingSoon, + "monthly", + "FHIR API or PDF/OCR (ADR-012)", + ), + c( + "walgreens", + "Walgreens", + ComingSoon, + "biweekly", + "Agentic-browser scrape (ADR-046)", + ), + c( + "lose_it", + "Lose It", + ComingSoon, + "daily", + "API / vaulted-credential pull", + ), + ] +} + +/// Load the registry: defaults, with `cadence` + `last_pull` overlaid from +/// `/connectors.json` when present. Never trusts persisted status/name. +pub fn load(vault_dir: &Path) -> Vec { + let mut reg = defaults(); + let path = vault_dir.join(CONNECTORS_FILE); + let Ok(text) = std::fs::read_to_string(&path) else { + return reg; + }; + let Ok(saved): Result, _> = serde_json::from_str(&text) else { + return reg; + }; + for entry in &mut reg { + if let Some(s) = saved.iter().find(|s| s.id == entry.id) { + entry.cadence = s.cadence.clone(); + entry.last_pull = s.last_pull; + } + } + reg +} + +/// Persist the registry to `/connectors.json` (best-effort, owner-only). +pub fn save(vault_dir: &Path, reg: &[Connector]) { + if std::fs::create_dir_all(vault_dir).is_err() { + return; + } + let path = vault_dir.join(CONNECTORS_FILE); + if let Ok(text) = serde_json::to_string_pretty(reg) { + if std::fs::write(&path, text).is_ok() { + restrict(&path); + } + } +} + +/// Stamp `last_pull` for the connector with `id` (no-op if unknown). +pub fn mark_pull(reg: &mut [Connector], id: &str, now_ms: i64) { + if let Some(c) = reg.iter_mut().find(|c| c.id == id) { + c.last_pull = Some(now_ms); + } +} + +fn restrict(path: &Path) { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + #[cfg(not(unix))] + let _ = path; +} diff --git a/crates/helix-ingest/src/serve/hae.rs b/crates/helix-ingest/src/serve/hae.rs new file mode 100644 index 0000000..81f5ccc --- /dev/null +++ b/crates/helix-ingest/src/serve/hae.rs @@ -0,0 +1,180 @@ +//! Health Auto Export (HAE) JSON → `Vec` — the live ongoing connector. +//! +//! HAE is the iPhone app that POSTs Apple Health data as JSON on a schedule. Its +//! automation payload is: +//! +//! ```json +//! { "data": { "metrics": [ +//! { "name": "heart_rate", "units": "count/min", +//! "data": [ { "date": "2026-06-01 10:00:00 -0700", "Min": 58, "Avg": 62, "Max": 121 } ] }, +//! { "name": "step_count", "units": "count", +//! "data": [ { "date": "2026-06-01 00:00:00 -0700", "qty": 8412 } ] } +//! ] } } +//! ``` +//! +//! Each metric datum carries a single `qty` OR an aggregate (`Min`/`Avg`/`Max`); +//! we take `qty` else `Avg`. We map a **documented subset** of HAE metric names to +//! (concept, LOINC) using the SAME LOINC codes as `helix-connect`'s Apple import, +//! so HAE and a raw `export.xml` normalize identically. Unmapped metric names are +//! reported honestly in [`HaeParsed::skipped`] — never silently dropped, never +//! faked into coverage. Records are stamped source `"Apple Health"` so they merge +//! with the Apple connector. + +use helix_provenance::{Confidence, EpochMillis, MeasurementMethod, ProvRecord, RecordId}; +use serde_json::Value; + +/// Source label for HAE records (they ARE Apple Health data). +pub const HAE_SOURCE: &str = "Apple Health"; +/// Upper bound on records parsed from one payload. +const HAE_MAX_RECORDS: usize = 100_000; + +/// Outcome of parsing one HAE payload. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct HaeParsed { + pub records: Vec, + /// `"name×N"` per mapped metric (N = points accepted) — for an honest summary. + pub mapped: Vec, + /// Metric names present in the payload but NOT in our supported subset. + pub skipped: Vec, +} + +/// Map a documented subset of HAE metric names to `(concept, LOINC code)`. +/// LOINC codes mirror `helix-connect`'s `hk_map`. Unknown → `None` (→ skipped). +fn hae_map(name: &str) -> Option<(&'static str, &'static str)> { + Some(match name { + "heart_rate" => ("Heart rate", "8867-4"), + "resting_heart_rate" => ("Resting heart rate", "40443-4"), + "heart_rate_variability" => ("Heart rate variability (SDNN)", "80404-7"), + "step_count" => ("Steps", "55423-8"), + "weight_body_mass" => ("Body weight", "29463-7"), + "body_mass_index" => ("Body mass index", "39156-5"), + "blood_oxygen_saturation" | "oxygen_saturation" => ("Oxygen saturation (SpO2)", "59408-5"), + "respiratory_rate" => ("Respiratory rate", "9279-1"), + "body_temperature" => ("Body temperature", "8310-5"), + "blood_glucose" => ("Blood glucose", "2339-0"), + "vo2_max" => ("VO2 max", "84376-3"), + "blood_pressure_systolic" => ("Systolic blood pressure", "8480-6"), + "blood_pressure_diastolic" => ("Diastolic blood pressure", "8462-4"), + "active_energy" => ("Active energy burned", "41981-2"), + _ => return None, + }) +} + +/// The supported metric names (for docs / an API self-description). +pub fn supported_metrics() -> &'static [&'static str] { + &[ + "heart_rate", + "resting_heart_rate", + "heart_rate_variability", + "step_count", + "weight_body_mass", + "body_mass_index", + "blood_oxygen_saturation", + "respiratory_rate", + "body_temperature", + "blood_glucose", + "vo2_max", + "blood_pressure_systolic", + "blood_pressure_diastolic", + "active_energy", + ] +} + +/// Parse a HAE payload. Accepts `{data:{metrics:[...]}}` or a bare `{metrics:[...]}`. +pub fn parse(payload: &Value) -> HaeParsed { + let metrics = payload["data"]["metrics"] + .as_array() + .or_else(|| payload["metrics"].as_array()); + let Some(metrics) = metrics else { + return HaeParsed::default(); + }; + + let mut out = HaeParsed::default(); + for metric in metrics { + let name = metric["name"].as_str().unwrap_or(""); + let Some((concept, code)) = hae_map(name) else { + if !name.is_empty() { + out.skipped.push(name.to_string()); + } + continue; + }; + let unit = metric["units"].as_str().unwrap_or("").to_string(); + let points = metric["data"].as_array().cloned().unwrap_or_default(); + + let mut accepted = 0usize; + for p in &points { + if out.records.len() >= HAE_MAX_RECORDS { + break; + } + let Some(value) = point_value(p) else { continue }; + let measured_at = p["date"].as_str().and_then(parse_hae_date).unwrap_or(0); + out.records.push(ProvRecord { + id: RecordId::from(format!("hae-{HAE_SOURCE}-{code}-{measured_at}")), + source: HAE_SOURCE.to_string(), + measured_at, + method: MeasurementMethod::Device, + code: Some(code.to_string()), + concept: concept.to_string(), + value, + unit: unit.clone(), + reference_range: None, + confidence: Confidence::new(0.9), + }); + accepted += 1; + } + if accepted > 0 { + out.mapped.push(format!("{name}×{accepted}")); + } + } + out +} + +/// A HAE datum's numeric value: `qty` if present, else `Avg` (aggregate metrics). +fn point_value(p: &Value) -> Option { + let v = p["qty"].as_f64().or_else(|| p["Avg"].as_f64())?; + v.is_finite().then_some(v) +} + +/// Parse HAE's `YYYY-MM-DD HH:MM:SS ±ZZZZ` (or ISO `T…Z`) into epoch millis. +/// Timezone offset is applied so the stored instant is UTC. Self-contained (no +/// chrono); mirrors `helix-connect`'s private Apple-date logic. +fn parse_hae_date(s: &str) -> Option { + let s = s.trim(); + let b = s.as_bytes(); + if b.len() < 10 || b[4] != b'-' || b[7] != b'-' { + return None; + } + let num = |a: usize, z: usize| s.get(a..z)?.parse::().ok(); + let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + if !(1..=12).contains(&mo) || !(1..=31).contains(&d) { + return None; + } + let (mut hh, mut mi, mut ss) = (0i64, 0i64, 0i64); + // Time part follows a 'T' (ISO) or a ' ' (HAE) separator at index 10. + if b.len() >= 19 && (b[10] == b'T' || b[10] == b' ') { + hh = num(11, 13)?; + mi = num(14, 16)?; + ss = num(17, 19)?; + } + // Optional " +0530" / "-0700" offset at the tail (HAE local time → UTC). + let off_ms = s.get(19..).and_then(|tz| { + let tz = tz.trim(); + let sign = match tz.chars().next()? { + '+' => 1, + '-' => -1, + _ => return None, + }; + let hh: i64 = tz.get(1..3)?.parse().ok()?; + let mm: i64 = tz.get(3..5)?.parse().ok()?; + Some(sign * (hh * 3600 + mm * 60) * 1000) + }); + // Civil-date → days since epoch (Howard Hinnant). + let yy = if mo <= 2 { y - 1 } else { y }; + let era = yy.div_euclid(400); + let yoe = yy - era * 400; + let doy = (153 * (if mo > 2 { mo - 3 } else { mo + 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + let local = (days * 86400 + hh * 3600 + mi * 60 + ss) * 1000; + Some(local - off_ms.unwrap_or(0)) +} diff --git a/crates/helix-ingest/src/serve/mod.rs b/crates/helix-ingest/src/serve/mod.rs new file mode 100644 index 0000000..bf6c7dd --- /dev/null +++ b/crates/helix-ingest/src/serve/mod.rs @@ -0,0 +1,337 @@ +//! `helix-ingest serve` — a localhost companion server for guided onboarding. +//! +//! # Security invariants (ADR-057 Sealed mode) — non-negotiable +//! * **Loopback only.** The bind host is hard-coded to `127.0.0.1`; only the port +//! is user-settable. [`serve`] additionally re-asserts the bound address is a +//! loopback IP at runtime and refuses to run otherwise. See the `binds_loopback` +//! test. +//! * **No outbound calls.** This module opens a listening socket and nothing else +//! — there is no HTTP client here and no dependency that makes one. +//! * **Cross-origin refused (CSRF / DNS-rebinding).** Every request is guarded +//! before routing: the `Host` must be a loopback authority (a rebound request +//! carries the attacker's hostname, so this defeats DNS rebinding) and any +//! `Origin` present must be a loopback origin (defeats a cross-site `fetch`). +//! See [`header_guard`]. The app's own same-origin requests always pass. +//! * **Passphrase in memory only.** Held in [`api::ServeState`], never logged. +//! * **PHI stays out of logs.** Only counts/paths/status are printed. +//! * **Dossier is gitignored.** Written to `/private/dossier.json`. +//! +//! The server is synchronous (`tiny_http`) with a small fixed worker pool sharing +//! one `Arc` and an `Arc>`. + +pub mod api; +pub mod connectors; +pub mod hae; + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use std::thread; + +use anyhow::{anyhow, bail, Result}; +use serde_json::json; +use tiny_http::{Header, Method, Request, Response, Server}; + +use api::ServeState; + +/// The loopback host. There is deliberately NO flag to change this (ADR-057). +const LOOPBACK_HOST: &str = "127.0.0.1"; +/// Worker threads serving the single shared listener (browsers open a few +/// parallel connections for assets; more than one worker avoids head-of-line). +const WORKERS: usize = 4; + +/// Configuration for [`serve`]. +pub struct ServeConfig { + /// Port to bind on `127.0.0.1`. + pub port: u16, + /// Vault directory (encrypted redb store + salt), created on first unlock. + pub vault_dir: PathBuf, + /// UI root served statically; `/` maps to `hybrid.html` under it. + pub ui_dir: PathBuf, +} + +/// A fully-formed HTTP response body (status + content-type + bytes). Kept +/// separate from socket I/O so the whole router is unit-testable via [`dispatch`]. +pub struct Http { + pub status: u16, + pub ctype: &'static str, + pub body: Vec, +} + +impl Http { + pub(crate) fn json(v: serde_json::Value) -> Http { + Self::json_status(200, v) + } + pub(crate) fn json_status(status: u16, v: serde_json::Value) -> Http { + Http { + status, + ctype: "application/json", + body: serde_json::to_vec(&v).unwrap_or_default(), + } + } + pub(crate) fn text(status: u16, msg: &str) -> Http { + Http { + status, + ctype: "text/plain; charset=utf-8", + body: msg.as_bytes().to_vec(), + } + } +} + +/// Bind the loopback listener on `port`. Host is fixed to `127.0.0.1`. +fn bind(port: u16) -> Result { + let addr = format!("{LOOPBACK_HOST}:{port}"); + Server::http(addr.as_str()).map_err(|e| anyhow!("failed to bind {addr}: {e}")) +} + +/// Start the companion server and block serving requests (loopback only). +pub fn serve(cfg: ServeConfig) -> Result<()> { + let server = bind(cfg.port)?; + + // Defense in depth: refuse to run if the bound address is somehow non-loopback. + let bound = server + .server_addr() + .to_ip() + .ok_or_else(|| anyhow!("server bound to a non-IP address"))?; + if !bound.ip().is_loopback() { + bail!("refusing to serve on non-loopback address {bound}"); + } + + println!("helix-ingest serve: http://{bound} (loopback only — no outbound, Sealed mode)"); + println!(" vault: {}", cfg.vault_dir.display()); + println!(" ui: {} (open http://{bound}/ )", cfg.ui_dir.display()); + + let state = Arc::new(Mutex::new(ServeState::new(cfg.vault_dir, cfg.ui_dir)?)); + let server = Arc::new(server); + + let mut handles = Vec::with_capacity(WORKERS); + for _ in 0..WORKERS { + let server = Arc::clone(&server); + let state = Arc::clone(&state); + handles.push(thread::spawn(move || worker_loop(&server, &state))); + } + for h in handles { + let _ = h.join(); + } + Ok(()) +} + +/// One worker: receive → read body → dispatch → respond, forever. +fn worker_loop(server: &Server, state: &Mutex) { + loop { + let mut req = match server.recv() { + Ok(r) => r, + Err(_) => break, + }; + // CSRF / DNS-rebinding guard (ADR-057 Sealed mode): validate Host/Origin on + // EVERY request before routing. The app's own same-origin requests pass; a + // malicious page (cross-site fetch, or a host that rebinds to 127.0.0.1) + // does not. Headers are copied out before the body is read. + let host = req + .headers() + .iter() + .find(|h| h.field.equiv("Host")) + .map(|h| h.value.as_str().to_string()); + let origin = req + .headers() + .iter() + .find(|h| h.field.equiv("Origin")) + .map(|h| h.value.as_str().to_string()); + if let Some(rej) = header_guard(host.as_deref(), origin.as_deref()) { + respond(req, rej); + continue; + } + let method = method_str(req.method()); + let url = req.url().to_string(); + let body = read_body(&mut req); + let resp = dispatch(state, method, &url, &body); + respond(req, resp); + } +} + +fn method_str(m: &Method) -> &'static str { + match m { + Method::Get => "GET", + Method::Post => "POST", + Method::Head => "HEAD", + _ => "OTHER", + } +} + +fn read_body(req: &mut Request) -> Vec { + let mut buf = Vec::new(); + let _ = req.as_reader().read_to_end(&mut buf); + buf +} + +fn respond(req: Request, resp: Http) { + let header = Header::from_bytes(&b"Content-Type"[..], resp.ctype.as_bytes()) + .expect("static content-type header is valid"); + let out = Response::from_data(resp.body) + .with_status_code(resp.status) + .with_header(header); + let _ = req.respond(out); +} + +/// Reject cross-origin and DNS-rebinding requests before any route runs (CSRF +/// hardening for the ADR-057 Sealed-mode companion). The `Host` must be a loopback +/// authority — a rebound attacker request carries its own hostname, so this defeats +/// DNS rebinding — and any `Origin` present must be a loopback http(s) origin, so a +/// cross-site `fetch()` is refused. The app's own requests satisfy both. Returns +/// `Some(403)` to reject, `None` to allow. +fn header_guard(host: Option<&str>, origin: Option<&str>) -> Option { + match host { + Some(h) if is_loopback_authority(h) => {} + _ => { + return Some(Http::json_status( + 403, + json!({ "error": "forbidden: request Host is not loopback" }), + )) + } + } + if let Some(o) = origin { + if !is_allowed_origin(o) { + return Some(Http::json_status( + 403, + json!({ "error": "forbidden: cross-origin request refused" }), + )); + } + } + None +} + +/// True iff `authority` (`host` or `host:port`, including `[::1]:port`) is loopback. +fn is_loopback_authority(authority: &str) -> bool { + let host = if let Some(rest) = authority.strip_prefix('[') { + rest.split(']').next().unwrap_or("") // IPv6 literal: [::1]:port + } else { + authority.split(':').next().unwrap_or("") + }; + matches!(host, "127.0.0.1" | "localhost" | "::1") +} + +/// True iff `origin` is an `http`/`https` origin whose authority is loopback. +/// `"null"`, `file://`, and remote origins all return false. +fn is_allowed_origin(origin: &str) -> bool { + match origin + .strip_prefix("http://") + .or_else(|| origin.strip_prefix("https://")) + { + Some(authority) => is_loopback_authority(authority), + None => false, + } +} + +/// Route a request to a handler and produce the response. Pure w.r.t. sockets: +/// takes the method, url and body; returns an [`Http`]. This is the seam the +/// integration tests drive directly. +pub fn dispatch(state: &Mutex, method: &str, url: &str, body: &[u8]) -> Http { + let path = url.split('?').next().unwrap_or("/"); + match (method, path) { + ("GET", "/api/status") => api::status(&state.lock().unwrap()), + ("GET", "/api/connectors") => api::connectors(&state.lock().unwrap()), + ("POST", "/api/vault/unlock") => api::unlock(&mut state.lock().unwrap(), body), + ("POST", "/api/import") => api::import(&mut state.lock().unwrap(), body), + ("POST", "/health/ingest") => api::health_ingest(&mut state.lock().unwrap(), body), + ("GET", _) | ("HEAD", _) => static_file(&state.lock().unwrap(), path), + _ => Http::json_status(404, json!({ "error": "not found" })), + } +} + +/// Serve a file from the UI root. `/` → `hybrid.html`. Path traversal is blocked +/// by rejecting `..` and confirming the canonical path stays under the UI root. +fn static_file(state: &ServeState, path: &str) -> Http { + let rel = if path == "/" { + "hybrid.html" + } else { + path.trim_start_matches('/') + }; + if rel.is_empty() || rel.contains("..") { + return Http::text(403, "forbidden"); + } + let full = state.ui_dir().join(rel); + let (Ok(canon), Ok(root)) = (full.canonicalize(), state.ui_dir().canonicalize()) else { + return Http::text(404, "not found"); + }; + if !canon.starts_with(&root) || !canon.is_file() { + return Http::text(404, "not found"); + } + match std::fs::read(&canon) { + Ok(bytes) => Http { + status: 200, + ctype: ctype_for(rel), + body: bytes, + }, + Err(_) => Http::text(404, "not found"), + } +} + +/// Content-type by file extension (small allowlist; default octet-stream). +fn ctype_for(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or("") { + "html" | "htm" => "text/html; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "css" => "text/css; charset=utf-8", + "json" => "application/json", + "wasm" => "application/wasm", + "svg" => "image/svg+xml", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "ico" => "image/x-icon", + "woff2" => "font/woff2", + "woff" => "font/woff", + "txt" | "map" => "text/plain; charset=utf-8", + _ => "application/octet-stream", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The bind host is loopback: binding an ephemeral port yields a loopback IP. + /// This is the ADR-057 Sealed-mode invariant, proven on the real listener. + #[test] + fn binds_loopback() { + let server = bind(0).expect("bind ephemeral loopback port"); + let addr = server.server_addr().to_ip().expect("bound to an IP"); + assert!( + addr.ip().is_loopback(), + "server MUST bind a loopback address, got {addr}" + ); + } + + #[test] + fn root_maps_to_hybrid_and_traversal_blocked() { + let tmp = tempfile::tempdir().unwrap(); + let ui = tmp.path().join("ui"); + std::fs::create_dir_all(&ui).unwrap(); + std::fs::write(ui.join("hybrid.html"), b"hi").unwrap(); + let state = ServeState::new(tmp.path().join("vault"), ui).unwrap(); + + let ok = static_file(&state, "/"); + assert_eq!(ok.status, 200); + assert_eq!(ok.ctype, "text/html; charset=utf-8"); + + let bad = static_file(&state, "/../../etc/passwd"); + assert_eq!(bad.status, 403); + } + + #[test] + fn header_guard_blocks_cross_origin_and_rebinding() { + // Loopback Host, no Origin → allowed (curl, direct navigation). + assert!(header_guard(Some("127.0.0.1:8799"), None).is_none()); + assert!(header_guard(Some("localhost:8799"), None).is_none()); + assert!(header_guard(Some("[::1]:8799"), None).is_none()); + // Same-origin app requests → allowed. + assert!(header_guard(Some("127.0.0.1:8799"), Some("http://127.0.0.1:8799")).is_none()); + assert!(header_guard(Some("localhost:8799"), Some("http://localhost:8799")).is_none()); + // DNS rebinding: attacker hostname in Host → blocked. + assert!(header_guard(Some("evil.example.com:8799"), None).is_some()); + // Missing Host → blocked. + assert!(header_guard(None, None).is_some()); + // Cross-site fetch: foreign Origin → blocked even with a loopback Host. + assert!(header_guard(Some("127.0.0.1:8799"), Some("https://evil.example.com")).is_some()); + // Opaque Origin (sandboxed iframe / file://) → blocked. + assert!(header_guard(Some("127.0.0.1:8799"), Some("null")).is_some()); + } +} diff --git a/crates/helix-ingest/src/vault.rs b/crates/helix-ingest/src/vault.rs index 7283ee1..20753df 100644 --- a/crates/helix-ingest/src/vault.rs +++ b/crates/helix-ingest/src/vault.rs @@ -137,6 +137,34 @@ pub fn seal_records(dir: &Path, key: &SealKey, records: &[ProvRecord]) -> Result Ok(()) } +/// Establish (`init = true`) or verify (`init = false`) the passphrase against the +/// vault verifier WITHOUT sealing any corpus records. Used by the companion +/// server's unlock endpoint: `Ok(())` iff the passphrase is correct (existing +/// vault) or the vault was freshly initialized. A wrong passphrase on an existing +/// vault is a hard error, so the caller must NOT retain the key on `Err`. +pub fn unlock(dir: &Path, key: &SealKey, init: bool) -> Result<()> { + open_verified(dir, key, init)?; + Ok(()) +} + +/// Count corpus records (non-reserved ids) WITHOUT decrypting — usable while the +/// vault is locked (no key held). Returns 0 if the store does not exist yet. +pub fn count_records(dir: &Path) -> Result { + let path = records_db_path(dir); + if !path.is_file() { + return Ok(0); + } + let store = PersistentVaultStore::open(&path) + .with_context(|| format!("opening vault store in {}", dir.display()))?; + let mut n = 0usize; + for id in store.ids().context("listing vault ids")? { + if !id.0.starts_with(RESERVED_PREFIX) { + n += 1; + } + } + Ok(n) +} + /// Re-open the store FRESH (proving durability across a close) and decrypt every /// corpus record back out. Internal/reserved records are skipped. pub fn reopen_records(dir: &Path, key: &SealKey) -> Result> { diff --git a/crates/helix-ingest/tests/serve.rs b/crates/helix-ingest/tests/serve.rs new file mode 100644 index 0000000..fbaa73e --- /dev/null +++ b/crates/helix-ingest/tests/serve.rs @@ -0,0 +1,363 @@ +//! Endpoint/integration tests for `helix-ingest serve`, on SYNTHETIC data only +//! (never real PHI). They drive the router seam [`helix_ingest::serve::dispatch`] +//! directly — no sockets — so they are deterministic, while the loopback-bind +//! invariant is proven separately by the `binds_loopback` unit test in the serve +//! module (it opens a real listener and asserts the bound IP is loopback). +//! +//! Covered: +//! * vault unlock — first-time create, then wrong-passphrase reject on reopen. +//! * `POST /api/import` round-trips (FHIR via `content`, Apple via base64 data +//! URL) and writes a valid, `meta`-free dossier the UI can load. +//! * `GET /api/status` reflects the sealed corpus (counts + by_source + mode). +//! * `GET /api/connectors` reports exactly one `live` source, rest `coming_soon`. +//! * `POST /health/ingest` maps a documented HAE subset, skips the rest honestly, +//! seals, and stamps the live connector's last_pull. +//! * locked vault rejects import / health ingest with 401. + +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; + +use base64::Engine as _; +use helix_ingest::serve::api::ServeState; +use helix_ingest::serve::{dispatch, Http}; +use serde_json::{json, Value}; + +const PASS: &str = "correct-horse-battery-staple"; + +/// 3 parseable Observations (Ferritin, HDL, TSH) + 1 unparseable (no LOINC). +const FHIR_BUNDLE: &str = r#"{ + "resourceType": "Bundle", + "entry": [ + { "resource": { "resourceType": "Observation", "id": "ferritin1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2276-4", "display": "Ferritin" }] }, + "valueQuantity": { "value": 28.0, "unit": "ng/mL" }, "effectiveDateTime": "2026-06-19T10:00:00Z" } }, + { "resource": { "resourceType": "Observation", "id": "hdl1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2085-9", "display": "HDL Cholesterol" }] }, + "valueQuantity": { "value": 58.0, "unit": "mg/dL" }, "effectiveDateTime": "2026-06-10" } }, + { "resource": { "resourceType": "Observation", "id": "tsh1", + "code": { "coding": [{ "system": "http://loinc.org", "code": "3016-3", "display": "TSH" }] }, + "valueQuantity": { "value": 2.1, "unit": "mIU/L" }, "effectiveDateTime": "2026-06-10" } }, + { "resource": { "resourceType": "Observation", "id": "bad1", + "code": { "coding": [{ "system": "http://snomed.info/sct", "code": "1" }] }, + "valueQuantity": { "value": 1.0, "unit": "x" }, "effectiveDateTime": "2026-01-01" } } + ] +}"#; + +/// 3 known HealthKit records + 1 unmapped (skipped by the importer). +const APPLE_XML: &str = r#" + + + + + +"#; + +// --- helpers ---------------------------------------------------------------- + +struct Env { + _tmp: tempfile::TempDir, + vault: PathBuf, + ui: PathBuf, +} + +fn env() -> Env { + let tmp = tempfile::tempdir().unwrap(); + let ui = tmp.path().join("ui"); + fs::create_dir_all(&ui).unwrap(); + fs::write(ui.join("hybrid.html"), b"helix").unwrap(); + let vault = tmp.path().join("vault"); + Env { + _tmp: tmp, + vault, + ui, + } +} + +fn state(e: &Env) -> Mutex { + Mutex::new(ServeState::new(e.vault.clone(), e.ui.clone()).unwrap()) +} + +fn jbody(h: &Http) -> Value { + serde_json::from_slice(&h.body).expect("response body is JSON") +} + +fn unlock(st: &Mutex, pass: &str) -> Http { + let body = json!({ "passphrase": pass }).to_string(); + dispatch(st, "POST", "/api/vault/unlock", body.as_bytes()) +} + +// --- 1. unlock: create + wrong-pass reject ---------------------------------- + +#[test] +fn unlock_creates_then_rejects_wrong_passphrase() { + let e = env(); + + // First unlock on a fresh vault creates it. + let st = state(&e); + let r = unlock(&st, PASS); + assert_eq!(r.status, 200, "create-unlock should succeed"); + let v = jbody(&r); + assert_eq!(v["ok"], json!(true)); + assert_eq!(v["first_time"], json!(true), "fresh vault is first_time"); + + // A brand-new session (fresh state) with the WRONG passphrase is rejected, + // and correct passphrase then succeeds without first_time. + let st2 = state(&e); + let bad = unlock(&st2, "not-the-passphrase"); + assert_eq!(bad.status, 401, "wrong passphrase must be rejected"); + assert_eq!(jbody(&bad)["ok"], json!(false)); + + let good = unlock(&st2, PASS); + assert_eq!(good.status, 200); + assert_eq!(jbody(&good)["first_time"], json!(false)); +} + +// --- 2. import (FHIR) round-trips + dossier + status reflects ---------------- + +#[test] +fn import_fhir_roundtrips_and_status_reflects() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + let body = json!({ "kind": "fhir", "content": FHIR_BUNDLE }).to_string(); + let r = dispatch(&st, "POST", "/api/import", body.as_bytes()); + assert_eq!(r.status, 200, "import should succeed"); + let v = jbody(&r); + assert_eq!(v["imported"], json!(3), "3 parseable, 1 held for review"); + assert_eq!(v["sealed"], json!(true)); + assert_eq!(v["by_source"]["FHIR"], json!(3)); + + // Dossier written to the gitignored ui/private path, valid and meta-free. + let dossier = e.ui.join("private").join("dossier.json"); + let doc: Value = serde_json::from_str(&fs::read_to_string(&dossier).unwrap()).unwrap(); + assert_eq!(doc["records"].as_array().unwrap().len(), 3); + assert!(doc.get("meta").is_none(), "no SAMPLE banner on real PHI"); + assert_eq!(doc["_helix_ingest"]["record_count"], json!(3)); + + // Status reflects the sealed corpus. + let s = dispatch(&st, "GET", "/api/status", b""); + let sv = jbody(&s); + assert_eq!(sv["vault_exists"], json!(true)); + assert_eq!(sv["unlocked"], json!(true)); + assert_eq!(sv["record_count"], json!(3)); + assert_eq!(sv["mode"], json!("sealed")); + assert_eq!(sv["by_source"]["FHIR"], json!(3)); +} + +// --- 3. import (Apple) via base64 data URL ---------------------------------- + +#[test] +fn import_apple_via_base64_data_url() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + let b64 = base64::engine::general_purpose::STANDARD.encode(APPLE_XML); + let data_url = format!("data:application/xml;base64,{b64}"); + let body = json!({ "kind": "apple", "data_base64": data_url }).to_string(); + + let r = dispatch(&st, "POST", "/api/import", body.as_bytes()); + assert_eq!(r.status, 200); + let v = jbody(&r); + assert_eq!(v["imported"], json!(3), "3 known HK records, 1 unmapped skipped"); + assert_eq!(v["by_source"]["Apple Health"], json!(3)); +} + +// --- 4. connector registry: exactly one live ------------------------------- + +#[test] +fn connectors_report_one_live_rest_coming_soon() { + let e = env(); + let st = state(&e); + let r = dispatch(&st, "GET", "/api/connectors", b""); + assert_eq!(r.status, 200); + let v = jbody(&r); + assert_eq!(v["live"], json!("apple_health")); + + let conns = v["connectors"].as_array().unwrap(); + let live: Vec<&str> = conns + .iter() + .filter(|c| c["status"] == json!("live")) + .map(|c| c["id"].as_str().unwrap()) + .collect(); + assert_eq!(live, vec!["apple_health"], "exactly one live connector"); + + for id in ["renpho", "quest_fhir", "walgreens", "lose_it"] { + let c = conns.iter().find(|c| c["id"] == json!(id)).unwrap(); + assert_eq!(c["status"], json!("coming_soon"), "{id} must be coming_soon"); + assert!(c["cadence"].is_string(), "{id} carries an ADR-049 cadence"); + } +} + +// --- 5. /health/ingest maps a documented HAE subset ------------------------- + +#[test] +fn health_ingest_maps_subset_and_marks_pull() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + let hae = json!({ + "data": { "metrics": [ + { "name": "heart_rate", "units": "count/min", + "data": [{ "date": "2026-06-01 10:00:00 -0700", "Min": 58, "Avg": 62, "Max": 121 }] }, + { "name": "step_count", "units": "count", + "data": [{ "date": "2026-06-01 00:00:00 -0700", "qty": 8412 }] }, + { "name": "totally_unmapped_metric", "units": "x", + "data": [{ "date": "2026-06-01", "qty": 1 }] } + ]} + }) + .to_string(); + + let r = dispatch(&st, "POST", "/health/ingest", hae.as_bytes()); + assert_eq!(r.status, 200); + let v = jbody(&r); + assert_eq!(v["imported"], json!(2), "heart_rate + step_count mapped"); + assert_eq!(v["sealed"], json!(true)); + assert_eq!(v["by_source"]["Apple Health"], json!(2)); + let skipped = v["skipped"].as_array().unwrap(); + assert!( + skipped.iter().any(|x| x == "totally_unmapped_metric"), + "unmapped metric reported honestly, not faked" + ); + + // The live connector's last_pull watermark is now set (and persisted). + let c = dispatch(&st, "GET", "/api/connectors", b""); + let apple = jbody(&c)["connectors"] + .as_array() + .unwrap() + .iter() + .find(|c| c["id"] == json!("apple_health")) + .cloned() + .unwrap(); + assert!(apple["last_pull"].is_number(), "live pull watermark stamped"); +} + +// --- 6. locked vault rejects writes ----------------------------------------- + +#[test] +fn locked_vault_rejects_import_and_health_ingest() { + let e = env(); + let st = state(&e); // never unlocked + + let imp = dispatch( + &st, + "POST", + "/api/import", + json!({ "kind": "fhir", "content": FHIR_BUNDLE }) + .to_string() + .as_bytes(), + ); + assert_eq!(imp.status, 401, "import requires an unlocked vault"); + + let hi = dispatch(&st, "POST", "/health/ingest", b"{}"); + assert_eq!(hi.status, 401, "health ingest requires an unlocked vault"); +} + +/// SYNTHETIC "CLAW envelope" export (never real PHI): 4 `lab_results` + 1 +/// `clinical_vitals`, ALL the SAME date, DISTINCT codes/displayNames, UNIQUE +/// wrapper ids, and inner resources that all reuse id `"obs"` — so ONLY the +/// wrapper-id override keeps them from colliding. `w-glu` has no +/// `effectiveDateTime` (proves the `dateAdded` fallback). `medications` is present +/// but out of scope and must be ignored. +const CLAW_ENVELOPE: &str = r#"{ + "lab_results": [ + { "id": "w-ferr", "dateAdded": "2026-06-15", "displayName": "Ferritin", + "fhirData": { "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2276-4", "display": "Ferritin" }] }, + "valueQuantity": { "value": 28.0, "unit": "ng/mL" }, "effectiveDateTime": "2026-06-15" } }, + { "id": "w-hdl", "dateAdded": "2026-06-15", "displayName": "HDL", + "fhirData": { "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2085-9", "display": "HDL" }] }, + "valueQuantity": { "value": 58.0, "unit": "mg/dL" }, "effectiveDateTime": "2026-06-15" } }, + { "id": "w-tsh", "dateAdded": "2026-06-15", "displayName": "TSH", + "fhirData": { "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": "3016-3", "display": "TSH" }] }, + "valueQuantity": { "value": 2.1, "unit": "mIU/L" }, "effectiveDateTime": "2026-06-15" } }, + { "id": "w-glu", "dateAdded": "2026-06-15", "displayName": "Glucose", + "fhirData": { "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": "2345-7", "display": "Glucose" }] }, + "valueQuantity": { "value": 92.0, "unit": "mg/dL" } } } + ], + "clinical_vitals": [ + { "id": "w-hr", "dateAdded": "2026-06-15", "displayName": "Heart rate", + "fhirData": { "resourceType": "Observation", "id": "obs", + "code": { "coding": [{ "system": "http://loinc.org", "code": "8867-4", "display": "Heart rate" }] }, + "valueQuantity": { "value": 60.0, "unit": "count/min" }, "effectiveDateTime": "2026-06-15" } } + ], + "medications": [ { "id": "m1", "displayName": "ignored", + "fhirData": { "resourceType": "MedicationStatement" } } ] +}"#; + +// --- 7. CLAW envelope: N items in → N records (same date, no collision) ------ + +#[test] +fn import_claw_same_date_no_collision() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + let body = json!({ "kind": "claw", "content": CLAW_ENVELOPE }).to_string(); + let r = dispatch(&st, "POST", "/api/import", body.as_bytes()); + assert_eq!(r.status, 200, "claw import should succeed"); + let v = jbody(&r); + assert_eq!(v["imported"], json!(5), "5 same-date items → 5 records (medications ignored)"); + assert_eq!(v["sealed"], json!(true)); + assert_eq!(v["by_source"]["Health Export"], json!(5)); + + // Status confirms the sealed corpus really holds 5 DISTINCT records — proof + // that the wrapper-id override defeats the same-date/same-inner-id collision. + let s = jbody(&dispatch(&st, "GET", "/api/status", b"")); + assert_eq!(s["record_count"], json!(5), "no id collision on same-date records"); + assert_eq!(s["by_source"]["Health Export"], json!(5)); +} + +// --- 8. auto-detection routes the CLAW envelope to the CLAW path ------------- + +#[test] +fn import_auto_routes_claw_envelope() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + let body = json!({ "kind": "auto", "content": CLAW_ENVELOPE }).to_string(); + let r = dispatch(&st, "POST", "/api/import", body.as_bytes()); + assert_eq!(r.status, 200, "auto should detect + import the CLAW envelope"); + let v = jbody(&r); + assert_eq!(v["imported"], json!(5), "auto routed to the CLAW adapter"); + assert_eq!(v["by_source"]["Health Export"], json!(5)); +} + +// --- 9. auto routes a FHIR bundle, and an Unknown blob fails LOUDLY ---------- + +#[test] +fn import_auto_routes_fhir_and_rejects_unknown() { + let e = env(); + let st = state(&e); + unlock(&st, PASS); + + // A plain FHIR bundle auto-routes to the FHIR importer (3 parseable). + let fhir = json!({ "kind": "auto", "content": FHIR_BUNDLE }).to_string(); + let rf = dispatch(&st, "POST", "/api/import", fhir.as_bytes()); + assert_eq!(rf.status, 200, "auto detects a FHIR bundle"); + assert_eq!(jbody(&rf)["by_source"]["FHIR"], json!(3)); + + // An unrecognized blob returns a LOUD error, NOT a silent 0-record success. + let junk = json!({ "kind": "auto", "content": "{\"foo\":1,\"bar\":2}" }).to_string(); + let ru = dispatch(&st, "POST", "/api/import", junk.as_bytes()); + assert_ne!(ru.status, 200, "unknown format must not return success"); + let uv = jbody(&ru); + assert_eq!(uv["error"], json!("unrecognized format")); + let keys: Vec = uv["top_level_keys"] + .as_array() + .unwrap() + .iter() + .map(|k| k.as_str().unwrap().to_string()) + .collect(); + assert!( + keys.contains(&"foo".to_string()) && keys.contains(&"bar".to_string()), + "top-level keys surfaced for diagnosis: {keys:?}" + ); +} From c65c94658976f22f243514e47b6f432f1d4f4d6a Mon Sep 17 00:00:00 2001 From: Stuart Kerr Date: Wed, 1 Jul 2026 14:39:01 -0400 Subject: [PATCH 5/5] feat(ui): guided onboarding wizard + filterable clinical panels + score-over-time - 3-stage onboarding wizard (Alex demo -> make-it-mine -> load-now / keep-fresh) - per-concept results grouped into filterable clinical panels; in-range collapses to a dense table - score-over-time trajectory chart (composite 0-100; per-domain history honestly omitted) - private/on-device banner shown when a real dossier is present --- ui/hybrid.css | 373 +++++++++++++++++++++++++++++++++++++++++++++ ui/hybrid.html | 83 +++++++++- ui/hybrid.js | 310 ++++++++++++++++++++++++++++++++----- ui/onboarding.js | 389 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 1116 insertions(+), 39 deletions(-) create mode 100644 ui/onboarding.js diff --git a/ui/hybrid.css b/ui/hybrid.css index 729c8a9..7e537d1 100644 --- a/ui/hybrid.css +++ b/ui/hybrid.css @@ -56,3 +56,376 @@ /* keep the report-empty helper the mockup's JS never needed but ours does */ .report-empty{ color:var(--text-low); font-size:.9rem; padding:8px 0; } + +/* ════════════════════════════════════════════════════════════════════ + ONBOARDING WIZARD + "Make it mine" CTA (feature-add, ADR-045→049) + Every rule below reuses the aurora hybrid's own tokens and vocabulary + (--teal / --warm / --living, .glass, .eyebrow, .tier, .chip). Nothing + is redesigned — this is the same design language applied to the + 3-stage "create your private twin" flow that sits over hybrid.html. + ════════════════════════════════════════════════════════════════════ */ + +/* ---- header "Make it mine" button (lives in .nav-right, reuses .chip) ---- */ +.chip.wizard-open{ cursor:pointer; color:var(--text-hi); font-weight:600; + border-color:rgba(55,230,208,.30); background:rgba(55,230,208,.06); } +.chip.wizard-open:hover{ border-color:var(--glass-hi); background:rgba(55,230,208,.11); } +.chip.wizard-open .spk{ color:var(--teal); } + +/* ---- "Make it mine — complete & private" CTA (demo mode only) ---- */ +.make-mine{ + position:relative; overflow:hidden; margin:14px 0 -6px; + display:flex; align-items:center; gap:clamp(14px,3vw,26px); + padding:clamp(16px,2.6vw,22px) clamp(18px,3vw,26px); border-radius:22px; + background:linear-gradient(120deg, rgba(23,183,171,.14), rgba(63,212,137,.08) 60%, rgba(246,189,124,.07)); + border:1px solid var(--glass-brd); box-shadow:var(--shadow); + backdrop-filter:blur(18px) saturate(140%); -webkit-backdrop-filter:blur(18px) saturate(140%); +} +.make-mine::before{ content:""; position:absolute; inset:0; border-radius:inherit; padding:1px; pointer-events:none; + background:linear-gradient(120deg,var(--glass-hi), transparent 40%, transparent 62%, rgba(63,212,137,.24)); + -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite:xor; mask-composite:exclude; opacity:.7; } +.make-mine .mm-spark{ flex:0 0 auto; width:52px; height:52px; border-radius:16px; display:grid; place-items:center; + border:1px solid rgba(55,230,208,.34); background:radial-gradient(120% 120% at 30% 20%, rgba(55,230,208,.22), rgba(63,212,137,.08)); + box-shadow:0 0 30px -6px rgba(55,230,208,.5); font-size:1.5rem; } +.make-mine .mm-copy{ flex:1 1 auto; min-width:0; } +.make-mine .mm-copy h3{ font-family:"Fraunces",serif; font-weight:400; font-size:clamp(1.15rem,2.5vw,1.5rem); + letter-spacing:-0.015em; margin:0 0 3px; color:var(--text-hi); line-height:1.1; } +.make-mine .mm-copy p{ margin:0; font-family:"Hanken Grotesk",sans-serif; color:var(--text-mid); font-size:.92rem; max-width:56ch; } +.make-mine .mm-copy b{ color:var(--teal); font-weight:600; } +.make-mine .mm-go{ flex:0 0 auto; } + +/* offline variant — honest "companion not running" note */ +.make-mine.offline{ background:linear-gradient(120deg, rgba(255,255,255,.03), rgba(255,255,255,.012)); + border-color:var(--line); box-shadow:none; } +.make-mine.offline .mm-spark{ border-color:rgba(246,189,124,.30); + background:radial-gradient(120% 120% at 30% 20%, rgba(246,189,124,.16), transparent); box-shadow:none; } +.make-mine.offline .mm-copy b{ color:var(--warm); } +.make-mine.offline .mm-copy code{ font-family:var(--tel); font-size:.86em; color:var(--text-hi); + background:rgba(255,255,255,.04); border:1px solid var(--line); border-radius:5px; padding:1px 6px; } + +/* shared pill button used by CTA + wizard */ +.hx-btn{ display:inline-flex; align-items:center; gap:.5em; cursor:pointer; white-space:nowrap; + font-family:"Schibsted Grotesk",sans-serif; font-weight:600; font-size:.9rem; letter-spacing:-0.01em; + color:#04120e; padding:11px 20px; border-radius:100px; border:0; + background:linear-gradient(90deg,var(--teal),var(--green)); box-shadow:0 10px 30px -10px rgba(55,230,208,.6); + transition:transform .3s var(--ease), box-shadow .3s var(--ease), filter .3s var(--ease); } +.hx-btn:hover{ transform:translateY(-2px); box-shadow:0 16px 40px -12px rgba(55,230,208,.7); filter:brightness(1.04); } +.hx-btn:disabled{ cursor:default; opacity:.5; transform:none; box-shadow:none; filter:none; } +.hx-btn.ghost{ color:var(--text-mid); background:transparent; border:1px solid var(--line); box-shadow:none; } +.hx-btn.ghost:hover{ color:var(--text-hi); border-color:var(--glass-hi); background:rgba(255,255,255,.03); transform:none; box-shadow:none; } +.hx-btn.warm{ color:#1a1206; background:linear-gradient(90deg,var(--warm),var(--warm-lo)); box-shadow:0 10px 30px -10px rgba(246,189,124,.5); } + +/* ---- overlay + dialog ---- */ +.wiz-overlay{ position:fixed; inset:0; z-index:200; display:grid; place-items:center; + padding:clamp(12px,3vw,28px); overflow-y:auto; + background:rgba(4,6,11,.74); backdrop-filter:blur(9px); -webkit-backdrop-filter:blur(9px); + animation:wizFade .3s var(--ease); } +.wiz-overlay[hidden]{ display:none; } +@keyframes wizFade{ from{ opacity:0; } to{ opacity:1; } } + +.wiz{ position:relative; width:min(640px,100%); max-height:calc(100vh - clamp(24px,6vw,56px)); + display:flex; flex-direction:column; border-radius:26px; overflow:hidden; + background:linear-gradient(180deg, rgba(12,20,30,.96), rgba(7,11,20,.97)); + border:1px solid var(--glass-brd); box-shadow:0 40px 120px -30px rgba(0,0,0,.9); + animation:wizRise .45s var(--ease); } +@keyframes wizRise{ from{ opacity:0; transform:translateY(18px) scale(.985); } to{ opacity:1; transform:none; } } +.wiz::after{ content:""; position:absolute; inset:0; z-index:0; pointer-events:none; opacity:.9; + background:radial-gradient(90% 60% at 50% -10%, rgba(23,183,171,.16), transparent 60%), + radial-gradient(70% 50% at 100% 108%, rgba(246,189,124,.08), transparent 60%); } +.wiz > *{ position:relative; z-index:1; } + +.wiz-x{ position:absolute; top:14px; right:14px; z-index:3; width:34px; height:34px; border-radius:50%; + display:grid; place-items:center; cursor:pointer; font-size:1.3rem; line-height:1; + color:var(--text-mid); background:rgba(255,255,255,.03); border:1px solid var(--line); + transition:.25s var(--ease); } +.wiz-x:hover{ color:var(--text-hi); border-color:var(--glass-hi); background:rgba(255,255,255,.06); } + +.wiz-head{ padding:clamp(22px,4vw,30px) clamp(22px,4vw,32px) 6px; } +.wiz-head .wiz-mark{ width:30px; height:30px; margin-bottom:12px; display:block; } +.wiz-head h2{ font-family:"Fraunces",serif; font-weight:340; letter-spacing:-0.02em; + font-size:clamp(1.5rem,4vw,2.1rem); line-height:1.04; margin:.28em 0 .3em; color:var(--text-hi); } +.wiz-head .wiz-lede{ margin:0; font-family:"Hanken Grotesk",sans-serif; color:var(--text-mid); + font-size:.96rem; max-width:52ch; } +.wiz-head .wiz-lede b{ color:var(--text-hi); font-weight:600; } + +/* ---- step rail ---- */ +.wiz-rail{ list-style:none; display:flex; gap:0; margin:20px 0 0; padding:16px clamp(22px,4vw,32px); + border-top:1px solid var(--line-soft); border-bottom:1px solid var(--line-soft); } +.wiz-step{ flex:1 1 0; display:flex; flex-direction:column; align-items:center; gap:7px; position:relative; + font-size:.62rem; letter-spacing:.14em; text-transform:uppercase; font-weight:600; color:var(--text-faint); } +.wiz-step::before{ content:""; position:absolute; top:8px; left:-50%; width:100%; height:1px; background:var(--line); z-index:0; } +.wiz-step:first-child::before{ display:none; } +.wiz-step .n{ position:relative; z-index:1; width:17px; height:17px; border-radius:50%; display:grid; place-items:center; + background:var(--navy-800); border:1px solid var(--line); font-size:.55rem; color:var(--text-faint); } +.wiz-step.active{ color:var(--teal); } +.wiz-step.active .n{ border-color:var(--teal); color:var(--teal); box-shadow:0 0 0 3px rgba(55,230,208,.12); } +.wiz-step.done{ color:var(--living); } +.wiz-step.done .n{ border-color:var(--living); background:var(--living); color:#062012; } +.wiz-step.done::before{ background:linear-gradient(90deg,var(--living),var(--teal)); } + +/* ---- body / footer ---- */ +.wiz-body{ padding:clamp(20px,3.5vw,28px) clamp(22px,4vw,32px); overflow-y:auto; flex:1 1 auto; } +.wiz-body .eyebrow{ margin-bottom:10px; } +.wiz-foot{ display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; + padding:16px clamp(22px,4vw,32px); border-top:1px solid var(--line-soft); background:rgba(255,255,255,.012); } +.wiz-foot .spacer{ flex:1 1 auto; } +.wiz-foot .step-note{ font-size:.72rem; color:var(--text-faint); } + +/* offline / preview ribbon inside the wizard */ +.wiz-offline{ display:flex; align-items:center; gap:.6em; margin-bottom:16px; padding:9px 14px; border-radius:12px; + font-size:.78rem; color:var(--warm); background:rgba(246,189,124,.08); border:1px solid rgba(246,189,124,.28); + font-family:"Hanken Grotesk",sans-serif; } +.wiz-offline .d{ width:7px; height:7px; border-radius:50%; background:var(--warm); box-shadow:0 0 8px var(--warm); flex:0 0 auto; } +.mock-tag{ font-size:.56rem; letter-spacing:.14em; text-transform:uppercase; font-weight:700; color:var(--warm); + border:1px solid rgba(246,189,124,.4); border-radius:5px; padding:1px 6px; margin-left:.5em; } + +/* ---- intro trust rows ---- */ +.wiz-trust{ display:flex; flex-direction:column; gap:12px; margin:18px 0 4px; } +.wiz-trust .row{ display:flex; align-items:flex-start; gap:13px; font-size:.9rem; color:var(--text-mid); + font-family:"Hanken Grotesk",sans-serif; } +.wiz-trust .ti{ flex:0 0 auto; width:34px; height:34px; border-radius:10px; display:grid; place-items:center; + border:1px solid var(--line); background:rgba(255,255,255,.02); } +.wiz-trust .ti svg{ width:16px; height:16px; color:var(--teal); } +.wiz-trust b{ color:var(--text-hi); font-weight:600; } + +/* ---- passphrase fields ---- */ +.wiz-field{ margin-top:16px; } +.wiz-field label{ display:block; font-size:.68rem; letter-spacing:.14em; text-transform:uppercase; + color:var(--text-low); font-weight:600; margin-bottom:7px; } +.wiz-input{ position:relative; display:flex; align-items:center; } +.wiz-input input{ width:100%; font-family:"Schibsted Grotesk",sans-serif; font-size:1rem; color:var(--text-hi); + background:rgba(255,255,255,.03); border:1px solid var(--line); border-radius:13px; padding:13px 46px 13px 15px; + letter-spacing:.02em; transition:border-color .25s var(--ease), background .25s var(--ease); } +.wiz-input input::placeholder{ color:var(--text-faint); } +.wiz-input input:focus{ outline:none; border-color:rgba(55,230,208,.55); background:rgba(55,230,208,.04); } +.wiz-input .peek{ position:absolute; right:8px; width:32px; height:32px; border-radius:9px; cursor:pointer; + display:grid; place-items:center; background:transparent; border:0; color:var(--text-low); } +.wiz-input .peek:hover{ color:var(--text-hi); } +.wiz-input .peek svg{ width:17px; height:17px; } +.wiz-hint{ margin:9px 2px 0; font-size:.78rem; color:var(--text-low); font-family:"Hanken Grotesk",sans-serif; } +.wiz-err{ margin:12px 0 0; padding:10px 13px; border-radius:11px; font-size:.84rem; + color:#ffd7dd; background:rgba(251,113,133,.10); border:1px solid rgba(251,113,133,.34); + display:flex; align-items:center; gap:.55em; } +.wiz-err[hidden]{ display:none; } +.wiz-err svg{ width:15px; height:15px; flex:0 0 auto; } + +/* ---- Stage 3A: category / drop-zone cards ---- */ +.wiz-cats{ display:grid; grid-template-columns:1fr 1fr; gap:13px; margin-top:6px; } +.wiz-cat{ position:relative; text-align:left; display:flex; flex-direction:column; gap:8px; + padding:16px 16px 15px; border-radius:18px; cursor:pointer; color:inherit; + background:linear-gradient(180deg, rgba(255,255,255,.03), rgba(255,255,255,.012)); + border:1px solid var(--glass-brd); transition:transform .3s var(--ease-soft), border-color .3s var(--ease-soft), box-shadow .3s var(--ease-soft); } +.wiz-cat:hover{ transform:translateY(-3px); border-color:var(--glass-hi); box-shadow:var(--shadow-warm); } +.wiz-cat.drag{ border-color:var(--teal); background:rgba(55,230,208,.07); box-shadow:0 0 0 3px rgba(55,230,208,.14); } +.wiz-cat .c-ico{ width:38px; height:38px; border-radius:11px; display:grid; place-items:center; + border:1px solid rgba(55,230,208,.24); background:rgba(55,230,208,.09); } +.wiz-cat .c-ico svg{ width:19px; height:19px; color:var(--teal); } +.wiz-cat h4{ font-family:"Fraunces",serif; font-weight:440; font-size:1.04rem; margin:0; letter-spacing:-0.01em; color:var(--text-hi); } +.wiz-cat .c-sub{ font-size:.78rem; color:var(--text-low); font-family:"Hanken Grotesk",sans-serif; margin:0; } +.wiz-cat .c-drop{ margin-top:2px; font-size:.74rem; font-weight:600; color:var(--teal); + display:inline-flex; align-items:center; gap:.4em; } +.wiz-cat .c-accept{ font-size:.64rem; color:var(--text-faint); letter-spacing:.02em; } +.wiz-cat.soon{ cursor:default; opacity:.72; } +.wiz-cat.soon:hover{ transform:none; border-color:var(--glass-brd); box-shadow:none; } +.wiz-cat.soon .c-ico{ border-color:var(--line); background:rgba(255,255,255,.02); } +.wiz-cat.soon .c-ico svg{ color:var(--text-low); } +.wiz-cat .soon-tag{ position:absolute; top:13px; right:13px; font-size:.56rem; letter-spacing:.14em; text-transform:uppercase; + font-weight:700; color:var(--text-low); border:1px solid var(--line); border-radius:100px; padding:3px 9px; } + +/* import progress + success */ +.wiz-prog{ margin-top:6px; padding:18px; border-radius:16px; border:1px solid var(--glass-brd); + background:rgba(255,255,255,.02); text-align:center; } +.wiz-prog .plabel{ font-size:.9rem; color:var(--text-mid); font-family:"Hanken Grotesk",sans-serif; margin:0 0 12px; } +.wiz-prog .pbar{ height:8px; border-radius:100px; background:rgba(150,214,206,.09); overflow:hidden; position:relative; } +.wiz-prog .pbar i{ position:absolute; inset:0 auto 0 0; width:38%; border-radius:100px; + background:linear-gradient(90deg,var(--teal-deep),var(--teal),var(--green)); animation:pslide 1.15s var(--ease) infinite; } +@keyframes pslide{ 0%{ left:-40%; } 100%{ left:100%; } } +.wiz-success{ margin-top:6px; padding:20px; border-radius:18px; text-align:center; + background:radial-gradient(120% 100% at 50% 0%, rgba(131,230,166,.12), transparent 62%); + border:1px solid rgba(131,230,166,.30); } +.wiz-success .seal{ width:46px; height:46px; margin:0 auto 12px; border-radius:50%; display:grid; place-items:center; + background:rgba(131,230,166,.14); border:1px solid rgba(131,230,166,.4); } +.wiz-success .seal svg{ width:22px; height:22px; color:var(--living); } +.wiz-success h4{ font-family:"Fraunces",serif; font-weight:400; font-size:1.35rem; margin:0 0 4px; color:var(--text-hi); } +.wiz-success p{ margin:0; font-size:.88rem; color:var(--text-mid); font-family:"Hanken Grotesk",sans-serif; } +.wiz-chips{ display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin-top:14px; } +.wiz-chips .sc{ display:inline-flex; align-items:center; gap:.45em; font-size:.72rem; font-weight:600; color:var(--living); + border:1px solid rgba(56,201,138,.28); background:rgba(31,157,109,.08); padding:5px 11px; border-radius:100px; } +.wiz-chips .sc .d{ width:6px; height:6px; border-radius:50%; background:var(--living); } + +/* ---- Stage 3B: connector rows ---- */ +.wiz-conns{ display:flex; flex-direction:column; gap:11px; margin-top:4px; } +.wiz-conn{ display:flex; align-items:center; gap:14px; padding:14px 16px; border-radius:16px; + background:linear-gradient(180deg, rgba(255,255,255,.028), rgba(255,255,255,.01)); border:1px solid var(--glass-brd); } +.wiz-conn .k-ico{ flex:0 0 auto; width:38px; height:38px; border-radius:11px; display:grid; place-items:center; + border:1px solid var(--line); background:rgba(255,255,255,.02); font-weight:700; font-size:.9rem; color:var(--text-mid); } +.wiz-conn .k-main{ flex:1 1 auto; min-width:0; } +.wiz-conn .k-name{ font-weight:600; font-size:.96rem; color:var(--text-hi); display:flex; align-items:center; gap:.5em; } +.wiz-conn .k-meta{ font-size:.76rem; color:var(--text-low); font-family:"Hanken Grotesk",sans-serif; margin-top:1px; } +.wiz-conn .k-pill{ font-size:.6rem; letter-spacing:.12em; text-transform:uppercase; font-weight:700; padding:3px 9px; border-radius:100px; } +.wiz-conn.live .k-ico{ border-color:rgba(56,201,138,.4); background:rgba(31,157,109,.12); color:var(--living); } +.wiz-conn.live .k-pill{ color:#062012; background:var(--living); display:inline-flex; align-items:center; gap:.4em; } +.wiz-conn.live .k-pill .p{ width:5px; height:5px; border-radius:50%; background:#062012; opacity:.65; } +.wiz-conn.soon .k-pill{ color:var(--text-low); border:1px solid var(--line); } +.wiz-conn.soon{ opacity:.82; } + +/* Apple Health ingest hint box */ +.wiz-hintbox{ margin:4px 0 2px; padding:13px 15px; border-radius:14px; + border:1px dashed rgba(56,201,138,.32); background:rgba(131,230,166,.04); } +.wiz-hintbox p{ margin:0 0 8px; font-size:.82rem; color:var(--text-mid); font-family:"Hanken Grotesk",sans-serif; } +.wiz-hintbox .url{ display:flex; align-items:center; gap:8px; } +.wiz-hintbox code{ flex:1 1 auto; min-width:0; overflow-x:auto; white-space:nowrap; font-family:var(--tel); font-size:.82rem; + color:var(--living); background:rgba(0,0,0,.28); border:1px solid var(--line); border-radius:8px; padding:8px 11px; } +.wiz-copy{ flex:0 0 auto; cursor:pointer; font-size:.72rem; font-weight:600; color:var(--text-mid); + background:rgba(255,255,255,.03); border:1px solid var(--line); border-radius:8px; padding:8px 11px; transition:.2s var(--ease); } +.wiz-copy:hover{ color:var(--text-hi); border-color:var(--glass-hi); } +.wiz-copy.ok{ color:var(--living); border-color:rgba(56,201,138,.4); } + +/* ---- responsive ---- */ +@media (max-width:560px){ + .make-mine{ flex-direction:column; align-items:flex-start; text-align:left; } + .make-mine .mm-go{ width:100%; } + .make-mine .mm-go .hx-btn{ width:100%; justify-content:center; } + .wiz-cats{ grid-template-columns:1fr; } + .wiz-step .lbl{ display:none; } + .wiz-rail{ gap:0; } + .wiz-foot{ flex-direction:column-reverse; align-items:stretch; } + .wiz-foot .hx-btn{ width:100%; justify-content:center; } + .wiz-foot .spacer{ display:none; } +} +@media (prefers-reduced-motion:reduce){ + .wiz-overlay, .wiz{ animation:none; } + .wiz-prog .pbar i{ animation:none; width:100%; } + .hx-btn:hover, .wiz-cat:hover{ transform:none; } +} + +/* ════════════════════════════════════════════════════════════════════ + INFORMATION-DESIGN PASS (score-over-time chart + grouped/filterable + per-concept results). Every rule reuses the aurora hybrid's own tokens + (--teal / --warm / --living / .glass / .nudge-grid) and its Fraunces / + Schibsted / Hanken type system. No new palette, no restyle. + ════════════════════════════════════════════════════════════════════ */ + +/* ---- Score-over-time trajectory ---- */ +.traj-panel{ padding:clamp(22px,3.4vw,34px); } +.traj-top{ display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; margin-bottom:14px; } +.traj-read{ margin:0; font-family:"Hanken Grotesk",sans-serif; letter-spacing:0; color:var(--text-mid); font-size:1rem; max-width:70ch; } +.traj-read b{ color:var(--text-hi); font-weight:600; } +.traj-read .tr-word{ font-family:"Fraunces",serif; font-weight:460; font-size:1.06em; letter-spacing:-0.01em; } +.traj-read .tr-word.up{ color:var(--living); } +.traj-read .tr-word.down{ color:var(--warm); } +.traj-read .tr-word.flat{ color:var(--teal); } +.traj-read .tr-cp{ color:var(--text-faint); font-size:.9em; } +.traj-badge{ flex:0 0 auto; font-size:.82rem; font-weight:700; padding:5px 12px; border-radius:100px; letter-spacing:.02em; } +.traj-badge.up{ color:#0a1512; background:var(--green); box-shadow:0 4px 16px rgba(139,243,164,.32); } +.traj-badge.down{ color:#1a1206; background:var(--warm); box-shadow:0 4px 16px rgba(246,189,124,.30); } +.traj-badge.flat{ color:var(--teal); border:1px solid rgba(55,230,208,.4); } + +.traj-chart{ margin:0; } +.tj-plot{ position:relative; height:clamp(210px,30vw,300px); } +.tj-svg{ position:absolute; inset:0; width:100%; height:100%; overflow:visible; } +.tj-grid{ stroke:var(--line-soft); stroke-width:1; } +.tj-line{ stroke-width:2.4; stroke-linecap:round; stroke-linejoin:round; filter:drop-shadow(0 0 7px rgba(55,230,208,.45)); } +.tj-cp{ stroke:var(--warm); stroke-width:1.4; stroke-dasharray:3 4; opacity:.6; } +.tj-yl{ position:absolute; left:0; transform:translateY(-50%); font-family:var(--tel); font-size:.62rem; color:var(--text-faint); letter-spacing:.02em; } +.tj-node{ position:absolute; width:9px; height:9px; border-radius:50%; transform:translate(-50%,-50%); + background:var(--teal); border:2px solid var(--obsidian); box-shadow:0 0 0 1px rgba(55,230,208,.5); cursor:default; z-index:2; } +.tj-node:focus-visible{ outline:2px solid var(--teal); outline-offset:2px; } +.tj-node.last{ width:13px; height:13px; background:var(--green); box-shadow:0 0 12px rgba(139,243,164,.7); } +.tj-node.last::after{ content:attr(data-v); position:absolute; left:50%; bottom:150%; transform:translateX(-50%); + font-family:"Fraunces",serif; font-size:.82rem; color:var(--text-hi); background:var(--navy-700); + border:1px solid var(--line); padding:2px 8px; border-radius:7px; white-space:nowrap; box-shadow:var(--shadow); } +.tj-node:hover::before, .tj-node:focus-visible::before{ content:attr(aria-label); position:absolute; left:50%; bottom:170%; + transform:translateX(-50%); white-space:nowrap; font-size:.6rem; color:var(--text-hi); background:var(--navy-700); + border:1px solid var(--line); padding:3px 7px; border-radius:6px; z-index:5; } +.tj-node.last:hover::before, .tj-node.last:focus-visible::before{ bottom:210%; } +.tj-xaxis{ position:absolute; left:0; right:0; bottom:-4px; height:14px; } +.tj-xl{ position:absolute; bottom:0; font-family:var(--tel); font-size:.62rem; color:var(--text-faint); white-space:nowrap; } + +.traj-subs{ margin-top:22px; border-top:1px solid var(--line-soft); padding-top:16px; } +.traj-subs-head{ display:flex; align-items:baseline; justify-content:space-between; gap:14px; flex-wrap:wrap; margin-bottom:12px; } +.traj-subs-head .ttl{ font-size:.72rem; text-transform:uppercase; letter-spacing:.2em; color:var(--text-low); font-weight:600; } +.traj-note{ font-size:.7rem; color:var(--text-faint); font-family:"Hanken Grotesk",sans-serif; letter-spacing:0; max-width:56ch; } +.traj-sublist{ display:grid; grid-template-columns:1fr 1fr; gap:8px 26px; } +.tsub{ display:grid; grid-template-columns:118px 1fr 30px auto; align-items:center; gap:12px; padding:5px 0; } +.tsub-name{ font-size:.84rem; color:var(--text-mid); font-weight:500; } +.tsub-bar{ height:7px; border-radius:100px; background:rgba(150,214,206,.09); overflow:hidden; } +.tsub-bar>i{ display:block; height:100%; border-radius:100px; background:linear-gradient(90deg,var(--teal-deep),var(--teal),var(--green)); } +.tsub-val{ font-family:"Fraunces",serif; font-size:1rem; text-align:right; color:var(--text-hi); } +.tsub-trend{ font-size:.72rem; display:inline-flex; align-items:center; gap:.35em; color:var(--text-low); font-family:"Hanken Grotesk",sans-serif; white-space:nowrap; } +.tsub-trend i{ font-style:normal; } +.tsub-trend.up{ color:var(--living); } .tsub-trend.down{ color:var(--warm); } + +/* ---- Filter / search bar over the grounded grid ---- */ +.gf{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin-bottom:22px; } +.gf-search{ position:relative; display:flex; align-items:center; flex:1 1 260px; min-width:0; } +.gf-search svg{ position:absolute; left:14px; width:16px; height:16px; color:var(--text-low); pointer-events:none; } +.gf-search input{ width:100%; font-family:"Schibsted Grotesk",sans-serif; font-size:.94rem; color:var(--text-hi); + background:rgba(255,255,255,.03); border:1px solid var(--line); border-radius:13px; padding:11px 14px 11px 40px; + letter-spacing:0; transition:border-color .25s var(--ease), background .25s var(--ease); } +.gf-search input::placeholder{ color:var(--text-faint); } +.gf-search input:focus{ outline:none; border-color:rgba(55,230,208,.55); background:rgba(55,230,208,.04); } +.gf-chips{ display:flex; flex-wrap:wrap; gap:8px; } +.gf-chip{ display:inline-flex; align-items:center; gap:.5em; cursor:pointer; font-family:"Schibsted Grotesk",sans-serif; + font-size:.76rem; font-weight:600; letter-spacing:.01em; color:var(--text-mid); + background:rgba(255,255,255,.02); border:1px solid var(--line); border-radius:100px; padding:8px 14px; transition:.22s var(--ease); } +.gf-chip:hover{ color:var(--text-hi); border-color:var(--glass-hi); } +.gf-chip .d{ width:6px; height:6px; border-radius:50%; } +.gf-chip.out .d{ background:var(--warm); } .gf-chip.inr .d{ background:var(--living); } .gf-chip.abs .d{ background:var(--text-faint); } +.gf-chip.active{ color:var(--text-hi); border-color:rgba(55,230,208,.45); background:rgba(55,230,208,.08); box-shadow:0 0 0 1px rgba(55,230,208,.10) inset; } +.gf-count{ font-family:var(--tel); font-size:.7rem; color:var(--text-faint); letter-spacing:.02em; margin-left:auto; white-space:nowrap; } +.gf-empty{ color:var(--text-low); font-size:.9rem; padding:22px 2px; } + +/* ---- Clinical panels (grouped sections) ---- */ +.panel{ margin-bottom:30px; } +.panel:last-child{ margin-bottom:0; } +.panel-head{ display:flex; align-items:baseline; gap:12px; flex-wrap:wrap; margin-bottom:14px; + padding-bottom:9px; border-bottom:1px solid var(--line-soft); } +.panel-head .p-name{ font-family:"Fraunces",serif; font-weight:420; font-size:1.24rem; letter-spacing:-0.01em; color:var(--text-hi); } +.panel-head .p-meta{ font-size:.66rem; text-transform:uppercase; letter-spacing:.16em; color:var(--text-low); font-weight:600; } +.panel-head .p-flag{ font-size:.64rem; letter-spacing:.08em; text-transform:uppercase; font-weight:700; color:var(--warm); + border:1px solid rgba(246,189,124,.42); border-radius:100px; padding:2px 10px; } +.panel-head .p-ok{ font-size:.64rem; letter-spacing:.08em; text-transform:uppercase; font-weight:700; color:var(--living); + border:1px solid rgba(131,230,166,.34); border-radius:100px; padding:2px 10px; } +.panel-cards{ margin-bottom:12px; } + +/* in-range dense collapse */ +details.inrange{ border:1px solid var(--line-soft); border-radius:14px; background:rgba(255,255,255,.012); overflow:hidden; } +details.inrange summary{ list-style:none; cursor:pointer; user-select:none; display:flex; align-items:center; gap:.55em; + padding:11px 15px; font-size:.78rem; font-weight:600; color:var(--text-mid); font-family:"Hanken Grotesk",sans-serif; } +details.inrange summary::-webkit-details-marker{ display:none; } +details.inrange summary .chev{ transition:transform .3s var(--ease); color:var(--text-low); flex:0 0 auto; } +details.inrange[open] summary .chev{ transform:rotate(90deg); } +details.inrange[open] summary{ border-bottom:1px solid var(--line-soft); color:var(--text-hi); } +details.inrange summary:hover{ color:var(--text-hi); } +.inrange-table{ padding:4px 6px 6px; } +.ir-row{ display:grid; grid-template-columns:1.4fr .9fr 1fr .9fr auto; align-items:center; gap:12px; + padding:9px 10px; border-bottom:1px solid var(--line-soft); font-size:.84rem; } +.ir-row:last-child{ border-bottom:0; } +.ir-name{ color:var(--text-hi); font-weight:500; } +.ir-val{ font-size:.98rem; color:var(--text-hi); } +.ir-val u{ text-decoration:none; font-size:.7rem; color:var(--text-low); font-family:"Schibsted Grotesk",sans-serif; } +.ir-range{ font-size:.78rem; color:var(--text-low); font-family:"Hanken Grotesk",sans-serif; } +.ir-trend{ font-size:.76rem; color:var(--text-low); display:inline-flex; align-items:center; gap:.35em; font-family:"Hanken Grotesk",sans-serif; } +.ir-trend i{ font-style:normal; } .ir-trend.up i{ color:var(--living); } .ir-trend.down i{ color:var(--warm); } +.ir-tag{ font-size:.56rem; letter-spacing:.1em; text-transform:uppercase; font-weight:700; color:var(--living); + border:1px solid rgba(131,230,166,.34); border-radius:100px; padding:3px 9px; justify-self:end; } + +/* ---- responsive (info-design pass) ---- */ +@media (max-width:720px){ + .traj-sublist{ grid-template-columns:1fr; gap:6px; } + .gf-count{ margin-left:0; width:100%; } +} +@media (max-width:560px){ + .tsub{ grid-template-columns:96px 1fr 26px; } + .tsub-trend{ grid-column:2 / -1; justify-self:start; margin-top:-2px; } + .ir-row{ grid-template-columns:1fr auto; gap:4px 12px; row-gap:2px; } + .ir-val{ text-align:right; } + .ir-range{ grid-column:1; font-size:.72rem; } + .ir-trend{ grid-column:2; justify-self:end; } + .ir-tag{ display:none; } + .tj-plot{ height:200px; } +} +@media (prefers-reduced-motion:reduce){ + details.inrange summary .chev{ transition:none; } +} diff --git a/ui/hybrid.html b/ui/hybrid.html index 6951a6b..61545a7 100644 --- a/ui/hybrid.html +++ b/ui/hybrid.html @@ -558,6 +558,9 @@ + +
+
+
+
+ Score over time +

Your trajectory — are you getting healthier?

+
+

+ + Composite 0–100 · your dated snapshots · computed on-device +

+
+ +
+
+

Reading your trajectory…

+ +
+
+
+
+ On which fronts + +
+
+
+
+
+
+
@@ -682,7 +716,22 @@

One grounded answer per concept — or an honest abstention.

Each card is computed from your own records against a reference range. No range on file and none in the population fallback → Helix declines rather than guesses.

-
+ +
+ +
+ + + + +
+ +
+ +