Skip to content

chore(deps): bump uvicorn from 0.35.0 to 0.52.4 in /backend/quiz-engine - #33

Open
dependabot[bot] wants to merge 159 commits into
mainfrom
dependabot/pip/backend/quiz-engine/uvicorn-gte-0.52.4
Open

dependabot[bot] wants to merge 159 commits into
mainfrom
dependabot/pip/backend/quiz-engine/uvicorn-gte-0.52.4

Conversation

@dependabot

@dependabot dependabot Bot commented on behalf of github Aug 26, 2026 •

Copy link
Copy Markdown

Bumps uvicorn from 0.35.0 to 0.52.4.

Release notes

Sourced from uvicorn's releases.

Version 0.52.4

Fixed

  • Remove duplicate Date headers from accepted WebSocket handshakes with websockets-sansio (#3078)

Full Changelog: Kludex/uvicorn@0.52.3...0.52.4

Version 0.52.3

Changed

  • Update zttp to 0.0.24 and use its combined receive path, improving HTTP/1.1 request parsing performance (#3067)

Full Changelog: Kludex/uvicorn@0.52.2...0.52.3

Version 0.52.2

Fixed

  • Update zttp to 0.0.22, fixing bodyless request receives and improving HTTP/1 request parsing performance (#3063)

Full Changelog: Kludex/uvicorn@0.52.1...0.52.2

Version 0.52.1

Fixed

  • Complete the closing handshake on server-initiated WebSocket closes in the websockets-sansio and wsproto implementations, waiting for the client's close reply with a 10 second timeout instead of resetting the connection (#3053)
  • Add missing write flow control to the websockets-sansio implementation, preventing data truncation on server-initiated closes with large in-flight payloads (#3048)
  • Handle connection loss while a WebSocket write is waiting on backpressure (#3050)
  • Remove duplicate Content-Type and Content-Length headers from WebSocket denial responses on the websockets-sansio implementation, and deliver non-UTF-8 denial bodies intact (#3041)

Full Changelog: Kludex/uvicorn@0.52.0...0.52.1

Version 0.52.0

This release adds an experimental HTTP/1.1 implementation backed by zttp, a sans-IO HTTP parser I've been developing on the side: a core written in Zig, with bindings to Python. It has been running under a fuzzer for some weeks now, and has been through multiple rounds of security auditing.

It is still experimental, so don't put it in front of production traffic yet. Try it with --http zttp, and please send any feedback to the issue tracker.

Added

  • Add an experimental zttp HTTP/1.1 implementation, selectable with --http zttp (#2979)

Fixed

  • Keep non-ASCII WebSocket request headers intact with websockets 17.0, which encodes them with ISO-8859-1 (#3036)

Full Changelog: Kludex/uvicorn@0.51.0...0.52.0

Version 0.51.0

What's Changed

... (truncated)

Changelog

Sourced from uvicorn's changelog.

0.52.4 (August 18, 2026)

Fixed

  • Remove duplicate Date headers from accepted WebSocket handshakes with websockets-sansio (#3078)

0.52.3 (August 13, 2026)

Changed

  • Update zttp to 0.0.24 and use its combined receive path, improving HTTP/1.1 request parsing performance (#3067)

0.52.2 (August 13, 2026)

Fixed

  • Update zttp to 0.0.22, fixing bodyless request receives and improving HTTP/1 request parsing performance (#3063)

0.52.1 (August 1, 2026)

Fixed

  • Complete the closing handshake on server-initiated WebSocket closes in the websockets-sansio and wsproto implementations, waiting for the client's close reply with a 10 second timeout instead of resetting the connection (#3053)
  • Add missing write flow control to the websockets-sansio implementation, preventing data truncation on server-initiated closes with large in-flight payloads (#3048)
  • Handle connection loss while a WebSocket write is waiting on backpressure (#3050)
  • Remove duplicate Content-Type and Content-Length headers from WebSocket denial responses on the websockets-sansio implementation, and deliver non-UTF-8 denial bodies intact (#3041)

0.52.0 (July 29, 2026)

This release adds an experimental HTTP/1.1 implementation backed by zttp, a sans-IO HTTP parser I've been developing on the side: a core written in Zig, with bindings to Python. It has been running under a fuzzer for some weeks now, and has been through multiple rounds of security auditing.

It is still experimental, so don't put it in front of production traffic yet. Try it with --http zttp, and please send any feedback to the issue tracker.

Added

  • Add an experimental zttp HTTP/1.1 implementation, selectable with --http zttp (#2979)

Fixed

  • Keep non-ASCII WebSocket request headers intact with websockets 17.0, which encodes them with ISO-8859-1 (#3036)

0.51.0 (July 8, 2026)

Added

  • Restart workers one at a time on SIGHUP, bringing each replacement up before retiring the old worker, so reloads no longer drop requests (#3025)

Removed

  • Remove colorama from the standard extra (#3027)

... (truncated)

Commits

faith-ogun added 30 commits July 2, 2026 14:06
Rebrand to the real Ohmlet tokens (cream/ink/gold, Nunito), add 14
looping SVG explainer animations for the significant tasks (mount on
card expand, motion.dev with a static fallback), and a full,
research-backed task list grouped into themed sections.
Add consent-gated build_complete and first_build_complete events and a
northstar helper that computes days_since_signup and fires the FBC7
numerator once per learner. Wire it into the finished-build capture in
the live tutor, carrying session length.
Add push-button LED (digitalRead, pull-down) and potentiometer dimmer
(analogRead, map, PWM) build guides, matching the existing guide bar
with parts tables, working code, troubleshooting, and FAQs.
The brand ink #14201e (rgb 20,32,30) was green-dominant. Swept it to #14181f
(subtle navy-black, same darkness, zero green) across the Tailwind tokens, CSS
variables, every hard-coded literal (simulator, code lab, lesson runner,
free-form editor, challenge art, avatar, author preview), the rgba(20,32,30)
shadow tints, and the build report. ink-soft #46514e -> #474d57.
…ixes

- Add blogArt.ts: 11 hand-authored on-brand SVG diagrams (Ohm's law, breadboard
  anatomy, wiring diagrams, resistor color code, PWM, multimeter, etc.), rendered
  inline so labels inherit Nunito. posts.ts media blocks gain an 'art' id and
  BlogPostPage renders the diagram, keeping the placeholder as fallback.
- Fix the grid-card corner gap: drop overflow-hidden and round the color band's
  own top corners, eliminating the border-radius clip seam (verified at 8x).
- Fix the Ohm's-law burned LED so its legs wire into the circuit.
…ild div

The grid cards showed a 1px white seam in the top corners on some cards but not
others (same swatch, same component) - a sub-pixel antialiasing gap between the
band's rounded-top corner and the card's rounded corner, which only aligns
cleanly at certain fractional pixel positions down the page. Neither a ring nor
matching the band's radius fixed it.

Structural fix: drop the child band entirely and paint the gradient as the
card's own background (bg-gradient + background-size 100% 7rem, no-repeat, top).
Now there is a single rounded element and no second corner to misalign, so a
seam is impossible at any pixel position or device-pixel-ratio. Verified at 4x
with a deliberate 0.5px offset and in the built app.
…n, twin share

The July 11 sprint. Ships the web app's safety, money, and store-readiness spine.

Child mode (#94): parent consent flow (Stripe Checkout setup-mode provider,
swappable to KWS), server-side consent gate at the live-bridge handshake, a
child-safe agent (hazard refusals, no PII, no emotion inference, BLOCK_LOW safety),
child-safe live defaults (rear-camera lock, mic/cam off, session cap), and
per-user age scoping. Ships dark behind OHMLET_CHILD_MODE / VITE_OHMLET_CHILD_MODE.

Monetization + trust:
- First-build conversion moment / paywall (#18) with BuildCelebration.
- Under-18 payment gate (#96): purchases blocked for minors.
- In-session AI safety acknowledgement (#98).
- Community moderation (#97): report/block, auto-hide threshold, minor lockdown.

3D-twin public sharing (#79): reporter share/unshare + unguessable public
share id; client share API. The premium public /t/:id page is the next piece.

Legal (#94): Terms/Privacy children + parental-consent updates staged behind the
flag; must not deploy ahead of solicitor sign-off (#99).

Validation: frontend `npm run build` green; `py_compile` green on all backend files.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Closes #79. The reusable virality asset: a completed twin gets an unguessable
public link, and the page behind it is a real brand surface, since for most
visitors it is the first thing they ever see of Ohmlet.

SharedTwinPage: public /t/:shareId, no auth. The model is the hero on a
full-width dark stage with a drag-to-spin affordance; the caption bar is welded
to the stage rather than floating as its own card. Below it, the conversion
beat: what Ohmlet is, a numbered three-step strip, and a closing CTA band.
Three real states, including a purposeful dead end (mascot + a way in) for an
expired or unshared link.

App: /t/:id resolves ahead of the auth gates so a cold visitor never sees a
splash; signed-out CTAs go to signup, signed-in go to the workspace.

TwinStudio: a share strip once a twin is ready. Publishes, copies the link, and
always shows the URL (clipboard can be refused), plus Stop sharing.

TwinViewer: optional margin prop (default unchanged). The hero passes 1.7, since
a full-bleed stage and narrow portrait viewports clipped the model at 1.2.

Analytics: twin_shared, shared_twin_view, shared_twin_cta make the loop
measurable end to end.

Validation: npm run build green (page code-splits to 2.41 kB gzip). Visually
verified in Chromium at 1440x900 and 390x844 against mocked public endpoints
with a real GLB: ready, mobile, and not-found, no console errors. The mobile
clipping was caught this way and fixed. Hosting already rewrites ** to
/index.html, so no config change is needed.

Not yet clicked through end to end: the TwinStudio share strip needs a signed-in
session and a configured reporter, so it goes in the deploy smoke test.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… fake data

Findings from a full-codebase audit. Every item below was verified against the
running code (or the live deployment) before being changed.

CRITICAL
- resilience: add the missing CircuitBreaker.call(). Three production call sites
  (reporter twin generation, interview report, resume extraction) called a method
  that did not exist. The AttributeError was swallowed by a broad `except
  Exception` and surfaced as 502/503, so the 3D digital twin and interview
  reports have NEVER worked. Applied to all five service copies, which stay
  byte-identical, plus regression tests so it cannot silently regress again.
- live tutor: the production bundle hardcoded ws://localhost:8082 because
  VITE_OHMLET_WS_URL was never set, so the Build tab and Interview Mode could
  not connect at all (also blocked as mixed content). The socket URL is now
  derived from the live-bridge REST base, which is already configured; in a
  production build with neither set it throws at load instead of silently
  shipping localhost.
- quiz-engine: /generate and /assess-drawing were fully unauthenticated on a
  service deployed --allow-unauthenticated, i.e. an open Vertex endpoint on our
  billing account with attacker-controlled prompt text. Added the standard
  auth + per-identity rate-limit guard, the firebase-admin dependency, pinned
  requirements to match the other services, and made the client send its token.
- auth: is_admin granted admin to any token whose email matched the allowlist
  with no email_verified check, so an unregistered allowlisted address could be
  claimed by anyone and self-grant Max. Now requires a verified email; the
  custom claim remains the real mechanism. Applied to all five copies.

HIGH
- idempotency: claim_event returned False on a Firestore error, which the Stripe
  webhook read as "already processed" and acked 200, so a transient blip meant a
  customer paid and was never upgraded, with no retry. It now raises so Stripe
  redelivers.
- live-bridge: Starlette RETURNS the websocket.disconnect frame rather than
  raising, so every clean hang-up fell through and was logged as an ERROR,
  burying real failures in 5xx alerting. Handled explicitly.

Fake data shipped to real users (violates the project's own no-placeholder rule)
- Every user was greeted "Welcome back, faith." Now uses their display name.
- The weekly league card showed a hardcoded "Copper League, Rank #4" to
  everyone, contradicted by the real leaderboard one click away. Now driven by
  the real fetchLeaderboard(), and hidden entirely when there is no data rather
  than inventing a position.
- "Recent achievements" showed two invented achievements to brand-new accounts.
  Now lists only genuinely earned ones, with an honest empty state.
- The "pick up where you left off" progress bar was a hardcoded 45%/15%. Now the
  real completed-over-total fraction.

Validation: all 99 backend tests pass (live-bridge 59 + 2 new, reporter 18,
vision-verifier 15, compiler 7); py_compile clean on all five services;
`npm run build` green; verified the built bundle no longer contains
ws://localhost or the hardcoded name/league.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… deploy

Two audit findings, both money/production-critical.

1. Live-session budget bypass (margin hole)

A session read its remaining allowance ONCE at connect and only wrote its
consumption back at close, while the watchdog compared that frozen snapshot
against this session's own clock. Two concurrent sessions (two tabs, phone plus
laptop) therefore each saw the full remaining balance and each spent it, so one
account could multiply live-tutor Gemini spend past the plan's margin with no
exploit beyond opening another tab.

Sessions now settle as they run: entitlements.settle_live_session() bills only
the not-yet-charged delta and re-reads the shared monthly counter, so siblings
become visible within one sync interval (OHMLET_BUDGET_SYNC_SEC, default 30s)
instead of never. Teardown settles the remainder through the same path, so a
long session is never billed twice for the same seconds. The settle runs via
asyncio.to_thread because the Firestore client is synchronous and that loop also
carries live audio.

The accounting moved into entitlements.py (where the money logic belongs) so it
is unit-testable rather than trapped in a closure. New tests pin both invariants:
never double-bill on repeated settles, and two concurrent sessions consuming the
same allowance see each other and the second one is cut off.

2. deploy.sh never deployed the frontend or the Firestore rules

deploy_frontend() built the bundle and then only PRINTED "deploy dist/ to your
hosting provider", so ./deploy.sh all updated five Cloud Run services and left
the live site untouched. The Firestore rules were never pushed at all, meaning
the deny-all rules in git were not proven to be what is live.

It now runs `firebase deploy --only hosting,firestore:rules`, failing early with
a clear message if the firebase CLI is absent. The rules ship with the app that
depends on them instead of drifting.

It also refuses to deploy a bundle containing a localhost:PORT service URL. That
is the exact failure that put ws://localhost:8082 into production: a missing
VITE_* key falls back to a dev URL. Verified in both directions - the guard
passes on a correct build, and blocks a build made with a service URL removed.
A bare "http://localhost" (the Firebase Auth SDK's popup constant) correctly
does not trip it, which is why the port is required.

Added a hosting predeploy hook so a bare `firebase deploy` rebuilds rather than
publishing a stale dist/ from an older branch.

Validation: 103 backend tests pass (live-bridge 63, reporter 18,
vision-verifier 15, compiler 7); npm run build green; bundle verified clean.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ated content

Both public pages presented invented content as real, to people deciding whether
to pay. Once money changes hands that is a consumer-protection problem (FTC
16 CFR 465, UK DMCC 2024, EU UCPD Annex I), not just a house-style violation.

Removed:
- Six invented builders ("Priya N.", "Marcus T.", ...) with invented engagement
  counts (214 likes, 38 comments), under the headline "Built by the community /
  Real circuits from real learners".
- Three invented testimonials with names, quotes and occupations, including a
  named high-school teacher.
- A hardcoded five-star rating with no reviews behind it.
- Four "build paths" (First Light, Sense the World, Make It Move, Code Your
  Circuit) with invented build counts. None of them exist in the curriculum.

Replaced with the data the product actually ships, which is both true and a
better pitch:
- /build renders BUILD_LIBRARY: six real projects with their real parts lists,
  time estimates, target board and difficulty, filterable by real level. The
  testimonial block became "What happens when you go live", describing three
  shipped capabilities (camera parts check, live wiring correction, voice Q&A
  and sketch debugging) rather than claims about other people's experiences.
- /learn renders CURRICULUM: the real 12 units in unlock order with their real
  subtitles and levels, and the lesson total computed from allLessons() so the
  public page can never drift from what a learner receives.

Deliberately no builder names, likes, comments, ratings or reviews anywhere:
there is no community data yet, and the honest version is the one that survives
first contact with a paying customer.

Validation: npm run build green; both pages rendered at 1440x1000 with no
console errors and visually checked.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… defects

Audit findings in the frontend. Ordered by what they cost a real user.

Camera and microphone could stay live after being turned off
toggleCam/toggleMic gated on the camOn/micOn STATE, which only flips after
getUserMedia resolves. The permission prompt stays open for seconds and the
control stays clickable, so a second tap started a second stream that
overwrote camStreamRef and camIntervalRef. The first stream's tracks were
never stopped and its interval never cleared: the UI said "camera off", the OS
camera indicator stayed lit, and frames kept uploading to Gemini for the rest
of the session. Added in-flight guards to both toggles, made startCameraStream
stop any stream it is about to replace, and made the failure paths tear down
whatever was half-opened.

Shared-device state leaked between accounts
Ohmlet is explicitly aimed at family and classroom machines, where
device-global keys hand one person's state to the next:
- ohmlet.safetyAck.v1 meant a user who had never seen the mains-power and
  AI-fallibility warning could start a live session without it.
- ohmlet.ageGate.v1 meant a child could inherit an adult's birth year
  (unlocking the front camera, community and self-purchase), or an adult could
  inherit a child's and be stuck at the parental-consent screen.
- Nothing was cleared on sign-out, and account deletion left the birth year,
  plan and progress on the device while promising to delete personal data.
Added services/localState.ts (per-user keys, safe read/write, and a cleanup
that runs on sign-out and deletion). Cookie consent is deliberately preserved:
it is a device-level legal choice, not a per-account preference.

Auth and plan defects
- getIdToken rejected on network failure while every service client awaits it
  OUTSIDE its own try/catch, so the community feed and plan lookup could spin
  forever. It now resolves null, fixing all nine callers at one point.
- Google sign-up always reported isNewSignup=false because the credential was
  discarded, so new Google users skipped onboarding entirely. Now derived from
  getAdditionalUserInfo().isNewUser, and it emits sign_up rather than login.
- A Max subscriber who ran out of live time was told to "Upgrade to max", the
  plan they already pay for. Top-tier and under-18 accounts now get an honest
  reset message and no upsell button.
- PLAN_META.max.label was lowercase, rendering "max plan" across the sidebar,
  account page and paywall.

Runaway emulator
CodeLabView started its rAF loop after an awaited compile with no mounted
check, and unmount cancelled only the rAF that existed at that moment. Leaving
the Code tab mid-compile left a 16MHz emulator and five setState calls per
frame running on a dead component, unreachable because the Stop button was
gone. Added a mounted flag checked after every await.

NOT fixed here, and tracked instead: 24 achievements remain unearnable because
9 of the 13 metrics (live sessions, drawings, perfect builds, twins, likes,
posts, comments, challenges, league wins) have no tracking anywhere in the
codebase. That is a feature to build, not a bug to patch, and faking the
numbers would be worse than leaving them locked.

Validation: npm run build green; 63 backend tests pass; all ten public routes
smoke-tested in Chromium with zero console or page errors, and /login verified
to still render working email and password fields.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…lly does

A privacy policy is a binding representation. The live one at /privacy promised
protections the code does not provide, which is a legal exposure rather than a
copy problem. Each item below was checked against the code before changing.

Removed claims the product does not keep:
- "We keep a text transcript of the session so you can review it." Live
  transcripts are React state in useLiveBridge and are never sent to or stored
  by us; a user exercising a subject-access request would receive nothing. Now
  stated accurately, and the retention table says transcripts are not retained
  at all, which is both true and a better privacy position.
- "The camera is off by default. A session is voice-first." startSession()
  auto-enables microphone AND camera for every non-child session. Replaced with
  what actually happens: nothing starts until you press Go live, the browser
  asks permission, both switch on because the tutor needs to see the bench, and
  either can be turned off mid-session.
- "our registered company details will be listed here once incorporation
  completes" was a live placeholder in a published legal document.
- A child-mode notice advertised "content safety at its strictest level". The
  audit established those safety settings never reach the model on the live
  path (LiveConnectConfig has no safety_settings field), so the claim is now
  limited to the child instruction set and community lockdown, which are real.
  Shorter retention for children was also claimed and is not implemented.

Gated the children's sections on the feature flag:
The Privacy §10 and Terms §2 child text described a parental-consent gate while
OHMLET_CHILD_MODE is off, so today a minor could sign up and get the full
camera-and-voice tutor with no age check at all, in direct contradiction of the
published policy. Both now render from CHILD_MODE_ENABLED: with the flag off
they state the accurate position (accounts require the local age of digital
consent, we do not knowingly collect a younger child's data, purchases require
18+), and the full parental-consent text returns automatically with the feature.

STILL FOR FAITH: GDPR Art 13 requires the controller's identity and contact
details. Section 1 names Ohmlet and gives an email but no legal entity or
address; that must be completed before taking real payments. Noted at the top of
the file, and solicitor review remains task #99.

Validation: npm run build green; the rendered /privacy asserted against eight
checks in Chromium (each removed claim absent, each replacement present, no
unresolved template placeholders, updated date shown).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
24 of the 50 achievements could never unlock. The workspace supplied only xp,
streak, builds and units, so every achievement about live sessions, drawings,
perfect builds, twins, posts, comments, challenges and league finishes sat at
zero permanently, no matter what the learner did. Gamification is a stated
retention pillar, so half the trophy case being unreachable is a real product
defect rather than cosmetic.

Approach: emitters publish an event, ONE listener owns the counters.
- services/achievementEvents.ts: recordMetric() / onMetric(). Avoids drilling a
  setter through half the view tree, and keeps a single writer so the debounced
  save cannot race itself.
- hooks/useAchievementMetrics.ts: persists the counters in their OWN state
  document (key 'metrics'), not inside the progress envelope, so the two saves
  are independent. Per user rather than per device, so a second device does not
  reset them.
- useOhmletUserState.updateState now also accepts an updater function. Counters
  need it: two increments in the same tick would otherwise both read the same
  stale value and one would be lost.

Wired at the points where the events actually happen: live sessions (session
start), twins (successful generation), drawings (a drawing the grader accepted),
perfect (a lesson completed with no wrong answer, tracked via the requeue path),
posts / comments / challenges (only on a confirmed server response), and league
wins (a top-three finish, keyed by the leaderboard's own week id so one week can
only ever count once). Preview mode never records, since the author console
renders lessons without a real learner.

Result, measured against the achievement table: 47 of 50 now reachable, up
from 26.

The remaining 3 are the "likes received" tier, deliberately left out. Another
person liking your post is not an event this client observes, and summing it
from the feed would depend on the author uid that the feed should stop exposing
for privacy reasons. It needs a small per-user community stats endpoint, which
is tracked separately rather than approximated.

Validation: npm run build green; 142 lessons lint clean; 63 backend tests pass.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…t address

Closes the last item the audit left open in the published policy.

Controller identity (GDPR Art 13). Section 1 previously said only that Ohmlet is
the controller and is "operated from Ireland", which left a user or regulator
unable to establish who actually controls their data. It now states plainly that
Ohmlet is an independent service operated from Ireland and NOT yet incorporated,
gives a monitored mailbox as the contact point, and undertakes to provide full
controller details in writing on request.

This is a deliberate founder decision, recorded in the file header: no personal
name and no home address on a public, scrapeable page while Ohmlet trades as a
sole trader. It is defensible for a small online service but not settled, since
a regulator may expect a named controller and a postal address. The header names
the two things that close it properly (incorporating, or a registered address if
launching first) and points at the solicitor review in task #99.

Dead contact address. The policy advertised privacy@ohmlet.org in six places and
the child-mode parent notice in a seventh, but that mailbox does not exist. Data
subject-access, deletion and parental-rights requests sent there would have
bounced or vanished, and answering them is a legal obligation with a statutory
deadline, so an unread address is worse than none. PRIVACY_EMAIL now points at
the mailbox that exists (hello@ohmlet.org) and the child notices import the same
constant, so the two can no longer drift. One line changes it back when a
dedicated privacy alias is created.

Note: the address given in the request was written as ohmlet.com; the domain
used throughout the repo and CLAUDE.md is ohmlet.org, so .org was kept rather
than silently introducing a new domain. Worth confirming.

Validation: npm run build green; privacy, terms and cookies rendered in Chromium
and asserted against seven checks (dead address absent everywhere, live address
present, controller status stated, details-on-request present, no personal name
published, no unresolved template placeholders, DPC complaint route intact) with
no page errors.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Follows the founder's decision to name herself as controller while keeping a
home address off a public page.

Section 1 now reads: Ohmlet is operated from Ireland by Faith Ogundimu as a sole
trader, is not yet incorporated, and Faith Ogundimu trading as Ohmlet is the data
controller. Contact remains the monitored hello@ohmlet.org, with full controller
details available in writing on request.

Naming the controller is the part of GDPR Art 13 that actually lets a user or a
regulator establish who holds their data, and that is now satisfied. The postal
address is deliberately withheld: for a sole trader that is a home address, and a
public page is scraped and archived, so this is a personal safety decision rather
than a compliance oversight. It is recorded as such in the file header, with the
two routes that close the remainder (incorporation, or a registered address) left
on the solicitor review in task #99.

Domain confirmed as ohmlet.org, so no change was needed there.

Validation: npm run build green; /privacy rendered in Chromium and asserted
against six checks (controller named, sole-trader status stated, not-yet-
incorporated stated, contact email present, no street or postal address
published, dead privacy@ address absent).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
First increment of the mobile app for Shipaton. Expo SDK 57 / React Native
0.86 / expo-router, TypeScript strict, targeting org.ohmlet.app.

Talks to the SAME backend and the SAME Firebase project as the web app, so an
account created on either surface works on both and the backend's per-user
isolation applies unchanged (it derives the UID from the verified token and
never trusts the client).

What is here:
- Brand tokens copied verbatim from frontend/tailwind.config.js, so the two
  surfaces are visually continuous rather than approximately similar.
- Firebase Auth with AsyncStorage persistence via getReactNativePersistence.
  Without that the session is dropped on every app restart, which is the most
  common Expo + Firebase mistake.
- getIdToken resolves null rather than rejecting, carrying over the fix from
  the web audit: every service client awaits it outside its own try/catch, so
  a rejection there left screens spinning forever.
- Service config derives the WebSocket origin from the REST base instead of
  keeping a second env var. That is the exact failure that shipped
  ws://localhost:8082 to web production and left the live tutor dead for every
  user; one value means one failure mode.
- Auth context that bumps a version counter on profile update, because Firebase
  mutates the same user object in place and a plain memo would keep showing the
  raw email for the whole session (a real defect found in the web app).
- Sign in / sign up with an in-flight guard, per-field labels, honest error
  copy mapped from Firebase codes, and a live region for screen readers.
- A Button that reproduces the web app's hard-offset press treatment, since RN
  has no shadow equivalent: the face animates down into a static plate.
- Home screen greeting the real display name. Never a hardcoded one.

Deliberately NOT here yet: the paywall is stubbed until Faith supplies a
RevenueCat public SDK key, and the live tutor, lesson runner and curriculum
fetch land next. react-native-purchases is installed but unwired; it is a
native module, so this app needs a development build rather than Expo Go.

Validation: tsc --noEmit clean; `expo export --platform ios` succeeds
(2.7MB bundle).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Its own header says to delete it after use. It moves src/ and scripts/ into
example/ and regenerates a blank app — destructive to run by accident, and
useless now the real app exists.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… screens (#70)

Executes the decision in metadata/decisions/2026-08-22_curriculum-client-vs-backend.md,
sequenced with the mobile build as planned.

Why now. The lessons were compiled into the web client. That is fine on the web,
where a fix is a deploy, but on mobile it would be an App Store review — days to
correct a typo in one question. Serving the content means a lesson fix reaches
both surfaces at once, and keeps the authored curriculum (the moat) out of any
client bundle.

Backend:
- frontend/scripts/export-curriculum.mjs bundles the TypeScript source with
  esbuild and evaluates it, rather than parsing it by hand, so the emitted JSON
  can never drift from what the web app actually compiles. Exports 12 units and
  142 lessons with a content hash for cache invalidation.
- backend/live-bridge/app/curriculum.py serves /v1/curriculum/{version,manifest,
  lessons/{id}}. The manifest deliberately excludes lesson bodies so a client can
  render the path and decide what to download without pulling ~700KB.
- Lesson ids are authored, human-readable strings containing spaces, so they are
  matched verbatim rather than slugified — the same class of mistake that broke
  twin share ids once already.

Mobile:
- services/curriculum.ts is cache-first: serve the cached copy immediately, ask
  for the version (a tiny request), refetch only on change. The app therefore
  works offline on a train, and a corrected lesson still lands next launch.
- Learning path, unit detail, and a lesson shell, all on real backend data.
- Failure states are honest: an unreachable backend and an empty path do not
  render identically, which is a mistake the web community feed made.
- services/billing.ts loads react-native-purchases LAZILY. It ships native code,
  so a static import crashes Expo Go — and Expo Go is how Faith reviews progress
  on her phone. Every entry point degrades to a stated reason instead of a dead
  button.

Validation: 68 backend tests pass (5 new, including a guard that every lesson the
manifest advertises actually has content, and that both files agree on version);
tsc --noEmit clean; expo export succeeds (2.8MB).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Expo Go on Faith's phone is ~SDK 54 (last App Store update ~10 months ago) while
the project is on SDK 57, so Expo Go refuses to open it. Rather than downgrade a
year of SDK for a workflow that dies anyway the moment RevenueCat lands (native
module, unloadable in Expo Go at any SDK), the browser preview gives the same
code and the same design with zero setup, and the development build becomes the
real device path once the Apple account exists.

Verified: renders at iPhone dimensions with no console or page errors, and the
sign-in / sign-up toggle works.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Faith hit "Project is incompatible with this version of Expo Go". Cause was mine:
I scaffolded on SDK 57 (current, June 2026) while her Expo Go — last updated on
the App Store ~10 months ago — is around SDK 54. Expo Go only runs the SDK it
shipped with.

Downgraded at her explicit request, knowing the trade: SDK 54 is a year old and
we re-upgrade when the development build lands. It buys device testing now, which
is worth more than SDK currency while the app is still being shaped.

- expo 54.0.37, react 19.1.0, react-native 0.81.5, expo-router 6.0.24, and every
  Expo package realigned by `expo install --fix`.
- @types/react and typescript pinned to what SDK 54 expects (5.9, not the 6.0 that
  had been resolved) — mismatched type versions cause failures that only surface
  at build time.
- Added .npmrc with legacy-peer-deps. expo-router 6 declares an OPTIONAL peer on
  react-server-dom-webpack that resolves to a React newer than SDK 54 pins, so a
  plain `npm install` fails with ERESOLVE. The dependency is optional and unused,
  so relaxing resolution beats forcing a React mismatch.

Verified: the dev server now advertises `exposdk:54.0.0` in the manifest Expo Go
actually reads; tsc --noEmit clean; expo export succeeds (2.91MB).

Unchanged by this: RevenueCat still cannot run in Expo Go at any SDK version, so
the paywall still needs a development build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… (#70)

The app opened straight onto a login form, which reads as a web page in a phone
frame rather than an app. There was no front door and no way in other than
email/password.

Added:
- welcome.tsx — the front door. Mascot, wordmark, value proposition, and two
  clear ways forward. One signature motion: a staggered rise, mascot first,
  rather than many small animations.
- onboarding.tsx — a three-beat tour (it sees your bench / it talks back / you
  keep the build), paged horizontally with a progress indicator and a skip.
  Every claim is a shipped capability; no promises the product does not keep.
- sign-in.tsx rebuilt as an auth CHOICE screen: Apple, Google, then email behind
  a disclosure so the form is not the first thing a person meets.
- socialAuth.ts — Apple and Google bridged into the same Firebase user the web
  app already uses, so an account works across both surfaces.

App Store guideline 4.8 shapes the ordering: an app offering ANY third-party
sign-in must also offer Sign in with Apple. Offering Google without it is a
rejection at review, so Apple ships first and is listed first.

Apple sign-in generates a raw nonce and sends only its SHA-256 to Apple, so
Firebase can verify the raw value against the hash. Skipping that is what makes
an Apple sign-in replayable. The full name is also captured on first
authorisation only, because Apple never returns it again.

Both providers degrade honestly: Apple hides where unavailable, Google hides
without a configured client id, so neither ever renders as a button that does
nothing.

firstRun.ts stores the tour flag DEVICE-scoped, deliberately unlike the age gate
and safety acknowledgement which are per-user: whether the tour has been seen is
a property of the install, and signing out should not replay it.

Assets: mascot poses and the wordmark copied from the web app and resized for
mobile — 1.8MB total against 7MB for the source mascots alone.

Verified: tsc clean, expo export succeeds (3MB), all three screens render at
phone dimensions with no console or page errors.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The onboarding slides had dead space below the copy. Each now carries an
animated scene showing the mechanic the text describes, rather than decoration:

- BoardScanScene: a breadboard with a scan sweeping down it and a component
  lighting up as the scan reaches it. The board and hole grid are drawn, not an
  image, so they stay crisp at any size.
- VoiceScene: a staggered waveform. Bar heights are a fixed table, not random,
  so it renders identically every time.
- TwinScene: an isometric wireframe rotating slowly. Suggests the 3D twin without
  pretending to be a real render.

Remotion was suggested but does not apply — it renders React to MP4 for video and
has no mobile runtime. react-native-svg plus reanimated is the equivalent here,
runs on the native thread, and works in Expo Go.

Mascot art shrunk to make room for the scenes.

Verified: renders with no console errors, tsc clean.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The Duolingo-style run, ported properly rather than approximated.

The run is a QUEUE of step indices, not a straight walk. A wrong graded answer is
pushed to the BACK of the queue so it returns later in the same run and must be
cleared. That requeue is what makes a run teach rather than merely test, and it
is the mechanic the whole curriculum was authored against. Progress is measured
in MASTERED steps, so a requeued step holds the bar rather than letting it march
on. Five hearts; running out offers a retry rather than a dead end.

Step types implemented: teach, multiple_choice, true_false, fill_blank (typed or
tile bank), match, drag_order, predict_reading, predict_behavior,
choose_resistor, identify_component. Measured against the authored data, those
cover 2,355 steps at 88%. The drawing family (draw_circuit, draw_fix,
draw_connection — 6.2%) needs a canvas and lands separately; unsupported types
are filtered out of the run rather than rendered broken, so the step count always
matches what the learner actually sees.

Two real bugs found and fixed while writing useRun, both of which would have
surfaced as skipped questions:
- advance() called setDone/setPos INSIDE a setQueue updater. React may invoke an
  updater twice, so under StrictMode that double-incremented the position and
  skipped a step. The next queue is now computed as a pure value first.
- The lesson re-seed ran during render. setState while rendering is a React error
  and made the hook order-dependent; moved into an effect.

Verified the requeue invariant by simulating advance() exactly: all-correct runs
in N advances, a once-wrong step reappears at the end ([0,1,2,3,4,2]) and is then
mastered, a twice-wrong step reappears twice, and a never-mastered step can never
let the run finish early.

Progress persists through the SAME /v1/state envelope the web workspace uses, so
XP, streak and completed lessons follow a person between surfaces instead of
forking. Re-completing a lesson never re-awards XP and never demotes a better
previous result. The completion write is guarded so a re-render cannot double
award.

Verified: tsc clean, expo export succeeds (4.32MB).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Home, rebuilt on real data. It previously showed a hardcoded "Light-Activated
Alarm" card and a "coming next" note — the same class of placeholder that was
stripped out of the web marketing pages, and it should not have gone in. It now
reads persisted progress and the curriculum manifest to show actual XP, streak,
lessons completed out of the real total, and the genuine next unfinished lesson.
Path completion is computed, not decorative. Pull to refresh, and it re-reads on
focus so finishing a lesson is reflected immediately. Offline and empty states
are distinct, so an unreachable backend never reads as "nothing here".

Live tutor, talking to the deployed live-bridge over the same protocol as the
web client: auth frame first, then text / image / stage frames, with ADK events
carrying the agent's text and audio back.

- The camera feed is the hero and fills the viewport, with the stage selector and
  transcript floating over it rather than taking space from it.
- Frames go up on a heartbeat while the camera is on, plus an explicit "look now".
- Agent audio plays for real. The agent streams raw 24kHz PCM, which browsers can
  hand to an AudioContext but React Native cannot, so each chunk gets a WAV
  container built by hand (no Buffer or atob in the RN runtime) and is played via
  expo-av. Chunks replace rather than layer, since they arrive faster than they
  play and overlapping them turns speech into noise.
- Verified the byte work rather than trusting it: the base64 encoder matches
  Node's Buffer exactly, decode round-trips, and the generated WAV parses back
  with correct RIFF/WAVE/fmt/data tags, sample rate, channels, byte rate, data
  size and payload.

MICROPHONE is deliberately not wired. Real-time PCM capture needs a native module
that Expo Go cannot load, so the pre-flight screen says so plainly instead of
showing a dead button: the camera and the tutor both work now, questions go up as
text, and replies come back as speech. Capture drops in when the development
build exists.

Teardown closes the socket and unloads audio on unmount — an orphaned socket
keeps a metered session open, and an orphaned sound holds the audio session.
A stale close cannot orphan a newer socket.

Verified: tsc clean, expo export succeeds (4.41MB).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Monetization, which Shipaton requires and the HAMM category judges.

- entitlements.ts / usePlan read /v1/me, the same server-authoritative endpoint
  the web app uses. The client never decides what someone is entitled to: the
  plan and remaining live budget come from Firestore and the Stripe webhook, and
  the live-bridge enforces the same cap again at the socket. This layer only
  displays state. Deliberately uncached — a stale entitlement either shows a paid
  feature to someone who stopped paying or hides one from someone who just
  upgraded, and both are worse than a brief spinner.

- plans.tsx renders RevenueCat offerings. Prices are NEVER hardcoded: App Store
  Connect owns them, they differ by storefront and currency, and a hardcoded
  figure would be wrong for most of the world and drift the moment it changed.
  Perks are stated, prices come from the store.

- Purchase re-reads /v1/me rather than assuming the entitlement landed, because
  it is granted server-side by webhook.

- Restore purchases is reachable in-app, which Apple requires, and the renewal
  and cancellation terms are stated on the screen.

- The live tutor now shows remaining minutes before the camera opens, and routes
  to plans when the month's budget is spent rather than failing at the socket.

Every unavailable path explains itself: no RevenueCat key, running in Expo Go, or
no offerings configured each produce a specific sentence instead of a button that
does nothing.

Verified: tsc clean, expo export succeeds (5.71MB).

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…(#70)

Four features, each complete rather than sketched.

DRAWING STEPS — lesson coverage 88% -> 98.6%
- draw_connection: terminals rendered from the authored coordinates, tap two to
  wire them. Connections are undirected, so the grader sorts each pair rather
  than demanding the learner tap in the author's order.
- draw_circuit / draw_fix: a freeform canvas of SVG paths (not a bitmap, so
  undo is a pop and the capture stays crisp), graded by the quiz-engine vision
  model. A tap with no movement is not counted as a stroke, otherwise "has ink"
  would lie.
- When the grader is unreachable the attempt is ACCEPTED and says so. Being told
  you are wrong because a service is down is the worst outcome available here.
- Remaining 1.4%: trace_current, spot_error, fix_the_circuit, build_to_spec (32
  steps), all of which need interactive circuit-diagram rendering.

ACHIEVEMENTS — 47 of 50 reachable, matching web
- The catalogue is served from the backend beside the curriculum, so a new
  achievement does not need an App Store review. Which ones are EARNED is
  computed on-device; the catalogue is identical for everyone.
- Metrics now tracked on mobile: liveSessions (counted when the socket actually
  connects, not when the button is pressed), drawings (graded correct), perfect
  (a run cleared with no wrong answer), posts, comments, challenges, leagueWins
  (once per league week, keyed by the week the board reports).
- Locked cards show a real progress ring. The three 'likes received' cards say
  plainly that the metric lives server-side rather than showing a ring stuck at
  zero — that needs the community stats endpoint.

COMMUNITY
- Feed, threaded comments, likes, challenges and the weekly league, plus report
  and block.
- Every call returns a discriminated result, so "empty" and "unreachable" cannot
  render identically. The web app returned null on error and showed a cheerful
  "be the first to post" during outages; that bug is not repeated here.
- A child account is refused server-side by require_non_minor; the screen states
  that rather than showing an empty feed that looks broken.

3D TWIN VIEWER
- Real three.js on the device GPU via expo-gl. The GLB is fetched with auth, so
  GLTFLoader.parse() takes bytes rather than .load() taking a URL.
- Auto-framing: any mesh the generator returns is centred and scaled to a fixed
  size, so every twin fills the view identically.
- Drag to orbit with clamped pitch so the model cannot flip; it also idles
  slowly, so a still screenshot still reads as 3D.
- The render loop is cancelled on unmount — an orphaned rAF keeps the GPU busy
  and holds the whole scene alive.
- Meshes load only when a twin is opened, not to render the list.
- Sharing goes through the existing share endpoint and the iOS share sheet.
- The reporter is still undeployed, so the screen states why instead of offering
  a button that has never worked.

Verified: 70 backend tests pass (2 new on the achievement catalogue), tsc clean,
expo export succeeds (8.96MB), achievements endpoint live and authenticated.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Xcode is installed, so the app now builds and runs as a real native binary
rather than inside Expo Go. That removes the constraint that blocked two
features: native modules load, so the RevenueCat paywall and eventually real
microphone capture become testable.

Two genuine defects surfaced only once a native build was attempted, neither
visible to tsc or to the JS bundler:

- @expo/ui was a CANARY prerelease left by the SDK 57 scaffold, still present
  after the downgrade to 54. Its Swift referenced ExpoModulesCore symbols that
  do not exist in 54 ('SafeAreaControllable', 'RNHostViewProtocol'), which failed
  the native compile. Nothing in src/ imported it, so it is removed. No canary or
  prerelease dependency remains.

- expo-av is deprecated and slated for removal, and it was carrying the live
  tutor's audio playback. Migrated to expo-audio: createAudioPlayer for the
  imperative path (playback happens in a socket callback, not a render), and
  setAudioModeAsync({ playsInSilentMode: true }) so the tutor is still audible
  with the ringer switch off — a talking tutor silenced by a hardware switch
  would look like a broken feature.

Kept on SDK 54 deliberately. The dev build no longer needs Expo Go, but Faith
does until a signed build can reach her phone, which needs the Apple Developer
account. Upgrading to 57 is the right move once that exists, not before.

ios/ stays gitignored: it is generated by prebuild from app.json, so there is no
custom native code to preserve and no Xcode merge conflicts to invite.

Verified: BUILD SUCCEEDED, installed and launched on the iPhone 17 Pro Max
simulator, welcome screen renders, tsc clean.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
faith-ogun and others added 27 commits August 28, 2026 18:21
"Did the new lessons reach production?" had no cheap answer. The manifest that
carries the content stamp needs a Firebase bearer token, so verifying today's
curriculum deploy came down to comparing a Cloud Run revision timestamp against a
git commit timestamp: revision 00035 was created three minutes after the commit,
therefore it probably contains it. That proves when a container was built, not
what is inside it, and it is the kind of reasoning that is right until the once
it is not.

`/health` is already unauthenticated and already uptime-checked every 60s, so the
stamp goes there. One curl now answers the question, and comparing it against
curriculum.json is the whole check.

The stamp is a hash of published lesson content, so exposing it discloses nothing
the app does not already hand to every signed-in learner.

Two tests, and the second is the one that matters: an empty stamp means the
corpus failed to load, and a service answering "status":"ok" while serving no
lessons is worse than one that fails outright.

358 backend tests.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…b too

The pricing page sells three things to Max on the web: a verified build record,
career coaching built on that record, and early access to Ohmlet Labs. The web
had none of them. `/v1/me/career`, `/v1/me/labs` and `/v1/curriculum/films` had
exactly one caller each and it was the phone, so the web has been charging
$34.99 for a build record it never showed.

That is the mirror of this morning's mobile defect, and finding the first is what
made me go and count the second. Both were the same shape: a feature complete on
the server, complete on one client, and sold on both.

WHAT THE WEB GAINS. The build record, with the figure nobody else can produce
sitting at the top: minutes with the camera genuinely open on a real bench. Then
Labs, with the "still rough" note kept prominent on every entry, because early
access to an unfinished feature is only a privilege if you are told which part is
unfinished. Then the 43 lesson films, which were mobile-only, with signed URLs
fetched at press-play and never cached: a cached signed URL is one that outlives
the reason it was short-lived.

Coach mode now works on the web live spine. Same camera, microphone and socket,
different persona, primed by the SERVER from its own records rather than from
anything the client sends. That is the whole point of the feature: every hardware
CV claims bench experience, and this is the one that was watched.

The web live hook also stops interpolating the mode into the URL template, so
`/ws/{}/{}` stays greppable for check-api-coverage.mjs. The mobile hook was fixed
the same way in August for the same reason.

Two things this screen refuses to do, on both surfaces now. It never flatters:
ten minutes reads as ten minutes, and there is no adjective anywhere on it,
because "solid hands-on experience" is a claim the learner would have to defend
in an interview and could not. And the caveat that every figure is a FLOOR is
shown rather than buried, because someone about to put it in front of an employer
needs that as prominently as they need the number.

Measured after, not assumed: every Max endpoint now has a caller on both
surfaces, and both personas connect from both.

All frontend guards, typecheck, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… on two surfaces

check-api-coverage.mjs asks whether every backend route has a client calling it.
That question was answered yes for `/v1/me/career`, `/v1/me/labs` and
`/v1/curriculum/films` for weeks, and the caller was the phone in all three
cases, while the web pricing page sold all three. The same day, in the other
direction, Interview Mode existed only on the web while `mode: 'interview'` sat
unused in the mobile hook. A caller anywhere is not a caller everywhere the
feature is sold.

BOTH_SURFACES names the five routes a paying learner is promised regardless of
which surface they open, each with the reason it belongs there. The check fails
unless the callers include at least one file under frontend/ AND one under
mobile/. A route that stops existing fails it too, so the list decays with the
code rather than outliving it.

The list is deliberately not "every route". A feature that is genuinely
one-surface stays out of it, with the reason written down rather than left as an
absence somebody has to infer.

Verified by hiding the web callers and confirming it reports exactly the state
the repo was in this morning, naming both routes and the file the single caller
lives in.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
AGENTS.md told future work to run `npm run build` and never mentioned
`npm run check`. The build was green through every defect found today: a report
view rendering an object as a JSX child, three Max features sold on the web and
callable only from the phone, and a curriculum step that shipped stale because a
fix landed after the export. The guards are the half that catches those.

Also states the expectation that a new guard proves it has teeth by failing on
the defect before it is fixed, which is what made today's two new ones worth
adding rather than merely present.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…speak

Each stat sat in a bordered white pill with its name captioned under the number.
Between the border, the padding and the word XP there was no room for the artwork
to be anything but small, so the painted icons that were commissioned to carry
the brand were drawn at 22pt and read as decoration beside the text.

The boxes and captions are gone. The icons go to 32pt on the phone and 36px on
the web, which is what removing the containers bought, and the number sits beside
each one at 20pt. The icon already says which stat it is: a flame is a streak, a
heart is a heart, and "XP" under a coin with resistor bands across it was telling
nobody anything they had not already worked out.

TWO THINGS THE CONTAINER WAS CARRYING, MOVED RATHER THAN DROPPED. The goal met
its target by turning the whole pill green; now the number turns green and its
icon stops being dimmed. Hearts ran empty by turning the pill grey; now the icon
dims and the count becomes the countdown. Both were always duplicated in the
content, which is why the box could go.

THE CAPTION WAS CARRYING ONE REAL THING, AND IT WAS NOT VISUAL. A screen reader
that reads "2070" and moves on has not read a stat. Every stat now carries an
explicit accessibilityLabel, which only hearts had before.

And the labels had to be made to WORK, which is the part that would have been
easy to get wrong and call done. A View is not an accessibility element in React
Native: without `accessible`, VoiceOver walks straight past the label and reads
the child Text instead. The two hearts Views already had that bug and it did not
matter while the caption was on screen. From today it would have. All four are
now marked `accessible`, so the labels are read rather than merely present.

Done on both surfaces on purpose. The web strip had the same boxes and the same
captions, and letting the phone and the web diverge on the first thing a learner
sees every morning is how a product stops looking like one product.

The countdown stays small: formatWait can return "14m 30s", which will not sit
beside a 32pt icon in a quarter of a phone bar at 20pt.

Mobile typecheck and all 34 guards, all frontend guards, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
Making the number 20pt instead of 14 quietly changed what fits. The arithmetic:
on a 430pt phone the strip has 398pt inside its padding, four stats share it
evenly at about 99pt each, and the icon and its gap take 37 of those. That leaves
~62pt, and a tabular digit of Nunito Black at 20pt is about 12pt wide. Five
digits fit. Six do not, and 100,000 XP is a number a committed learner reaches
rather than a hypothetical.

Inside the old box at 14pt this was not a problem, which is the point worth
recording: a purely cosmetic change stopped being purely cosmetic the moment the
type got bigger, and nothing would have told us until a real learner's XP crossed
five digits and their strip started overlapping.

10.0K through 99.9K, then 100K, then 1.0M. Checked across the range rather than
assumed: every output is five characters or fewer, from 0 to 150M.

The first version got 999,999 wrong. It is under a million, so it took the K
branch, and rounding 999.999 gave "1000K", which is not a thing anyone writes. It
promotes on the ROUNDED value now, so 999,499 is 999K and 999,500 is 1.0M.

The accessibility label keeps the full number. "12.4K XP" read aloud is a
rounding, and a screen reader should not be told a different number from the one
the learner actually has.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…s lying about which exist

FAITH ASKED WHERE THE VIDEOS PLAY. Answering it turned up a defect first.

Three places decided independently which skills have a film, and all three
decided it the same way: not a review and not a gateway means it has one.
films.py, mobile's Labs screen and the web's LabsView each carried their own copy
of that guess. It was true on the day the films were rendered, and false the
moment six skills were authored on 2026-08-28. The index claimed 49 films. The
bucket holds 43. Labs drew a play button on all six of the new skills, and
pressing it signed a V4 URL for an object that is not there.

An inference about the contents of a bucket is a guess. Now it asks:
sync-films.mjs lists the bucket and writes content/films.json plus its generated
TS twin, curriculum.ts stamps `hasFilm` on every skill from that, the export
carries it to the backend, and everybody reads it. A skill counts as filmed only
when both renders, a poster AND captions are present, because a half-uploaded
film is the same defect in a smaller costume.

check-films.mjs enforces all of it and, deliberately, does not touch GCS: CI has
no credentials for the bucket and a check that cannot run is a check that gets
deleted. It fails if any of the three files goes back to deciding for itself, and
it prints the unfilmed skills every run, because a silent gap is how six of them
went unnoticed.

THE PATH NODE, WHICH IS WHAT WAS ACTUALLY ASKED FOR. The film now sits on the
path as its own full-width node, the way the checkpoint does. It goes AFTER the
checkpoint, not before the skill, which is not a layout preference: it is the
decision recorded on 2026-08-28. Before practice, motion is decoration. After it,
motion is what makes a dozen discrete facts cohere into one picture. A learner
who has answered eight questions about complete loops has the parts, and watching
the current stop when the wire is cut is what assembles them. So it unlocks when
the skill is cleared, which the existing sequential rule already gives for free.

Deliberately a different animal from the checkpoint it follows. The checkpoint is
a white card with a chest and an XP figure and it looks like a reward. This is
ink and glass with a scope screen on it, a trace across the face and a play
triangle, drawn in the same SVG vocabulary as the resistors on the wire. Two
nodes that differ only in their words are two nodes a learner stops reading. If
painted artwork arrives to sit beside the chest, it swaps for an Image and
nothing else changes.

The locked card is cream rather than the ink card drained, so an unwatchable film
reads as somewhere you have not got to rather than a broken version of the
watchable one. Its subtitle needed its own colour: white at 55% on cream is an
invisible line of text saying how to unlock the thing, which is the one sentence
that has to be readable when it is locked.

The web path groups by skill and has no checkpoint node to hang it on, so the
film lives in the skill's header there, under the same rule. Same behaviour, each
layout's own shape, rather than inventing a checkpoint the web path does not
have. FilmModal moved out of LabsView so both surfaces open the same player.

43 skills have a film. Six do not, and no surface offers one for them.

358 backend tests including two new ones, all frontend guards, web and backend
still agree on every lesson, mobile typecheck and guards, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ing that is actually Faith's

The 3D twin was listed as blocked on two things: a Stability AI key and a storage
bucket. Only one of those needed Faith. gs://ohmlet-app-twins now exists in
europe-west1 with uniform access and public access prevention enforced, and the
reporter's service account has object access on it.

Public access prevention is the point rather than a default: a twin is streamed
through the authenticated /v1/twins/{id}/model endpoint after an ownership check,
so it is as private as the rest of a learner's data.

ops/enable-twins.sh does everything else in one step once the key exists: stores
it, grants access scoped to that ONE secret rather than the project, deploys the
reporter and curls /health to prove it. The key never appears in an argument,
because $1 lands in shell history and in ps output for every user on the box; it
is read from a hidden prompt or a file you then delete.

The bucket is recorded in ops/observability.md. A bucket nobody remembers
creating is a bucket nobody remembers to bill for.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…duction for weeks

Faith said go, so the curriculum is now evenly filmed: 43 skills to 49, every
non-review skill in all twelve units.

rtos-basics, volatile-and-isrs, clock-domains, can-bus, sensor-fusion and
brushless-and-foc. 202 narrated segments, 3m 11s to 3m 41s each, all inside the
five minute rule. Each was written around the thing a quiz genuinely cannot do:
a task stopping mid-instruction because something more urgent became Ready; four
bytes of a counter going across one at a time while an interrupt lands in the
middle; an output sitting at an undefined level between the rails; two nodes
transmitting at the same instant and one of them silently discovering it lost.

TWO FILMS HAD BEEN DEAD IN PRODUCTION AND NOTHING SAID SO. Found only because
fixing the index made me compare the bucket against what the server actually
signs rather than against what folders exist.

films.py signs v1/<skill>/ohmlet-lesson-<skill>-<shape>.mp4. Two folders had been
renamed by hand from the film id to the skill id and the FILES inside them had
not, so circuits-current held ohmlet-lesson-closed-loop-*.mp4 and rc-charging
held ohmlet-lesson-time-constant-*.mp4. Both 404ed while all 41 others worked.
circuits-current is the first film in Foundations, which made it the one a new
learner was most likely to press.

Repaired in the bucket, both scripts renamed so id, skillId and file name are one
string, upload.sh now publishes by skill id read from the script itself, and
check-films.mjs refuses the mismatch. sync-films.mjs stopped counting extensions
and now requires the five exact object names the server signs, which is the check
that would have caught this in the first place.

A CORRECTION TO MY OWN WORK, CAUGHT BEFORE IT RENDERED. I had used the `plot`
scene for MTBF against resolution time and for gyro drift. That scene is
specifically the RC charging curve: its y axis is labelled V and its x axis is in
tau. It would have put a voltage axis on a chart about neither, and gyro drift is
a linear ramp rather than an exponential rise, so the shape would have been wrong
as well as the labels. Both replaced with comparisons that say the same thing
honestly. Reading the scene vocabulary rather than assuming it was generic is
what caught it.

Verified after upload, not before: all 49 films resolve at the exact five object
names the server asks for. 0 would 404.

360 backend tests, all frontend guards, web build, mobile typecheck and guards.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… icons grow

Faith asked for the treatment the top strip got: take the words away and let the
icons be big enough to speak. Six tabs, no captions, icons from 30pt to 34.

34 AND NOT MORE, AND THE LIMIT IS MEASURED RATHER THAN CHOSEN. The painted set is
authored at 30pt, so its @3x files are 90x90, and the ink already fills 90% of
that canvas: there is no padding to reclaim. 34pt asks a 3x screen for 102px and
gets 90, a 13% upscale that soft edges absorb. 40 would ask for 120 and start
blurring the linework, which is the opposite of what making them bigger was for.
Past 34 the art has to be re-exported, not stretched, and that is on the list.

The caption was carrying one thing and it was not visual: the tab's name for
anyone who cannot see the picture. That moved to accessibilityLabel on the
Pressable, which unlike a plain View is already an accessibility element, so the
label is read rather than merely present. The stat strip needed `accessible`
added explicitly for exactly this reason; a touchable does not.

A PLANS TAB, because the one screen that takes money was reachable only from
whatever happened to prompt you. Hidden in child mode for the same reason
community is, and it is the more important of the two: a minor cannot
self-purchase, so a doorway to a paywall should not exist for them. The plans
screen still refuses a minor on arrival, because hiding a tab is a courtesy and
not a control. It is wrapped in AppTabs now so the bar stays under it, and its
back link is gone: a tab has no back, it has five siblings one tap away.

The web nav gained the same destination. It could only reach pricing from inside
an upgrade prompt, so the page that takes money was the page with no way in.

THE ICON IS DRAWN, AND ONLY UNTIL FAITH'S ARRIVES. She is making the real one and
asked for a placeholder, so this is the best version of one rather than a grey
box: a battery, built to the painted set's rules, off state ink linework with no
plate and on state carrying its own plate so the two never change scale when you
tap between them. A battery rather than a coin or a crown because every other
icon in this bar carries an electronics idea, and a coin says "payment" in a way
that belongs to any app while a battery says how much you have got, which is what
a plan is here. Three cells for three tiers.

The risk with a temporary thing is that it stops being temporary, so the swap is
enforced: check-stat-icons.mjs fails the moment plans-*.png exist and the SVG is
still being drawn, fails on a half-present density ladder, and fails if the tab
ends up with neither. Verified by dropping stand-in art in and watching it fail
before taking it out again.

360 backend tests, all frontend and mobile guards, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…older is gone

The SVG battery lasted one day, which is the right lifetime for a placeholder.

The source is a 1254px mascot with a tool belt, so it needed preparing rather
than dropping in: trimmed to its alpha bounding box, squared so the aspect
survives a square slot, and rendered at 86% of the tile so it does not touch the
plate's edge, the way the painted set is framed.

THE PLATE WAS MEASURED, NOT GUESSED, AND THE FIRST ATTEMPT WAS WRONG. A mascot
has no plate of its own, so the selected state needs one composited in. I drew a
gold-bordered rounded square from the design tokens, rendered the whole row of
six side by side at true 3x size, and it was obviously wrong: every other icon's
plate is a flat fill with NO outline. Sampling community-on@3x gave the real
numbers, 9px inset and a corner curve running y=9 to 25 on a 90px canvas, so 10%
and 18%, on a flat #fff6d6. Redrawn to those, it sits in the row as a sibling.

Rendering the row was the whole trick. Reading my own code would never have shown
me the border was too heavy; putting the six icons next to each other at the size
a phone actually draws them showed it in a second.

It is the only full-colour icon in the bar in BOTH states, and that is left
deliberately. The other five are ink linework until selected, so the one tab that
asks for money is the one that catches the eye, which is what Duolingo does with
Super and is presumably why a mascot was drawn for it rather than a glyph.

Checked at true size before shipping: at 34pt on a 3x screen the silhouette, the
face, the red crest and the omega on the pouch all read. The plug and the LED in
the belt become specks. That is the cost of a detailed mark at tab size and it is
worth naming rather than discovering later.

`plans` now joins TABS in check-stat-icons.mjs, so it is held to the same density
ladder as the other five, and a separate check refuses the drawn placeholder if
it ever comes back. Verified by removing plans-off@2x.png and watching it fail.

All mobile and frontend guards, typecheck, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…there all along

I said the icons could not go past 34 without redrawing them. That was wrong, and
it took one find to disprove: frontend/assets-source/nav holds all ten at 512x512.
The shipped set was 30/60/90, so 34pt was already asking a 3x screen for 102
pixels and getting 90. Nothing had to be redrawn. It had to be cut again.

40pt now, authored for it: 40/80/120. The bar grows by 2pt rather than 6, because
the padding came down as the icons went up.

WHAT THE SCRIPT PRESERVES, AND WHY IT IS NOT THE OBVIOUS THING. Scaling the 512
canvas straight down would have been the one-liner, and it would have made every
selected icon about 6% smaller than it is today. Measured first: the ten shipped
icons sit between 58% and 100% of their canvas, and those numbers are not a
formula. They are somebody normalising the set on its LINEWORK so that an `off`
state and its `on` state carry the same visual weight, which is the thing that
stops the bar twitching when you tap between them.

So each icon is trimmed to its ink, rescaled to the ink fraction it already had,
and re-centred where it already sat. Verified after: learn 96%, practice 100%,
live 90%, community 88%, the same as before. The set looks identical. It is
simply no longer the limit, and the script takes a size argument, so the next
time this comes up it is one command rather than an investigation.

Faith's mascot source is saved beside the others as plans-source.png rather than
left in a temp directory, so the next export has all six.

Rendered the whole row at true 3x before and after rather than trusting the
arithmetic. The linework is visibly cleaner: the resistor bands on the practice
icon separate, and the mascot's tool belt holds together where it was starting to
smear.

All mobile and frontend guards, typecheck, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…e artwork

The previous icon had one drawing doing both jobs: full colour whether selected
or not, on a plate I composited myself. It worked, but it made the plans tab the
only one that never went quiet, and the plate was mine rather than the artist's.

Faith sent both states in one sheet, ink linework on the left and full colour on
its own plate on the right, which is exactly the shape the painted set uses. So
this splits rather than composites, and the plate-drawing code is gone.

MY FIRST CUT MADE IT THE SMALL ONE, AND ONLY THE RENDERED ROW SHOWED IT. I set
the plate at 80% of the tile from a 10% inset I had measured off the OLD 90px
files. Putting the six side by side at true 3x, the plans plate was visibly
smaller than its neighbours. Measuring the set again at 120px gave the real
numbers: off states run 62% to 75% of the tile, on states 92% to 100%. Recut at
69% and 95%, it sits in the row as a sibling.

That is twice now that rendering the row has caught something reading the code
never would: first a plate border that should not have existed, now a plate a
sixth too small. It is three lines of PIL and it should happen every time this
artwork changes.

The off state is the actual win. The other five are ink until you select them, so
the bar had one tab permanently shouting. Now it is quiet like the rest and
lights up when you are on it, which is what a selected state is for.

Source saved as plans-states.png beside the other ten, and export-nav-icons.py
splits it, so re-exporting at another size stays one command.

All mobile guards, typecheck.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
mobile/.gitignore already ignored *.p8 and *.pem. The root did not, and the root
is where a file downloaded from App Store Connect actually lands.

Prompted by today's in-app purchase key. Nothing was committed, and nothing
key-shaped is tracked, but the gap was real and one careless `git add` wide.

*.p8, *.pem, *.p12 and *.mobileprovision now refused at the root as well.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The endpoint has existed since Interview Mode shipped and the secret was never
deployed, so OHMLET_REVENUECAT_WEBHOOK_SECRET was empty in production.

Checked what that actually meant rather than assuming the worst: the handler
refuses with 503 when the secret is absent, exactly as the Stripe one does, so an
unset secret is a dead endpoint and never an open one. hmac.compare_digest on two
empty strings returns True, which is why that guard has to exist and why it was
worth reading before raising an alarm.

The consequence was still real, just quieter: a purchase would have charged the
card and granted nothing, because the only writer of a paid plan on iOS would
have been returning 503 to every retry.

Generated with secrets.token_urlsafe and piped straight into Secret Manager, so
it never entered a shell history or a chat.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
RevenueCat stamps every webhook event with an environment, SANDBOX or
PRODUCTION. The handler ignored the field completely.

Sandbox purchases cost nothing: Apple does not charge a sandbox tester. So
anybody who could make one had a route to a genuine Max plan, written into
production Firestore, permanently, alongside plans people had actually paid for
and indistinguishable from them.

Found while answering a question about RevenueCat's "secure your sandbox
access" card. That list is the other half of this and neither is sufficient
alone: theirs controls who can make a sandbox purchase, this controls what one
does to our data.

REFUSING ALWAYS WOULD HAVE BEEN THE EASY FIX AND THE WRONG ONE. The end-to-end
test would then be unverifiable: a purchase on the phone would reach RevenueCat
and stop, and "the webhook fired" is not the same evidence as "the learner's
plan changed". So it is an explicit switch, on now in deploy.sh with the
launch-gate warning sitting directly above it, and off by default in code so a
service deployed without that line is safe rather than open.

An event with NO environment field is treated as production. The unknown case
must not be the free one, or a missing field becomes a way in.

Plans now carry an `environment` field on the document, so a sandbox grant can be found
and swept instead of living on looking like revenue.

363 backend tests, three of them new: refused by default, accepted when
deliberately switched on, and production when the field is absent.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…hat it charges

Apple sells subscriptions only at fixed price points, so an annual price is never
just the advertised monthly times twelve.

Pro: 11.99 x 12 is 143.88, which Apple does not offer, and 143.99 which it does.
Eleven cents a year, invisible on a page that says $11.99.

Max: 26.99 x 12 is 323.88, and 323.99 is not offered either. Faith scrolled the
whole list rather than assuming a .99 exists at every dollar the way it does
lower down. The nearest is 324.99, which works out at $27.08 a month, ABOVE what
the page advertised.

SO THE PAGE CHANGED, NOT THE STORE. A page quoting $26.99 while Apple charges
$27.08 is a page that is wrong, and the fix is not to pick a cheaper price point
so the marketing number survives. Max annual now reads $27.08.

The page also said "billed annually" and never once said HOW MUCH. It gave the
cadence and withheld the amount, which is the half a buyer actually needs. It now
reads "billed annually at $324.99", and carrying that figure explicitly is what
made the Apple mismatch visible in the first place.

check-prices.mjs only ever checked monthly. It now checks that each tier's annual
total agrees with its advertised monthly rate, and fails in the direction that
matters: quoting less than you charge. Verified by setting Pro's total to 159.99
and watching it refuse.

Both annuals remain a real discount on paying monthly: 25% on Pro, 23% on Max.

All frontend guards, typecheck, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
… hiding

Faith went to screenshot the plans tab for Apple's review requirement and found
it only shows monthly. Four products exist in App Store Connect and half of them
had nowhere to be bought.

THE TOGGLE WAS THE SMALLER HALF. packageFor matched a package to a tier by
looking for the tier name and nothing else:

    packages.find((pkg) => `${pkg.id} ${pkg.title}`.includes(plan))

`pro` matches BOTH pro_monthly and pro_annual. With one product per tier that
worked. With four it returns whichever Apple happens to list first, so the Pro
card could show $15.99 above a button that charges $143.99. Harmless yesterday, a
live mischarge the moment the offering imports, and invisible until then because
there was nothing to be ambiguous about.

It now matches tier AND interval.

The toggle defaults to Annual: it is the better value and the one a learner is
least likely to go looking for, which is how the web page already behaves.

An annual package's priceString is the YEARLY total, so it is divided back to sit
under the "/month" label, and the real total is printed underneath. The web
gained the same line this morning for the same reason: "billed annually" alone
gives the cadence and withholds the amount, which is the half somebody needs.

check-prices.mjs compared MONTHLY across three sources. The phone now publishes
annual prices too, so two files can disagree about them, which is precisely what
this script exists to stop. It now compares both annual figures between the page
and the phone. Verified by setting the phone's Max total to 319.99 and watching
it name both sides of the disagreement.

All frontend and mobile guards, typecheck, lint, web build.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
The webhook mapped `pro` and `max`. RevenueCat's onboarding wizard names
entitlements after the app, so Ohmlet's are `ohmlet_pro` and `ohmlet_max`, and
RevenueCat does not allow the ones it creates to be deleted.

A purchase would have charged the card, RevenueCat would have recorded the
subscription, and _plan_from_entitlements would have returned free because it
recognised neither name. That is the worst shape a billing bug can take:
everything downstream looks healthy and the learner sits on 60 minutes having
paid for 240.

I had written the opposite instruction into the runbook, that the identifier has
to be `pro`. Fixing the map is one line. Insisting on the names I would have
preferred, over the names that exist and cannot be deleted, would have been a
purist position paid for by a customer.

Highest tier still wins when both are held, so this cannot silently downgrade.

365 backend tests, two new: the wizard's names grant the right plan, and holding
both keeps the higher.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…ested

It shipped with a bug that nothing in the repo could have caught, because pure
logic that decides which product to charge for was living inside a React screen.

    packages.find((pkg) => `${pkg.id} ${pkg.title}`.includes(plan))

`pro` matches BOTH pro_monthly and pro_annual. Correct while there was one
product per tier; a mischarge the moment four existed this morning. The Pro card
would take whichever package Apple listed first and could show $15.99 above a
button that takes $143.99. Nothing would fail, nothing would log, and the only
symptom would have been somebody's bank statement.

Now in services/packageMatch.ts, with scripts/check-package-match.mjs running it
against the four identifiers actually configured in RevenueCat. The guard reads
the matcher out of the module rather than restating it: a check that reimplements
the logic it guards proves only that it can copy.

Three things it holds:

  every tier finds its own package at both intervals, and not its sibling
  the wizard's undeletable Test Store leftovers, `monthly` and `yearly`, name no
    tier and must never be sold as one
  an empty or absent offering returns null so the screen can say why, rather
    than guessing

Verified by reverting the matcher to the shipped version and watching it name
both mismatches and the consequence.

The package identifiers are a contract between a dashboard nobody diffs and a
screen that takes money, so if the offering is ever rebuilt, EXPECTED in the
guard changes with it.

Mobile guards and typecheck, frontend guards, 365 backend tests.

Claude-Session: https://claude.ai/code/session_01VktW16TST1KkgfptudESST
…t lied about who granted it

Two defects the first real sandbox purchase exposed, neither of which was the
thing being tested.

RevenueCat's `configure()` is one-shot and `initBilling` memoised its state, so
whichever of four call sites landed first decided the identity permanently.
Three pass no uid; the plans screen passes `user?.uid`, which is undefined on the
first render before auth resolves. Lose that race and RevenueCat invents a
`$RCAnonymousID:` that nothing can change, the webhook refuses the event as
unattributable, and a learner who has paid sits on the free tier's 60 minutes
with no error anywhere they can see.

Fixed twice over, deliberately. A uid arriving after configure now attaches via
`logIn()`, which aliases any purchase made in that window; and `purchasePackage`
refuses outright while the id is anonymous. Blocking a purchase is recoverable,
swallowing one is not.

`setBy` was separately wrong. `set_plan` merges, so a field nobody rewrites
outlives the truth: a document read `setBy: admin-console` from a manual edit
five days earlier while RevenueCat was granting Max. Every write now names its
source, and the tests record the provenance rather than tolerating the kwarg.

The sandbox-billing switch is off by default. It was a hardcoded `=true` above a
comment saying REMOVE BEFORE LAUNCH, which is a note asking a human to remember
on the day they are busiest, not a gate. It is opt-in for one invocation now and
prints in red when thrown.

Guards: check-billing-identity.mjs and test_deploy_posture.py, each proven
against the defect it exists for.
…ame back

Reported: "I had a 2-day streak, then I went a full day without doing a lesson,
but the 2-day streak still persists," and later the same of the daily goal.

The write rule was already right. `applyCompletion` resets when the last active
day is neither today nor yesterday. But it can only run ON a completion, and on
the day a streak dies there is by definition no completion to run it, so nothing
ever wrote the break.

Every surface therefore showed the number last stored, and it self-corrected the
next time the learner finished something: far too late, and DOWNWARD at the exact
moment they had come back and done the work. The app rewarded a return by taking
the streak away in front of them.

The daily goal is worse in one respect. A stale streak flatters the learner; a
stale goal DELETES the thing they were meant to do today, because the ring is
already full and there is nothing left to close.

So there is a read rule beside the write rule now: `currentStreak`,
`streakAtRisk`, `completedTodayNow`, `todayLessonIdsNow`. Note the asymmetry they
encode. Yesterday keeps a STREAK alive, because the learner has until midnight;
yesterday does nothing for a GOAL, which is the work of one calendar day.

achievements.py gained the same rule in Python. It stamps medals permanently, so
reading a stale streak there could mint a seven-day medal for someone the app was
already showing as broken, and never take it back.

The web guarded the goal inline and the phone did not, which is exactly the split
the shared rule exists to prevent, and exactly how the streak bug survived. The
guard now runs both TypeScript copies and the Python one over a single table.
… user who does not exist

Pro $15.99/$143.99 -> $12.99/$77.99, 240 -> 150 min. Max $34.99/$324.99 ->
$24.99/$149.99, 540 -> 300 min. Both annuals are 50% off monthly rather than 25%.
Ireland now sees EUR 184.99 for Max annual instead of EUR 399.99.

Faith's instinct was that $324.99 was too high. It was, but not for the reason
she gave and not to the number she proposed.

It was too high because it had DRIFTED. metadata/strategy/2026-07-02 set Max at
"about 29.99/mo or 239.88/yr"; live was $34.99/$324.99, 35% above a target that
had analysis behind it, and nobody decided that gap.

Duolingo was the wrong yardstick and was anchoring us low: their marginal cost is
fractions of a cent, ours is $0.053 every minute. And $210/yr does not work at
all, because after Apple's cut it affords 231 minutes at the 30% rate, which is
fewer than Pro's 240. The tier collapses.

Two findings decided the shape:

Annual was ALREADY underwater at full cap and had been since it existed, because
the break-even arithmetic only ever tested the MONTHLY price. The plan we
discount people onto was the loss-making one.

And pricing so a 100%-of-cap user is profitable was double-insurance, because the
hard minute cap already bounds the worst case. We now price for expected
utilisation and let the cap absorb the tail.

What makes the deep discount safe: MONTHLY clears full cap on both tiers, +$3.09
and +$5.34, breaking even at 134-139% of a cap the server enforces. Most early
subscribers take monthly, so the monthly cohort funds the learning while the
$0.053/min figure is still unmeasured.

check-prices.mjs gained two sections: the discount badge is checked against the
actual arithmetic, overstating failing harder than understating because one is a
false claim on a paywall; and every "N hours" claim on every surface must equal
the real minute cap. That second one caught UpgradeSuccess still selling 9 hours
on the post-checkout receipt.
…t leave

Reported: "if you press Live Tutor and then you press back, it takes you back to
the login screen... if you slide to the left, it takes you straight back to the
login page."

The tab bar navigates with `router.replace`, so a tab root is the only entry in
the stack with nothing behind it. But iOS enables the interactive edge-swipe on
every stack screen by default, and the gesture still fired: it unwound PAST the
tab root to the entry gate in index.tsx, which re-runs auth, re-reads the age gate
and shows the brand splash for 1.4 seconds. To a learner that is the app throwing
them out mid-session.

`plans` was also missing from the layout list entirely, which is how it acquired
both the wrong animation and the gesture the moment it was added.

Then the fix created a worse trap. `live.tsx` was the only one of six tab roots
that never rendered AppTabs; its single exit was a "‹ Back" link that always
returns to Learn. The broken swipe had been quietly serving as the second way
out, so disabling it closed the trapdoor and left the room with one door.

And then the segmented control added two MORE paths with no bar, hours later, in
the same file. The guard stayed green because it only asked whether the file
MENTIONED AppTabs. It now walks each tab root and checks what every `return (`
actually opens with; a screen root that is not AppTabs must be declared
deliberate, and only the live session is, because there the camera owns the
screen and End is a real exit.

That stricter rule immediately found four more: the bar vanished while Community
loaded, on its offline state, and on both child-mode refusal screens. Navigation
was being taken away at the moment the learner most wants it. The rule is simply
that the bar never disappears.

Separately, a full-screen Modal is a SEPARATE native view hierarchy presented
above the root shell, so it inherits none of that shell's paddingTop: insets.top.
FilmPlayer stood in with a fixed 32 against the 59 a Dynamic Island reserves, and
the close button sat under the island. Same defect on the twins camera bar
against the home indicator. check-modal-insets.mjs fails any full-screen modal
whose content does not read insets.

Also here: Bench, Interview and Coaching as a segmented control on the Live tab,
so Interview Mode and Career Coaching finally have a home in the navigation
rather than living on a settings page.
Light, Dark and System, where System follows the device live rather than on next
launch. Scoped to the WORKSPACE on the web: the landing pages are the marketing
face and a dark landing page is a different product, not a preference.

The two surfaces were very different problems.

The web palette became RGB CHANNEL variables bound into Tailwind as
`rgb(var(--x-rgb) / <alpha-value>)`, which themed ~1,700 class references with no
component edits. The channel form is not stylistic: 83 places write
`text-ohmlet-ink/70`, and Tailwind cannot compose an opacity modifier over an
opaque colour, so a plain var() would have silently rendered every one at full
opacity.

That was the cheap half. 439 raw Tailwind colours across 46 files bypassed the
palette entirely, because a raw colour is immune to any amount of token
architecture. 234 bg-white, 113 text-white, 55 slate/gray, and 42 pale tint
washes written as arbitrary values like bg-[#fdece8], which is why the Live Tutor
card kept a pale pink ground and swallowed the text on it.

text-white could not be swept. On gold or blue it is right in both themes; on
bg-ohmlet-ink it is a bug, because ink inverts to near-white and 23 places would
have rendered WHITE ON WHITE.

Mobile could not be done with tokens at all. 69 StyleSheet.create calls sit at
module scope, evaluated once at import, capturing whatever colour was current.
They can never react to a theme however correct the palette is. A makeStyles
factory replaced them across 76 files.

Three defects that predate dark mode and were only visible once it existed:

  bg-ohmlet-slate-900 is not a token. TwinStudio and InterviewView have been
  shipping with NO background. A class that looks like a token survives review
  precisely because it looks right.

  UIUserInterfaceStyle was Light in ios/Ohmlet/Info.plist while app.json said
  automatic. iOS obeys the plist, so it forced the app to light and
  Appearance.getColorScheme() returned light for ever: System reported "following
  your phone, which is set to light" on a phone set to dark. ios/ is a committed
  native project, so app.json never reaches the plist without a prebuild. Two
  sources of truth, and the one that ships is the one nobody edits.

  The Built tile counted SESSIONS while the shelf beside it counted authored
  lessons. 142 lessons ship as 284 sessions, so one screen carried two
  denominators for the same thing.

Vocabulary the work forced into existence, because one word was doing two jobs:
`surface` (a raised card), `slab` (dark in BOTH themes), `onInk` (inverts, for
the ink fill), `onSlab` (does NOT invert, for a permanently dark chip), `onGold`,
`plate` (the press shadow, invisible on dark as ink). The sandbox ruler labels
found onSlab: their chip is a fixed dark wash and their text was `cream`, so the
F1 / A2 / GND names went dark on dark off a breadboard that is white in both
themes.

The logo split into mascot art plus a text wordmark. The combined PNG bakes the
lettering in as dark ink, so the name vanished and the sidebar showed a floating
mascot. A second PNG would be two assets to keep in sync for ever.

Nav icons got dark variants generated by flipping HSV value and leaving hue and
alpha alone: ink becomes a light stroke, fills recede, silhouette identical. A
tintColor was tried and rejected, because it recolours every opaque pixel and the
icons that survive do so precisely because they are filled.

Profile was rebuilt: real achievement CARD art rather than generated glyphs, the
existing painted XP and streak icons, two new painted glyphs for Built and Live
minutes, three structurally different card types, and the module-scope snapshot
that Learn and Community use, because router.replace unmounts a tab and Profile
was repainting "0 XP, 0 streak" at a learner with a month of history.

Two check-theme guards, one per surface, each proven against its defect. The web
one caught a hardcoded colour in my own logo change mid-flight. It also records
its own limit rather than hiding it: it reads one line at a time and cannot see
white text whose dark ground is set on a parent, so three categories are listed
deliberately, each with the judgement it encodes written down.
Bumps [uvicorn](https://github.com/Kludex/uvicorn) from 0.35.0 to 0.52.4.
- [Release notes](https://github.com/Kludex/uvicorn/releases)
- [Changelog](https://github.com/Kludex/uvicorn/blob/main/docs/release-notes.md)
- [Commits](Kludex/uvicorn@0.35.0...0.52.4)

---
updated-dependencies:
- dependency-name: uvicorn
  dependency-version: 0.52.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
@dependabot dependabot Bot changed the title chore(deps): update uvicorn requirement from >=0.32.0 to >=0.52.4 in /backend/quiz-engine chore(deps): bump uvicorn from 0.35.0 to 0.52.4 in /backend/quiz-engine Sep 1, 2026
@dependabot
dependabot Bot force-pushed the dependabot/pip/backend/quiz-engine/uvicorn-gte-0.52.4 branch from 1ca1eb8 to 867d396 Compare September 1, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant