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
S5's on-glass soak: does not block this slice. See "Does the S5 soak block S6?" below. It is a start gate for S6b, the board cutover.
Goal
Move the three non-board runtime hosts — obc-host-core's shared HostLoop, obc-sim (GUI and
headless) and obc-web-demo — off the fifteen-method prose frame contract and onto the one entry
point that already exists: App::run_pass. Give them a typed executor: bounded effects in,
token-carrying outcomes back, no HostCommand in the middle.
After this slice there is exactly one production drain_host_commands call site left
(firmware/obc-fw-nrf54l/src/ride.rs:1086), and it belongs to S6b.
This issue does not move the board, does not delete the App façade wrappers or the bulk
feeders, and does not build Recorder's or Bond's machines. See "What is deliberately not in this
slice".
Read this first: the S6 row is three slices, and its clauses are not in dependency order
S6 — Frame-pass driver; delete façade wrappers; storage rows (after FS7). Gate: all four hosts use pass(); retain manual dirty logic for S4
Three findings from reading origin/develop at 43dcd09d, each of which changes the shape of
the work.
1. "Frame-pass driver" and "storage rows" cannot be separate slices, in either order
The obvious decomposition — first move the hosts onto run_pass alongside the existing drain, then
do the storage rows — does not exist, because the two paths consume the same one-shots. stage_ui takes activity.take_route_delete() / take_ride_delete() and turns them into a CatalogIntent (device_core/pass.rs:454–465); drain_host_command reads the same latches
(activity.rs:617, 637). A host that runs the pass has already moved catalog deletion into CatalogMachine, whether or not anyone planned that for this slice. The conformance harness states
the rule as an assertion:
assert!(!pass_owned(&command),"{command:?} is DeviceCore's now — running it here would repeat the effect that carries it");
(host/obc-host-core/tests/device_core_conformance.rs:589–593; pass_owned is the twelve commands
at :231–247.)
And the moment CatalogMachine owns deletion, the compatibility adapter cannot finish the job: CatalogEffect::RemoveObject is namespace-free, the legacy deletes are namespaced, so catalog_row returns UnansweredRow::Absent(LegacyOwned::ObjectNamespace)
(device_core/compat.rs:293) — the object survives — and because no HostEvent can build a CatalogOutcome, the domain's in-flight latch never clears. DC7's gate records exactly this as
three accepted compatibility-runner cells (catalog.route-delete, catalog.ride-delete, retention.expiry-retry-and-trusted-clock), and compat.rs:50–52 states the cost in the plainest
possible terms:
This path completes at most one operation for each of CatalogMachine, RetentionMachine and WeatherDomain — for the whole life of the device.
So "hosts run the pass through the LegacyAdapter" is not a shippable intermediate state: it is a
device where the first route delete silently does nothing and every later one wedges. The typed
executor is not a follow-on to the cutover; it is the cutover. The conformance harness has always
said so — Executor::Typed is documented at device_core_conformance.rs:265 as "What #1397 S6
builds for real."
The honest split is therefore by host, not by mechanism: one slice per production drain site.
2. There are exactly two production drain sites, and they partition the four hosts cleanly
Site
Serves
Slice
host/obc-host-core/src/dispatch.rs:228
sim GUI (gui.rs:494), sim headless (main.rs:1318, :745), web demo (demo.rs:272)
S6a — this issue
firmware/obc-fw-nrf54l/src/ride.rs:1086
the board
S6b
Every other drain_host_commands call in the repository is a test. The three hosts in S6a share one
executor (HostLoop::dispatch_commands, dispatch.rs:213–…), run against in-process repositories,
and are covered by the replay corpora, the 270-frame V0 manifest and present.rs's exact-diff
oracle. None of them touches glass. The board is a second executor over the flat store, a
different memory regime, and an on-glass soak. Putting them in one PR buys nothing and risks both.
3. "Delete façade wrappers" cannot land until the last host migrates
The 55 one-line delegators on App and the 22 DeletingSlice::PassCutover feeders
(device_core/feeders.rs) are one shared surface. The board still calls 30 of the 55 and most of the
22. Deleting them here is impossible; deleting them in S6b would make that slice "board cutover +
repo-wide sweep". They get their own slice, S6c, which is compiler-verified and carries no
behavioural risk.
What this slice is not allowed to absorb
The deletes_in prose in compat.rs:196–212 assigns nine of the tenLegacyOwned rows to
"#1397 S6". Read literally, that makes S6 the whole of Gates 2, 3 and 4. The tracker controls global
order (#1448 §3 precedence), and it says otherwise:
That is not a contradiction to resolve by argument: the rows were written before the tracker's gate
order was reconciled, and this slice corrects the four prose strings it invalidates. Concretely, S6
leaves a named two-command residual on the legacy protocol — FinishTrack and ForgetBond, the
two domains that cannot validate a token (#1433 §4.3) — and that residual is exactly what the
conformance harness has been running since DC6:
"the legacy half, for the two domains without a machine — Recorder and Bond, and nothing else."
(device_core_conformance.rs:573–580)
PlannerPacing also survives S6 by construction: NavigatorMachine emits Acquire and the executor
runs the whole search, so NavigatorEffect::Step / CommitRoute are never produced. The typed
harness asserts that (device_core_conformance.rs:458). Stepped pacing is Gate 4.
S6a cannot be blocked by it: it compiles no board code, touches no arena claim, and the
mechanism the soak exercises (a refused arena claim degrading into a map that never redraws) is
not reachable from any host in this slice.
S6b must not start before the soak runs. S6b replaces the very loop the soak drives. Running
S5's soak afterwards would no longer isolate CoreMode, and a defect found then would be
ambiguous between two merged slices. The driver is committed (tools/s5_core_mode_soak.py), so
this is a scheduling item, not new work.
Source of truth
The pass contract and stage order: #1433 §6
and firmware/obc-app/src/device_core/pass.rs:1–86.
The effect/outcome vocabularies this slice executes: catalog_state.rs:627–699, retention.rs, navigator.rs, settings.rs, dfu.rs, device_core/storage_info.rs.
What the legacy protocol cannot express, and who owns each gap: device_core/compat.rs:142–213
(LegacyOwned) and device_core/feeders.rs:148–160 (DeletingSlice).
The two-executor conformance harness this slice makes real: host/obc-host-core/tests/device_core_conformance.rs — Executor::Typed (:401–436, serve_catalog:477, serve_navigator:443, serve_dfu:464), serve_mailbox (:582), pass_owned (:231).
Gate 2's boundary and what belongs to Gate 3/4: #1448 §4.
feeders → host.reconcile :494 → fill_nav_preview :541 → app.tick :589/replay_step :551 → sample_terrain :608 → weather feeders → render_frame :669 → take_dirty :691 (stats only). Input is applied after the render (:845), so a gesture is seen by the next frame's tick.
queue → apply_gesture :346 → host.reconcile :272 → fill_nav_preview :296 → replay_step :300 → take_dirty :321 → render_frame :325. No advance_animations, no set_hold_progress, no ms_until_next_wake.
Shared executor
host/obc-host-core/src/dispatch.rs
432 (0 test)
reconcile :99 → dispatch_commands :213 (drain_host_commands:228, then an 18-arm match over the popped mailbox, with repository work and bulk re-feeds) → step_plan :134 → reconcile_track :135.
Production apply_event sites outside the board: 21 (sim GUI 4, sim headless 11, web demo 1, obc-host-core 5 — dispatch.rs:317, nav.rs:203/216/276/321).
What run_pass already does, and the one thing it does not
App::run_pass (device_core/pass.rs:299) runs fourteen stages once each and returns a PassPlan
carrying render, next_wake_ms, derived_needs, sources, effects, immediate. Everything
this slice needs on the DeviceCore side exists:
Needed
Where it already is
Catalog intents, effects, outcomes, the cascade order
device_core/shared.rs, consumed at pass.rs:373–411
The residual seam for Recorder/Bond
stage_recorder :589 (a held position), stage_platform :659
The one gap on the DeviceCore side: a store-revision change still becomes the legacy rescan cue. stage_facts calls self.note_store_changed() (pass.rs:378), which latches HostCommand::RescanStore; CatalogIntent::Refresh exists (catalog_state.rs:630) but nothing
raises it, which is why the typed harness has to panic at serve_catalog:
CatalogEffect::ReadCatalog{ .. } => panic!("no catalog refresh intent exists until #1397 S6 moves the store executor")
(device_core_conformance.rs:476–482)
Wiring that is this slice's only change inside obc-app's pass.
The two-command residual, stated exactly
After this slice, the three hosts' legacy drain emits only:
Command
Why it stays
Retires in
FinishTrack(Save|Discard)
Recorder has no machine; the legacy close is answered by a catalog re-feed, not a ride identity (LegacyOwned::RideCloseAck)
The bond removal is confirmed by a link-status fact, not a reply (LegacyOwned::BondAck)
Gate 3/4
LoadRideTrack and RefreshNavPreview are levels, not one-shots: they leave the drain entirely
and become PassPlan::derived_needs keys (derived_level, device_core_conformance.rs:252). RescanStore, DeleteRoute, DeleteRide, DeleteTrip and both Stamp* commands stop being
emitted by these hosts.
Locked design
HostLoop becomes a typed executor.HostLoop::reconcile is replaced by HostLoop::execute(&mut App, plan: &mut PassPlan, …) -> OutcomeSlots-shaped seam: it takes the
bounded effects out of PassPlan::effects, performs them against the repositories it already
owns, and returns token-carrying outcomes for the next pass. It performs no product policy —
no ordering decision, no cascade, no replacement rule. The canonical-order match at dispatch.rs:231 is replaced by one arm per domain effect, not one arm per command.
The catalog cascade moves to CatalogMachine, where it already is.dispatch.rs's DeleteTrip arm (member routes, then the .obt, then two re-feeds) is deleted; the domain
already owns that order through ReadTripMembers → RemoveObject → ReadCatalog. The executor
answers TripMembersRead { members }, ObjectRemoved { object, existed } and CatalogRead { revision }, and existed: false is a success (EPIC — Device core: one product kernel, typed effects, thin platform executors #1433's "trip member
disappears before the delete commit").
ExternalFacts::store_revision raises CatalogIntent::Refresh.stage_facts's note_store_changed() is replaced by the intent the type was written for. The fact never orders a
refresh; the intent does (catalog_state.rs:628–630). This is the one obc-app change in the
slice, and it is what lets CatalogEffect::ReadCatalog be produced at all.
The bulk re-feeds keep their shape and gain a revision.feed_routes / trips.refeed / set_rides stay: the resident catalogs are filled by the executor, and the outcome reports only
the revision it read at (CatalogOutcome::CatalogRead). Catalogs do not enter either protocol
(compat.rs:78–84). No new copy of the catalog is created.
Retention stamps are acknowledged.RetentionEffect::Write{Route,Ride}Metadata becomes a
sidecar write whose success or failure returns as a RetentionOutcome. App::mirror_stamp
(pass.rs:520) already prevents the re-enqueue storm; the ack is what lets a failed write
re-queue through RetentionMachine::apply_outcome instead of wedging.
The three hosts call run_pass once per frame, and nothing else in the protocol. Each host
builds PassInputs { now, gestures, sensors, route, support, outcomes, facts, derived, targets }
from what it already has, and consumes PassPlan:
plan.render replaces app.take_dirty();
plan.sources replaces base_needs_reader() at the reader-open site;
The gesture/tick reordering is deliberate, declared, and the top risk. Today the sim GUI
applies input after the render and the web demo runs no animation clock at all. run_pass fixes
the order (outcomes → facts → gestures+tick → UI/animations → domains → plan). Frames will
move. Every moved frame is explained and deliberately re-pinned in the PR — never absorbed. The
precedent is DC7's one corrected mismatch, where three DeviceCore runners legitimately settle one
screen shallower than the legacy runner.
Nothing shared with the board is deleted.drain_host_commands, apply_event, peek_host_command, drain_host_command, DRAIN_ORDER, class(), the 55 façades and the 22 PassCutover feeders all stay compiling and tested. S6b removes the second caller; S6c removes
the surface.
LegacyAdapter gets no production caller here, and that is the design. These hosts go straight to typed. compat.rs remains what DC6 built it as: the path a host can run before its
executor migrates, exercised by Executor::Compatibility. If S6b finds the board needs a staged
loop-then-executor migration, the adapter is there for it.
Wiring ExternalFacts::note_transfer from the flat engine (S5 open question 2): the party that
knows is Engine::busy, which lives on the board. S6b.
store_writable cannot come back down — no unmount fact exists (pass.rs:682–689, S2 open
question 4). It belongs with whoever adds the fact; naming it here does not make it this slice's.
Files
Changed
File
Change
host/obc-host-core/src/dispatch.rs (432)
HostLoop becomes a typed effect executor; the 18-arm command match becomes per-domain effect arms; the trip cascade is deleted (the domain owns it)
host/obc-host-core/src/nav.rs (334)
the four apply_event sites become NavigatorOutcomes; fill_nav_preview answers a derived_needs key
Added: the typed executor arms in dispatch.rs (~200), PassInputs construction per host
(~40 × 3).
LOC expectation: roughly flat to +150 production, and this is stated as a fact rather than
defended. The deletion this epic promised for Area D lands in S6c, where the shared surface
loses its last caller. A slice that claims a deletion it cannot make is how the budget stops meaning
anything.
Tests
Ported before anything is deleted (the #1256 rule): every dispatch.rs behaviour that has a
named regression gets its assertion on the typed path in the same PR, green before the command arm
it replaces is removed.
obc-host-core
device_core_conformance.rs: all DC1 scenarios × 5 runners green. The three accepted ObjectNamespace cells (catalog.route-delete, catalog.ride-delete, retention.expiry-retry-and-trusted-clock) stay accepted for the compatibility runner — this
slice does not change the adapter — but the production hosts no longer run that path, and the
disposition table records that. A new differing cell is a blocking failure.
device_core_legacy_traces.rs (8 scenarios): must stay byte-identical. It describes the board's
shipping path, which this slice does not touch. A diff here means the slice leaked.
tests/host_protocol.rs: unchanged and green — the protocol still exists for the board.
A pass test that a store-revision fact produces CatalogIntent::Refresh → ReadCatalog, and that
one commit produces exactly one refresh.
Rendering / replay
firmware/ui-snapshots.sh + ui_snapshot_manifest.py check — 270 frames. Frames may move
(design point 7). Every moved frame is diffed, explained and re-pinned in the PR body; an
unexplained move is a blocking failure.
obc-sim --script / --png corpora and present.rs's exact-diff oracle.
The web demo's #[cfg(test)] chapter suite (demo.rs:465+) — the ambient replay, the upload
chapter, the ride-start chapter.
Deletion ledger: python3 tools/loc_ledger.py --pr <n>, reported in the PR.
Resource limits
Baseline is firmware/tools/resource_baseline.json at 43dcd09d. This slice compiles no new board
code, so the expectation is "unchanged to the byte" — and it is measured, not assumed, because obc-app is on the device path.
Item
Baseline
Rule
compile_time_allocations.app (size_of::<App>())
50,928 B
Must not grow. The refresh intent uses the existing ui_catalog connection slot; a growth here means a second pending copy was created.
Host-side: no allocator change in obc-app, no new task, no combined Effect/Outcome/Intent
enum, no event bus (#1433 §14, §16).
Verification
obc test -p obc-app
obc test -p obc-host-core
obc test fixtures -p obc-host-core
obc test -p obc-sim
cargo clippy -p obc-app -p obc-host-core -p obc-sim -p obc-web-demo --all-targets -- -D warnings
cargo test --workspace
obc check fmt device board
obc suites check
python3 tools/loc_ledger.py --pr <n># the board must still build and measure identically, even though it is untouchedcd firmware/obc-fw-nrf54l && cargo build --release --locked
python3 ../tools/resource_guard.py board --profile default --elf target/thumbv8m.main-none-eabihf/release/obc-fw-nrf54l
cargo build --release --locked --features resource-report
python3 ../tools/resource_guard.py report --profile default --elf …
# pixels
bash firmware/ui-snapshots.sh /tmp/s6a-snaps
python3 firmware/tools/ui_snapshot_manifest.py check firmware/ui-snapshots.sha256 /tmp/s6a-snaps
python3 docs/build_docs.py --check-links # only if docs/content describes the host protocol
No on-glass work in this slice. The board build is run to prove it did not move.
Definition of done
host/obc-host-core/src/dispatch.rs:228 no longer calls drain_host_commands; the shared
executor takes PassPlan::effects and returns typed outcomes.
obc-sim (GUI and headless) and obc-web-demo each drive exactly one App::run_pass per
frame, and consume plan.render / plan.sources / plan.derived_needs / plan.next_wake_ms
/ plan.immediate.
The 21 non-board apply_event call sites are gone; their facts and outcomes arrive on PassInputs.
ExternalFacts::store_revision raises CatalogIntent::Refresh; note_store_changed() is no
longer called from stage_facts; one commit produces exactly one refresh.
The trip cascade is deleted from dispatch.rs and is served by CatalogMachine's own order; existed: false is a success.
Retention stamps are acknowledged, and a failed stamp re-queues through RetentionMachine::apply_outcome rather than latching.
These three hosts' legacy drain emits onlyFinishTrack and ForgetBond, pinned by a
production assertion equivalent to pass_owned — not a comment.
Open questions for the implementer to settle in review
Does HostLoop survive as a type, or become a plain executor struct? Once the mailbox, the
canonical order and the command match are gone, what is left is the in-flight InflightPlan, the DetourReady hold and ActiveRouteSession. That may be three fields on the sim/web hosts rather
than a shared type. Deleting a type that no longer answers a question is preferable to keeping a
name; decide with the diff in hand.
Does the web demo want plan.next_wake_ms? It is rAF-paced and ignores wake scheduling today.
Ignoring a plan field is fine if it is stated; silently dropping it is not. Confirm which.
PlanHold (dispatch.rs:44–59) — does it survive the cutover? It exists so the simulator can
freeze a planning screen for --hold nav snapshots. Under the pass, the same freeze is "do not
serve the NavigatorEffect::Acquire", which the executor can express without a shared type. If it
can, delete it.
Sim GUI input ordering. Moving handle_input ahead of the pass is correct and will move
frames. Confirm the moved set is exactly the screens whose animation phase advanced, and not a
deeper stack difference.
Does the PassInputs construction want a shared helper? Three hosts building the same nine
fields invites a fourth copy. A helper in obc-host-core is right for the three; the board's is
different enough that S6b should not be forced through it.
Parent epic: #1397
Program tracker: #1448
Program position: App core, S6 (frame-pass driver), first of three slices
Start gates:
43dcd09d). Met.CoreModeis the single owner of"what may run now", stage 12 reads it, and
MapQuiesced/TransferReadycan only be minted fromit.
SettingsMachine,DfuStateandStorageInfoown their pending state and validate their own tokens.compatibility adapter and the conformance gate all exist.
what makes the storage rows in this slice writable at all: the flat store removes an object by
identity, which is the shape
CatalogEffect::RemoveObjectalready has.is a start gate for S6b, the board cutover.
Goal
Move the three non-board runtime hosts —
obc-host-core's sharedHostLoop,obc-sim(GUI andheadless) and
obc-web-demo— off the fifteen-method prose frame contract and onto the one entrypoint that already exists:
App::run_pass. Give them a typed executor: bounded effects in,token-carrying outcomes back, no
HostCommandin the middle.After this slice there is exactly one production
drain_host_commandscall site left(
firmware/obc-fw-nrf54l/src/ride.rs:1086), and it belongs to S6b.This issue does not move the board, does not delete the
Appfaçade wrappers or the bulkfeeders, and does not build Recorder's or Bond's machines. See "What is deliberately not in this
slice".
Read this first: the S6 row is three slices, and its clauses are not in dependency order
The row in #1397 reads:
Three findings from reading
origin/developat43dcd09d, each of which changes the shape ofthe work.
1. "Frame-pass driver" and "storage rows" cannot be separate slices, in either order
The obvious decomposition — first move the hosts onto
run_passalongside the existing drain, thendo the storage rows — does not exist, because the two paths consume the same one-shots.
stage_uitakesactivity.take_route_delete()/take_ride_delete()and turns them into aCatalogIntent(device_core/pass.rs:454–465);drain_host_commandreads the same latches(
activity.rs:617, 637). A host that runs the pass has already moved catalog deletion intoCatalogMachine, whether or not anyone planned that for this slice. The conformance harness statesthe rule as an assertion:
(
host/obc-host-core/tests/device_core_conformance.rs:589–593;pass_ownedis the twelve commandsat
:231–247.)And the moment
CatalogMachineowns deletion, the compatibility adapter cannot finish the job:CatalogEffect::RemoveObjectis namespace-free, the legacy deletes are namespaced, socatalog_rowreturnsUnansweredRow::Absent(LegacyOwned::ObjectNamespace)(
device_core/compat.rs:293) — the object survives — and because noHostEventcan build aCatalogOutcome, the domain's in-flight latch never clears. DC7's gate records exactly this asthree accepted
compatibility-runner cells (catalog.route-delete,catalog.ride-delete,retention.expiry-retry-and-trusted-clock), andcompat.rs:50–52states the cost in the plainestpossible terms:
So "hosts run the pass through the LegacyAdapter" is not a shippable intermediate state: it is a
device where the first route delete silently does nothing and every later one wedges. The typed
executor is not a follow-on to the cutover; it is the cutover. The conformance harness has always
said so —
Executor::Typedis documented atdevice_core_conformance.rs:265as "What #1397 S6builds for real."
The honest split is therefore by host, not by mechanism: one slice per production drain site.
2. There are exactly two production drain sites, and they partition the four hosts cleanly
host/obc-host-core/src/dispatch.rs:228gui.rs:494), sim headless (main.rs:1318,:745), web demo (demo.rs:272)firmware/obc-fw-nrf54l/src/ride.rs:1086Every other
drain_host_commandscall in the repository is a test. The three hosts in S6a share oneexecutor (
HostLoop::dispatch_commands,dispatch.rs:213–…), run against in-process repositories,and are covered by the replay corpora, the 270-frame V0 manifest and
present.rs's exact-difforacle. None of them touches glass. The board is a second executor over the flat store, a
different memory regime, and an on-glass soak. Putting them in one PR buys nothing and risks both.
3. "Delete façade wrappers" cannot land until the last host migrates
The 55 one-line delegators on
Appand the 22DeletingSlice::PassCutoverfeeders(
device_core/feeders.rs) are one shared surface. The board still calls 30 of the 55 and most of the22. Deleting them here is impossible; deleting them in S6b would make that slice "board cutover +
repo-wide sweep". They get their own slice, S6c, which is compiler-verified and carries no
behavioural risk.
What this slice is not allowed to absorb
The
deletes_inprose incompat.rs:196–212assigns nine of the tenLegacyOwnedrows to"#1397 S6". Read literally, that makes S6 the whole of Gates 2, 3 and 4. The tracker controls global
order (#1448 §3 precedence), and it says otherwise:
LegacyOwnedrowcompat.rssaysStoreRevision,ObjectNamespace,TripCascade,SidecarAckRecorderJournal,RideCloseAckBondAckble.rs. Not S6.PlannerPacing,PlannerReleaseHostPassinto typed effect staging", with #1400. Not S6.WeatherProtocolThat is not a contradiction to resolve by argument: the rows were written before the tracker's gate
order was reconciled, and this slice corrects the four prose strings it invalidates. Concretely, S6
leaves a named two-command residual on the legacy protocol —
FinishTrackandForgetBond, thetwo domains that cannot validate a token (#1433 §4.3) — and that residual is exactly what the
conformance harness has been running since DC6:
PlannerPacingalso survives S6 by construction:NavigatorMachineemitsAcquireand the executorruns the whole search, so
NavigatorEffect::Step/CommitRouteare never produced. The typedharness asserts that (
device_core_conformance.rs:458). Stepped pacing is Gate 4.Does the S5 soak block S6?
No for this slice; yes for S6b.
nonetheless records S5 as complete and names S6 the frontier — the code gate is met, the physical
one is outstanding.
mechanism the soak exercises (a refused arena claim degrading into a map that never redraws) is
not reachable from any host in this slice.
S5's soak afterwards would no longer isolate
CoreMode, and a defect found then would beambiguous between two merged slices. The driver is committed (
tools/s5_core_mode_soak.py), sothis is a scheduling item, not new work.
Source of truth
and
firmware/obc-app/src/device_core/pass.rs:1–86.pass and effect protocol" section of #1397.
catalog_state.rs:627–699,retention.rs,navigator.rs,settings.rs,dfu.rs,device_core/storage_info.rs.device_core/compat.rs:142–213(
LegacyOwned) anddevice_core/feeders.rs:148–160(DeletingSlice).host/obc-host-core/tests/device_core_conformance.rs—Executor::Typed(:401–436,serve_catalog:477,serve_navigator:443,serve_dfu:464),serve_mailbox(:582),pass_owned(:231).Current code
The three hosts and what they call per frame
All line numbers are
origin/developat43dcd09d.apps/obc-sim/src/gui.rs—update:947 →render_to_texture:449host.reconcile:494 →fill_nav_preview:541 →app.tick:589/replay_step:551 →sample_terrain:608 → weather feeders →render_frame:669 →take_dirty:691 (stats only). Input is applied after the render (:845), so a gesture is seen by the next frame's tick.apps/obc-sim/src/main.rs—apply_script:820, hook :1281, replay loop :1526render_frame:1305 →reconcile_headless:1318 →app.tick:1347. 11apply_eventinjection sites at :1394–1481.apps/obc-web-demo/src/demo.rs—Demo::tick:246apply_gesture:346 →host.reconcile:272 →fill_nav_preview:296 →replay_step:300 →take_dirty:321 →render_frame:325. Noadvance_animations, noset_hold_progress, noms_until_next_wake.host/obc-host-core/src/dispatch.rsreconcile:99 →dispatch_commands:213 (drain_host_commands:228, then an 18-armmatchover the popped mailbox, with repository work and bulk re-feeds) →step_plan:134 →reconcile_track:135.Production
apply_eventsites outside the board: 21 (sim GUI 4, sim headless 11, web demo 1,obc-host-core5 —dispatch.rs:317,nav.rs:203/216/276/321).What
run_passalready does, and the one thing it does notApp::run_pass(device_core/pass.rs:299) runs fourteen stages once each and returns aPassPlancarrying
render,next_wake_ms,derived_needs,sources,effects,immediate. Everythingthis slice needs on the DeviceCore side exists:
catalog_state.rs:627–699apply_outcomere-queueretention.rspass.rsstages 8/9/11ride_track,nav_preview)device_core/derived.rsdevice_core/shared.rs, consumed atpass.rs:373–411stage_recorder:589 (a held position),stage_platform:659The one gap on the DeviceCore side: a store-revision change still becomes the legacy rescan cue.
stage_factscallsself.note_store_changed()(pass.rs:378), which latchesHostCommand::RescanStore;CatalogIntent::Refreshexists (catalog_state.rs:630) but nothingraises it, which is why the typed harness has to panic at
serve_catalog:(
device_core_conformance.rs:476–482)Wiring that is this slice's only change inside
obc-app's pass.The two-command residual, stated exactly
After this slice, the three hosts' legacy drain emits only:
FinishTrack(Save|Discard)LegacyOwned::RideCloseAck)ForgetBondLegacyOwned::BondAck)LoadRideTrackandRefreshNavPrevieware levels, not one-shots: they leave the drain entirelyand become
PassPlan::derived_needskeys (derived_level,device_core_conformance.rs:252).RescanStore,DeleteRoute,DeleteRide,DeleteTripand bothStamp*commands stop beingemitted by these hosts.
Locked design
HostLoopbecomes a typed executor.HostLoop::reconcileis replaced byHostLoop::execute(&mut App, plan: &mut PassPlan, …) -> OutcomeSlots-shaped seam: it takes thebounded effects out of
PassPlan::effects, performs them against the repositories it alreadyowns, and returns token-carrying outcomes for the next pass. It performs no product policy —
no ordering decision, no cascade, no replacement rule. The canonical-order
matchatdispatch.rs:231is replaced by one arm per domain effect, not one arm per command.The catalog cascade moves to
CatalogMachine, where it already is.dispatch.rs'sDeleteTriparm (member routes, then the.obt, then two re-feeds) is deleted; the domainalready owns that order through
ReadTripMembers→RemoveObject→ReadCatalog. The executoranswers
TripMembersRead { members },ObjectRemoved { object, existed }andCatalogRead { revision }, andexisted: falseis a success (EPIC — Device core: one product kernel, typed effects, thin platform executors #1433's "trip memberdisappears before the delete commit").
ExternalFacts::store_revisionraisesCatalogIntent::Refresh.stage_facts'snote_store_changed()is replaced by the intent the type was written for. The fact never orders arefresh; the intent does (
catalog_state.rs:628–630). This is the oneobc-appchange in theslice, and it is what lets
CatalogEffect::ReadCatalogbe produced at all.The bulk re-feeds keep their shape and gain a revision.
feed_routes/trips.refeed/set_ridesstay: the resident catalogs are filled by the executor, and the outcome reports onlythe revision it read at (
CatalogOutcome::CatalogRead). Catalogs do not enter either protocol(
compat.rs:78–84). No new copy of the catalog is created.Retention stamps are acknowledged.
RetentionEffect::Write{Route,Ride}Metadatabecomes asidecar write whose success or failure returns as a
RetentionOutcome.App::mirror_stamp(
pass.rs:520) already prevents the re-enqueue storm; the ack is what lets a failed writere-queue through
RetentionMachine::apply_outcomeinstead of wedging.The three hosts call
run_passonce per frame, and nothing else in the protocol. Each hostbuilds
PassInputs { now, gestures, sensors, route, support, outcomes, facts, derived, targets }from what it already has, and consumes
PassPlan:plan.renderreplacesapp.take_dirty();plan.sourcesreplacesbase_needs_reader()at the reader-open site;plan.derived_needsreplacesride_track_request()/nav_preview_missing();plan.next_wake_msbecomes the sim GUI's repaint request and is ignored by the web demo(rAF-paced) — stated, not silently dropped;
plan.immediateforces another pass before the frame is considered settled.App::take_hold_cancel()stays a direct seam (EPIC — Device core: one product kernel, typed effects, thin platform executors #1433 §6) and is untouched.The gesture/tick reordering is deliberate, declared, and the top risk. Today the sim GUI
applies input after the render and the web demo runs no animation clock at all.
run_passfixesthe order (outcomes → facts → gestures+tick → UI/animations → domains → plan). Frames will
move. Every moved frame is explained and deliberately re-pinned in the PR — never absorbed. The
precedent is DC7's one corrected mismatch, where three DeviceCore runners legitimately settle one
screen shallower than the legacy runner.
Nothing shared with the board is deleted.
drain_host_commands,apply_event,peek_host_command,drain_host_command,DRAIN_ORDER,class(), the 55 façades and the 22PassCutoverfeeders all stay compiling and tested. S6b removes the second caller; S6c removesthe surface.
LegacyAdaptergets no production caller here, and that is the design. These hosts gostraight to typed.
compat.rsremains what DC6 built it as: the path a host can run before itsexecutor migrates, exercised by
Executor::Compatibility. If S6b finds the board needs a stagedloop-then-executor migration, the adapter is there for it.
Manual dirty logic is retained, per the row. S4 S4 — Replace manual dirty mirrors with declared render keys #1447 proves render keys at the final pass
boundary; this slice must not pre-empt it.
What is deliberately not in this slice
ride.rs:1086and its 28apply_eventsites) — S6b.PassCutoverfeeders,HostCommand/HostEvent,compat.rs,migration.rs,feeders.rs— S6c, after the last caller is gone.NavigatorEffect::Step/CommitRoute) — Gate 4 with EPIC — Navigation: one route state, one sweep, one lookahead #1400.LegacyOwned::WeatherProtocol, the twoWeatherCutoverfeeders) — AUDIT — Device weather: one freshness law holds; the A/B store is the removable part, and FS7 already owns it #1401.ExternalFacts::note_transferfrom the flat engine (S5 open question 2): the party thatknows is
Engine::busy, which lives on the board. S6b.store_writablecannot come back down — no unmount fact exists (pass.rs:682–689, S2 openquestion 4). It belongs with whoever adds the fact; naming it here does not make it this slice's.
Files
Changed
host/obc-host-core/src/dispatch.rs(432)HostLoopbecomes a typed effect executor; the 18-arm commandmatchbecomes per-domain effect arms; the trip cascade is deleted (the domain owns it)host/obc-host-core/src/nav.rs(334)apply_eventsites becomeNavigatorOutcomes;fill_nav_previewanswers aderived_needskeyhost/obc-host-core/src/lib.rs,session.rs,stores.rsActiveRouteSessionreparse driven byplan.sources.routeapps/obc-sim/src/gui.rs(1,024)render_to_texturedrives onerun_pass; input moves ahead of the passapps/obc-sim/src/main.rs(1,873)reconcile_headless, replay loop and the 11 injection sites become facts/outcomes onPassInputsapps/obc-web-demo/src/demo.rs(741)Demo::tickdrives onerun_passfirmware/obc-app/src/device_core/pass.rs(1,374)stage_factsraisesCatalogIntent::Refreshinstead ofnote_store_changed()firmware/obc-app/src/device_core/compat.rs(1,583)deletes_instrings corrected to their real ownersfirmware/obc-app/src/device_core/feeders.rs(304)DeletingSlice::PassCutover's doc names S6c, not "S6"Deleted
matchdispatch.rs:231–…dispatch.rsDeleteTriparmapply_eventcall sitesride_track_request,nav_preview_missingpolling)gui.rs,main.rs,nav.rsAdded: the typed executor arms in
dispatch.rs(~200),PassInputsconstruction per host(~40 × 3).
LOC expectation: roughly flat to +150 production, and this is stated as a fact rather than
defended. The deletion this epic promised for Area D lands in S6c, where the shared surface
loses its last caller. A slice that claims a deletion it cannot make is how the budget stops meaning
anything.
Tests
Ported before anything is deleted (the #1256 rule): every
dispatch.rsbehaviour that has anamed regression gets its assertion on the typed path in the same PR, green before the command arm
it replaces is removed.
obc-host-coredevice_core_conformance.rs: all DC1 scenarios × 5 runners green. The three acceptedObjectNamespacecells (catalog.route-delete,catalog.ride-delete,retention.expiry-retry-and-trusted-clock) stay accepted for thecompatibilityrunner — thisslice does not change the adapter — but the production hosts no longer run that path, and the
disposition table records that. A new differing cell is a blocking failure.
serve_catalog'spanic!("no catalog refresh intent exists until #1397 S6…")is replaced by areal
CatalogRead { revision }answer, and mandatory trace obc-render: polygon fill silently drops scanline crossings past MAX_CROSSINGS=256 #3 ("store change during catalogrefresh") runs the literal situation for the first time instead of the substituted one. Same
for obc-render: static scratch budget assumes 512 KB RAM; nRF54L15 has ~256 KB #8 (trip member disappearance), which now has a real bounded member read to race with.
device_core_legacy_traces.rs(8 scenarios): must stay byte-identical. It describes the board'sshipping path, which this slice does not touch. A diff here means the slice leaked.
the_conformance_replay_wake_profile_and_pass_costre-pinned.obc-apptests/host_protocol.rs: unchanged and green — the protocol still exists for the board.CatalogIntent::Refresh→ReadCatalog, and thatone commit produces exactly one refresh.
Rendering / replay
firmware/ui-snapshots.sh+ui_snapshot_manifest.py check— 270 frames. Frames may move(design point 7). Every moved frame is diffed, explained and re-pinned in the PR body; an
unexplained move is a blocking failure.
obc-sim --script/--pngcorpora andpresent.rs's exact-diff oracle.#[cfg(test)]chapter suite (demo.rs:465+) — the ambient replay, the uploadchapter, the ride-start chapter.
Deletion ledger:
python3 tools/loc_ledger.py --pr <n>, reported in the PR.Resource limits
Baseline is
firmware/tools/resource_baseline.jsonat43dcd09d. This slice compiles no new boardcode, so the expectation is "unchanged to the byte" — and it is measured, not assumed, because
obc-appis on the device path.compile_time_allocations.app(size_of::<App>())ui_catalogconnection slot; a growth here means a second pending copy was created.measured_residentresident_ram_maxuninit_maxarena_total/render/nav/usbfull_frame_sized_writable_countpoll_frame_measuredtask_frame_measuredresidual_stack_measuredboot_chain_measured_pinned_hostdeep_ride_high_watermeasured_flashHost-side: no allocator change in
obc-app, no new task, no combinedEffect/Outcome/Intentenum, no event bus (#1433 §14, §16).
Verification
No on-glass work in this slice. The board build is run to prove it did not move.
Definition of done
host/obc-host-core/src/dispatch.rs:228no longer callsdrain_host_commands; the sharedexecutor takes
PassPlan::effectsand returns typed outcomes.obc-sim(GUI and headless) andobc-web-demoeach drive exactly oneApp::run_passperframe, and consume
plan.render/plan.sources/plan.derived_needs/plan.next_wake_ms/
plan.immediate.apply_eventcall sites are gone; their facts and outcomes arrive onPassInputs.ExternalFacts::store_revisionraisesCatalogIntent::Refresh;note_store_changed()is nolonger called from
stage_facts; one commit produces exactly one refresh.dispatch.rsand is served byCatalogMachine's own order;existed: falseis a success.RetentionMachine::apply_outcomerather than latching.FinishTrackandForgetBond, pinned by aproduction assertion equivalent to
pass_owned— not a comment.serve_catalog'spanic!is gone; mandatory traces obc-render: polygon fill silently drops scanline crossings past MAX_CROSSINGS=256 #3 and obc-render: static scratch budget assumes 512 KB RAM; nRF54L15 has ~256 KB #8 run the literalsituation, and the substitution notes in DC7's table are removed.
device_core_legacy_traces.rsis byte-identical; the DC7 disposition table gains no new cell.LegacyOwned::deletes_instrings andDeletingSlice::PassCutover's docname their real owners (EPIC — One ride domain: one record, one recorder, one retention rule #1398 Gate 3, Gate 4, S6c).
row name S6b as the next frontier.
Open questions for the implementer to settle in review
HostLoopsurvive as a type, or become a plain executor struct? Once the mailbox, thecanonical order and the command match are gone, what is left is the in-flight
InflightPlan, theDetourReadyhold andActiveRouteSession. That may be three fields on the sim/web hosts ratherthan a shared type. Deleting a type that no longer answers a question is preferable to keeping a
name; decide with the diff in hand.
plan.next_wake_ms? It is rAF-paced and ignores wake scheduling today.Ignoring a plan field is fine if it is stated; silently dropping it is not. Confirm which.
PlanHold(dispatch.rs:44–59) — does it survive the cutover? It exists so the simulator canfreeze a planning screen for
--hold navsnapshots. Under the pass, the same freeze is "do notserve the
NavigatorEffect::Acquire", which the executor can express without a shared type. If itcan, delete it.
handle_inputahead of the pass is correct and will moveframes. Confirm the moved set is exactly the screens whose animation phase advanced, and not a
deeper stack difference.
PassInputsconstruction want a shared helper? Three hosts building the same ninefields invites a fourth copy. A helper in
obc-host-coreis right for the three; the board's isdifferent enough that S6b should not be forced through it.