Skip to content

SportMode: Loop changes needed to run autonomously on the watch - #2545

Draft
jeremybarnum wants to merge 137 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev
Draft

jeremybarnum wants to merge 137 commits into
LoopKit:next-devfrom
jeremybarnum:sportmode/next-dev

Conversation

@jeremybarnum

@jeremybarnum jeremybarnum commented Oct 1, 2026 •

Copy link
Copy Markdown

Sport Mode: the phone lends the Omnipod to the watch. The watch runs Loop itself (its own G7 glucose, its own stores, the identical Loop algorithm) until it hands the pod back. The phone stays the home of the user's data and can take the pod back at any time. It's behind SPORT_MODE_ENABLED, off by default; with it off, neither app runs any of this..

How it works

  • Start: the phone releases the pump and sends the watch a grant: the pump's exported configuration, the settings and dosing strategy, and recent insulin, carbs and glucose. The watch builds its own pump and CGM managers from what the phone exports. One file on the watch names the device kits; everything else goes through LoopKit protocols.
  • During the loan: the watch loops and doses. The phone's loop keeps computing to the extent it can still reach the pump and sensor but never acts on it. Doses, carbs and overrides from the watch come back to the phone as records, stored once each. The watch also keeps its own dosing decisions, alert history and device log; they go home to the phone in one file at hand-back. The phone stores no dosing decisions of its own during a loan.
  • The watch glance: a Sport Mode page on the watch shows glucose and trend, the loop status ring, IOB, COB and the running temp, with Start and End. It's the landing page during a loan. The ring is tappable to open/close loop.
  • Carbs on the watch: during a loan, carbs can be added and also deleted (swipe left in the Active Carbs list). Both reach the phone at hand-back. Outside a loan the watch's carb list is stock, read-only.
  • Diagnostics: a last watch page, behind the flag, for bench work. It shows read-only loan, pod and sensor state and the logs, with buttons to read the pod, reconnect the sensor and refresh. No toggles.
  • Hand-back: the watch hands back, or the user takes the pod back from the phone. A take-back from the phone reaches for the pod first. If the pod answers, the phone tells the watch to stop, imports the records the watch has already sent, checks the pod's delivered total against them and resumes looping, without waiting for the watch. If the pod doesn't answer within a few seconds, nothing changes and the user is asked to try again. With both devices available, records are synced and dosing should be exact. Otherwise, the safety mitigants described below kick in.
  • Without the phone: the watch can start from a stored copy of the grant when the phone is unreachable. When the watch comes back into range, the phone notes that it doesn’t have control even though it can see the sensor and the pod and as soon as the watch hears from the phone, the user is asked whether to hand back or keep going. The scenario here is forgetting the phone at home, or having the phone battery die and then starting SportMode on the watch as a way to continue looping. The watch offers to start without the phone as soon as the phone is out of reach, or after 10 seconds without an answer; a phone that answers first still starts it the normal way.
  • Phone UI during a loan: a "Sport Mode Active" banner (tap to take the pod back), and the pod tile offers the same. Carbs, bolus and presets are disabled, and charts show history only. Settings is also disabled as a change in settings during a loan where the watch isn't reachable would be problematic. Any attempt to tap action buttons on the phone UI will offer the “reclaim Pod?” Option.
  • Alerts: the watch raises the stock glucose alarms itself, plus pod and Loop-failure alerts. Acknowledging a pod alert on the wrist reaches the pod. The phone posts a notice if the watch reports a pod fault.
  • Relaunch: loan state is saved as one value per side, so either app can be relaunched mid-loan. A watch that restarts (say, after a dead battery and a recharge) reads the pod before its first cycle and counts any insulin given while it was off, then resumes. If it was off for longer than insulin's action time, it doesn't resume: its records go to the phone and it shows "Sport Mode Ended".
  • Remote uploads during a loan are not in this PR. On its own, the phone keeps uploading its own glucose while it's in range, and the loan's treatments and loop status reach Nightscout and Tidepool from the phone at hand-back. Uploading from the watch during a loan comes in two stacked PRs: SportMode - Nightscout uploads from the watch during a loan #2554 (Nightscout) and SportMode - Tidepool uploads from the watch during a loan #2555 (Tidepool, with an optional refresh-token pair for real-time Tidepool). Their limitations, such as cellular-only uploads waiting for the app to be in front, are described there.

Safety

The insulin audit. At every hand-back the phone reads the pod's own delivered-insulin total and compares it with what the records say was delivered during the loan (the doses plus the scheduled basal, counted in whole pod pulses). Important to understand that the pod knows how much it delivered but not when it delivered it.

  • If the pod delivered more than the records explain (beyond ±0.2 U, amount to be discussed): after an ordinary hand-back the watch's records are complete, so the phone reports the difference and books nothing. After a take-back from the phone, records may be missing, so the phone books the difference as a placeholder bolus delivered now and reminds the user at 2 and 4 hours. That's always conservative, and on a dose taken long ago it can be quite conservative. When the watch's records arrive they replace the placeholder. The loop stays closed in both cases.
  • If the records claim more than the pod delivered, it alerts urgently ("Insulin On Board May Be Overstated") but doesn't open the loop. This is the scenario where during sport mode, a low temp was running (this might be quite common during exercise) and then the watch dies before sending records. The risk is therefore having less IOB than Loop believes, which is safe albeit undesirable. As soon as the watch comes back into range/gets charged, predictions are exact again.
  • During a loan the watch sends checkpoints. This means that to the extent the phone is in range during a loan, the “knowledge gap” about carbs and insulin declared on the watch is minimized. Imagine for example starting a loan, walking around the house or doing some yardwork, then departing the area and leaving the phone behind, and then having the watch die. Upon returning in range of the phone, the gap is limited to the last sync, rather than to everything that has happened since the start of the loan. Also helpful that the sync can happened over Wifi, not just BT.
  • If the phone has to take the pod back by force (the watch is gone), it holds dosing until the audit has run. When the watch's records arrive later, they replace the placeholder.
  • The watch does the same at takeover: insulin the phone's copy can't explain (say, a bolus given on the phone while the watch was on the charger in another room, not connected over wifi, followed by leaving the phone behind and start a loan on the watch without the phone present) is booked before the watch's first loop cycle.
  • The phone does the same at every reading of the pod, on a phone set up for Sport Mode. On both devices the check runs as the pump reports the reading, before the dose that reading feeds is decided. A watch that restarts mid-loan does the same at its first reading, so it never doses without counting insulin given while it was off, for instance by the phone. This covers a loan started on the watch away from the phone, where the watch then dies: the phone never heard about that loan and would otherwise resume control with the watch's insulin missing. The difference is booked as a placeholder bolus, as after a take-back, and the watch's records replace it if they cover the gap. Each estimate stands for its own stretch of time and is replaced only by records from that stretch. One older than insulin's action time stops being tracked; its dose stays in the history.

Duplicate carbs. Extra carbs mean extra insulin, so this is the dangerous direction.

  • Every carb entered on the wrist carries a fixed identity. The phone stores it with a new "add if absent" call, so a resent entry is stored once, however many times it arrives.
  • Records are resent until acknowledged, and the phone saves which ones it has stored before it acknowledges them. A relaunch at the wrong moment can't store one twice.
  • Records from a loan that's already over can't add carbs.
  • Carb deletions travel as records too, so a carb deleted on the wrist doesn't come back.
  • During a loan, entering carbs on the wrist needs the same confirmation as a bolus.

Known limitations

  • During a loan the phone's dose history is incomplete, so its predicted-low and missed-meal checks can misfire while the phone is still near the sensor. That's moot when the phone is away. Easy to change if we want to.
  • The phone recognizes a loan it wasn't told about only from the watch's own messages, by design. Detecting it from the pod was tried and removed after false alarms.
  • Glucose-based partial application isn't carried to the watch; it's an algorithm experiment, left for later.

-After a force reclaim, the placeholder uploads as an ordinary bolus and is replaced when the watch's records arrive. If the watch returns more than a day later, Nightscout may keep it.
-No migration from earlier Sport Mode builds; update only when no loan is running.

Worth a look

  • Stock files touched: about 30, mostly small hooks. One additive initializer on PersistedProperty, for tests.
  • Differences in behavior, predictions and dosing should be absolutely minimal, if any, at this point.
  • From the device-agnostic rework:
    • The success haptic plays on every confirmed bolus.
    • The watch no longer discards an old G7 sensor by age (the kit knows each sensor's own 10- or 15-day life).
  • The watch's entitlements come from a build setting, LOOP_WATCH_ENTITLEMENTS: empty by default, so with the flag off the watch signs as stock does. Sport Mode builds set it next to SPORT_MODE_ENABLED; without it, a Sport Mode watch can't keep itself running during a loan (unless it has a workout keepalive, which is a separate discussion). The Bluetooth background mode stays in the watch's Info.plist either way.

Testing: phone loan tests (164) and watch tests (218) pass. Both apps build with the flag off. A simulated loan runs end to end. Not yet re-tested on a real phone, watch and pod since the hand-off rework.

Needs LoopKit/LoopKit#626, LoopKit/RileyLinkKit#21, LoopKit/G7SensorKit#80 and loopandlearn/OmnipodKit#177; merge those first. Draft.

Built on this PR (drafts)

Jeremy Barnum and others added 30 commits October 1, 2026 09:39
The phone lends the Omnipod to the watch. The watch runs Loop itself (its
own G7 glucose, its own stores, the stock algorithm) and doses the pod until
it hands it back. The phone stays the home of the user's data and takes the
pod back on demand.

Loan protocol (LoopCore/PodLoan, compiled into the phone, the watch and both
test targets):
- LoanMessages (grant, hand-back, revoke, acks, status reports),
  LoanRecords (the dose, carb and override records that move between
  devices) and LoanTransport over WatchConnectivity.
- The envelope carries a protocol version and throws on a mismatch.
  Same-version field changes ride capability flags, because the two apps
  install separately and a watch newer than its phone is routine.

Phone, the lender (PodLoanPhoneController and its extensions,
LoanReconciler):
- Grant: releases the pod's connection and sends its state and the therapy
  settings. A standing copy on the watch lets the watch start without the
  phone.
- Hold: while the pod is out, the phone's loop computes but never enacts.
- Records from the watch are stored idempotently by sync identity.
  Reconciliation audits the pod's delivered total against the books, and
  opens the loop and books a placeholder for insulin it cannot explain.
- Reclaim and settle: a hand-back or a forced reclaim, a status read to
  verify, then a resumed loop. A silent watch is warned about, never taken
  from.
- Hooks in stock files: DeviceDataManager, LoopDataManager (the enact
  interlock), WatchDataManager, AlertManager, NotificationManager,
  TemporaryPresetsManager, StatusTableViewController. SettingsProvider and
  LoopUpdateContext move to their own files so the watch can compile them.

Watch, the borrower (WatchApp Extension/StockLoop):
- StockLoopStack and StockLoopSession: the watch's own glucose, dose and
  carb stores, a G7 manager reading the sensor directly, and a pump only
  during a loan.
- WatchLoopManager: the Loop cycle with the stock algorithm and the granted
  settings. Missing settings deny dosing; nothing is defaulted.
- PodLoanWatchController: takeover, hand-back, revoke, resume after a
  relaunch, and a start from the standing copy when the phone cannot be
  reached, with a prompt when it returns.
- An HKWorkoutSession keeps the app running across takeover and hand-back.
- The glance, carb and bolus entry during a loan, wrist alerts for a stalled
  loop and a stuck hand-back, a diagnostics page and a device log.

Tests: LoanProtocolV2Tests, LoanBooksHarnessTests and
PodLoanPhoneControllerTests in LoopTests, and a WatchAppTests target that
reaches the watch extension's own code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… G7 fixes

Phone, while the pod is loaned to the watch:
- A "Sport Mode Active" banner in the status rows; tapping it offers the
  reclaim, or the settling notice while a reclaim is in flight.
- Add Carbs, Bolus and Presets are disabled and dimmed.
- The loop circle stays fresh with no minutes caption, and its dialog says
  the watch is running the loop.
- Charts show history only: no forecast or "Eventually", blank Active
  Insulin and Active Carbs values, IOB and COB curves cut at now. A loan
  starting or ending refetches everything.

Automatic bolus on the watch:
- The grant carries the phone's dosing strategy instead of forcing
  tempBasalOnly, and the watch runs the matching recommendation type.
- A cycle whose temp needs no command still enacts its automatic bolus;
  boluses are rounded to the pump's volume and drive the glance's delivery
  bar.
- LoanDoseRecord.automatic carries the flag home, so the phone stores watch
  automatic boluses as automatic.

Feature flag: sportModeEnabled (SPORT_MODE_ENABLED, default off) gates the
watch's Sport Mode and diagnostics pages.

Watch G7:
- While the watch searches for a sensor it has never connected to, the glance
  and diagnostics ask the user to keep Loop open, and a pre-scheduled
  "Sensor Not Found" alert fires if the app stays in the background for ten
  minutes.
- A persisted sensor is past its life only after its reported session end,
  or 15.5 days when the length is unknown, so a 15-day sensor in its grace
  period is no longer discarded at launch.
- Bluetooth wake tasks are never completed twice; a redelivered task threw
  mid-pairing and crashed the app.
The glance, diagnostics page and sensor-search alert read
watchNeedsCodeFor and watchIsSearching from the stack's G7CGMManager and
observe G7CGMManager.watchStatusDidChange. The diagnostics page no longer
offers the re-lodge picker.
… stock

Device hand-off contract:
- The grant carries the pump's exported configuration
  (SharedDeviceConfiguration) instead of its raw state. LoanProtocol.version
  is 3, so a version-2 peer is refused rather than guessed at.
- WatchDeviceManagers builds the pump and the CGM through init(adopting:),
  keyed by plugin identifier. The CGM is built from the phone's export on
  the watch context and rebuilt only when that export changes.
- Gone with it: the watch's edits to the pod's raw state, its handle cache,
  the device-specific loan calls and the G7 state reads. Also gone: the
  launch-time G7 past-life discard and the forgotten-sensor guard. G7SensorKit
  keeps a passed sensor itself and knows each sensor's own session length.
- Takeover readiness comes from deviceManagerControlDidBecomeReady. The
  pre-Start "keep your wrist up" note asks takeControlNeedsSearch(adopting:)
  of the standing copy.

Loan state:
- PodLoanPhoneState and PodLoanWatchState hold each side's persisted loan
  fields as one value, saved before any send that depends on them. The
  watch's loop mode, correction model and last cycle are one WatchLoopState.
  The loaned pump's state is saved the way stock saves a pump manager's.
- Fixed on the way: the grant's audit base was saved under the previous
  loan's epoch; the watch never saved the phase its launch normalised; a
  seized loan's reunion token was saved after its active phase.
- Injectable storage seams for tests.

Hand-off behaviour:
- Take-back: a deferred force is satisfied by the close, and a yield closes
  the settle window.
- Offline start: a confirm with the phone back is a normal request, and a
  grant or a new Start withdraws the offer.
- First contact with a new pod asks for the wrist up and says when it can
  come down.
- No whole-loan workout session. The watch's bolus progress comes from the
  pump manager's reporter. Wrist overrides are journaled again.

Alerts during a loan:
- Glucose alarms on the wrist: stock GlucoseAlertManager compiled into the
  watch, built from the phone's alert settings carried in the grant, fed
  direct G7 readings and the watch cycle's forecast.
- A pod fault the watch reports gives one phone notice per fault, with the
  kit's own fault text (localizedInoperableDescription).
- OK or dismiss on the loaned pump's wrist alert acknowledges it on the pump,
  which is what stops an Omnipod beeping.
- Only what is critical on the phone becomes time-sensitive on the wrist.
  The loan's own notices are time-sensitive when nobody is dosing or insulin
  is booked wrong, and normal when they are routine.
- Any behaviour-relevant settings change, glucose alert settings included,
  refreshes the watch's standing copy at once.

Back to stock and lean:
- SPORT_MODE_ENABLED switches Sport Mode off at the entry points on both
  apps, not just its pages.
- Back to stock: the configurable development team, project settings (eager
  linking, the watch simulator's x86_64 exclusion), two stock-bug fixes in
  LoopAppManager.
- Removed: code only earlier test builds needed (migrations, the iCloud log
  mirror, a demo gallery), the log-only start gate, the phone's residual
  history, unused device-kit imports, Siri from the watch entitlements, and
  a test file for a feature not in this tree.
- Comments state intent in a line or two. Comments, log strings and
  identifiers carry no internal tags; temp-cancel reasons and placeholder
  sync identifiers have device-neutral names; the phone's string catalog
  matches its strings.

Tests: one for each change above; racy tests wait on their condition; loan
tests no longer write into the app's real Documents or notification centre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Gate: the phone loop's existing loan hook (`isPumpConnectionReleased`) now reads the
  controller's `holdsAutomaticDosing`, true while the pump is released OR a force-reclaim audit
  is still owed. Before, `takeControl()` lifted the only gate at the force reclaim, so the phone
  could enact a temp or automatic bolus on books missing the watch's insulin until the audit ran.
- The owed audit is already persisted state, so the hold survives a relaunch; the settle
  window's five-minute ceiling still ends it by opening the loop.
- The hold lifts after the verdict is applied, so a loop-open on a breach is dispatched first.
- Test: the dosing-hold test asserted only the notification pause; it now asserts the gate the
  loop reads, and fails against the release-only gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A wrist carb is now stored on the watch and journaled under one identity, its event ID,
  which is what the phone stores it under. Before, the watch's store minted its own sync ID, so
  deleting a carb an earlier interim drain had committed missed on the phone and the carb stayed.
- Wire unchanged: the delete record's existing `syncIdentifier` now carries the right value. No
  protocol version bump; an older phone matches it the same way.
- The phone's match moves into `DeletedCarb.matches`, shared by the store wiring and tests.
- Comments that said carbs have no identity now give the real reasons.
- Tests: a watch test from carb entry to delete record to the phone's match; a phone test for a
  carb committed by an interim drain and removed by a later delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The grid-delay arm is gone from G7SensorKit; remove the tests that
  pinned its arithmetic and the arm toggle.
- Pin the remaining hold decision through relodgeHold(sinceLinkUp:).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- needsCodeForSensor, isSearchingForSensor and G7CGMManager.statusDidChange
  replace the watch-prefixed names in the sensor-search alert, the glance
  note and the diagnostics page.
- The diagnostics page no longer reads the G7 bluetooth manager's
  isConnected and isScanning, which wait on its queue; it shows what the
  CGM manager publishes: last reading, code needed, searching. The "link"
  row goes.
- The "Reconnect sensor" button goes with the kit's reconnect action.
- Tests: a refused connect is retried after the back-off every time (no
  stand-down after two); the watch's bluetooth manager carries the
  acquisition arm.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- LoanReconciler.expectedInsulin takes the pulse size as a parameter; the
  hard-coded Omnipod 0.05 U is gone. Rate records are still floored to
  whole pulses, now of the lent pump's size.
- The phone controller reads it from the lent pump through
  PumpDeliveryOdometer, which it already requires to lend. With no pump
  there is nothing to judge against: the force-reclaim audit takes its
  existing "cannot verify" path (the loop opens), a checkpoint is skipped,
  and a hand-back sets no pending audit. None of these could reach a
  verdict before without the phone's own pod read, so no outcome changes.
- The hand-back log's pulse-floored drain total uses the same size in place
  of its own x20/20 rounding.
- The watch's check of a seized loan's standing copy takes the size from
  the pump it just read.
- Behaviour is unchanged for Omnipod: the pod reports 0.05 U.

Tests: the reconciler, harness and phone-controller tests and the test pump
use 0.05 U.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch no longer splits G7's sync identifier to match a relayed phone
  reading against its own reading of the same sample. The watch's adopted
  G7 manager takes the sensor's activation from the phone's export, so both
  devices name a reading alike, and both watch paths write to the one
  store, under its one provenance. Stock GlucoseStore.addGlucoseSamples
  already skips a sample whose sync identifier that provenance has stored.
- The relay path counts a reading as delivered only when the store kept it.
- Narrow edge: if the phone exports before it knows the sensor's
  activation, the watch latches its own estimate, and the two names differ
  until the next export; until then a reading both devices deliver can be
  stored twice.

Tests: a relayed phone reading and the watch's own reading of the same
sample, read through the adopted G7 manager, are one stored row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Carb and glucose window: stock's 12 h plus a minute, not 10 h. Stock reads it from
  `LoopConstants.maxCarbEntryPastTime`, which is phone-only, so the value is repeated.
- ISF and override history start at `min(neededSensitivityTimeline.start, carbsStart)`, as in
  stock. Glucose can start later than the oldest carb (a CGM gap, or the watch's 4 h glucose
  cache), and CarbMath traps on a carb the ISF timeline does not cover. An override over that
  carb now scales its ratio as well.
- Still different from stock `fetchData`, on purpose: the pump-data recency gate lives in the
  fetch; doses are trimmed per dose (no forward credit) and ongoing doses are not projected; no
  pre-meal "ending now" for a carb-entry bolus and no high-needs suspend floor; missing
  settings throw instead of defaulting.
- Also different, not changed here: the automatic-bolus application factor is LoopAlgorithm's
  default 0.4, which is stock's value unless glucose-based application is on (a phone setting
  the grant does not carry); settings are the grant's snapshot projected over the window, not
  settings history.

Test: hourly ISF items, 3 h of glucose, a 6 h-old carb under a 50 % override that has ended.
The ISF timeline covers the carb, its ratio and ISF are doubled, and the algorithm runs (before:
CarbMath trap, confirmed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `StockLoopStack` sets `TemporaryScheduleOverrideHistory.relevantTimeWindow` to 24 h when it
  builds the watch's history, enough for the algorithm's ~18 h lookback. The watch ran on
  LoopKit's 1-second default, so every glance or HUD refresh pruned an ended override and its
  minutes went unscaled from the next cycle: after an exercise preset, more insulin.
- The window is a LoopKit static. The phone sets its own (90 days) in `TemporaryPresetsManager`,
  which compiles into the iPhone `Loop` target only, so the two values never meet in one process.
- Each grant starts the watch's history over before applying the granted override. With ended
  overrides kept, an override the phone sends again (same identifier) would be stretched back
  over a wrist override the phone never heard of, and LoopKit traps on overlapping overrides
  (reproduced in the test). Starting over is what the 1-second window did in effect.

Tests: an override that expired 30 min ago still halves basal and doubles ISF and carb ratio
for its minutes after a refresh (before: unscaled); a grant re-sending an override after a
wrist override does not trap and keeps only what the phone sent (without the reset: trap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.overrideHistoryRaw`: the phone's overrides overlapping the last 24 h, ended ones
  included, as a binary plist of stock raw values inside the JSON envelope, like the override
  record. The raw form drops `actualEnd`, so an early end is folded into the duration (as
  `queryByAnchor` does) and deleted overrides are left out. The standing copy is the same grant,
  and `withEpoch` passes the field on for a phoneless start.
- The watch adopts it at intake, before the first cycle, each override at its original start
  and end, so an override that ended before Start still scales its minutes. A grant without
  the field (an older phone) leaves the watch as before: the active override only.
- Version skew: an optional field, no capability flag. An older watch's decoder skips the
  unknown key and behaves as today; an older phone sends nil. Neither side can misread the
  other, which is what the envelope's "optional means an older build" rule covers; no flag,
  no version bump.
- Standing-copy refresh: not extended. Its fingerprint already includes the active override,
  and every change the phone can make to its history (set, change, clear, natural end)
  changes that.
- Size: three overrides are 945 B of plist and add 1.3 KB to the grant's JSON (measured in
  the test), beside grants of about 25 KB and the 60 KB urgent-channel limit.

Tests: the phone's grant asks from 24 h back, folds an early end, drops a deleted override;
the field round-trips, is nil from an older phone, and an unknown grant field does not break
decoding; on the watch, an override that ended 2 h before Start halves basal and doubles ISF
and carb ratio for its minutes on the first cycle; a start from the standing copy keeps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The watch's override history is saved in the loop manager's persisted state on every
  change, through stock's `TemporaryScheduleOverrideHistoryDelegate` callback, as LoopKit's own
  Codable events: the 24 h window, ended overrides included, each with its actual end.
- A resume reads it back before the pump manager is rebuilt, so before the first cycle, and
  makes the override active in it current again, as stock's `TemporaryPresetsManager` does at
  launch. Before, a resumed loan dosed with no override at all: the history lived only in
  memory and the resume state held settings and flags.
- Only a resume reads it. An ordinary launch starts empty and the next grant replaces the
  history anyway. Opening the history through a SwiftData container, as the phone does, would
  also work but brings SwiftData into the watch extension.

Tests: a relaunch mid-loan brings back the active override and one that ended before it
(without the restore: no override, empty history); the resume checklist test now includes the
override.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- An override change journaled on the wrist now reaches the phone's apply path with its time:
  `LoanReconciler.OverrideChange` carries the record's date, `.set(_:at:)` and `.cleared(at:)`.
- The phone records the change in its override history at that time with stock's
  `recordOverride(_:at:)`, then sets `scheduleOverride` as before; the setter's own record then
  has nothing left to do. This is a sibling, `TemporaryPresetsManager.setScheduleOverride(_:changedAt:)`,
  in the loan wiring; the stock setter is unchanged. Before, a wrist clear drained at hand-back
  ended the phone's override at the hand-back, so the phone's next cycles treated the minutes
  in between as overridden (after an exercise preset, less insulin than it should). A wrist set
  already kept its own start date.
- Idempotency unchanged: committed event IDs and the syncIdentifier check. One guard added: a
  clear made before the phone's current override started does not end it. Recorded at its own
  time, such a clear would delete the newer override from the history.

Tests: end to end, an override set on the phone, a loan, a wrist clear an hour before the
hand-back: the phone's history ends the override at the clear; a replayed drain still applies
once, at the wrist's time; a clear older than the phone's override is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Therapy settings can't change on the phone during a loan, matching Carbs, Bolus and
  Presets; settings reach the watch only with a grant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanOverrideTests` was missing from the phone gate's test list, so two of its tests had been
  failing unseen.
- `testOverrideReachesTheWristInTheGrant` asserted that the schedules ride in
  `LoopSettings.rawValue`. Stock's raw value has never carried them; the grant sends them in its
  settings supplement. The test also built a grant by hand, so it never exercised the phone.
  Replaced by `testTheGrantCarriesTheActiveOverrideInItsOwnField` in
  `PodLoanPhoneControllerTests`: the phone's own grant carries the active override, with its
  identity, in `activeOverrideRaw`, and the schedules in the supplement, not the settings blob.
- `testPhoneApplyIsIdempotentAcrossAReplayedDrain` cleared at the same instant the override
  started. The wire rounds dates to whole milliseconds, so about half the time the clear landed
  just before the start and the newer-override guard skipped it. The clear is now a minute later.
- Two comments still described the override riding in the settings blob; corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The loan controller drains the wrist's journal on its own queue and applied override changes
  from there, into `TemporaryPresetsManager`, which is main-actor state. The wiring now hops to
  main with `DispatchQueue.main.async`, as its other settings writes do, through a sibling of
  the setter, `setScheduleOverrideOnMain(_:changedAt:unapplied:)`.
- Async, so the controller's queue never waits on main. The main queue runs blocks in the order
  they were queued, so changes land in the order the wrist made them.
- Idempotency: the controller decides on its own queue from what the phone holds, which it read
  synchronously before. A change on its way to main is now held in a small locked list until main
  applies it, and the wiring's reader answers with the newest one, so a drain right behind
  another still sees the first one's change (otherwise a clear right behind a set would read "no
  override" and be skipped). Committed event IDs and the syncIdentifier check are unchanged.

Tests: two changes queued from a background queue apply nothing there and show as unapplied,
then apply on main, set before clear, with the history ending the override at the clear's time.
Fails when the apply runs on the calling queue.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `LoanGrant.settingsHistory`: at the grant, the phone runs stock's own settings-history queries
  (`getBasalHistory`, `getInsulinSensitivityHistory`, `getCarbRatioHistory`,
  `getTargetRangeHistory`) over the 24 h up to it and sends the resulting timelines, as stock's
  `AbsoluteScheduleValue`s with glucose in mg/dL. The standing copy is the same grant, and
  `withEpoch` passes the field on for a phoneless start.
- On the watch, `WatchSettingsProvider` answers each history query from the phone's timeline for
  the part of the window it covers and from the granted snapshot for the rest, clipped so the
  result is continuous. Before, the snapshot was projected back over the whole window, so a
  basal, ISF or carb ratio change made on the phone shortly before Start was applied to doses and
  carbs from before it.
- A grant without the field (an older phone) leaves the provider as before, snapshot only.
- The history is saved with the granted settings, so a resume after a relaunch keeps it.
- No mid-loan update: Settings is disabled on the phone during a loan. The standing copy's
  refresh fingerprint already covers the schedules, so a settings change refreshes the copy and
  its history with it.
- Version skew: an optional field, no capability flag, no version bump, as for the override
  history. An older watch's decoder skips the key; an older phone sends nil.
- Size: a day with one change, basal in 6 blocks, adds 1.8 KB to the grant's JSON; with hourly
  basal, 3.1 KB (measured in the test). Grants are about 25 KB, under the 60 KB urgent limit.

Tests: built by stock's queries over a real settings store with every value changed 2 h before
the grant, the field round-trips, and is nil from an older phone; the phone's grant asks for
the 24 h up to it; on the watch, after a change 2 h before Start, basal, ISF, carb ratio and
target read the old values before it and the new ones after, continuously across the window, in
the provider and in the first cycle's input (both fail with the history ignored); a grant
without the field projects the snapshot back; the standing copy and a resume keep the history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`G7RelayDedupTests` built the phone's name for the relayed reading with
`t / 3600`. G7SensorKit computes the hours as minutes then hours, `t / 60 / 60`,
and the two round differently for about a quarter of timestamps. When they did,
the watch's own reading of the same sample got a different name, the store kept
both, and the test saw three rows. It failed 3 of 7 full runs.

Test-only: on devices both names come from G7SensorKit's one formula, and the
activation date reaches the watch exactly (property lists over WatchConnectivity
and in the watch's saved files keep full precision).

Checked: 40 of 40 runs pass; with the old formula, 12 of 40 fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- PodLoanWatchController keeps each pump kit's localState in its own file,
  PumpLocalState, keyed by managerIdentifier. It is saved wherever the
  manager's state is saved and is not cleared when the loan ends.
- Start and the first-contact note pass it to the adopt and to
  takeControlNeedsSearch. Resume still restores from saved state, as stock
  does.
- A state update that arrives after teardown (the release clearing an
  unproven handle) is saved too; that is the point.
- The CGM adopt passes nil. The phone side only updates its test double.
- Why: device state belongs in the manager's saved state, which the host
  keeps, not in a kit's own UserDefaults.

Tests: WatchAppTests 159 passed, including two new FirstContactNoteTests
(handle survives teardown and reaches the next adopt; an unproven handle
is dropped at release); phone loan suites 152 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch now runs stock's three algorithm passes, each fed as stock feeds
it, from a labelled copy of stock's fetchData.

- Automatic loop: running temps cut at now and a bolus in flight counted
  whole (was pro-rated); stock's input checks, adding future-dated glucose;
  temp rate rounded down by the pump manager (was nearest); stock's enact
  refusals, adding a faulted pod and a running manual temp; last loop
  completed moves only on a closed-loop cycle.
- Recommended bolus: a running temp credited through its end (was cut at
  now); a pre-meal preset ends when bolusing with carbs; no pump-data check;
  no longer changes what the screens show.
- Both dosing runs: a very-high-insulin-needs preset raises the suspend
  threshold.
- Display: stock's display run now feeds the glance (IOB, COB, eventual),
  the stock watch pages and the complication, replacing the watch's own IOB
  calculation. Diagnostics shows the loop run.
- Kept deliberately: enact follows the watch's loop switch; a
  recommendation older than five minutes is refused; the stall watchdog
  refreshes on any error-free cycle.

Tests: StockRunsTests (16, each sabotage-verified); four suites moved to
the new runs. WatchAppTests 175/175, phone gate 152/152.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The display run (glance IOB, COB and eventual, and the stock pages' context) now refreshes on stock's triggers: a change in the watch's carb, glucose or dose store, an override set or cleared, and a change of loop mode. Before, it ran only at the end of each cycle, at takeover, when the grant's glucose landed and on resume, so the glance could lag a wrist bolus or carb entry by up to a cycle.
- Each trigger runs the existing display pass and republish on the data access queue, with no debounce, as stock. The run reads the stores and writes none, so it cannot retrigger itself.
- Only while a pod is held: between loans a store change leaves the display run alone, as before (the context publish already required a pod).

Tests: StockRunsTests +3 (a dose store change and a carb entry each update the glance IOB/COB with no loop cycle; between loans nothing runs), each sabotage-verified. WatchAppTests 178/178.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch stores dosing decisions as stock does, to match stock and to
prepare for uploading from the watch. Until then they go home in one
file at hand-back and the phone uploads them. The phone's loop keeps
running during a loan without dosing; it no longer stores decisions for
those cycles.

Tests: StockRunsTests +6, PodLoanPhoneControllerTests +2,
LoanDosingDecisionTests (new, 4). WatchAppTests 184/184, phone gate 158/158.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app now sets LoopLocalCacheDurationDays from the same build
setting as the phone (90 days). Without it the watch fell back to one day,
so decisions not yet sent home could be purged during a loan longer than
a day, or before a watch revived after a force reclaim sent them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An earlier test's controller can still post an alert into the shared
scheduler while these run; the failed-acknowledgement test then counted
two pending requests instead of one (1 failure in a full run). The tests
now look only at requests for the alert they presented.

Checked: 3 full runs 184/184; with the put-back removed, the failed-
acknowledgement test fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch records the alerts it issues, acknowledges and retracts in
stock's AlertStore while it holds the pod, so the phone's alert history
covers the loan; a kit's alert lookups answer from it. The records go
home in the loan's history file at close (the decisions file, renamed);
the phone adds each once by sync identifier with its original dates,
through a new AlertStore method that starts stock's alert upload.

Tests: LoanHistoryTests (new, watch, 4), PodLoanPhoneControllerTests +1,
LoanHistoryIntakeTests (new, 1). WatchAppTests 188/188, phone gate 160/160.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While the watch holds the pod it writes every device log line to stock's
PersistentDeviceLog as DeviceDataManager does, so the phone's device log
covers the loan (the throttled text log stays). The lines go home in the
loan's history file at close; the phone adds each once, comparing whole
lines, with its original time.

Tests: LoanHistoryTests +4, LoanHistoryIntakeTests +1,
PodLoanPhoneControllerTests +1. WatchAppTests 192/192, phone gate 162/162.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The watch app signs with $(LOOP_WATCH_ENTITLEMENTS), as the phone signs
with $(LOOP_ENTITLEMENTS). It defaults to empty, so with Sport Mode off the
watch signs with no entitlements, as stock does, and Browser Build's watch
app ID needs no new capabilities. Sport Mode builds set it to the watch
entitlements file (HealthKit for the workout session, time-sensitive
notifications for wrist alarms) next to SPORT_MODE_ENABLED.

Tests: WatchAppTests 192/192 with it set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No cycle after a wrist carb save, a carb delete or an accepted manual bolus;
the store observers already refresh the display. On the bench the carb-save
cycle auto-bolused for the meal before the manual bolus reached the pod.
A G7 reading runs a cycle only when the store kept something new (stock's
storedNewGlucose), and the shared 4.2-minute gate is claimed under a lock.
Tests: five new watch tests, sabotage-verified; StockRunsTests and G7RelayDedupTests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
… to stop

A take-back revoked the watch and took the pump at once. A phone that then
couldn't reach the pod left a pump nobody dosed. The phone now takes
control and waits up to 5 s for the pod's link; only with it up does it
revoke the watch and take over. Without it, it lets go again, nothing
changes and the watch keeps running, with a note to bring the phone close.
Dosing stays held meanwhile, as the session is not idle. As LoopKit/Loop#2545.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
The watch doses with the settings that came with its grant; a change on
the phone during a session never reached it. The screens whose values the
grant carries now show "Locked While the Watch Loops" until the hand-back:
basal, ISF, carb ratio and target editors, meal, bolus calculator, dynamic
ISF, autosens, SMB, target behaviour, advanced algorithm settings and units
and limits. Settings › Apple Watch, Nightscout and the rest stay open, so
the session can still be ended. As LoopKit/Loop#2545 locks Settings during
a loan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
…ne ends it

Set again after a hand-over, a temp runs at least Omnipod's 30 minutes,
longer than it had left, should the new side not loop: the risky way for a
high temp. A temp above the scheduled rate now ends at the hand-over
instead, as LoopKit/Loop#2545 does:

- Phone to watch: the phone cancels it at the release and hands nothing
  over; the schedule runs until the watch's first loop.
- Watch to phone: the watch leaves it running and reports it with the
  hand-back; once the phone has read the pod it cancels it, and ends the
  watch's record of it where it stopped it, final, so IOB and the audit
  don't count the planned rest. The schedule runs until the phone's loop.

Low and zero temps are handed over as before, cancelled and set again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
…tch has the pump

On 2026-10-18 the watch went without readings from 09:24 to 10:39: a
sensor connect hung while its app was suspended, and only opening it put
that right. A phone in reach now wakes it, as LoopKit/Loop#2545 does with
every phone cycle:

- Phone: 45 s after its own reading, clear of the watch's link to the same
  sensor, a small application context ("untetheredWake"); at most one per
  four minutes, as storing the watch's readings fires the same publisher.
- Watch: that context re-checks the sensor's acquisition and the alarms
  and sends the history. A WatchConnectivity wake is now held until what it
  brought is delivered (at most 20 s, never past the system's expiry), so
  the callbacks run before the app is suspended again; it was completed on
  arrival.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
The watch stopped dosing at the hand-back, let go of the pod, and the phone
then waited up to 60 s for its last records before it judged the pump.
Now, as LoopKit/Loop#2545:

- Drain: in the ending phase the watch keeps looping and sends its
  backlog, paced around the sensor as before, until the phone has
  confirmed all of it (at most 3 min; the rest goes with the last records).
- Last stage: no new loop or bolus (isFinalizing), the temp handled as at
  any hand-over, the pod read, the pump status recorded, the last records
  sent and confirmed (at most 30 s), then the pump offered.
- The pod is let go only when the phone acknowledges. The phone takes
  control before it acknowledges, so its connect is standing as the watch
  lets go, and the history it judges is complete. Without an
  acknowledgement for a minute the watch lets go anyway and keeps
  offering, as before; it never takes the pump back on its own.
- A relaunch mid-hand-back goes on draining, or stays in the last stage
  without loops when the pump was offered already.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
…e on its word

While it has the pump, the phone keeps a standing grant queued on the watch: the full grant, issued
expired, resent after carbs, boluses and adjustments (30 s floor), on any settings change and every
30 min. With the phone out of reach the watch starts from it on an epoch of its own, if the copy is
younger than the insulin's action time, and books what the pod delivered past the copy's history as
a bolus before its first loop. The phone takes on a newer epoch it hears of before storing its
records, lets go of the pod and stops dosing; the watch tells it at once when it is back and asks
the wrist once to hand back. As LoopKit/Loop#2545.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
kristinkand pushed a commit to kristinkand/Trio that referenced this pull request Oct 18, 2026
…insulin no record explains

Each loop keeps the pod's delivered total; when the next comes ten minutes or more later, on the
same pod, the difference is judged against the history like a return's audit, and what no record
explains is booked and shown. This catches a watch that started without the phone and then failed.
A return starts the check over. As LoopKit/Loop#2545.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Jeremy Barnum and others added 22 commits October 19, 2026 00:34
…r ends it

Problem: one unreachable reading of the link to the phone ended a hand-back at once, at
the tap and at every resend. The link flaps while either app changes state, so a Hand
Back the user chose was dropped while the phone was already acknowledging it, and the
prompt came back.

- At the tap, an unreachable reading is read again a second later before it refuses, as a
  Start does; End with the phone truly absent still fails at once.
- Once the exchange is under way, a resend that finds the phone unreachable sends nothing
  and waits for the next; the hand-back holds for its two-minute budget, and an
  acknowledgement inside it completes it. A live offer is still never queued.
- Tests: the fail-fast test runs the re-check; a resend's unreachable reading leaves the
  hand-back pending, and the next reachable resend sends again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: control of the pod changed hands on clocks and on the reachability flag, and the rules
were spread over the mechanisms of both apps. A watch back from being off resumed beside a phone
that had taken the pod back; a silent watch kept the pod; the Start and the End trusted a flag
that says whether the other app can take a live message this second, not whether the device is
there; and a message sent before the watch's link to the phone had started up was lost.

- LoanControl.decide (LoopCore, new file): the decisions of the table in the control document as
  one function. Inputs: the control state and what the acting device can see. Output: query and
  what silence means, take, stand aside, send the stop, refuse, prompt or tell. One case per row,
  commented with the row's reason. afterQuery holds the one rule for answers: silence, "I hold
  the pod but cannot reach it" and "I hold nothing" all lead to the query's on-silence branch.
  Two answer waits: 10 s while the looper waits on screen, 30 s in the background. A watch's
  report is overdue after 6 minutes.
- Phone: one place describes the situation, calls the function and carries out the decision.
  With the watch's report overdue and the sensor in view, it asks the watch and, with no answer,
  takes the pod back and says so ("Watch Not Responding"), in place of the timed "Watch Not
  Reporting" warnings. A reading with insulin its records cannot explain asks the watch. A
  watch's claim to a loan started before the phone took over on silence gets the stop; a later
  one is honoured. An unconfirmed grant is taken back on silence, with one notice.
- Watch: back from being off, it tells the phone it holds the pod and waits for an answer before
  the loop has the pod; the stop stands it aside before any dose. End with the phone away waits
  for an answer, then is refused on screen with how to stop dosing. Its status report says
  whether it reached the pod on its last cycle and when its loan started. The "iPhone Is Back"
  prompt is gone: a loan started without the phone continues when the phone returns.
- Watch: loan messages wait until the link to the phone has started up, and messages that arrive
  before the Sport Mode stack is ready are held and replayed.
- Tests: LoanControlTests, one per row; the phone asking, taking and standing aside; the watch's
  question waiting for the link; End refused and answered; a revived claim refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: with no loan running and the phone dead or left behind, nothing told the looper that
nothing was looping: a dead phone fires none of its pre-scheduled Loop Failure notifications, and
the watch armed its own only during a loan.

- With no loan, every word from the phone pushes out a pre-scheduled, time-sensitive watch
  notification, "Not Hearing From iPhone", to 25 minutes later, after the phone's own Loop
  Failure at 20. It fires only if the phone goes quiet, even with the watch app not running. A
  loan starting removes it. Nothing takes the pod.
- The Sport Mode page with no loan shows the phone's loop ring, and "Not hearing from iPhone" in
  place of "via iPhone" after 25 minutes without a word.
- Tests: a word from the phone arms the warning; a loan removes it; the row's case in the control
  tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: a status query and its answer always went queued, even with the other device reachable,
so a watch back from being off could wait out its 30 s with the phone beside it, then loop until
the phone's stop arrived. Sent live alone, a message is lost if the sending app goes inactive
first, and a lost question reads as silence.

- A status query and a status report go by the live path when the other device is reachable, and
  are queued as well, on the phone and on the watch, as a grant already is. Both sides drop the
  later duplicate.
- Tests: both messages go live and queued; a stop goes by one path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: after a restart the watch showed the loan as running while it asked the phone, and its
rebuilt pump manager could reach the pod during the check while the phone was looping it.

- The pump manager is built only once the check has passed, when the phone does not answer
  within the wait. The phone's stop ends the loan before it is ever built.
- During the check the Sport Mode page says "Checking with your iPhone…" and "Sport Mode resumes
  if it doesn't answer.", with no End and no IOB, COB or temp.
- Tests: the check's screen state, no pump manager until it ends, and its end on the stop and on
  silence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: when the pod did not answer within 12 s of a take-back, the phone switched the pod
driver to searching for the pod, and nothing on the phone ended that search if the take-back
finished another way or gave up.

- The take-back's end, verified, closed early or at its 5-minute limit, ends the escalation it
  started: the control document's transfer rule.
- Test: the escalation ends when the take-back verifies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the rules for which device controls the pod were written only in the code and its
tests, and comments pointed to documents outside the repository.

- Documentation/SportMode/Control.md: the world Sport Mode is designed for, the principles in
  priority order and how they resolve conflicts, the vocabulary, the warnings, one row per
  situation with what each device sees and what happens and why, the transfers, testing
  artifacts, known limits, and where each decision lives in code.
- Code comments point to it, and no longer carry the dates of the incidents behind them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the document named the transfers as the next part to move into LoanControl without
saying whether that was needed.

- Section 9 says the transfers stay in their own code on purpose: each is a sequence of steps
  rather than one decision, every failed step follows the transfer rule, and what differs
  between them is timing. A different outcome for one transfer would move that choice into
  LoanControl, with its test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream's new carb entries carry their own sync identifier, and edits use CarbEntryUpdate.
Conflicts resolved:
- LoopDataManager: the NewCarbEntry conversion stays in LoopAlgorithmSupport, shared with the
  watch, and upstream's CarbEntryUpdate conversion joins it there.
- GlucoseAlertManager: upstream's new silent-mode keys, with the episode-state key left visible
  to the alert-sharing extension.
- The project file.

The Sport Mode carb code follows in the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Problem: the carb store now takes a new entry's sync identifier from the entry itself, and the
separate add-under-an-identifier call that Sport Mode used is gone.

- Phone: a carb from the watch's records is created under its journal event's ID, as before, and
  added the ordinary way; the wrapper that paired an entry with that ID is gone.
- Watch: a carb entered during a loan keeps the identity it was composed with as its journal
  event's ID, so it is stored under the same identity on both devices.
- Tests: carb entries carry an identity. The harness test of add-once behaviour is removed: the
  carb store now guarantees and tests it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tch's own question

Problem: say the watch's battery dies during Sport Mode while the phone is at home. Back home, the
phone asks the watch, hears nothing, and takes over. When the watch comes back on, it sends the
records it saved before it died, and asks whether it still has the pod. The control document's
principles decide this case as a judgement call: the phone asked, heard nothing, and has looped
since, so it keeps the pod and tells the watch to stop. That needs to know when the watch's Sport
Mode started. The question says it; the records didn't, so if they arrived first, the phone could
step aside for a watch you had stopped using.

- Nothing is logged or sent before Sport Mode starts, so a batch of records says the latest it
  could have started: its first record, or its send time. The phone judges the records by that, as
  it judges the question, so the order they arrive in no longer matters.
- The control document states the rule.
- Tests: records from before the takeover get the stop, with and without a send time.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: the phone's stop is its answer to the watch's "do I still have the pod?", and it went only
by the live path when the watch looked reachable. A live message is lost if the watch app goes
inactive first, and a lost stop leaves the watch looping beside the phone until its next records
happen to prompt another one.

- The stop goes live and queued, like a question and its report. The watch drops the later copy: a
  stop for Sport Mode it has already stopped changes nothing, where before it tore down twice and
  sent an extra offer.
- Tests: the stop rides both channels; a second copy is dropped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: a Start, with the phone or without it, gave the watch five minutes to reach the pod.
Every takeover seen reached the pod within 25 seconds, most within 4, and a pod not reached in a
minute was never reached in five. Meanwhile the looper waited up to five minutes to be told, and
for a Start with the phone, the phone had released the pod and held its dosing the whole time.

- One shared lease of 60 seconds, for a grant and for an offline start alike, kept in LoanControl
  beside the other control timings; the phone's lease-end check follows it.
- The control document's transfer table says 60 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: when the phone takes the pod back, the watch tears down its pod driver and stops being
its delegate at once. A command the stop interrupted may still finish and report its dose a moment
later; the driver treats a missing delegate as impossible and the app crashes. A Start right after
a take-back showed it.

- The watch stays the driver's delegate after a teardown; reports from a driver that is no longer
  the current one are logged and ignored, and any insulin they carry is counted by the phone's
  take-back check, which books what the records cannot explain. The pod's handle is still saved
  after the loan, since a release can drop one that never connected.
- Test: a driver reporting after its teardown changes nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: tap Start, then cancel at the "Start without it?" offer, and a grant that arrives
afterwards still starts Sport Mode: an idle watch accepted any grant. The request may already have
reached the phone when the looper cancelled.

- A grant is accepted while a request is outstanding or the offer is still showing; after a cancel
  it is answered "takeover failed", so the phone takes the pod back at once, and a new Start asks
  afresh.
- The control document's transfer G says so.
- Tests: a grant after a cancel is refused and the phone told; a grant while the offer shows is
  still taken.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Problem: when the phone asks the watch whether it holds the pod and waits 30 seconds for an
answer, nothing cancelled the wait if the loan ended meanwhile. End Sport Mode and Start again
within those 30 seconds, and the old question's "no answer" landed on the new Sport Mode: the
phone took the pod back from a watch it had just granted, cutting off a bolus in progress. Seen in
the Simulator; on a device it needs an End and a Start within half a minute of a silent question.

- The question remembers the loan and the phone's role it was asked in; when the wait ends, a
  verdict for any other loan or role is dropped and logged.
- A change of the loan's state clears the open question, so the next loan can be asked at once.
- Tests: a question's silence after a hand-back and a new grant takes nothing back; a question can
  be asked again once the loan changes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A pod handed over mid-bolus leaves the record wrong on both sides: the
watch's record says the whole bolus with an end in the future, the phone
books it as finished, and the insulin check has to correct it afterwards
with estimates. A take-back during a watch bolus showed it.

The control document now states the rule once, under the transfers:
nobody takes the pod while it is giving a bolus. A Start while the
phone's own bolus is being given, and an End or a Take Back while the
watch's is, are refused with "A bolus is still being given. Try again in
N seconds."; the loan is left exactly as it was. A take-over on silence,
where nobody can retry, reads the pod first and waits for it to finish
the bolus before it sends the stop; its time to reach the pod starts
again after the wait. An offline Start cannot ask the phone and takes
the pod whatever it is giving; the phone's records are trued up from the
pod's delivered total when it is back with the pod, as before.

Tests: each refusal, and the take-over on silence waiting out a bolus.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The test for a grant after a failed takeover asked for it the instant
the phone had taken the pod back. The phone refuses until the pod has
answered, and on a loaded Mac that takes longer than the test allowed;
its helper then ended the test process and the suite's result with it.
The test now waits for the pod's answer first, like the other settle
tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A Take Back during a watch bolus went through, and the insulin check
then warned of overstated insulin on board: the check asked the phone's
pump driver, which does not count a bolus another device started. It now
asks the pod's own status, read just before the stop. The take-over on
silence uses the same answer. The tests now model the pod reporting the
watch's bolus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An End refused during a bolus was not seen: the bolus progress has the
same line on screen and covered the refusal. A refused End now shows why
for its 20 seconds, over the bolus it waits for, and the progress comes
back after. A Start the phone refuses, and an End the watch refuses
during a bolus, now play the watch's failure haptic, so the looper feels
the refusal as well as reads it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An End tapped while a watch bolus was still being sent to the pod was
accepted, and the hand-back would have finished while the pod was giving
the bolus: the rule counted a bolus only once the pod had accepted it,
and only at the tap. It now counts from the moment the bolus is sent
until it finishes, for a Start, an End and a Take Back alike, and the
release checks again, so a bolus started while an End waits for the
phone also refuses the End. The rule and its message are stated once.
Tests: End and Start while a bolus is being sent, and a bolus started
during a pending End.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
While an End waited for the phone, each offer carried a watch bolus
still being given, and the phone saved it as finished. Loop's dose store
does not accept a finished bolus that ends in the future, and stopped
the phone app. Loop's own drivers report a bolus as finished only once
it is; the phone now does the same with the watch's records. A bolus
still being given is acknowledged but kept back, like the running temp
the phone already keeps back, and saved once it has finished. Tests: an
offer carrying a bolus still being given saves nothing of it, and the
next offer after it has finished saves it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants