Skip to content

feat: generative detail screen, on-device NL engines, encryption at rest - #11

Merged
itstanany merged 24 commits into
mainfrom
feat/v0.11.0-generative-detail
Jul 31, 2026
Merged

itstanany merged 24 commits into
mainfrom
feat/v0.11.0-generative-detail

Conversation

@itstanany

Copy link
Copy Markdown
Owner

Completes the plan's Phase 1. 18 commits, 164 files.

What lands

The contract. A generative detail screen driven by the same UiSpec as home, sharing one decaying PreferenceOverlay. Decay is emergent — an override carries appliedAtMs + ttlMs and merge(baseline, overlay, nowMs) asks whether it is still live. No timer, no sweep, no expiry job, and the whole pipeline stays a pure function.

Natural language, on-device, three tiers. A parser-first resolver escalating to Gemini Nano / Apple Foundation Models (Tier A) and a downloaded Qwen3-0.6B via llama.cpp (Tier B). Parser first is deliberate: AICore's per-app inference quota and daily battery quota are both unpublished, and you cannot design against a number that does not exist. Vendor errors collapse into a five-category ModelFailure taxonomy where only retryable differs by platform.

Four input channels, one typed surface. Implicit signals, the in-app chatbox, Android AppFunctions and iOS App Intents all reach the same contract. The OS channels deliberately use typed setters and no model, because Android hard-blocks background inference.

Encryption at rest (ADR-0007). SQLCipher with a Keystore-wrapped passphrase on Android; Data Protection on iOS, since SQLDelight's native driver has no SQLCipher build and the OS gives the same guarantee against the same threat. The threat model states what is out of scope rather than implying total coverage.

Ranking widened. Opens alone became opens + dwell + time-of-day. Dwell is a multiplier rather than an addend — every dwell follows an open, so summing counts one visit twice.

Plus: engine picker across three surfaces, accessibility (focus preservation + stable identity across regeneration), a flags module with a CI gate, a baseline profile, and the study docs / ADRs 0006–0007 / README rewrite.

Findings that changed the code

Documentation alone would have shipped these broken:

  • BUSY = 9 is documented as either "out of quota" or "background use not allowed" — two states, opposite responses, one code. Resolved using the app's own foreground state.
  • Inference is foreground-only on Android (BACKGROUND_USE_BLOCKED = 30). This killed a planned Nano-backed assistant channel, since an assistant invokes you with no screen alive.
  • The 3-second timeout was wrong by 6×. Warm, prewarmed, session-reused inference measured 4.2–19.6 s.
  • A non-optional "unchanged" schema sentinel made Apple's model restate current state in every field, so "play some jazz" returned a full layout patch. Qwen3-0.6B echoed identically — not a vendor quirk.
  • Grammar-constrained decoding segfaults inside llama_grammar_accept_token on three devices. A SIGSEGV cannot be caught from Kotlin, so the taxonomy is powerless and the mitigation is avoidance.

Verification

:shared runs 303 tests on the JVM target and 209 on Kotlin/Native (commonTest overlaps both), plus 20 Compose interaction tests, 6 in :core:flags, 10 Roborazzi snapshots and 7 architecture rules that read the source tree — including the privacy thesis as an executable test.

Encryption verified end to end rather than asserted: random DB header, sqlite3 reports "file is not a database", zero plaintext markers under strings, and a product opened in one process reappeared in Recommended after a full restart.

Known gaps

  • Gemini Nano's happy path is unverified — no emulator ships AICore. Error mapping is exhaustively unit-tested with ML Kit off the classpath; inference needs a Pixel 8+/S24-class device.
  • The OS channels are registered but never invoked. Confirmed via dumpsys; execution needs a real assistant.
  • Grammar-constrained Tier B is disabled, pending the upstream crash fix.
  • Phase-2 commerce is untouched — several items need a real payment-provider account.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq

itstanany and others added 21 commits July 27, 2026 02:03
…decaying overlay

The detail screen is now render(spec) too (ADR-0005 P2): a DetailSpec of typed
DetailBlocks (Hero/Title/Price/Description) produced by a DetailInferenceEngine and
rendered through a DetailRenderer registry walk — no hand-written screen. The SAME
decaying PreferenceOverlay that drives the list drives detail, so density and
price-visibility stay consistent across screens ("hide prices" on the list is honored
on detail too). List-only dims (layout, section order) don't apply.

- DetailSpec + DetailBlock (spec pkg) + merge(DetailSpec, overlay, now).
- DetailInferenceEngine seam + deterministic impl (canonical block order).
- ProductDetailViewModel: combine(baseline, overlay) -> merged spec; no state machine
  (detail has no concurrent-append race). ProductOpened + dwell signals unchanged.
- Extract testable ProductDetailContent; 3 detail Roborazzi snapshots.

Verified: Android host tests + snapshots green; iOS compiles. Detail nav isn't
simctl-scriptable, so detail correctness is covered by host snapshots + unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…006)

The phase-1 NL seam was designed against an assumed shape for the on-device
model APIs. Reading the real ones showed it cannot carry a real engine.

Android (ML Kit GenAI Prompt API): AICore's codes surface verbatim on
GenAiException.ErrorCode, but BUSY (9) is documented as *either* transient
quota *or* disallowed background use — opposite correct responses. Structured
output has its own failure family (-104/-105/-106, where -106 is a truncated
object) and policy refusals are a third family (4/15/11), neither of which was
modelled. Inference is permitted only while the app is top foreground.

Apple (read from the iOS 26 SDK swiftinterface/swiftdoc, Xcode 26.6):
rateLimited is separate from concurrentRequests, and its documented cause is
"your app is running in the background or the system is under load" — so iOS
soft-limits background inference where Android hard-blocks it. Context window
is 4,096 tokens shared with output and queryable at runtime. The framework
carries two @objc annotations in total: it is Swift-only and unreachable from
cinterop, and Swift cannot implement a Kotlin suspend function.

Changes, all pure commonMain and unit-tested:

- Parser-first ordering. The parser runs first; the model is consulted only on
  a miss or ambiguity. AICore's quota has no published numeric value, so the
  only safe design is to not spend it — and most real commands are literal
  vocabulary hits, so this also makes the common case instant.
- Five-category ModelFailure (Unavailable / NotReady / Throttled(retryable) /
  Rejected / Cancelled) that each platform maps its native errors into. The
  model tier still never sees NlResolution, per phase 1.5.
- Rule-table parser over tokens with clause-scoped negation, synonyms, an
  absolute > relative > bare priority order, and an explicit Ambiguous verdict.
  Fixes three real defects: "list" matched *listing* and *stylist*, "don't hide
  prices" hid prices, and no synonym a shopper might type was recognised.
- validate() as the single bounded-vocabulary gate phase 1.5 promised — it
  rejects rather than repairs, so a model defect cannot be laundered into a
  layout the shopper never asked for.
- NlContext, projected from the merged UiSpec rather than ViewPreference:
  relative requests ("switch layout") are unresolvable without current state,
  and ViewPreference only supplies the inference engines' defaults.
- Serialised inference plus a resolution cache; the ViewModel cancels the
  previous command job.
- Tri-state CapabilityProbe (NOT_READY must not cache as unavailable) and a
  ModelFactoryRegistry, which is how the Swift-side iOS engine will register.
- prewarm() wired to ViewModel init, so the first command is not the one that
  pays model load and gets demoted for timing out.

Verified: 118 Android host tests pass; 99/100 Kotlin/Native iOS tests pass. The
one iOS failure (HomeStateMachineRaceTest.naive_canLoseBaseline) is
pre-existing, reproduced on a clean tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-0006)

Closes the two caveats left open by the previous commit: Tier A was unwired,
and the prompt-prefix / context-budget design was deferred for want of a real
engine to measure. Both engines now exist, and running the real model revised
four decisions that reading the docs had settled wrongly.

Shared (commonMain):
- NlPrompt splits the prompt into a cacheable prefix (instruction + few-shot)
  and a per-request suffix (current state + request). Both platforms expose a
  real prefix-caching API, so the split is a cost decision, not a style one.
- NlGoldenSet is the single source of truth for the parser tests, the model's
  few-shot examples and the future accuracy gate. It lives in commonMain rather
  than commonTest precisely so the prompt can read it; a golden set only tests
  could see would drift from what the model is shown.
- NlPatchWire decodes what text-generating models actually emit: code fences,
  surrounding prose, nested objects, invented values. Unknown values are
  dropped rather than guessed, and an unknown section role drops the whole
  order because a partial reorder is a layout nobody asked for.
- PromptBudget.fits declines a prompt that would leave no room for a reply,
  which is cheaper than paying for a truncated one.

Measured on an M1 Pro against Apple's real on-device model: contextSize 4,096;
prefix 476 tokens; schema 205; suffix 33-43. Nothing hardcodes a window size --
both platforms expose a tokenizer.

Three defects only running it could reveal:

- The "unchanged" sentinel was wrong. Optional @generable fields do work on
  iOS 26, and the sentinel made the model echo current state into every field:
  asking for "grid" came back as an instruction to set all four dimensions, and
  "play some jazz" came back with a full patch. Fixed with optional fields, a
  required isDisplayRequest decline flag (a forced schema otherwise gives the
  model no way to say "not my problem"), and withoutNoOps(context) in shared
  code -- which is correct on its own terms, since an override equal to the
  inferred value changes nothing visible while pinning a dimension for a whole
  TTL. It is not applied to parser output, which is exact.
- The timeout was wrong by an order of magnitude. Warm, prewarmed, session-
  reused guided generation takes 4.2-19.6s, so the 3s budget would have demoted
  a working engine on nearly every request. Now 20s.
- ModelFailure.Throttled gained retryAfterMs: GenAiException.getRetryDelay()
  exists, and the platform's own advice beats a fixed constant.

iOS: FoundationModelsBridge (Swift, in iosApp) does guided generation via
@generable and translates GenerationError into the shared taxonomy's codes. It
has to live in Swift -- the framework is Swift-only and Swift cannot implement a
Kotlin suspend function -- so the bridge is callback-shaped and iosMain adapts
it back to suspend. All interpretation stays in Kotlin.

Android: MlKitGenerativeBridge (in androidApp) over genai-prompt 1.0.0-beta4,
sending the prompt via promptPrefix so the invariant half is not re-tokenised.
Structured output is deliberately not used: ML Kit's typed path needs the
genai-schema-compiler KSP processor this project has avoided by design, and it
cannot be verified anywhere because no emulator ships AICore. Text + JSON keeps
the decoding in commonMain where it is fully tested. Both engines live in their
app module; shared keeps only mirrored constants and pure mapping functions,
which is what lets the AICore error table be unit-tested with no ML Kit on the
classpath. API 24-25 stays supported on the parser via tools:overrideLibrary
plus a runtime guard, rather than raising the app minimum for everyone.

Verified: 167 Android host tests; 141/142 Kotlin/Native iOS tests (the one
failure, HomeStateMachineRaceTest.naive_canLoseBaseline, is pre-existing and
reproduces on a clean tree). App launches on the iOS 26.5 simulator with the
Swift bridge wired and prewarm running at init. On an API-34 emulator with no
AICore, Tier A correctly reports unavailable and degrades to the parser; driven
through the UI, "compact grid, hide prices" applied all three dimensions and
"do not hide prices" restored prices -- the exact negation case the old
substring parser got backwards.

Not verified, and not verifiable here: Gemini Nano inference itself needs a
Pixel 8+ / S24-class device. Its error mapping is unit-tested exhaustively
instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs found by exercising the app on an API 37 emulator, neither reachable
from the existing tests.

ChatBox declared KeyboardActions(onDone = ...) but never set an imeAction, so
the keyboard showed no action key and the handler could not fire: pressing enter
silently did nothing and the Send button was the only way to submit. Now
ImeAction.Send with a matching onSend. Both halves are required, which is what
made the omission invisible.

SqlDelightSignalLog.record launched one coroutine per call on a multi-threaded
dispatcher, so rows were inserted concurrently and out of order. Observed on
device: a preference_changed emitted before its nl_command was inserted after
it, despite an earlier timestamp. Since two events in the same millisecond share
a timestampMs -- which the sweep shows is the normal case for a command and its
patch -- the autoincrement id is the only tiebreaker a reader has, and it is
only meaningful if insert order is emission order. Writes now go through an
unlimited channel drained by a single writer: record() still never blocks a
click, nothing is dropped, and ordering is faithful. selectRecent orders by
(timestampMs, id) so reads are deterministic too.

An append-only log whose ordering cannot be trusted does not support the
decision-log story it exists for, so this is a correctness fix rather than a
tidy-up.

Verified on an API 37 (Android 17) emulator, all 22 vocabulary cases driven
through the UI and checked against the recorded patch in the signal log:
absolutes, synonyms (tiles/rows), both negation forms including "no prices"
where the rule owns the negation word, multi-dimension, section order, relative
requests reading current state, absolute-beats-relative precedence, and all four
out-of-vocabulary shapes including the "listing"/"stylist" substring regressions
and the "grid and list" contradiction. 22/22 correct. Ordering fix re-verified on
device afterwards.

Also ran on a physical Galaxy A16 (API 36): ML Kit's genai classes load and the
process starts clean with no AICore present, which exercises the Tier A
degradation path on real hardware rather than an AOSP image. The device is
PIN-locked so the UI could not be driven on it.

168 Android host tests; 141/142 Kotlin/Native iOS (the one failure is
pre-existing); iOS app builds for the simulator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wnload infra

Works through the improvement list from the last round, plus the two features
that list unblocked, plus Tier-B download infrastructure.

Parser vocabulary. Rules now combine word *groups* rather than fixed token
lists, so a rule reads "(hide OR conceal OR drop OR omit ...) AND (price OR cost
OR pricing OR amount ...)". Verbs and nouns multiply instead of being enumerated
pairwise, and widening one group widens every rule built from it. Several
collisions found while doing it: " with " as a clause separator broke "with
prices"; "display" in both the layout nouns and the show-verbs made "display
prices" ambiguous; "wide"/"up" as column nouns fired on ordinary phrases. All
narrowed, with the reasoning recorded where the group is defined.

Columns. A spec-driven grid column count, end to end: PreferencePatch,
PreferenceOverlay, the merge, the wire format, the prompt, and the renderer,
which now reads spec.columns instead of a hardcoded chunked(2). It is the first
continuous dimension, so it is also the first with a range to enforce — which
finally exercises the numeric branch of validate() that was described but unused.
Out-of-range values are rejected, not clamped; relative requests ("more
columns") coerce, because "more" at the maximum should sit still rather than
report a failure.

Reset. "reset" / "undo" / "revert" / "back to default" clears the overlay via a
new PreferenceOverlayRepository.clear(). This was in the plan's vocabulary and
missing from the table, which meant a shopper who made a mess had to wait out a
24-hour TTL. Reset is checked before dimensions, so "reset, then grid" is a
reset. Recorded as recognized-and-applied so the decision log shows why the
overrides vanished.

Clarification. ParseResult.Ambiguous now carries the dimension, and
NlResolution.NeedsClarification reaches the UI as a specific question ("Show
prices or hide them?") rather than a recital of the whole vocabulary at someone
who already named the thing they wanted. Only reached after the model also
declines, so it never pre-empts an engine that could have resolved it.

Progress and accessibility. A resolving state replaces Send with a spinner —
guided generation was measured at 4-20s, so the wait has to be visible. A polite
live region announces the regenerated layout, which is the announce-layout-
changed requirement a self-recomposing UI owes a screen-reader user. A hint no
longer sets isError: guidance is not a mistake.

Signal store. Pruning is amortised (every 50 writes) instead of running a
full-table DELETE per event; retention was always a bound, not an exact size, and
the test now pins that trade explicitly.

Bugs fixed:
- PatchPayload had no `columns` field, so a real device logged
  preference_changed with an empty patch — silently lossy, exactly what the
  persist-raw decision forbids. Caught on the emulator, not by a test; now
  covered by one asserting every dimension survives the round trip.
- chatBoxKeyboard() now produces the ImeAction and its handler together, so
  neither can be declared without the other. That pairing is what failed last
  time. A full Compose interaction test needs a host-Activity harness this module
  does not have, so the contract is asserted directly instead.

Model download (ADR 0004 phase 4, first half). ModelDownloader fetches a GGUF to
disk, shaped by what actually happens on a phone during a ~400 MB transfer:
resumable via Range against a .part file; free space checked up front, counting
only the remaining bytes on a resume; refused on a metered connection without
explicit per-call consent; verified by streaming SHA-256 and only then renamed,
so a crash can never leave a truncated file looking installed; a corrupt partial
is discarded rather than resumed forever; progress reports throughput and a
remaining estimate, with verification as its own visible state because hashing
400 MB is not instant. A server that ignores Range is detected and restarted
rather than concatenated onto — that one would have produced a silently corrupt
model.

The catalog entry is real, not a placeholder: Qwen's own repo ships only Q8_0
(639 MB), so this points at unsloth's Q4_K_M at 397 MB, with the size and the
sha256 read from the published artifact's Git-LFS oid.

Verified: 186 Android host tests, 155/156 Kotlin/Native iOS (the one failure is
pre-existing). The download path is tested against a fake file system and, in a
separate test, the real one. It was additionally verified against the real
Hugging Face CDN end to end — 1,153,380 bytes through a 302 redirect, progress
reported, and the streaming checksum matched the published LFS oid; that probe
was temporary and is not committed.

On an API 37 (Android 17) emulator, driven through the UI: "gallery" -> grid,
"3 columns" then "more columns" -> four columns rendered, "hide the cost" ->
prices gone, "price" -> the targeted clarification, and "reset" -> everything
back to the inferred baseline. The keyboard now shows a working Send action key.

Not included, and not a session's work: the llama.cpp GGUF inference engine that
would make a downloaded model usable. Downloading is complete and tested; running
is the separate deliverable. iOS platform storage/network conditions are also
still to write — only Android has them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… tests

Finishes the three items the previous commit left open.

Tier B (llama.cpp) is implemented and verified on device. No NDK build and no
cinterop were needed: com.llamatik:library publishes llama.cpp for android,
iosArm64 and iosSimulatorArm64, which makes the hand-rolled-JNI plan moot.
GgufInference keeps the native call behind an interface, so the schema, decoding
and failure classification are host-tested with no model file present, and
TierBSupport reports the engine as NOT_READY (not UNAVAILABLE) until a verified
model is on disk — the difference decides whether a picker offers an engine the
user could actually have. Resolving Tier B never starts a download.

Grammar-constrained decoding had to be abandoned. ADR 0004 specified GBNF, and
the wrapper exposes generateJsonWithContext(..., jsonSchema) for exactly that. It
crashes the process inside llama_grammar_accept_token — reproduced on an API 34
emulator, an API 37 emulator and a physical device, with two different schemas,
the second using nothing but enum and type. That symbol is a llama.cpp API whose
shape has since changed, so it reads as a version mismatch inside the published
wrapper rather than anything about the schema.

That is not merely a missing feature. A native SIGSEGV cannot be caught from
Kotlin, so it is a failure the entire ModelFailure taxonomy is powerless against:
it takes the app down. Avoiding the call is the only safe response. The shape is
instead enforced the way the Android Tier-A text path already enforces it —
schema in the prompt, tolerant decode, then validate — which is fully tested and
cannot crash.

Measured on an API 37 emulator with the catalogued Qwen3-0.6B Q4_K_M: download of
396,705,472 bytes (exactly the catalogued size) with the checksum verified and 408
progress updates; "make it a grid and hide the prices" -> GRID + prices hidden in
11.1s; "I want to see more items at once" -> GRID + 3 columns in 6.5s; "play some
jazz" -> declined in 7.6s. Two things worth recording: that paraphrase is one
Apple's on-device model got backwards, so Tier B is not uniformly the weaker
engine; and this model echoes untouched dimensions exactly as Apple's does, which
independently confirms withoutNoOps was a real requirement rather than a
workaround for one vendor's quirk.

Llamatik's AAR is ~52 MB because it ships llama.cpp, whisper.cpp and
stable-diffusion.cpp for four ABIs. Restricting to arm64-v8a and x86_64 took the
debug APK from 150 MB to 86 MB. A 32-bit-only device still installs and still
works: a library that cannot load makes Tier B degrade to the parser rather than
fail hard. An attempt to also exclude the unused whisper/stable-diffusion .so
files did not take effect and was removed rather than left in place pretending to
work — it is ~2 MB.

iOS download platform. IosModelStorage uses Application Support (not Caches,
which the OS may evict, and not Documents, which is user-visible and iCloud
backed) and reports space via VolumeAvailableCapacityForImportantUsage, the figure
Apple says to use for content an app genuinely needs. IosNetworkConditions reads
NWPathMonitor, caching the latest path in atomics because the monitor is
asynchronous while the interface is synchronous — and starting pessimistic, so no
download can slip out on cellular during the window before the first callback.

Compose interaction tests now exist, in :androidApp rather than :shared, because
runComposeUiTest needs a host Activity registered in a manifest and a library has
no business declaring one. A debug-only manifest supplies ComponentActivity, so it
never reaches a release build. Six tests cover the IME action that shipped broken,
the Send button, field clearing, blank rejection, the clarification hint, and the
progress indicator replacing Send. ProductListContent became public to allow this.

Verified: 217 shared Android host tests, 6 androidApp unit tests, 1 on-device
instrumented test, 155/156 Kotlin/Native iOS (the one failure is pre-existing).
The committed on-device test uses SmolLM2-135M (84 MB) so a run is ~30s rather
than three minutes; the catalogued model's numbers above came from running that
same test against it manually.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…R-0004 phase 2)

Closes the last gap: the Tier-B download infrastructure and engine were complete
and wired into the graph, but nothing in the app offered them.

All three surfaces sit over one EnginePicker and one EnginePickerViewModel. One
state holder rather than three, because the hard part is not layout but being
truthful about what each tier can do on this device, and three copies of that
judgement would drift.

Decisions:

- Unavailable tiers stay visible and disabled, with the reason. Hiding them
  leaves the user unable to tell "this phone can't" from "this app doesn't", and
  the one thing the picker must never do is let someone select a tier that
  silently falls through to the parser.
- NOT_READY is selectable only for the downloadable tier, where the user can make
  it ready. For Tier A it means the OS is still fetching assets, which selecting
  cannot hurry.
- First run is derived, not stored. It is exactly "the user has never chosen",
  which the existing engine_choice key already answers, so no second flag exists
  to drift. Dismissing without choosing is therefore not recorded as consent: the
  app keeps running on Auto and asks again next launch.
- The download size is on screen before anything starts. Consenting to "a model"
  is not consenting to 397 MB.
- Deleting a model steps the selection back to Auto, since leaving
  LocalDownloaded selected would point at a tier that can no longer do anything.
- Refusals are explained in terms of the next action — connect to Wi-Fi, free
  this much space — rather than reported as a failure.

A bug only the device could show: pausing cancelled the download job, but a
cancelled flow simply stops emitting, so the last Downloading value survived
every later refresh and the row went on claiming "3 MB/s, 1 min left" over a
transfer that had stopped minutes earlier. cancelDownload now rewrites the state
to Paused, and refresh additionally treats a Downloading state with no live job
as stale whatever produced it — process death, a dropped connection. Covered by
a regression test.

Verified on an API 37 emulator: the first-run dialog appears with Tier A
correctly disabled ("Not available on this device") and Tier B offering "Qwen3
0.6B Instruct · 396 MB · Q4_K_M"; Download reaches "8 MB of 396 MB · 4 MB/s · 1
min left" with a live progress bar and a real .part file on disk; pause leaves
"122 MB of 396 MB downloaded" with Resume, surviving an app restart; and the same
picker is reachable from the chatbox's settings affordance and the inline
"Understanding: automatic — Change" row.

235 shared host tests, 16 androidApp unit tests (10 of them new picker
interaction tests), 155/156 Kotlin/Native iOS — the one failure is pre-existing.
iOS app builds for the simulator.

Note: SignalSectionRankerTest.coldStart_emitsOnlyAllProducts failed once during
this work and passed on three consecutive full re-runs. Not caused by these
changes, but it is a second flaky test alongside the iOS race demo and worth
looking at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ilistic

Both flaky tests traced back to one cause: a test that hoped a thread race would
happen, and burned enough CPU trying that it destabilised its neighbours.

HomeStateMachineRaceTest.naive_canLoseBaseline asserted that a naive
read-modify-write *does* lose an update, and tried to make that happen by firing
5,000 concurrent dispatches across 20 trials. That is probabilistic by
construction on the JVM, and on Kotlin/Native it never occurred at all —
Dispatchers.Default there never produced the interleaving, so the test failed on
every single iOS run rather than occasionally.

The hazard is now proven deterministically. NaiveStateMachine takes a
betweenReadAndWrite hook — the exact gap where a lost update happens — and the
test dispatches the competing event into it. No threads, no luck, identical
result on every platform. The CAS side keeps a real concurrent storm, because
"never loses it" is the invariant that matters and contention is the only honest
way to check it; that direction cannot flake, since the invariant holds whether
or not the interleaving occurs.

The second flake follows from the first. SignalSectionRankerTest is fully
deterministic — fixed clock, canned records, no timing — so it had no way to fail
on its own. What it did have was a neighbour running 100,000 dispatches on
Dispatchers.Default in the same JVM, which pins every core; a runTest that gets
starved long enough trips its 60s timeout. The storm is now 500 dispatches, and
the race test's total runtime dropped from minutes to 30ms.

Verified: five consecutive clean full runs on the Android host target and three
on iOS. 235 shared host tests and 156 Kotlin/Native iOS tests now pass with zero
failures — the iOS suite is fully green for the first time, where it had been
reporting 1 failure every run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ks 3, 5a, 6)

Closes the four Phase-1 core gaps an audit of the tree found: no CI, no decision
log, no module boundary enforcement, no flags seam.

:core:flags is its own Gradle module, not a package in :shared. The point of the
seam is gating the engine tiers and experimental sections from outside them, and
a flag type that lives inside :shared can be read by the very code it is meant to
gate. Flags are a closed enum so a typo is a compile error and the set is
enumerable — which is what lets a debug surface list them without a registry to
keep in sync.

The flags gate real behaviour, verified: turning ON_DEVICE_MODELS off forces
every tier to null so the resolver falls to the rule-table parser, which is the
plan's stated reason for wanting the seam — showing the fallback live without a
rebuild or uninstalling a model. DOWNLOADABLE_MODEL gates Tier B separately,
because a ~400 MB download deserves its own switch. SIGNAL_RANKING off makes the
ranker emit the cold-start shape, which is how you demonstrate the implicit half
was doing work rather than asserting it.

The decision log is Block 6's observability. The privacy thesis forbids an
analytics backend, so "see it in prod" has to mean reconstruct it from the
device. explainMerge derives the explanation from the same inputs as merge, so
the two cannot disagree — an explanation computed from a different source would
eventually lie, and a decision log that lies is worse than none. It records only
which contributor won and which bounded-vocabulary value resulted: no product
data, no user text. DECAYED_TO_INFERRED is its own source because a layout that
changes with no user action is the case that most needs explaining, and logging
it as plain "inferred" would leave it looking spontaneous. In-memory and bounded
at 50, since a decision log that outlived the process would be a behavioural
record on disk needing the same retention argument the signal store had to make.

The boundary tests (Block 5a) read the source tree rather than a JVM classpath,
because Konsist and ArchUnit only see the Android compilation and the rules that
matter here are about commonMain and the platform source sets. Seven rules, one
of which found something on its first run: the privacy rule flagged App.kt, which
turned out to be a legitimate composition root handing the pooled HTTP client to
Coil — the allowlist was incomplete, not the code. The rules now pin that only
the catalog, the model downloader and the two composition roots may reach the
network; that SQLDelight types stay in the signal package; that the domain layer
has no framework imports; that platform engines never name NlResolution (ADR
0006); that vendor SDKs stay out of shared code; and that the .sq file exposes no
UPDATE and only one bounded DELETE.

CI runs two jobs. The JVM job is split from the iOS one so the fast feedback most
commits need does not wait on a macOS runner queue, and it assembles the APK as
well as running tests — a green suite over a module that no longer links is not a
green build. The macOS job builds Kotlin/Native *and* the Xcode project, because
a Gradle-only iOS job would never compile FoundationModelsBridge.swift, which is
where the Tier-A error mapping lives. All five task paths were verified to
resolve before committing.

Snapshot correction: my earlier audit reported zero recorded snapshots. That was
a bad grep — seven were committed and tracked. What was genuinely missing is
coverage of the columns dimension added this week, so three cases are added (3
columns, 4 columns, and the compact-grid-no-prices combination a real command
produces). Column count is the one dimension whose bug mode is purely visual — a
wrong count still renders every product, just at an unusable width — so it is
exactly what a snapshot catches and a unit test cannot. CI now passes
-Proborazzi.test.verify=true, without which the snapshots run, pass and check
nothing. Confirmed by corrupting a golden: 3 failures with it wrong, green when
restored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…regeneration

The plan calls a self-recomposing layout "an accessibility hazard" and asks for
three things from Block 2 onward: stable semantics across recompositions, an
announcement when the layout changes, and preserved focus. Only the announcement
existed.

Item keys included the section index, so any reorder changed every key — Compose
destroyed and recreated every node rather than moving it, and focus went with
them. Keys are now (role, product id), which survives a reorder, so a keyed item
is moved rather than replaced. That is the mechanism focus preservation actually
depends on; without it a FocusRequester would have nothing left to restore to.

A FocusRequester on the collection restores focus into the list rather than to
whatever happened to be next in traversal order. It re-requests only when the
products change identity, not on every re-merge: the merge is time-dependent and
runs constantly, and yanking the cursor back on a presentation-only change (grid
to list) mid-read would be worse than doing nothing.

Four tests assert the mechanisms a screen reader depends on, since a screen
reader itself cannot be driven from a unit test: that a live region exists, that
its description is derived from the spec rather than hardcoded (a fixed string
would pass a weaker test and then lie forever), that section order reaches the
announcement, and that products survive a reorder uniquely identifiable.

The reorder test initially failed because the fixture put identical products in
both sections, so every product matched twice — a fixture flaw, not a code bug.
Sections now hold distinct products, as a real spec does: "Recommended" is a
subset, not a duplicate.

Also drops focusGroup, which does not exist in Compose Multiplatform's common
surface — the requester alone achieves the same restore-into-the-list behaviour.

20 androidApp tests pass; snapshots re-verified unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The privacy differentiator had two halves and only one was implemented: nothing
is uploaded (enforced by an architecture test), but the signal log was a plain
SQLite file. Anyone with read access to app-private storage — a rooted device, an
adb backup, a stolen unlocked phone — could open it and read the full behavioural
history. "Encrypted at rest" appeared in the plan and in no code.

Android uses SQLCipher with a two-level key, because SQLCipher needs the
passphrase in the clear at open time and the Keystore never exports its keys: a
256-bit SecureRandom passphrase, wrapped by an AES-256-GCM Keystore key that
never leaves secure hardware. The wrapped blob is written to a temp file and
renamed, so a kill mid-write cannot leave a truncated key — indistinguishable
from a corrupt one, and it would orphan the whole log.

iOS uses the OS instead. SQLDelight's native driver links the system SQLite,
which has no SQLCipher build, and Data Protection already provides the same
guarantee against the same threat: a key derived from the passcode and hardware
UID. So the platforms differ in mechanism and agree in guarantee, and
IosOsProtectedDatabase is named distinctly from UnencryptedDatabase so the two
situations stay distinguishable — one is encrypted by the OS, the other is not
encrypted at all.

Both platforms skip user authentication on the key, and ADR 0007 records that as
a real weakening rather than taking it quietly: the ranker reads signals during
launch, so requiring authentication would mean the app cannot render its own home
screen until the user unlocks something.

The ADR states the threat model first, because an unstated one is how security
theatre gets shipped. In scope: offline access to storage. Explicitly out of
scope: an attacker already executing as the app, against whom no client-side key
can help, since the app must be able to decrypt its own store to function.

Verified on an emulator, which matters because an untested crypto path is a claim
rather than a feature. The key blob is exactly 60 bytes (12-byte IV + 32-byte key
+ 16-byte GCM tag). The database header is random bytes rather than "SQLite
format 3"; sqlite3 reports "file is not a database"; a strings scan finds zero
occurrences of the table name or any event type, all of which were plainly
readable in this same store yesterday. Round-trip was verified across processes,
which is the part that could silently break: a product opened in one process
appeared in a Recommended section after a full force-stop and restart, so the log
was written encrypted, decrypted by a new process, and ranked.

Degradation is loud rather than silent. If SQLCipher's native library cannot load
the app falls back to a plain driver and logs a warning, because the alternative
is an app that cannot start — but a store that is quietly unencrypted while the
app claims otherwise is worse than one that is openly plain.

No migration, and none needed: the filename changed because an encrypted file
cannot be opened by the plain driver, and the log is a bounded rebuildable cache,
so starting fresh costs one cold-start default layout rather than user data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pters

The plan calls AppFunctions "the fourth input channel onto the same Block-4
contract" and asks for one shared typed function surface with thin per-platform
adapters: build once, N callers. This is that.

PreferenceCommands in commonMain is the surface. Every setter routes through the
same validate() the chatbox and the model tiers use, because the OS assistant is
not our code and gets no more trust than a downloaded model — an assistant saying
"twelve columns" is refused, not silently given four. 18 tests cover it, which
matters because neither platform channel can be exercised without an OS
assistant, so the shared half is the only part that can be proven.

Typed setters only. No applyPreference(freeText) on either channel: the assistant
has already done the natural-language work, so feeding its text back through our
parser would strictly degrade the result and widen the cloud-disclosure surface
for nothing (ADR 0006). It also means the channel needs no model, which is the
only reason it can work on Android at all — AICore refuses inference outside the
foreground, so a Tier-A-backed OS channel would fail by construction.

OS commands get a 7-day TTL against the chatbox's 24 hours. Speaking to the
system assistant is a more deliberate act than typing in a box, and having it
evaporate on the chatbox's schedule would read as the app forgetting. ADR 0006
flagged this as its weakest default; it is settled here, and a test pins that the
override still decays — a permanent one would break ADR 0005.

Android: @appfunction functions whose KDoc is the schema, over
androidx.appfunctions alpha10. Verified rather than assumed — KSP generates the
inventory, invoker and id constants, and the KDoc really is extracted into the
aggregated function registry the assistant reads. On an API 37 emulator the OS
registers our PlatformAppFunctionService against the
android.app.appfunctions.AppFunctionService action, guarded by
BIND_APP_FUNCTION_SERVICE so only the system can bind it, and the framework's own
IAppFunctionManager is present. The Application supplies the enclosing-class
factory, without which the library would instantiate the functions with a no-arg
constructor and the assistant would write to a different overlay than the app
reads.

iOS: App Intents in Swift over the same shared surface, using AppEnum parameters
so Siri resolves phrasing against a closed set and an out-of-vocabulary value
never reaches our code — the bounded-vocabulary principle enforced one layer
earlier than the parser does it. openAppWhenRun is false, since reshaping the
store without opening it is the entire point. Two AppShortcuts so the intents are
reachable without the user building a shortcut first.

Toolchain bumps were required, not optional: appfunctions alpha10 demands
compileSdk 37 and AGP 9.1.0+, AGP 9.1.1 demands Gradle 9.3.1. The wrapper pins a
distribution checksum, so that was updated too — against the SHA-256 published at
services.gradle.org, which matched the downloaded artifact exactly, rather than
trusting the file's own hash.

Also adds the :core:flags module dependency wiring and PreferenceCommands to the
DI graph, app-scoped because an assistant invokes it with no screen alive.

235+ shared host tests, 20 androidApp tests, 6 flags tests, 156 iOS tests, all
green. Both apps build.

What cannot be verified here: actually invoking either channel needs an OS
assistant, which no emulator or simulator ships — the same wall as Gemini Nano.
Registration is verified; execution is not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…items)

Two stretch items from the plan, plus a permission allowlist.

Baseline profile. A profile is only worth generating if the startup path is
expensive, and this app's genuinely is: cold start does not inflate a static
screen, it opens an encrypted SQLCipher database, reads the signal log, runs the
ranker, merges the spec and renders a layout computed at runtime. All of that is
interpreted on the first frame without a profile.

So the generator drives the *generative* path rather than just
startActivityAndWait: it waits for real products (not a fixed sleep, which would
be either flaky on a slow emulator or mostly idle on a fast one), then sends a
chatbox command so the parser, validation, overlay write and re-merge are all
recorded, then flings the list to capture the lazy-item path.

Verified it captured what was intended rather than framework noise — 31,990 rules
including 485 for the NL layer, 217 for SQLCipher's open path, 195 for the signal
store, 73 for the parser and 23 for the ranker. The compiled profile is packaged
into the release APK at assets/dexopt/baseline.prof, with profileinstaller to
apply it; without that dependency the profile ships and is never used, which is
the quiet way this feature ends up doing nothing.

useConnectedDevices is set explicitly because the plugin otherwise refuses an
emulator. An emulator is not a legitimate place to *measure* performance, but it
is a legitimate place to record which methods a path touches — which is all a
profile is. Stated in the build file rather than silently overridden.

Proactive recomposition. The app offers a layout change instead of making one.
That distinction is the feature: applying it unasked would both be the
filter-bubble failure the plan warns about and move things under a user who did
not ask for movement. Every nudge carries an exact undo — the order that was
rendered, not a guess at a default — because a nudge you cannot walk back is an
unannounced change with extra steps.

The detector reads the inferred baseline against what is rendered rather than
re-reading signals, so it cannot disagree with the ranker. Presentation
dimensions are deliberately excluded: proposing a comfortable list to someone who
asked for a compact grid is second-guessing an explicit instruction, not
surfacing new information. A one-position shift is treated as arithmetic drift
rather than a finding, since the ranker's scores move constantly as recency
decays and nudging on every reshuffle would make the app chatty enough to ignore.

Also fixes an unnecessary-safe-call warning left by the retryAfterMs change, and
adds a .claude/settings.json allowlist for the repetitive read-only commands this
work leans on (archive and class inspection, adb device/log reads, simctl list,
awk, curl fetches) plus the named Gradle verification tasks. Deliberately no
wildcard for ./gradlew, adb shell, xcrun simctl spawn, or python3 — each is
arbitrary code execution.

284 shared host tests, 20 androidApp tests, 6 flags tests, 156 iOS tests, all
green. Debug and release both assemble.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three rendered diagrams the README embeds: the before/after layout change,
the two-contributor architecture, and the measured-latency chart.
The gap the write-up admitted publicly: the ranker consumed exactly one signal
type while the app was already recording dwell time and throwing it away. Half
the thesis was engineered and the other half was a scoring function written early
and never revisited.

Scoring moves out of the ranker into ProductScorer. The ranker's job is
merchandising sections; deciding what matters is a different job, and a
three-input formula is the part most likely to change, so it needs its own tests.

Dwell is a multiplier on opens rather than an added term. Every dwell follows an
open, so summing both counts one visit twice — and would rank "opened twice,
bounced both times" above "opened once, read for a minute", which is backwards. A
test pins that ordering directly.

Raw duration is unbounded, so it saturates: min(duration / 30s, 1) mapped to a
1.0-2.0 multiplier. A thirty-second read is worth double a bounce, and a
ten-minute detail screen left open in the background scores the same as a
thirty-second read rather than dominating everything. The longest single dwell
counts, not the sum, because many brief visits are not one engaged one.

Time-of-day is a mild 1.0-1.15 nudge by circular distance between an event's hour
and now's. Continuous rather than bucketed — buckets put a cliff edge at the
boundary, so 11:59 and 12:01 would score very differently for no defensible
reason. A test pins that three extra opens still beat a time-of-day match, so a
weak signal cannot overpower a strong one.

Dormant signal types stay excluded. AddedToCart and ProductImpression have no
emitter, so scoring on them would be scoring on data that never arrives.

The score carries its reasoning rather than being a bare Double — opens, longest
dwell, the multiplier, and whether the dirty-data path was taken. "Recommended
contains X" is otherwise unexplainable, which is exactly what the decision log
exists to prevent. Exposed but deliberately not wired into the decision log yet;
DecisionSource models the merge, not the ranker, and wiring it has its own
decisions.

Two defensive paths, both tested: a dwell with no matching open is still scored
and flagged, since a killed process can produce one and dropping real signal is
worse than counting it weakly; and an undecodable payload degrades to a neutral
multiplier rather than taking the ranking down.

All three original ranker tests pass unmodified, which was the point — widening
the formula must not regress "recommends by recency-weighted frequency". 19 new
scorer tests alongside.

One test fixture bug found and fixed on the way: the fixed "now" was truncated to
an hour boundary rather than midnight, so it landed on an arbitrary hour and the
time-of-day assertion failed. The production code was correct.

303 shared host tests, 20 androidApp, 6 flags, 156 iOS — all green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README was still the default KMP scaffold — Goals/Non-Goals plus
JetBrains' getting-started text. It described none of the work.

Leads with the thesis and a demo image, then the engineering: the merge
function that makes decay emergent (and the pipeline snapshot-testable),
the shared/platform split with its one leftover expect/actual, the three
on-device tiers with measured latency, and why the rule table runs before
the model.

Includes the findings that only measurement produced — the ambiguous
BUSY code, foreground-only inference killing a planned channel, a timeout
wrong by 6x, the schema sentinel that made both vendors' models echo, and
a SIGSEGV walking past the entire error taxonomy — plus a Status section
that names the four things still unverified.

Counts verified against the tree rather than recalled: 70 commonMain vs
11 platform files, one expect/actual pair, and per-suite test numbers
with a note that commonTest overlaps across both shared targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The .part bytes already survived process death and already resumed via a
Range header. What did not survive was any record that a transfer was
outstanding, so the next launch rendered an idle row over a 300 MB
partial and the user had to notice and re-tap.

The missing fact is not progress, it is *why* the transfer stopped. A
partial on disk looks identical whether the user deliberately paused it
or the OS killed the app mid-download, and those two want opposite
behaviour. ModelDownloadRecord carries exactly that one bit, plus the
metered consent, since consent is per-attempt and a resume is a new
attempt.

ACTIVE resumes: a dropped connection and a killed process are nobody's
decision. USER_PAUSED never does; resuming what someone deliberately
stopped is a bug. The automatic resume is announced rather than silent
and can be refused, and refusing writes USER_PAUSED so the decline
outlives the process instead of lasting until the next launch. Refusing
keeps the bytes — declining must not throw away progress.

Disk wins over the record on every question it can answer: a record
naming an installed model is stale, and one whose .part file is gone has
nothing to resume. Both are cleared rather than allowed to drive a bogus
offer. A .part with no record predates this feature and reads as
USER_PAUSED, because no consent evidence exists and offering is the only
defensible read.

One automatic attempt per process, shared by both triggers, so a
permanently failing URL cannot loop unattended over a 400 MB transfer.

Not included: background execution. Continuing a transfer after the
process dies needs WorkManager and a background URLSession, which is a
different transfer implementation per platform and would fork the shared
Ktor downloader. That belongs with its own ADR.

deleteModel() and cancelDownload() are now asynchronous, because clearing
the record is a persisted write. The delete test awaits the resulting
state rather than advanceUntilIdle(), which does not cover work the
scheduler never sees — the same reason awaitInstalled() already existed.

Storage is the shared DataStore alongside the engine choice, so nothing
platform-specific was needed on either side. Back-compatible per ADR
0003: absent keys mean "nothing outstanding", and an unrecognised intent
reads as USER_PAUSED, failing toward asking rather than toward spending.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
Adds the Tier-B download design — resumable .part transfers, up-front
space and metered checks, hash-then-atomic-move — and the part worth
reading: what a .part file cannot tell you. A partial looks identical
whether the user paused it or the OS killed the app, so the persisted
record holds intent rather than progress, and the two intents get
opposite treatment.

Test count corrected to the measured 332 on the shared JVM target, and
background transfer named in Known gaps rather than left implied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The LinkedIn series and its assets are personal write-up material, not
part of the codebase, and the video alone was 300 KB of binary the repo
had no reason to carry. They stay on disk and stay usable — they are
just no longer tracked.

The three diagrams the README embeds are the deliberate exception: the
rendered README has to work on GitHub, so those stay tracked and the
ignore rule negates them explicitly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
The study guide moves out of the repo, so linking to it from the README
would leave seven dead links for anyone browsing on GitHub. The ADR
index carries the same reasoning and stays, with a pointer to the two
that matter most for the on-device work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
Same call as the write-up material: it is reading notes about the
codebase rather than part of it. Stays on disk and stays usable, just
untracked. The README's Documentation section was removed in the
previous commit so nothing links into it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
@itstanany
itstanany force-pushed the feat/v0.11.0-generative-detail branch from 026725b to d19fc21 Compare July 31, 2026 18:15
itstanany and others added 3 commits July 31, 2026 21:26
Ranking over opens, dwell and time-of-day shipped but the Complete list
still read as though only the contract and engine work were done. An
omission rather than an error, but the status section is the part a
reviewer trusts, so it has to be exhaustive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
… app as a POC

Two things.

**The test.** The OS-assistant channel was registered but never executed,
because the caller is normally an assistant and no emulator ships one. It
turns out AppFunctionManager.executeAppFunction needs the privileged
EXECUTE_APP_FUNCTIONS permission only for *other* packages — an app may
invoke its own — so the dispatch path is reachable from an instrumented
test after all.

Eight cases: one per function plus two refusal paths. Parameters are built
from metadata the runtime itself reports, so passing would prove KSP
extracted the KDoc schema and AppSearch indexed it. Each asserts the
overlay actually changed, not just that a response came back — a function
returning "Done." and writing nowhere would pass a response-only check.

It currently FAILS on the API 37 emulator, and is committed failing rather
than weakened. All eight stop at the same line: the functions are never
discoverable via searchAppFunctions, despite getInstance() reporting the
feature supported, both platform services being present, and KSP emitting
all six ids. Polls for 30s, so not a plain indexing race. Whether that is
an emulator limitation or a wrong query shape is unresolved; worth
retrying on a physical device. Not in CI — no emulator on the runners.

Also fixes TierBOnDeviceTest, which still called androidModelDownloader
with the pre-record-store signature. androidTest is not built by the
regular check tasks, so that break survived the download-persistence work.

**The framing.** The README led as though this were a store. It is a
proof-of-concept for on-device generative UI, and saying so up front makes
every scope decision in it legible rather than looking like omissions. The
gap list moves out of the repo; the README now states only that Phase 2 is
hardening this into an actual e-commerce app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
storeWrittenBeforeThisFeature_loadsCleanly failed on CI with
IllegalStateException at FileStorage.kt:52 — DataStore refusing a second
instance over a file the first had not released.

The test opens a store, closes it, then reopens the same file, because
reading back through the same instance is served from cache and proves
nothing about what reached disk. close() cancelled the scope but did not
wait for it, and cancel() returns before teardown finishes. On a fast
local machine that window closes on its own; on CI it does not.

Now joins the job. The race was mine, not the production code's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NVXXc1e2DUSWebbX255Gwq
@itstanany
itstanany merged commit 035c588 into main Jul 31, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant