← back to the timeline
Jun 12 2026

20 things got done: Sign-in no longer gives up too early on a good network (the real reason saves were failing). Tap any of these to read the whole thing.

Sign-in no longer gives up too early on a good network (the real reason saves were failing)

After the reset fix, saving a workout still failed, but for a new reason.

Done
the problem

After the reset fix, saving a workout still failed, but for a new reason. On a perfectly good wifi, the app's anonymous sign-in (the thing that gets you a login) was timing out, so the app had no login at all. With no login, it fell back to a placeholder id and the database refused every save with a "row-level security" rejection. The console was full of quic… max 5 reached and "Operation timed out".

what I changed

Three small things in the sign-in code: (1) each attempt now gives up after 8 seconds of silence instead of hanging; (2) it retries up to three times, and a failed first try makes iOS drop back to the older, reliable connection type, so the retry goes through; (3) the overall wait went from 5 to 30 seconds. The longer wait is safe because sign-in runs in the background, it never freezes the app's screen; it only gives onboarding more time to get a login before it sets you up.

how I tested it

The whole app builds, and all 7 sign-in tests pass (the change doesn't affect the failure/timeout cases). The user spotted that this was a timing problem, not a wifi problem, they were right.

Why it was the app, not the wifi. Earlier in the same run, a different request to the database succeeded, so the network was fine. The problem: the sign-in only waited 5 seconds, tried once, and shared its connection with the rest of the app. iOS had learned the server supports a newer connection type (HTTP/3, "QUIC"), tried it for sign-in, and that handshake stalled. Five seconds wasn't enough to recover, so sign-in quit and the app carried on with no login.

merged as PR 392, sign-in now retries with a longer, bounded wait; build + 7/7 sign-in tests green.

The "reset app" button now actually gives you a fresh start (found from a gym-log crash)

After the database lock went live (the auth work), I tried to start a workout and it wouldn't save, the app kept retrying and then gave up.

Done
the problem

After the database lock went live (the auth work), I tried to start a workout and it wouldn't save, the app kept retrying and then gave up. The reason: turning on the lock gave my install a brand-new login id, but nothing ever created a matching row for that new id in the users table. Every save points back to that table, so the database rejected the workout. Onboarding is the only place that creates the users row, and my install had onboarded long ago, so it never re-ran.

what I changed

The reset button now also clears the saved login session (the access token, refresh token, expiry, and the login id). The next launch then signs in fresh, mints a new login id, and onboarding creates the matching users row for it, so saving works again. I also changed the confirmation message to tell you to quit and reopen the app before onboarding, because the fresh login only kicks in on the next launch.

how I tested it

Confirmed the four login keys are real, that nothing else in the app does an "identity wipe" that would need the same fix, and that the whole app still builds (BUILD SUCCEEDED). The old failed-and-parked workout in the local queue lives in the same storage the reset wipes, so it's gone after the reset too.

Why it matters. Since there's only one user right now (me), the clean fix is to wipe and start over from week one. But the in-app "Reset All App Data" button was written before the auth work, it cleared everything except the new login session. So a reset would quietly keep the same broken login, and you'd land right back in the same hole.

Follow-up filed. The deeper fix, so this can never happen again to any future user, is a database trigger that creates the users row automatically the moment a new login is made, instead of relying on onboarding. Logged as a separate issue.

merged as PR 389, the reset button now clears the login session; whole app still builds green.

Writing down what the auth work decided, so we don't forget (auth slice 6, docs, part of #369)

Slices 1–5 of the auth/RLS workstream all shipped and the database lock is now live.

Done

What this is. Slices 1–5 of the auth/RLS workstream all shipped and the database lock is now live. This last slice is just paperwork: it writes down the decisions in a permanent record (ADR-0027) so future contributors understand why we went the way we did, and it fixes two older records that had an assumption that turned out to be wrong.

What ADR-0027 records. The audit found that row-level security was switched off on the five core tables, workouts, programs, trainee models, users, and set logs, so the per-user rules that existed for programs and set logs were doing nothing. The gym-profiles table had its lock on but a rule that said "everyone can see everything." The server functions trusted the user ID in the request body with no check on who was actually sending the request (an IDOR: anyone who found the URL could act as any user). The decision: give every fresh install a real Supabase login via anonymous sign-in (no account creation UI needed), turn on the database lock on all six tables with proper "you can only see your own rows" rules, and make the server functions verify the login token before touching the database. We also accepted that old data written before this change, tagged with a locally-generated device ID, not a real login ID, becomes invisible; that's the price of the fix at alpha scale.

The two records that needed a correction. ADR-0016 (written when we removed the service-role key from the app) said "all client database access is subject to RLS." ADR-0018 (the atomic program-save RPC) said the programs owner rule governs its writes. Both were saying what should be true; neither was true at the time because the lock was off. Both ADRs now have a short note at the top explaining that their "RLS is enforcing" assumption held only after ADR-0027 turned the lock on. Their core decisions (no service-role key on the client; atomic save via RPC) are unaffected.

CONTEXT updated. Added a new "Auth, identity, and access control" section so the terms, anonymous identity, resolvedUserId, RLS, the Edge Function ownership check, are part of the domain glossary.

Status: docs only. No code, no prod impact. PR to be reviewed and merged.

docs only. No code, no prod impact. PR to be reviewed and merged.

The database now hides everyone else's data from you (auth slice 5, the gate-flip, part of #369)

This is the keystone of the auth work.

Done
the problem

This is the keystone of the auth work. Up to now the database had no lock on the core tables, workouts, programs, your trainee model, your user row, and your set logs were all readable and writable by any logged-in client, because "row-level security" (the database's own per-user filter) was switched off on them. The two tables that did have it on were either fine (the memory embeddings) or wide open anyway (gym profiles had an "anon full access" rule that let everyone see everything). Slices 1–4 set up real per-user identities and made the server functions check them; this slice finally turns the database lock itself.

what I changed

One forward migration (plus its documentation-only reverse). It (1) turns on row-level security for the five unprotected tables; (2) adds an "owner access" rule to each that says, in effect, you can only see and only write rows tagged with your own login id, both the read side (USING) and the write side (WITH CHECK), so a client can't even sneak in a row owned by someone else; for set logs (which have no owner column of their own) ownership is traced through the workout session they belong to; for the user table the row's own id is the owner; and (3) replaces the gym-profiles "everyone sees everything" rule with the same owner-only rule. No changes to who's granted access at the coarse level, once these id-based rules are on, the anonymous role matches no rows anyway, so the old grants are harmless. The server functions keep working because they connect with the privileged account that skips these rules and do their own id check (from slice 4).

how I tested it

SQL review against the baseline schema only, I could not run it against a database here (the local Postgres in Docker is down; supabase db lint / migration list both failed with connection-refused, as expected). I read the exact current table, column, and policy names out of the baseline and confirmed every "drop the old rule" line names the real existing rule verbatim, the owner columns are right, and the paren-balanced SQL matches the baseline's style. I also checked the reverse migration exactly undoes the forward one (turns the lock back off on those five tables, drops the new rules, and restores the original gym-profiles "anon full access" rule). Touched only the two migration files and this diary, no app code, no server functions, no other files.

Heads-up for whoever merges this. merging runs db push in CI, which turns the lock on in production, this is the live data-visibility flip. Any rows still tagged with the old placeholder id (not a real login id) become invisible to everyone; that's the accepted alpha data-wipe, and clients must already be on the slice-3 build (which tags data with the real login id) for their data to remain visible.

merged as PR 386, RLS enabled on production (the gate-flip), with the user's explicit go.

Fix: the end-to-end smoke test now sends a login token (PR #387, part of #369)

Slice 4 added the server-side ownership check (the function rejects a request whose login token doesn't match the body's user id).

Done
the problem

Slice 4 added the server-side ownership check (the function rejects a request whose login token doesn't match the body's user id). But the end-to-end "smoke" test for the trainee-model function fires a real HTTP request at the served function with no login token, so the new check correctly returned 401, the smoke test failed, and because CI's deploy step only runs when the Edge-Function tests pass, the deploy was skipped and slice 4's check never actually went live. (The slice-4 agent couldn't catch this, the smoke test needs a live local Postgres in Docker, which wasn't available, so it only ran the unit tests.)

what I changed

The smoke harness's shared postWithRetry helper now attaches an Authorization: Bearer … header carrying a token whose sub equals the request's user_id (the local serve runs with --no-verify-jwt and the code only decodes the token, so the signature is a placeholder). Both smoke cases now pass the ownership gate and reach the orchestrator.

how I tested it

Verified the token the helper builds is accepted by slice 4's real subFromAuthorization decoder (extracts the matching sub). Can't run the full smoke test here (no Docker); CI runs it on merge, and a green Edge-Function-tests job is exactly what re-enables the deploy.

merged as PR 387. Unblocks the auto-deploy so slice 4's ownership check finally deploys.

The server now checks it's really you before saving your data (auth slice 4, part of #369)

Two of our server functions, the one that updates your trainee model after a workout, and the one that saves your goal, trusted the userid…

Done
the problem

Two of our server functions, the one that updates your trainee model after a workout, and the one that saves your goal, trusted the user_id written in the request body, no questions asked. That meant a logged-in person could put someone else's id in the body and write to that person's data (a classic "insecure direct object reference" hole). And we can't lean on the database's own row-level security to stop it here, because these functions connect with a super-privileged account that bypasses those row rules. So the function itself has to be the bouncer.

what I changed

Before either function does any database work, it now reads the login token the platform already verified, pulls out the user id baked into that token, and checks it matches the user_id in the request body. If they don't match, it refuses (403). If there's no token, or the token is garbled, or it has no user id, it also refuses (fail-closed, 401), it never quietly lets the write through. The token-reading logic lives in one small shared helper so both functions use exactly the same check. We only read the id from the token, we don't re-check its signature, the platform already did that.

how I tested it

Added unit tests for the shared helper (14) covering the good decode and every fail-closed case, plus four handler tests on each function (mismatch → 403, no token → 401, garbled token → 401, matching id → passes the gate). All pass. The existing tests still pass: shared helpers 390, model validator 21, goal validator 31. The database-integration tests can't run here (they need a local Postgres in Docker, which is down, they failed with connection-refused as expected and will run on CI when merged). Did NOT turn on row-level security, add migrations, touch the app, or deploy, those are other slices / the orchestrator's job.

opened as PR (see below); NOT merged, NOT deployed.

The app now uses your real login identity instead of a stand-in (auth slice 3, part of #369)

Until now every install used the same hardcoded placeholder id (…0001) or a random one minted at onboarding to tag all your data.

Done
the problem

Until now every install used the same hardcoded placeholder id (…0001) or a random one minted at onboarding to tag all your data. Earlier in this auth work (slice 1) the app started quietly logging itself in anonymously at launch, which gives it a real, stable identity, but nothing was using that identity yet. For the upcoming security lock (slice 5, which will only let you read rows that are tagged with your id), the data has to be keyed to that real login id, not the placeholder.

what I changed

Two things. (1) The single place the app asks "who am I?" now answers with the real anonymous-login id (read instantly from where slice 1 saved it), falling back to the old placeholder only in the brief moment on a brand-new install before the background login finishes, that fallback is safe right now because the security lock is still off, and it goes away for good once slice 5 lands. (2) Onboarding now writes your user row tagged with that real login id (and skips writing the row entirely if the login somehow hasn't finished yet, rather than saving a row under the placeholder that the security lock would later orphan). Pulled the "who am I?" logic into a small, separately-testable helper so the priority order is pinned down by unit tests.

how I tested it

Build passed (build-exit=0). Added six unit tests for the identity helper (real id wins over the placeholder and over the older mirror; placeholder only when nothing else exists; onboarding refuses to write under the placeholder), all pass. Full suite: 561 run, 10 skipped (the live-API tests, off by default), 1 failure, and that one is the known network-timeout flake unrelated to this change; it passed on its own when re-run. Did NOT turn on the security lock or touch any server functions or migrations, that's later slices.

opened as PR (see below); NOT merged, awaiting review.

A camera for the hand-drawn charts (PR, part of #342)

The redesign draws its charts and gauges by hand, pixel by pixel, a 2px "floor" line that has to read heavier than a 1px "stretch" line, so…

the problem

The redesign draws its charts and gauges by hand, pixel by pixel, a 2px "floor" line that has to read heavier than a 1px "stretch" line, solid dots for real data versus hollow dots for guesses, dashed lines for predictions. None of that shows up when you read the code; you only see if it's wrong by looking at the picture. Until now the app had no way to take a picture of a chart and notice when it changes.

what I changed

Two things. First, a set of cheap, fast checks that read the exact numbers behind the drawings, that the floor line really is 2px and the stretch line really is 1px, that the prediction dash is the 4-2 pattern, that the spacing scale is 4/8/16/24/32/48, that the "work is dark ink, time is light pencil" colour split always holds. These run on every push and need no saved pictures. Second, the camera itself: a small harness (built on a well-known open-source tool, swift-snapshot-testing, our very first outside dependency, pinned to one exact version) that photographs a component and compares it to a saved reference, failing if anything drifts. The worked example photographs the whole token gallery in light and dim, at normal and largest text sizes.

how I tested it

The fast geometry/number suite builds and passes; the outside dependency resolves and links (the whole app + tests compile against it). The photo suite is intentionally reference-pending and stays off.

One deliberate gap. The reference photos are NOT recorded yet, on purpose. My machine runs a slightly newer Xcode than the build server, and a photo taken on the wrong toolchain would bake in the wrong fonts and subtly-off colours, poisoning every later comparison. So the camera is wired up but parked: the photo tests are switched off by default (behind a APEX_SNAPSHOT_TESTS flag) and a human/CI step on the pinned Xcode must take the first reference photos. The fast number-checks run regardless.

Also fixed a quiet contradiction. The test plan secretly forced the "run the expensive live-API tests" flag on, while a comment in the build server config claimed it was off. Removed the flag from the test plan so the comment is finally true, the live-API tests are genuinely opt-in now.

open PR, part of 342 (not closed, recording the reference photos and signing off on how they look is still to come).

Coach-voice constitution drafted (PR for #330)

Wrote docs/design/coach-voice.md, a single reference document that governs the voice and honesty rules for every coach-voiced string in the…

Done

What. Wrote docs/design/coach-voice.md, a single reference document that governs the voice and honesty rules for every coach-voiced string in the app: AI-generated Today lines, post-workout reads, per-set coaching cues and set framings, and static UI strings (alerts, rest-day cards, calibration notices).

Why. The flow audit (G-F13, #316) found three different registers in three prompts, the inference prompt enforces terse anti-platitude copy, the swap assistant says "Happy to help", the post-workout insights are a third voice, and no single source governed all of them. The rebuild needs one constitution before the first coach-line screen (#348) is written.

What it covers. The honesty laws (grounded-in-numbers, deterministic fallback, no fabricated precision, no echo, witness rule), the banned registers (praise- inflation, hype, mascot-cheer, accent-colored keywords), length and format contracts per surface (Today line, post-workout two-deck read, coaching cue, set framing, swap display_message), and a short "how to apply" section for both prompt authors and UI-string authors. Cites the source docs throughout.

Status: opened as a DRAFT PR (human review required, voice is the product owner's call). Not merged. Part of #330.

opened as a DRAFT PR (human review required, voice is the product owner's call). Not merged. Part of 330.

Two-day-a-week lifters can now onboard honestly (PR #380, part of #369)

The onboarding "days per week" picker only offered 3, 4, 5 and 6.

Done
the problem

The onboarding "days per week" picker only offered 3, 4, 5 and 6. Someone who trains twice a week had no honest option and was forced to claim 3, even though the program engine fully supports 2-day weeks (the phase-advance math handles down to 1 day; the macro-plan prompt builds exactly as many days as you pick) and the redesign spec explicitly lists 2 / 3 / 4 / 5+.

what I changed

Added 2 to the picker (now 2/3/4/5/6). One-line, no option removed, so nothing regresses. This is the one onboarding item from the audit that's both cheap and not throwaway, the rest of the onboarding cluster is either already handled (the answer-wiring shipped earlier in #339) or belongs to the full onboarding rebuild (#362), and one finding turned out stale (height/age are now used by the coach, so they must NOT be removed).

how I tested it

Build passed (build-exit=0). Pure picker-option change, can't affect other logic.

merged as PR 380.

Manually-logged workouts now count toward your records (PR #379, part of #369)

When you logged a past workout by hand, that session row was saved with no "status" value.

Done
the problem

When you logged a past workout by hand, that session row was saved with no "status" value. But the code that finds your previous bests (for personal records and the "last time" line) filters sessions where status is not "abandoned", and in a database, comparing a missing value to "abandoned" is itself "unknown", not "true", so those hand-logged sessions were silently skipped. Your manual entries never counted toward a PR or showed up as your last-time anchor.

what I changed

The manual-log save now writes status: "completed" on the session row (it always set completed: true, but the queries filter on status, not that flag). A completed session passes the "not abandoned" filter, so manual workouts now contribute to PR baselines and last-time anchors like any normal session.

how I tested it

Build passed (build-exit=0). The change is a single payload field on an additive struct, so it can't affect other logic; verified by the build plus the query semantics.

merged as PR 379.

Three correctness fixes in the server-side learning functions (BUG-4, part of #369)

the cross-dimension audit found three bugs in the Supabase Edge Functions, the server-side code that turns a finished workout into an updat…

Done
the problem

the cross-dimension audit found three bugs in the Supabase Edge Functions, the server-side code that turns a finished workout into an updated training model. (1) Each exercise keeps a list of its best recent sets ("top sets"); that list grew forever, one entry per workout, with nothing trimming it, a constant for the cap existed but was never used. (2) The transfer-learning math (how much progress on one lift predicts another) divided by zero when every recorded number was identical, producing "not a number", which silently becomes null when written to the database, corrupting the stored value with no error. (3) The goal-update function did four separate database writes one after another with no shared transaction, so a crash halfway through could leave the user's record half-updated.

what I changed

(1) After adding new top sets, trim the list to the most recent 10 (the existing constant), newest are at the end, so keep the tail. Pulled the trim into a tiny pure helper so it could be unit-tested. (2) Detect the zero-variance case (all "from" values identical, or all "to" values identical) and return a "no transfer learnable" signal instead of the bad number; the caller then simply doesn't record a fit for that pair, while still keeping the observation so it can learn later once the numbers differ. The guard is narrow, it only triggers on genuinely flat data, not on legitimately weak correlations. (3) Wrapped all four goal-update writes in one transaction so they all succeed or all roll back together; the row-locking reads stay inside that same transaction, so the safety is real.

how I tested it

Deno was available, so I ran the pure-logic tests. Added new unit tests for (1) the trim keeps the correct newest 10 and (2) the math returns the skip-signal for both flat-input cases but still returns a real fit for normal input. All passed. Could not run the database-integration tests for (3)'s atomicity, they need a live local Postgres, and Docker was not running in this environment, so (3) was verified by careful code inspection; CI runs those tests on merge. Confirmed the goal function still type-checks and its only remaining check-error is a pre-existing one on main in a test file I did not touch.

opened as PR 378 (not merged). Part of 369.

Security hardening: Keychain backup protection and DEBUG-only PII logging (BUG-6, part of #369)

two security gaps found in the cross-dimension audit.

Done
the problem

two security gaps found in the cross-dimension audit. First, API keys and auth tokens in the Keychain used kSecAttrAccessibleWhenUnlocked, which lets iOS include them in unencrypted iTunes/Finder device backups, so a user's Anthropic API key could sit in a backup file on their laptop. Second, four service files wrote raw LLM responses and user training history (exercise IDs, session dates) to the device console unconditionally, even in release builds, visible to anyone with a Mac and a USB cable.

what I changed

switched the Keychain accessibility to kSecAttrAccessibleWhenUnlockedThisDeviceOnly so items are excluded from backups. Wrapped seven sensitive print calls in #if DEBUG … #endif so they compile out of release builds, the four raw-LLM-response / training-history logs the audit cited, plus three more found by a follow-up grep (two non-canonical-exercise-id warnings and the macro-skeleton log that includes the user's historical day labels). InferenceSpike also logs prescription details unconditionally, but it is dead code (run() has no production caller), so its prints never execute in a real build, left in place and flagged, not gated. The Keychain change only applies to items stored after the update, existing items in a dev's Keychain keep the old attribute until they re-store the key (fresh install, or clear + re-enter via Developer Settings). That is acceptable for alpha. Two new Keychain tests confirm the round-trip still works and that the correct attribute is set.

how I tested it

build passed (exit 0). Full test suite ran, all Keychain round-trip tests pass, including the two new ones. Logging changes are not unit-testable (no observable side effect to assert), so they were verified by code inspection.

merged as PR 377. Part of 369.

Four concurrency fixes in the live workout loop: no double-counting, no stale results, no torn reads (part of #369)

the same cross-dimension audit (issue 369) found four subtle threading problems in the actor that runs a live workout and in the screen tha…

Done
the problem

the same cross-dimension audit (issue #369) found four subtle threading problems in the actor that runs a live workout and in the screen that reads from it. None of them showed up every time, they only bite when two things happen close together.

  1. Ending a session could run its wrap-up twice. The rest timer can fire "the session is over" at almost the same moment the user taps "end early". Both reached the same finish routine, and each one bumped the saved session count and queued a learning-update for the AI. So one workout could count as two. Fix: a one-way latch flipped the instant the finish routine starts, the second caller just returns. The latch is cleared whenever a brand-new session begins, so the next workout still counts.

  2. The "Retry" button could apply a stale answer. When the AI fails and the user taps Retry, the app asks again, but if the user moved on (finished a set, skipped, or swapped the exercise) while that retry was still in flight, the late answer used to overwrite the newer state. Fix: the retry now remembers which "generation" of the session it belongs to and throws its own answer away if the session has moved on, exactly like the normal inference path already did.

  3. Swapping an exercise (or resuming a paused session) didn't cancel the old in-flight answer. The app already had a "generation" counter that invalidates stale answers, but swap and resume never advanced it, so an answer meant for the old exercise could land on the new one. Fix: bump the counter at both points. (Judgment call on resume, documented in code + PR: bumping rather than resetting to zero catches the most common straggler, paused right as the first answer was coming back; a fully bulletproof version needs a wider change and is noted as deferred.)

  4. The live screen read the actor one field at a time, about nine separate hops. Between hops the actor could change, so the screen could show, say, a prescription from one moment next to a state from another. Fix: one method on the actor returns all the fields at once, so the screen always paints a single consistent moment.

how I tested it

built clean (build-exit=0). Full suite green, 553 XCTest cases (543 passed, 0 failed, 10 skipped live-API/integration tests) plus 293 Swift Testing tests (0 failed). Four new tests: end-session runs its side effects once, the retry guard drops a stale result after a swap bumps the generation, the latch resets for the next session, and the one-hop snapshot matches the individual fields. The hard-to-reproduce timing races are pinned with deterministic latch/guard tests rather than flaky sleeps.

PR open, not yet merged. Part of 369.

Five performance fixes: less CPU, less memory, fewer wasteful reads (PR #374, part of #369)

a cross-dimension audit (issue 369) found five efficiency problems that were silently burning CPU and memory on every session.

Done
the problem

a cross-dimension audit (issue #369) found five efficiency problems that were silently burning CPU and memory on every session.

  1. WorkoutView called traineeModelService.digest() three times back-to-back to read three different fields, decoding the full model each time. Fix: one let digest = await ... at the top, then read all three fields from the local copy. Same pattern applied to the two onDismiss closures.

  2. LiveSessionWatcher polled the session actor every 500 ms for the full lifetime of the app, even when no workout was running. That is 2 actor hops per second, all day. Fix: keep polling at 500 ms while a session is active or paused; slow to 5 s when idle. The observable properties stay the same; views see no difference.

  3. ProgressViewModel re-fetched 90 days of sessions and all set_logs every time the Progress tab appeared, with no caching. Fix: cache the result, but reuse it only when it's both within a 5-minute window AND no session has been logged since the last load, the cache compares the session counter that every finished or manually-logged workout already bumps. So a just-finished workout always appears on the next Progress visit (no stale window), while flipping tabs mid-session skips the redundant 90-day fetch.

  4. ProgressSessionRow.date allocated up to three DateFormatter/ISO8601DateFormatter objects on every call to .date, and .date was called twice per row. Fix: hoist all three formatters to static let so they are created once per process lifetime. DateFormatter is thread-safe for read-only use after setup.

  5. WriteAheadQueue called persistQueue() after every single item during a batch flush, encoding and writing the entire queue array to UserDefaults N times for N items, making flushing O(N²). Fix: remove all per-item persists inside the loop. The defer at the end of flush() writes the queue once after the whole batch. Dead-letter items are still persisted immediately (separate store, crash-safety). On a crash mid-flush, successfully-sent items may be re-sent on the next launch, but set_log inserts are idempotent by UUID primary key, so no data is lost or duplicated in a user-visible way.

how I tested it

built clean (build-exit=0). Ran the full test suite, all existing WriteAheadQueue tests passed. Two new WAQ tests verify the batch-flush end-state (empty queue, all items sent, persisted state also empty) and the permanent-failure dead-letter path. One pre-existing live-API flake unrelated to these changes.

PR open, not yet merged. Part of 369.

A fresh install can now reach Supabase (auth slice 2, PR TBD)

a clean install had no Supabase anon key, so the SupabaseClient was created with an empty string and any network call to Supabase would fai…

Done
the problem

a clean install had no Supabase anon key, so the SupabaseClient was created with an empty string and any network call to Supabase would fail. The Anthropic key already had a bundled-key mechanism (PR #368), but the Supabase anon key did not.

what I changed

mirrored the existing Anthropic bundling pattern for the Supabase anon key. Added APEXSupabaseAnonKey to Info.plist expanded from a new SUPABASE_ANON_KEY build variable in the xcconfig files. Added BundledAPIKey.supabaseAnon() and SupabaseAnonKeyResolver (same Keychain-first precedence as the Anthropic resolver). AppDependencies now resolves the anon key through the resolver instead of a bare Keychain lookup, seeding it into the Keychain on first run. The launch gate in ContentView was extended from "AI key required" to "both keys required", either missing triggers NeedsSetupView.

how I tested it

built with no real key in place (build-exit=0). Ran the full test suite, all 23 tests in BundledKeyResolutionTests passed (9 new tests for the Supabase path). One pre-existing timing flake in AIInferenceServiceTests unrelated to this change.

PR open, not yet merged. Closes 329 (both keys now bundleable so a fresh install can complete onboarding end-to-end).

The app now quietly signs in behind the scenes (PR #372)

Right now the app talks to our database using one shared "house key" (the anon key) - there's no notion of this particular person being sig…

Shipped
the problem

Right now the app talks to our database using one shared "house key" (the anon key)

  • there's no notion of this particular person being signed in. Before we can lock data down so each person only sees their own, the app first needs to get its own private sign-in token. This is the first small step of that bigger job: get the token, but change nothing anyone can see yet.
what I changed

On launch the app now asks our auth service for an anonymous sign-in, like getting a wristband at the door without giving your name. It tucks the resulting token away safely (in the iPhone's secure Keychain) and uses it on its database calls. Next time you open the app it reuses the same wristband instead of grabbing a new one, so you stay the same "person" across launches. The token also quietly refreshes itself when it's about to expire.

Crucially, this is built to fail softly. If the sign-in can't happen, for example because we haven't flipped the "allow anonymous sign-ins" switch on the dashboard yet, or the network is slow, the app shrugs and carries on exactly as it does today with the shared house key. It never blocks the app waiting. So nothing about how the app behaves changes in this step: it still reads and writes the same data the same way. We did NOT switch over which user-id the app uses, and we did NOT turn on the per-person data locks, those are deliberately later steps.

how I tested it

Wrote tests that fake the auth server (no real network): a successful sign-in saves the token and sets it; a relaunch with a saved token reuses it without signing in again; a rejected sign-in leaves things on the old shared-key behavior without crashing or hanging; and the token-refresh / retry-after-expiry path works. Full app test suite still green (532 ran, 0 failures, 10 skipped, the usual live-API ones that need real credentials). The build is clean.

Done on branch feat/369-auth-slice1-anon-session, PR 372. Not merged. Live end-to-end sign-in still needs the dashboard's Anonymous provider turned on; until then the app correctly runs on the old shared-key path.

Three broken data paths in the trainee-model learning pipeline fixed (PR #371, part of #369)

The trainee model learns from your workout data, but three bugs meant it was learning almost nothing in production.

Done
the problem

The trainee model learns from your workout data, but three bugs meant it was learning almost nothing in production.

First, every set you complete gets sent to an Edge Function that updates your model. That payload was missing the AI's original prescription, without it, the Edge Function's accuracy-learning loop hit a guard and bailed out immediately. The learning loop has never run since it was written.

Second, because the prescription was never sent, the weekly fatigue signals (deload triggers, rep-rate alarms) could never fire either. They depend on comparing what the AI prescribed to what you actually did. With no prescription in the data, every set looked like a no-prescription set and the signals stayed at zero.

Third, the muscle-volume breakdown (setsPerPrimaryMuscle) was being sent to the AI digest as a flat alternating array like ["chest", 4, "quads", 6] instead of a proper JSON object {"chest": 4, "quads": 6}. The AI can't reason about a flat array the way it can a named object.

what I changed

Added ai_prescribed to the set-log payload that goes to the Edge Function, it's the same nested prescription object the EF already knows how to read (intent, reps, user_corrected_weight). Added a custom encode(to:) and init(from:) to WeekFatigueSignals so the muscle-volume map serialises as an object (using the same encodeEnumKeyedDict helper already used in TraineeModel). Also added an explicit memberwise init since the custom Codable init suppresses the synthesised one.

how I tested it

Added 6 new tests: two prove the ai_prescribed field is present (or correctly absent) in the encoded WAQ payload; two prove the deload signals fire when a prescription is present and stay silent when it isn't; two prove the muscle-volume field round-trips as a JSON object. All 276 tests pass (build-exit=0).

merged as PR 371.

A brand-new install can now get past the front door (PR #368)

If someone installed the app fresh, they could never finish setting it up.

Done
the problem

If someone installed the app fresh, they could never finish setting it up. The app needs a secret "key" to talk to the AI (for scanning your gym and building your program). But the only way to put that key in was a hidden developer screen buried inside setup, so on a clean phone there was no key, and setup would chug along for about ten minutes until the gym-scan step suddenly died with an ugly error.

what I changed

Two things. First, the app can now carry a key baked into the build itself, so a fresh install just works. The real key is never stored in the project's shared code

  • it lives in a private file on the builder's machine (or as a build secret), and only a fake "REPLACE_ME" placeholder is shared. The app looks for a key in this order: one you already saved → the baked-in build key (which it then tucks away so the rest of the app behaves exactly as before) → none.

Second, if there genuinely is no key (someone built it without setting one up), the app no longer lets you wander into a doomed setup. Instead it shows a calm, honest "This build needs setup" screen right at launch, explaining the build is missing its configuration and to contact the developer. When a key is present, nothing changes at all, the normal app opens as usual.

how I tested it
  • Built the app with NO real key present, it builds cleanly (a missing key is never a build error), and the baked-in value correctly reads as the placeholder, which the app treats as "no key" and shows the setup screen.
  • Built again with a (fake) key in the private file, the value flows all the way through to the app as expected.
  • Wrote 13 new automated tests covering the "which key wins" order and the show/hide logic of the setup screen. Full test run: 525 passed, 10 skipped (the usual live-internet tests), 0 failures.
Done, opened as PR 368 (closes 329). Note for later: the longer-term plan is to route AI keys through a server so nothing ships in the app at all, this build-time key is the alpha-stage stopgap.

Built the new 3-tab layout, but kept it switched off (PR #370)

The redesign moves the app from four bottom tabs (Program, Workout, Progress, Settings) to three, Today, Train, Progress, with Settings tuc…

the problem

The redesign moves the app from four bottom tabs (Program, Workout, Progress, Settings) to three, Today, Train, Progress, with Settings tucked into a corner button instead of a tab. I built that new three-tab layout in code, drawn in the redesign's own colours and fonts (yesterday's building blocks), with a little corner gear for settings.

The important part: it's switched off by default. The app still runs the old four-tab screen exactly as before. There's a single on/off switch in the code, set to "off". That's on purpose, turning it on means moving some delicate plumbing (the first-time setup screen, crash recovery, and "resume a paused workout"), and that deserves its own careful, separately-tested step later. So this slice lays the new layout down next to the old one without disturbing any of that plumbing.

A few things worth calling out simply:

  • The new bottom bar follows the design rule: each tab's icon is quiet ink normally and turns ink-blue + filled-in only for the tab you're on.
  • One tab (Progress) already shows its real screen; Today and Train show a simple honest placeholder for now, and nobody sees these yet, because the whole thing is switched off.
  • There's a small translator so the app's existing "jump to that tab" buttons keep working unchanged once the new layout is eventually switched on.
how I tested it

I wrote five small tests for the translator first, and they all pass. The whole app still builds cleanly with no warnings, and I confirmed I didn't touch the old main screen at all.

Opened pull request 370 (part of issue 343). Switched off, so nothing changes for the user yet, waiting on your review before it merges. Turning it on (and moving the delicate plumbing) is a later step.