You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Start gate: Gate 2 is closed (S4 #1447 / PR #1536). DC4 #1437, DC5 #1438, DC6 #1439 merged. W0 is complete by absorption (#1401 closeout, 2026-08-25). Item 1 is merged (#1541 / PR #1545). Item 2 (#1548) is parallel-safe — no semantic overlap, mechanical contention only (see Resource limits).
Goal
Every fact the rider is told about weather is decided by WeatherDomain. No platform executor decides whether an update is running, when a weather screen must repaint, how many rain steps exist, when an alert is evaluated, or that opening the dashboard is worth a radio trip.
This slice moves the decisions and their repaint edge, not the bundle. The frames stay in the store behind WeatherSnapshot::sample, exactly as DC4 locked.
Current code
DC4 built the domain and T3b (#1542) gave it the alert marks. Nothing in production writes to it. Six decisions still live outside.
1. The domain has no production writer, so its render key is inert.note_installed is reached only through ExternalFacts::weather_data() at pass.rs:447, and no host calls note_weather_data — the only callers are pass.rs:1814, shared.rs:799, and apps/obc-sim/tests/dirty_parity.rs:806. WeatherKey { installed } (render_key.rs:173, built at :332) is therefore always None on glass; the S4 differential harness is the only place it moves. visible() (weather.rs:1370) and last_refresh() say so in their own docs and have no readers at all.
2. "A refresh is running" has two sources of truth, and the screens read the platform's.Render::weather_refreshing (screen/mod.rs:436) is filled at app.rs:3128 from the WeatherFeed argument; the board fills it from crate::ble::weather_refresh_in_flight() (ride.rs:2618, an AtomicBool at ble/weather.rs:75) and the simulator from a CLI flag (main.rs:638,1755). WeatherDomain::refreshing() (weather.rs:1305) is the domain's answer and nobody asks it. The dashboard's one cue (weather_dash.rs:97) is drawn off the platform's copy.
3. The seventh hand-written repaint mirror survived S4.App::weather_feed_changed() (app.rs:1548) sets map_dirty after sniffing the top screen. It is exactly the shape tools/check_render_keys.py exists to prevent, and it survived because its writer is between-pass: a resample changes the snapshot's contents, and no stack-local key can see a value the domain does not hold.
4. The host derives the rain map's view state and hands it back as a setter.ride.rs:2391 and apps/obc-sim/{main.rs:1446,1736, gui.rs:735} call snapshot.steps_ahead(now) and snapshot.rain_zoom_floor(cam_lat) themselves and push the result through App::set_rain_view (app.rs:1529) into AppState::rain_steps_ahead / rain_zoom_min (app.rs:170,177). Both are pure functions of the installed bundle plus the camera — domain derivations living in three executors. AppState::rain_step is different: it is the rider's cursor on the rain map, written by weather_dash.rs:75 and weather_map.rs:103, and it is UI selection state that stays where it is.
5. The alert engine is ticked by the executor.App::weather_alert_tick (app.rs:1654) is the domain's decision behind a public method, called from ride.rs:2392 and apps/obc-sim/{main.rs:1739, gui.rs:737} inside each host's resample branch — so when the honesty law runs is the executor's choice. Stage 10 (pass.rs:780) does not reach for it.
6. The refresh trigger is a screen sniff in the board's ride loop.ride.rs:2254-2256 compares was_on_weather (sampled at :2070) against app.top_screen() and calls crate::ble::request_weather_now(). That is UI placement policy in a platform executor, and it is the rule #1433 §2 names first. WeatherIntent::RefreshRequested exists (weather.rs:1119) with no producer; WeatherCapabilities::refresh (shared.rs:267) gates an effect that can never be emitted; device_core_conformance.rs:350 and ride.rs:1901 both assert that a weather effect must not appear.
7. The corpus claims four weather requirements and tests none of them.Requirement::{WeatherRefreshState, WeatherInstalledDataChange, WeatherStaleData, WeatherAlertDelivery} are in ALL_REQUIREMENTS (device_core_corpus/mod.rs:139-142) and attached to weather.refresh-install-stale-alert (:1126), whose four actions (:689-704) are set_rain_view(3,0.5), weather_feed_changed(), set_rain_view(0,0.0), show_weather_alert(Storm,12). None touches WeatherDomain, and no test asserts any of the four — the S6c lesson in its purest form.
Owner decision — what RequestRefresh's outcome means (resolve before step 1)
WeatherEffect::RequestRefresh's doc says "ask the companion for a fresh bundle and install it" (weather.rs:1136). Taken literally, its terminal outcome is a landed bundle, which the board can only report by correlating a flat-store commit with a request id inside the due plane — the request-kernel work #1401 W2 owns. Worse, it does not cover the cue at all: the due plane raises periodic refreshes the domain never ordered (ble/weather.rs:222, the weather_refresh cadence setting), which carry no token, so a token-only refreshing() would go dark during exactly the fetches today's cue shows.
Recommendation. Split the two facts, which is what they already are on the board:
RequestRefresh means "raise a request", and its outcome answers only that. The executor calls request_weather_now() and answers in the same execute — Failed { LinkLost } when there is no companion to ask, otherwise a raised verdict. Narrow the effect's doc to match. WeatherEffect/WeatherOutcome keep their shape and their ≤16 B / ≤24 B tripwires (weather.rs:1178-1180).
"A fetch is running" is an external fact, because nobody asked for it — EPIC — DeviceCore: finish runtime store parity and typed platform outcomes #1433 §4.4's own definition. The due plane reports its IN_FLIGHT level (ble/weather.rs:75) as ExternalFacts::note_weather_refreshing(bool); WeatherDomain::refreshing() becomes self.in_flight.is_some() || self.platform_fetching.
RefreshResult::Installed comes from the installed-data fact's revision moving, not from an outcome.
Alternative, and why it is not recommended. Defer request intent to W2 and ship visible-only. It is smaller, but it leaves WeatherIntent, apply_intent, next_effect and WeatherCapabilities::refresh dead for another slice, and it leaves ride.rs:2254's screen sniff standing — which is the one line #1401 W1's exit evidence is written against. Choose one before step 1.
Target design
One writer, three producers, one owner
Fact
Producer
Entry point
Weather data is installed at this identity/revision
The sample revision is what deletes the mirror.weather_feed_changed() exists because a resample is invisible to a stack-local key; a monotone counter the domain holds makes it visible, WeatherKey names it, and the mirror goes. It cannot be folded into installed.revision: a resample at a new rider position happens under an unchanged installed revision. The host already computes the key it would bump from (weather_sample_key, ride.rs:2373).
The key does not name now. The countdown and the expiry are time-driven, and the dashboard's minute ticker (weather_dash.rs:87) already reports them as a ScreenTick. Adding a clock field to the key would repaint every pass.
The view derivations move to their one interpreter
steps_ahead and rain_zoom_floor are already WeatherSnapshot methods (weather.rs:385,409). WeatherDomain gains note_sampled(&WeatherSnapshot, now, cam_lat) — one call replacing three copies of the same two-line derivation — and owns steps_ahead / zoom_floor. AppState::rain_steps_ahead and rain_zoom_min are deleted; rain_step stays (rider cursor) and clamps against the domain. set_rain_view's live re-clamp of the camera when the rain map is the base screen (app.rs:1537-1543) is UI-plane work with no WeatherVisible field — it stays, moving to the stage-10 tail where the domain's new value is known, and Feeder::RainView's row already says this is the awkward half (feeders.rs:271-281).
The alert decision runs in stage 10
weather_alert_tick's body moves into stage_weather, which already runs after UiRuntime and can see the open card. App::weather_alert_tick stays as a test/simulator seam with its doc corrected to that (the force_retention_sweep precedent from #1548 step 6). App::show_weather_alert stays: it is the presentation seam the mark deliberately depends on (weather.rs:1395).
WeatherFeed collapses
With refreshing gone the struct is a one-field newtype. Delete WeatherFeed, WeatherFeed::NONE and the Default derive (weather.rs:531-549), and take Option<&WeatherSnapshot> in the three render entries (app.rs:2835,2921,2987) and the six construction sites. Render::weather_refreshing is filled from self.weather.refreshing(), the same shape as card_free_bytes: storage.free_bytes() two lines above it (app.rs:3126).
The intent has one producer
Ctx gains weather: &'a mut WeatherDomain beside dfu and storage (screen/mod.rs:208-212), and menu.rs:176 — the only push site of Screen::Weather — names WeatherIntent::RefreshRequested, exactly as settings/system.rs:51 names the free-space refresh. This reproduces ride.rs:2254's edge precisely: the only way to reach Screen::Weather with was_on_weather false is that push, and Back from Hourly/RainMap does not pass through it. ride.rs:2070's was_on_weather, the sniff at :2254, and weather_refresh_in_flight() are deleted; the board's executor gains a RequestRefresh arm that calls request_weather_now().
The feeder table stops naming a slice that no longer deletes it
Feeder::WeatherFeed (feeders.rs:266) is App::weather_feed_changed, which this slice deletes — the row goes with the method. Feeder::RainView retires to BootAndFacts for item 1's stated reason: what survives it is the camera re-clamp, a runtime UI seam and not weather ownership. DeletingSlice::WeatherCutover (feeders.rs:179) then names no row and is deleted; the exhaustive feeder_migration match is the structural record that this slice is finished, as CatalogOwnership's deletion was for item 1.
Implementation steps
Resolve the owner decision above.
Add sample: Revision, steps_ahead: u8, zoom_floor: f32, platform_fetching: bool to WeatherDomain; add note_sampled and note_refreshing; extend refreshing(). Keep the ≤120 B tripwire (weather.rs:1411) — raise it only with an itemized reason.
Add note_weather_sample and note_weather_refreshing to ExternalFacts with their rows in the fact table (shared.rs:429-437); consume both at stage 2 (pass.rs:447).
Give WeatherKey the visible facts: installed, sample, refreshing. Delete App::weather_feed_changed and add weather_feed_changed to tools/check_render_keys.py's RETIRED blocklist.
Move the steps_ahead/rain_zoom_floor derivation out of all three hosts into note_sampled; delete AppState::rain_steps_ahead and rain_zoom_min; re-home the camera re-clamp to the stage-10 tail; keep rain_step and clamp it against the domain.
Move weather_alert_tick's body into stage_weather; keep the method as a named test/sim seam with a corrected doc; delete the three executor call sites.
Delete WeatherFeed and thread Option<&WeatherSnapshot>; fill Render::weather_refreshing from the domain.
Add Ctx::weather; name the intent at menu.rs:176; delete ride.rs:2070/:2254-2256 and ble::weather_refresh_in_flight; add the board, simulator and conformance executor arms for RequestRefresh; delete the two "weather cannot be reached" assertions (dispatch.rs:418, device_core_conformance.rs:350) and ride.rs:1901's weather half.
Report the due plane's IN_FLIGHT level as the fact from both edges (ble/weather.rs:166,222,245); replace the sim's --weather-refreshing flag plumbing with the fact.
Re-point Feeder::RainView, delete Feeder::WeatherFeed and DeletingSlice::WeatherCutover.
Make the four corpus requirements real (below).
Add the tests below and a grep guard for a refreshing bool crossing a render signature.
drop the board's note_weather_data call — installed() stays None on glass, which is today's tree (write this one first, against the merged tree, and record that it fails)
leave the tick in the executor — a host that resamples twice between passes evaluates twice
a_stale_bundle_alerts_nothing_from_stage_ten
drop the validity gate on the way into the stage — the engine's own law, re-proved at its new call site
Corpus (host/obc-host-core/tests/device_core_corpus/mod.rs): the four existing weather requirements are tautologies and this slice fixes them, it does not add a fifth. Rewrite the four actions of weather.refresh-install-stale-alert to drive WeatherDomain — RefreshWeather names the intent and expects one WeatherEffect; InstallWeatherData reports note_weather_data; MarkWeatherStale reports a resample with no current frame; DeliverWeatherAlert keeps show_weather_alert. Add a conformance test asserting each of the four by name, the way CatalogDeleteOrdersRefresh is asserted at device_core_conformance.rs:1327. ALL_REQUIREMENTS membership is unchanged — the S6c lesson here is the other half: a requirement in the list with no assertion behind it is equally ungated.
Expected trace movement.weather.refresh-install-stale-alert gains one WeatherEffect::RequestRefresh step; no other scenario moves. Both answer cadences must still agree. If WAKE_PROFILE (device_core_conformance.rs:1769, currently (193, 4, 129, 60)) moves, the whole delta must be accounted for by the rewritten actions and reproduced by re-running with the scenario removed.
Verification
obc test -p obc-app weather
obc test -p obc-app device_core
obc test -p obc-app render_key
obc test -p obc-host-core device_core_conformance
obc test -p obc-sim
obc test -p obc-sim dirty_parity
obc test affected --base origin/develop --dry-run
obc suites check && obc suites validate-filters
python3 -m unittest discover -s firmware/tools/tests && python3 -m unittest discover -s tools/tests
python3 tools/check_render_keys.py
firmware/ui-snapshots.sh /tmp/obc-ui-wx1
python3 firmware/tools/ui_snapshot_manifest.py check firmware/ui-snapshots.sha256 /tmp/obc-ui-wx1
Board: cargo build --release --locked in firmware/obc-fw-nrf54l/, then python3 ../tools/resource_guard.py board --profile default --elf …, base and head from one checkout.
The 270-frame manifest is expected byte-identical: the cue, the step count and the zoom floor all take the same values from a different owner. A moved frame is a defect until proven otherwise, named and explained, never silently re-blessed. dirty_parity.rs's settings-and-weather replay (:1040) is the differential gate for step 4 and must show fewer or equal repaints, never more.
On glass (board). This is the first slice to make WeatherDomain live on a real device, and three of its facts have no host-side proof: open the dashboard cold and confirm one urgent fetch is raised; confirm the UPDATING cue rises and falls with a real fetch and with a cadence refresh nobody asked for; confirm a resample while the dashboard is up repaints the card; confirm Home does not repaint during a background resample.
Resource limits
size_of::<App>() is pinned at 50,936 B, an exact-match gate (firmware/tools/resource_baseline.json → compile_time_allocations.app, reported by main.rs:429). Expected delta small and itemizable: AppState gives back 8 B (rain_steps_ahead + rain_zoom_min), WeatherDomain takes ~16 B (a Revision, a u8, an f32, a bool). Any positive delta must be itemized per struct, base-to-head in one checkout.
RenderKey is pinned at ≤288 B (render_key.rs:215) and WeatherKey grows here. It is residual stack, not resident, but it is the ride loop's deepest frame — state the measured figure.
PassState ≤288 B (pass.rs:330) and WeatherDomain ≤120 B (weather.rs:1411) are unchanged tripwires; the facts land in ExternalFacts, not on PassState.
Board flash: expected flat to negative. Resident, arena, poll frame, task body, and wake rate unchanged. No new resident buffer, no second snapshot, no per-frame allocation.
The bundle itself. Frames stay in the store behind WeatherSnapshot::sample, and the snapshot stays a per-frame borrow. DC4's rule, unchanged: "the bundle never lives here."
WeatherOutcome::Opened / WeatherEffect::OpenInstalledData. Still unreachable after this slice — the installed data arrives as a fact, not as an answer to an effect this domain issues. Either delete the pair as capability nothing produces, or record here that W2 issues it; name the choice in the PR rather than leaving a third inert arm.
AppState::rain_step. Rider cursor, not weather policy; stays in the UI plane.
Definition of done
WeatherDomain has production writers on the board and in the simulator; installed() is non-None on a real device with a bundle.
Render::weather_refreshing reads the domain; weather_refresh_in_flight() and the simulator's flag plumbing are gone; a periodic fetch nobody ordered still shows the cue.
App::weather_feed_changed is deleted, its name is in check_render_keys.py's blocklist, and WeatherKey names what replaced it.
steps_ahead and the zoom floor are derived in exactly one place; AppState::rain_steps_ahead and rain_zoom_min are gone.
The alert decision runs in stage 10; no executor calls weather_alert_tick; the "no snapshot / expired data never alerts" law is re-proved at the new call site.
WeatherFeed is deleted; the render entries take Option<&WeatherSnapshot>.
Opening the Weather dashboard raises exactly one request through WeatherIntent; ride.rs's screen sniff is gone; three executors serve RequestRefresh and no assertion says weather is unreachable.
Feeder::WeatherFeed is gone, RainView is re-pointed, and DeletingSlice::WeatherCutover names no row and is deleted.
The four weather corpus requirements are asserted by named conformance tests, not merely claimed by a scenario; both answer cadences agree.
Every new test is demonstrated against its named mutant in the PR, and the two "write first, against the merged tree" tests are recorded as failing before the change.
270/270 snapshot frames byte-identical; dirty_parity repaint counts equal or lower.
Production line count is net negative and the PR states the figure; if it is not, the PR itemizes every added line and states what mechanism it removed in exchange.
Parent epic: #1433
Scope row: #1401 W1 (Gate 3 item 6)
Program tracker: #1448
Program position: Gate 3 ownership cutover, item 6 (#1433 Phase 2)
Start gate: Gate 2 is closed (S4 #1447 / PR #1536). DC4 #1437, DC5 #1438, DC6 #1439 merged. W0 is complete by absorption (#1401 closeout, 2026-08-25). Item 1 is merged (#1541 / PR #1545). Item 2 (#1548) is parallel-safe — no semantic overlap, mechanical contention only (see Resource limits).
Goal
Every fact the rider is told about weather is decided by
WeatherDomain. No platform executor decides whether an update is running, when a weather screen must repaint, how many rain steps exist, when an alert is evaluated, or that opening the dashboard is worth a radio trip.This slice moves the decisions and their repaint edge, not the bundle. The frames stay in the store behind
WeatherSnapshot::sample, exactly as DC4 locked.Current code
DC4 built the domain and T3b (#1542) gave it the alert marks. Nothing in production writes to it. Six decisions still live outside.
1. The domain has no production writer, so its render key is inert.
note_installedis reached only throughExternalFacts::weather_data()atpass.rs:447, and no host callsnote_weather_data— the only callers arepass.rs:1814,shared.rs:799, andapps/obc-sim/tests/dirty_parity.rs:806.WeatherKey { installed }(render_key.rs:173, built at:332) is therefore alwaysNoneon glass; the S4 differential harness is the only place it moves.visible()(weather.rs:1370) andlast_refresh()say so in their own docs and have no readers at all.2. "A refresh is running" has two sources of truth, and the screens read the platform's.
Render::weather_refreshing(screen/mod.rs:436) is filled atapp.rs:3128from theWeatherFeedargument; the board fills it fromcrate::ble::weather_refresh_in_flight()(ride.rs:2618, anAtomicBoolatble/weather.rs:75) and the simulator from a CLI flag (main.rs:638,1755).WeatherDomain::refreshing()(weather.rs:1305) is the domain's answer and nobody asks it. The dashboard's one cue (weather_dash.rs:97) is drawn off the platform's copy.3. The seventh hand-written repaint mirror survived S4.
App::weather_feed_changed()(app.rs:1548) setsmap_dirtyafter sniffing the top screen. It is exactly the shapetools/check_render_keys.pyexists to prevent, and it survived because its writer is between-pass: a resample changes the snapshot's contents, and no stack-local key can see a value the domain does not hold.4. The host derives the rain map's view state and hands it back as a setter.
ride.rs:2391andapps/obc-sim/{main.rs:1446,1736, gui.rs:735}callsnapshot.steps_ahead(now)andsnapshot.rain_zoom_floor(cam_lat)themselves and push the result throughApp::set_rain_view(app.rs:1529) intoAppState::rain_steps_ahead/rain_zoom_min(app.rs:170,177). Both are pure functions of the installed bundle plus the camera — domain derivations living in three executors.AppState::rain_stepis different: it is the rider's cursor on the rain map, written byweather_dash.rs:75andweather_map.rs:103, and it is UI selection state that stays where it is.5. The alert engine is ticked by the executor.
App::weather_alert_tick(app.rs:1654) is the domain's decision behind a public method, called fromride.rs:2392andapps/obc-sim/{main.rs:1739, gui.rs:737}inside each host's resample branch — so when the honesty law runs is the executor's choice. Stage 10 (pass.rs:780) does not reach for it.6. The refresh trigger is a screen sniff in the board's ride loop.
ride.rs:2254-2256compareswas_on_weather(sampled at:2070) againstapp.top_screen()and callscrate::ble::request_weather_now(). That is UI placement policy in a platform executor, and it is the rule #1433 §2 names first.WeatherIntent::RefreshRequestedexists (weather.rs:1119) with no producer;WeatherCapabilities::refresh(shared.rs:267) gates an effect that can never be emitted;device_core_conformance.rs:350andride.rs:1901both assert that a weather effect must not appear.7. The corpus claims four weather requirements and tests none of them.
Requirement::{WeatherRefreshState, WeatherInstalledDataChange, WeatherStaleData, WeatherAlertDelivery}are inALL_REQUIREMENTS(device_core_corpus/mod.rs:139-142) and attached toweather.refresh-install-stale-alert(:1126), whose four actions (:689-704) areset_rain_view(3,0.5),weather_feed_changed(),set_rain_view(0,0.0),show_weather_alert(Storm,12). None touchesWeatherDomain, and no test asserts any of the four — the S6c lesson in its purest form.Owner decision — what
RequestRefresh's outcome means (resolve before step 1)WeatherEffect::RequestRefresh's doc says "ask the companion for a fresh bundle and install it" (weather.rs:1136). Taken literally, its terminal outcome is a landed bundle, which the board can only report by correlating a flat-store commit with a request id inside the due plane — the request-kernel work #1401 W2 owns. Worse, it does not cover the cue at all: the due plane raises periodic refreshes the domain never ordered (ble/weather.rs:222, theweather_refreshcadence setting), which carry no token, so a token-onlyrefreshing()would go dark during exactly the fetches today's cue shows.Recommendation. Split the two facts, which is what they already are on the board:
RequestRefreshmeans "raise a request", and its outcome answers only that. The executor callsrequest_weather_now()and answers in the same execute —Failed { LinkLost }when there is no companion to ask, otherwise a raised verdict. Narrow the effect's doc to match.WeatherEffect/WeatherOutcomekeep their shape and their ≤16 B / ≤24 B tripwires (weather.rs:1178-1180).IN_FLIGHTlevel (ble/weather.rs:75) asExternalFacts::note_weather_refreshing(bool);WeatherDomain::refreshing()becomesself.in_flight.is_some() || self.platform_fetching.RefreshResult::Installedcomes from the installed-data fact's revision moving, not from an outcome.Alternative, and why it is not recommended. Defer request intent to W2 and ship visible-only. It is smaller, but it leaves
WeatherIntent,apply_intent,next_effectandWeatherCapabilities::refreshdead for another slice, and it leavesride.rs:2254's screen sniff standing — which is the one line #1401 W1's exit evidence is written against. Choose one before step 1.Target design
One writer, three producers, one owner
read_catalogs(ride.rs:184-198), sim's bundle loadExternalFacts::note_weather_data→WeatherDomain::note_installed(exists,pass.rs:447)ride.rs:2375)ExternalFacts::note_weather_sample(Revision)→ a newsample: Revisionon the domainExternalFacts::note_weather_refreshing(bool)→platform_fetchingThe sample revision is what deletes the mirror.
weather_feed_changed()exists because a resample is invisible to a stack-local key; a monotone counter the domain holds makes it visible,WeatherKeynames it, and the mirror goes. It cannot be folded intoinstalled.revision: a resample at a new rider position happens under an unchanged installed revision. The host already computes the key it would bump from (weather_sample_key,ride.rs:2373).The key does not name
now. The countdown and the expiry are time-driven, and the dashboard's minute ticker (weather_dash.rs:87) already reports them as aScreenTick. Adding a clock field to the key would repaint every pass.The view derivations move to their one interpreter
steps_aheadandrain_zoom_floorare alreadyWeatherSnapshotmethods (weather.rs:385,409).WeatherDomaingainsnote_sampled(&WeatherSnapshot, now, cam_lat)— one call replacing three copies of the same two-line derivation — and ownssteps_ahead/zoom_floor.AppState::rain_steps_aheadandrain_zoom_minare deleted;rain_stepstays (rider cursor) and clamps against the domain.set_rain_view's live re-clamp of the camera when the rain map is the base screen (app.rs:1537-1543) is UI-plane work with noWeatherVisiblefield — it stays, moving to the stage-10 tail where the domain's new value is known, andFeeder::RainView's row already says this is the awkward half (feeders.rs:271-281).The alert decision runs in stage 10
weather_alert_tick's body moves intostage_weather, which already runs after UiRuntime and can see the open card.App::weather_alert_tickstays as a test/simulator seam with its doc corrected to that (theforce_retention_sweepprecedent from #1548 step 6).App::show_weather_alertstays: it is the presentation seam the mark deliberately depends on (weather.rs:1395).WeatherFeedcollapsesWith
refreshinggone the struct is a one-field newtype. DeleteWeatherFeed,WeatherFeed::NONEand theDefaultderive (weather.rs:531-549), and takeOption<&WeatherSnapshot>in the three render entries (app.rs:2835,2921,2987) and the six construction sites.Render::weather_refreshingis filled fromself.weather.refreshing(), the same shape ascard_free_bytes: storage.free_bytes()two lines above it (app.rs:3126).The intent has one producer
Ctxgainsweather: &'a mut WeatherDomainbesidedfuandstorage(screen/mod.rs:208-212), andmenu.rs:176— the only push site ofScreen::Weather— namesWeatherIntent::RefreshRequested, exactly assettings/system.rs:51names the free-space refresh. This reproducesride.rs:2254's edge precisely: the only way to reachScreen::Weatherwithwas_on_weatherfalse is that push, and Back from Hourly/RainMap does not pass through it.ride.rs:2070'swas_on_weather, the sniff at:2254, andweather_refresh_in_flight()are deleted; the board's executor gains aRequestRefresharm that callsrequest_weather_now().The feeder table stops naming a slice that no longer deletes it
Feeder::WeatherFeed(feeders.rs:266) isApp::weather_feed_changed, which this slice deletes — the row goes with the method.Feeder::RainViewretires toBootAndFactsfor item 1's stated reason: what survives it is the camera re-clamp, a runtime UI seam and not weather ownership.DeletingSlice::WeatherCutover(feeders.rs:179) then names no row and is deleted; the exhaustivefeeder_migrationmatch is the structural record that this slice is finished, asCatalogOwnership's deletion was for item 1.Implementation steps
sample: Revision,steps_ahead: u8,zoom_floor: f32,platform_fetching: booltoWeatherDomain; addnote_sampledandnote_refreshing; extendrefreshing(). Keep the ≤120 B tripwire (weather.rs:1411) — raise it only with an itemized reason.note_weather_sampleandnote_weather_refreshingtoExternalFactswith their rows in the fact table (shared.rs:429-437); consume both at stage 2 (pass.rs:447).WeatherKeythe visible facts:installed,sample,refreshing. DeleteApp::weather_feed_changedand addweather_feed_changedtotools/check_render_keys.py'sRETIREDblocklist.steps_ahead/rain_zoom_floorderivation out of all three hosts intonote_sampled; deleteAppState::rain_steps_aheadandrain_zoom_min; re-home the camera re-clamp to the stage-10 tail; keeprain_stepand clamp it against the domain.weather_alert_tick's body intostage_weather; keep the method as a named test/sim seam with a corrected doc; delete the three executor call sites.WeatherFeedand threadOption<&WeatherSnapshot>; fillRender::weather_refreshingfrom the domain.Ctx::weather; name the intent atmenu.rs:176; deleteride.rs:2070/:2254-2256andble::weather_refresh_in_flight; add the board, simulator and conformance executor arms forRequestRefresh; delete the two "weather cannot be reached" assertions (dispatch.rs:418,device_core_conformance.rs:350) andride.rs:1901's weather half.IN_FLIGHTlevel as the fact from both edges (ble/weather.rs:166,222,245); replace the sim's--weather-refreshingflag plumbing with the fact.Feeder::RainView, deleteFeeder::WeatherFeedandDeletingSlice::WeatherCutover.refreshingbool crossing a render signature.Tests
Each names the mutant it must fail against.
an_installed_bundle_reaches_the_domain_from_a_real_hostnote_weather_datacall —installed()staysNoneon glass, which is today's tree (write this one first, against the merged tree, and record that it fails)a_resample_repaints_an_open_dashboard_without_a_dirty_flaga_resample_does_not_repaint_homeweather_feed_changed's screen sniff was written to avoidthe_updating_cue_follows_the_domain_not_the_platform_flagRender::weather_refreshingfed from the executor — the cue andrefreshing()can disagreea_periodic_fetch_nobody_ordered_still_shows_the_cuerefreshing()token-only — the cadence refresh goes dark (the owner decision's load-bearing case)two_taps_of_the_dashboard_row_are_one_requestapply_intent's coalesce — two radio trips on a metered linka_refresh_asked_for_without_a_companion_is_not_startedWeatherCapabilities::refreshgate — an effect is emitted that no link can serveopening_hourly_and_coming_back_asks_nothinghandleinstead of the pushing row — Back from Hourly manufactures a second urgent requestthe_rain_step_clamps_to_the_domains_step_countrain_steps_aheadinAppState— two copies, and the cursor clamps against the stale onean_alert_is_evaluated_once_per_pass_not_once_per_resamplea_stale_bundle_alerts_nothing_from_stage_tenCorpus (
host/obc-host-core/tests/device_core_corpus/mod.rs): the four existing weather requirements are tautologies and this slice fixes them, it does not add a fifth. Rewrite the four actions ofweather.refresh-install-stale-alertto driveWeatherDomain—RefreshWeathernames the intent and expects oneWeatherEffect;InstallWeatherDatareportsnote_weather_data;MarkWeatherStalereports a resample with no current frame;DeliverWeatherAlertkeepsshow_weather_alert. Add a conformance test asserting each of the four by name, the wayCatalogDeleteOrdersRefreshis asserted atdevice_core_conformance.rs:1327.ALL_REQUIREMENTSmembership is unchanged — the S6c lesson here is the other half: a requirement in the list with no assertion behind it is equally ungated.Expected trace movement.
weather.refresh-install-stale-alertgains oneWeatherEffect::RequestRefreshstep; no other scenario moves. Both answer cadences must still agree. IfWAKE_PROFILE(device_core_conformance.rs:1769, currently(193, 4, 129, 60)) moves, the whole delta must be accounted for by the rewritten actions and reproduced by re-running with the scenario removed.Verification
Board:
cargo build --release --lockedinfirmware/obc-fw-nrf54l/, thenpython3 ../tools/resource_guard.py board --profile default --elf …, base and head from one checkout.The 270-frame manifest is expected byte-identical: the cue, the step count and the zoom floor all take the same values from a different owner. A moved frame is a defect until proven otherwise, named and explained, never silently re-blessed.
dirty_parity.rs's settings-and-weather replay (:1040) is the differential gate for step 4 and must show fewer or equal repaints, never more.On glass (board). This is the first slice to make
WeatherDomainlive on a real device, and three of its facts have no host-side proof: open the dashboard cold and confirm one urgent fetch is raised; confirm the UPDATING cue rises and falls with a real fetch and with a cadence refresh nobody asked for; confirm a resample while the dashboard is up repaints the card; confirm Home does not repaint during a background resample.Resource limits
size_of::<App>()is pinned at 50,936 B, an exact-match gate (firmware/tools/resource_baseline.json→compile_time_allocations.app, reported bymain.rs:429). Expected delta small and itemizable:AppStategives back 8 B (rain_steps_ahead+rain_zoom_min),WeatherDomaintakes ~16 B (aRevision, au8, anf32, abool). Any positive delta must be itemized per struct, base-to-head in one checkout.resource_baseline.jsonis contended three ways. Retention's expiry is retired by the catalog's verdict, not by a timer waiting for a rescan #1548 is in implementation and The chord plane, the drawer host, and the universal quick drawer (D2 of #1515) #1547 (D2, +2,500/−152, open, already rebased onto T3b) both touch it. Whichever of the three lands last re-measures from the merged tree — never sums the notes. T3b's own note already records this rule for the 50,936 B figure.RenderKeyis pinned at ≤288 B (render_key.rs:215) andWeatherKeygrows here. It is residual stack, not resident, but it is the ride loop's deepest frame — state the measured figure.PassState≤288 B (pass.rs:330) andWeatherDomain≤120 B (weather.rs:1411) are unchanged tripwires; the facts land inExternalFacts, not onPassState.Mechanical contention with #1548
Stated in both issues, per the #1548 orchestrator disposition (2026-08-27): no semantic overlap, but three files collide.
ALL_REQUIREMENTS(device_core_corpus/mod.rs:96) — Retention's expiry is retired by the catalog's verdict, not by a timer waiting for a rescan #1548 appendsRetentionCandidateRetiredByVerdict; this slice appends nothing and rewrites the four weather rows' actions. Textually adjacent, semantically disjoint.WAKE_PROFILE(device_core_conformance.rs:1769) — both change it. The second to land re-runs and re-pins; neither may arithmetic the other's delta.resource_baseline.json— see above; The chord plane, the drawer host, and the universal quick drawer (D2 of #1515) #1547 is the third contender.feeders.rs— Retention's expiry is retired by the catalog's verdict, not by a timer waiting for a rescan #1548 deletesDeletingSlice::RetentionOwnership, this slice deletesWeatherCutover. Same enum, different variants; the exhaustivefeeder_migrationmatch resolves both mechanically.Out of scope, with reasons
DueScheduler, the §11.4 context assembly,App::weather_snapshot()(app.rs:2447) and the board/simulator duplication are AUDIT — Device weather: one freshness law holds; the A/B store is the removable part, and FS7 already owns it #1401 W2. This slice moves who asks, not how the asking is scheduled. Theweather_refreshcadence setting stays the platform's: DC4 assigns provider timing to the task.obc_app::ble::WeatherSnapshot's name collision withobc_app::weather::WeatherSnapshot. AUDIT — Device weather: one freshness law holds; the A/B store is the removable part, and FS7 already owns it #1401 W3, ~30 mechanical call sites, and renaming it inside this slice would bury the ownership diff.obcg.rs's placement note and the three stale architecture-doc lines (architecture.md:97,451,index.md:137). AUDIT — Device weather: one freshness law holds; the A/B store is the removable part, and FS7 already owns it #1401 W3/W4; startable independently today.WeatherSnapshot::sample, and the snapshot stays a per-frame borrow. DC4's rule, unchanged: "the bundle never lives here."WeatherOutcome::Opened/WeatherEffect::OpenInstalledData. Still unreachable after this slice — the installed data arrives as a fact, not as an answer to an effect this domain issues. Either delete the pair as capability nothing produces, or record here that W2 issues it; name the choice in the PR rather than leaving a third inert arm.AppState::rain_step. Rider cursor, not weather policy; stays in the UI plane.Definition of done
WeatherDomainhas production writers on the board and in the simulator;installed()is non-Noneon a real device with a bundle.Render::weather_refreshingreads the domain;weather_refresh_in_flight()and the simulator's flag plumbing are gone; a periodic fetch nobody ordered still shows the cue.App::weather_feed_changedis deleted, its name is incheck_render_keys.py's blocklist, andWeatherKeynames what replaced it.steps_aheadand the zoom floor are derived in exactly one place;AppState::rain_steps_aheadandrain_zoom_minare gone.weather_alert_tick; the "no snapshot / expired data never alerts" law is re-proved at the new call site.WeatherFeedis deleted; the render entries takeOption<&WeatherSnapshot>.WeatherIntent;ride.rs's screen sniff is gone; three executors serveRequestRefreshand no assertion says weather is unreachable.Feeder::WeatherFeedis gone,RainViewis re-pointed, andDeletingSlice::WeatherCutovernames no row and is deleted.dirty_parityrepaint counts equal or lower.size_of::<App>(),RenderKey, resident, arena, poll frame and wake rate do not regress; deltas measured base-to-head in one checkout, re-measured if Retention's expiry is retired by the catalog's verdict, not by a timer waiting for a rescan #1548 or The chord plane, the drawer host, and the universal quick drawer (D2 of #1515) #1547 landed first.🤖 Generated with Claude Code