Confidential · Executive Team & Development

Biometrics → Healthy Coins
Spec vs. Shipped Build

Dr Rob's Biometrics → Healthy Coins API Integration Spec v1.0 (21 August 2026) compared against what RKWO has actually built and what is running in production today.

🔒 Gated + noindex Reviewed 12 September 2026 Spec v1.0 · Phase 1 Against: health_coin_awards
4Principles already honoured
3Partly built
6Not built at all
6.7×Steps overpaid vs spec
THE VERDICT

Rob designed a vault. We built a coin counter.

The shipped build honours the privacy half of Rob's spec almost perfectly and skips the integrity half almost entirely. That is a fixable asymmetry, but it is the whole difference between a demo and something a health plan will underwrite.

What RKWO got right, and it is the hard part

The phone scores, the server banks. health_coin_awards genuinely has no column that could hold a heart rate, a weight or a blood pressure. Rob's central claim — "there is no column in the ledger that could hold them" — is true of our production schema today. That is the sentence we get to say to a school district or a payer, and it survives inspection.

What is missing, and it is load-bearing

Rob's ledger stores rule_id and n — which rule fired and how many units — so the server can recompute every award and discard whatever the client claimed. Ours stores only a coin_group and a final coin total. We cannot recompute anything. The number the phone sends is the number we bank, and — because of the RLS update policy — the number the member can afterwards edit.

01

The ledger schema — the load-bearing gap

Everything else in this document follows from this one table difference.

Rob's spec · §6
player_id · local_date · rule_id · n · coins
  · event_id (UNIQUE) · envelope_id · created_at
  • Append-only event ledger. One row per rule firing.
  • rule_id — which of the rules paid.
  • n — the count. Server recomputes from it.
  • event_id UNIQUE — replays refused, retries free.
  • envelope_id — ties the row to a signed payload.
  • player_id — pseudonymous ULID, not an account id.
What is in production
user_id · coin_group · local_day · coins
  · source_metric (now empty) · updated_at
PRIMARY KEY (user_id, coin_group, local_day)
  • Mutable daily aggregate. UPSERT overwrites in place.
  • No rule id — 10 metrics collapse into 7 groups.
  • No n. Recomputation is impossible.
  • No event id, no envelope, no signature.
  • user_id references auth.users directly.
Four things the missing columns cost us
  1. No server-side verification. Rob's anti-cheat layer 2 is not implementable against our table.
  2. No Provenance resource. The FHIR mapping in the PHIT Vault spec needs an immutable event with a timestamp and a source. An overwritable daily total cannot produce one.
  3. No FHIR Observation code. A movement coin might be steps, a workout or exercise minutes — three different LOINC codes. source_metric exists in the schema but is no longer written.
  4. No demo. We cannot show a partner "14,203 steps became 946 coins", only "946 coins arrived."
The good news — the fix is two columns

Adding rule_id and n to the award row unlocks all four at once, and it costs nothing in privacy terms: a step count is Green tier in Rob's own sensitivity gate — "behaviours and counts … full scoring, full transparency." Rob's spec explicitly permits the count to cross the wire. It is the Yellow and Red tiers — weights, blood pressures, clinical flags — that must never leave the phone, and none of those are in scope for the ten shipped metrics.

02

Who computes the coin

Rob and RKWO both say "the phone scores." They mean different things by it.

LayerRob's specShipped
Who scores firstPhone, from the local rules bundlePhone, from hardcoded rates match
Who scores lastServer recomputes from (rule_id, n). The client's coins field is discarded.Nobody. The client's number is written verbatim. gap
Rules sourceVersioned Airtable export, rules_version stamped on every envelopeHardcoded in the app binary, unversioned gap
Rules update pathAirtable edit — no App Store releaseRequires an App Store release gap
Payload signingHMAC-SHA256, per-install key in Keychain / KeystoreNone gap
Install attestationApp Attest (iOS) / Play Integrity (Android)None gap
IdempotencyClient-generated ULID per event, unique indexDay-level only, via the primary key partial
Caps enforcedServer-side, against the ledgerClient-side only gap
Compounded by two RLS holes already on the P0 list
-- coin_events (FFH's own, 2026-05-22) — anyone can mint coins for anyone
create policy "insert_own" on public.coin_events for insert
  with check (true);

-- health_coin_awards — a member can rewrite their own coin totals
create policy "health_coin_awards_update_own"
  on public.health_coin_awards for update
  using (auth.uid() = user_id) with check (auth.uid() = user_id);

Rob's design makes client tampering pointless — you can inflate n, but the server recomputes and the caps bite. Our design makes it unnecessary: a member can PATCH their own coin total straight through the REST API. The leaderboard is currently an honour system with a login screen in front of it.

Cheapest interim fix, no RKWO dependency

Drop the UPDATE policy on health_coin_awards and make the insert path a security definer function that clamps to the per-group daily cap. That removes self-service coin editing tonight and does not require the app to change at all. It is not Rob's full design, but it turns "anyone can" into "nobody can" while we wait.

03

Two different economies

These are not the same rate card with rounding differences. They price the same behaviour up to twelve times apart, and only one of them was calibrated against what a Healthy Coin buys.

The same behaviourRob's specShippedShipped pays
4,000 steps402666.7×
10,000 steps1006666.7×
22,500 steps120 (cap)1,500 (cap)12.5×
30 min vigorous exercise3003001.0×
150 min workout300 (cap)1,5005.0×
20 mindful minutes150 (cap)2001.3×
8 hours sleep100961.0×
12 stand hours120600.5×

Daily ceilings

CeilingCoins/dayNote
Rob's stated hard ceiling1,500"A good day lands near 500, a great day near 1,000"
Rob's own per-rule caps, summed1,530minor exceeds his own stated ceiling by 30 — worth a line in his v1.1
Shipped — iOS2,51067% above the design ceiling
Shipped — Android2,150Mindful minutes and stand hours have no Android source
A realistic active day

9,000 steps · 20-minute workout · 10 mindful minutes · 10 stand hours · 8 hours sleep → Rob: 640 coins. Shipped: 1,167 coins. Rob's open question #4 — "does 500 coins for a good day sit right against what Healthy Coins already buy in Reality Health Games?" — was never answered, and the app shipped at roughly double his figure. Every redemption price in Reality Health Games is now calibrated against the wrong denominator.

And Android members cannot win

Mindful minutes (300/day) and stand hours (60/day) are iOS-only with no Android substitute in their group. An Android member's ceiling is 360 coins/day lower — 14.3%, or 10,800 coins over a 30-day challenge. Two equally active members on different phones do not compete on equal terms. For an organisation whose mission is health equity, a leaderboard that is unfair by construction is the finding I would raise before the rate card itself.

Which economy is right is Lucy's call, not an engineering one

Rob's is calibrated to redemption value. RKWO's is calibrated to feel generous. Both are defensible; running both at once is not. The decision needs to be made once, written into a versioned rules bundle, and stamped on every award — otherwise coins earned in September and coins earned in November are not the same unit and the ledger cannot be audited.

04

Anti-cheat — the provenance filter

Rob calls this the single most important implementation detail in his document

"Any app can write steps into Apple Health. Without provenance filtering, 'gamified steps' is an honour system." His filter excludes hand-typed samples and our own writes:

// iOS
let notManual = HKQuery.predicateForObjects(
    withMetadataKey: HKMetadataKeyWasUserEntered, operatorType: .notEqualTo, value: true)
// Android
val kept = records.filter { it.metadata.recordingMethod == RECORDING_METHOD_AUTOMATICALLY_RECORDED
                         || it.metadata.recordingMethod == RECORDING_METHOD_ACTIVELY_RECORDED }

Status: unknown. We have never been sent RKWO's read code and cannot see it. This is a direct yes/no question for Rahul, and it is the one that decides whether the leaderboard means anything. It also has to be asked before Dr Rob's watch test, or the test proves the wrong thing.

Rob's four layersStatusNote
1 · Provenance filter (client)unknownAsk Rahul. Cannot be verified from our side.
2 · Server recomputes every awardnot builtImpossible without n.
3 · Caps everywhere, against the ledgerclient onlyCaps exist in the app; server does not enforce.
4 · Attestation, signing, idempotencynot builtNo HMAC, no App Attest, no event id.
05

Where we already agree

Worth stating plainly, because it is the part that is genuinely hard to retrofit and we did not have to.

Rob's principleShipped
The phone is the scorekeeper, the server is the bank. Raw measurements never leave the handset.honoured Conversion happens in the app; only a coin total is POSTed.
No column in the ledger could hold a health value.honoured health_coin_awards holds a group, a date and an integer. Nothing else.
Collapsed disclosure by default — bucket codes rather than per-metric rule ids.honoured — accidentally. coin_group is a 7-bucket collapse. But it is collapsed for Green rules too, where Rob allows full transparency, which is why we lost the demo.
Per-day partitioning on local_date so no timezone reconstruction is possible.honoured local_day is part of the primary key.
Read-only scopes, no write access requested.believed Consistent with what we see; unverified.
Streak scored from dates alone, never from health data.honoured The streak engine reads only coin totals and dates.
One thing to correct in our own work

Rob's rate card pays a streak 250 / 750 / 2,500 coins at 7 / 30 / 100 days. Our Streak Challenge page runs 3 / 7 / 14 / 21 / 30 days and pays nothing at any milestone. Neither the ladder nor the payout matches the spec. That is our gap, not RKWO's, and it is a small fix — but the two ladders should be reconciled before the page is shown to a partner.

06

What this does to the HIPAA question

Rob's reframe is the useful one, and it survives scrutiny

"No HIPAA obligations arise in Phase 1 because there is no protected health information to protect — FFH never receives any." Applied to what is actually in production: a table of (user_id, coin_group, local_day, coins) contains no health information, no inference about an identified person's health status, and nothing a Business Associate Agreement would attach to. The unsigned Supabase BAA is not a blocker for Phase 1 as built. It is an absolute blocker for Phase 3.

Two caveats before that becomes a talking point
  1. It is only true of health_coin_awards. The lit_* tracker tables and any free-text tracker entries are a separate question and have not been reviewed against this standard. One table being clean does not make the database clean.
  2. Rob's own footnote applies. Consumer health-data statutes — Washington My Health My Data, Nevada SB 370, the FTC Health Breach Notification Rule — reach further than HIPAA and cover consumer apps regardless. His spec says counsel should review before launch. That has not happened.
Sequencing for the partner conversations

The story that holds up is: "Phase 1 handles no PHI by construction — here is the schema, there is no column that could hold a measurement. Phase 3, where a member opts in to share detail with their clinician, is a separate service with a separate database and a separate consent, and it is not built yet." Rob's Wall — build Phase 3 as a new service, never as a migration — is the part to commit to out loud, because it is the promise that makes Phase 1 credible.

07

Proving the Apple Watch chain

Dr Rob has the watch. Here is precisely what a test can and cannot demonstrate with today's schema, and what one added column would change.

Link in the chainProvable today?How
Watch → iPhone HealthKityesApple Health app shows the sample with the watch as source.
HealthKit → FFH app readyesThe app's own dashboard renders the reading.
App conversion → coinsnoThe gap. Without n we see the output but never the input. We cannot show the arithmetic.
Coins → Supabase rowyesRow appears for that user_id, local_day, coin_group.
Which metric earned itnosource_metric is in the schema but is no longer being written.
Sync freshnessyesupdated_at gives real latency: now − max(updated_at).
Supabase → web pageyesThe Streak Challenge page already renders it live.
The test protocol that works with what we have
  1. Rob wears the watch on a zero-baseline day — confirm no rows exist for today before starting.
  2. He does one deliberate, countable thing: a timed 10-minute walk, phone left behind so the watch is unambiguously the source.
  3. He screenshots Apple Health showing the step count and the source attributed to the watch. That screenshot is the input side of the proof, held outside the system.
  4. Force a sync. The Connectivity Proof panel shows the row appearing, with latency.
  5. We compare his screenshot against the coins banked, and check the arithmetic by hand against the published rate card — 15 steps = 1 coin.
Be honest about what step 5 is

That is a manual reconciliation, not a system proof. It demonstrates connectivity convincingly and it is enough for a partner demo — but it does not scale, it cannot be automated, and it cannot be repeated by anyone who is not holding Rob's phone. Restoring source_metric and adding n turns a five-step manual ritual into a line on a page. That is the argument to make to Rahul, and it is a better argument than "the Vault needs it" because he can see the value this week.

08

The ask to Rahul

Ordered by value per unit of his effort, not by how much we want them.

#AskEffortWhy it earns its place
1Add rule_id and n to the award row; restore source_metricsmallUnlocks server recomputation, the FHIR Observation code, the Provenance resource, and the Apple Watch demo — all four, from one change. Green-tier data by Rob's own gate.
2Confirm the provenance filter is implementedanswerYes or no. If no, the leaderboard is an honour system and we should know before we promote it.
3Take MAX within a coin group, not highest-prioritysmallFixes the Q3 bug his own worked example #7 demonstrates — a member currently loses up to 1,200 coins for logging a run.
4Stamp rules_version on every awardsmallWithout it, a future rate change silently restates history and the ledger stops being auditable.
5Close the Android parity gap, or exclude iOS-only groups from challenge scoringmedium14.3% ceiling difference. A mixed leaderboard is currently unfair by construction.
6Move the rules bundle out of the binarymediumRob's design. Rate changes without an App Store release. Not urgent, but every week it waits the migration gets bigger.
7HMAC signing + App Attest / Play IntegritylargeThe real fix for coin integrity. Phase 2 work — but the schema decisions in #1 and #4 should not foreclose it.
What we do ourselves, this week, without waiting for him
  • Drop the UPDATE policy on health_coin_awards; clamp inserts in a security definer function.
  • Fix the coin_events insert policy — with check (true) has been live since 22 May.
  • Reconcile our streak ladder against Rob's 250 / 750 / 2,500 at 7 / 30 / 100 days.
  • Answer Rob's open question #4 — decide the economy, and write the number down.
  • Send Rob's spec to counsel with the consumer-health-statute footnote flagged.