Skip to content

S6a — The shared host loop runs the pass: obc-host-core, the simulator and the web demo drive App::run_pass #1489

Description

@timohueser

Parent epic: #1397

Program tracker: #1448

Program position: App core, S6 (frame-pass driver), first of three slices

Start gates:

Goal

Move the three non-board runtime hostsobc-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

The row in #1397 reads:

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 ten LegacyOwned 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:

LegacyOwned row compat.rs says Actual owner per #1448
StoreRevision, ObjectNamespace, TripCascade, SidecarAck S6 S6a/S6b — the store and retention executors. Correct.
RecorderJournal, RideCloseAck S6 Gate 3 — the Recorder cutover in #1398. Not S6.
BondAck S6 Gate 3/4 — the bond domain in ble.rs. Not S6.
PlannerPacing, PlannerRelease S6 Gate 4 — "convert board HostPass into typed effect staging", with #1400. Not S6.
WeatherProtocol #1401 #1401. Correct.

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.

Does the S5 soak block S6?

No for this slice; yes for S6b.


Source of truth

  • The pass contract and stage order: #1433 §6
    and firmware/obc-app/src/device_core/pass.rs:1–86.
  • The revised pass and protocol design, superseding EPIC — App core: one mode machine, one card scheduler, one frame pass #1397 §2.3/§2.5: the "Amendment: DeviceCore
    pass and effect protocol"
    section of #1397.
  • 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.rsExecutor::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.

Current code

The three hosts and what they call per frame

All line numbers are origin/develop at 43dcd09d.

Host Loop LOC Per-frame shape
Sim GUI apps/obc-sim/src/gui.rsupdate :947 → render_to_texture :449 1,024 (0 test) 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.
Sim headless apps/obc-sim/src/main.rsapply_script :820, hook :1281, replay loop :1526 1,873 (1,725 prod) throwaway render_frame :1305 → reconcile_headless :1318 → app.tick :1347. 11 apply_event injection sites at :1394–1481.
Web demo apps/obc-web-demo/src/demo.rsDemo::tick :246 741 (464 prod) 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 catalog_state.rs:627–699
Retention stamps + apply_outcome re-queue retention.rs
Navigator / Settings / DFU / StorageInfo token validation S2, pass.rs stages 8/9/11
Keyed derived reads (ride_track, nav_preview) DC4, device_core/derived.rs
External facts (store revision, transfer, link, uploads, warnings, boot update) 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) #1398, Gate 3
ForgetBond 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

  1. 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.

  2. 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 ReadTripMembersRemoveObjectReadCatalog. 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").

  3. 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.

  4. 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.

  5. 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.

  6. 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;
    • plan.derived_needs replaces ride_track_request() / nav_preview_missing();
    • plan.next_wake_ms becomes the sim GUI's repaint request and is ignored by the web demo
      (rAF-paced) — stated, not silently dropped;
    • plan.immediate forces 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.
  7. 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.

  8. 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.

  9. 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.

  10. 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


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
host/obc-host-core/src/lib.rs, session.rs, stores.rs the executor's repository seam; ActiveRouteSession reparse driven by plan.sources.route
apps/obc-sim/src/gui.rs (1,024) render_to_texture drives one run_pass; input moves ahead of the pass
apps/obc-sim/src/main.rs (1,873) script hook, reconcile_headless, replay loop and the 11 injection sites become facts/outcomes on PassInputs
apps/obc-web-demo/src/demo.rs (741) Demo::tick drives one run_pass
firmware/obc-app/src/device_core/pass.rs (1,374) stage_facts raises CatalogIntent::Refresh instead of note_store_changed()
firmware/obc-app/src/device_core/compat.rs (1,583) the four invalidated deletes_in strings corrected to their real owners
firmware/obc-app/src/device_core/feeders.rs (304) DeletingSlice::PassCutover's doc names S6c, not "S6"

Deleted

Thing Where Lines
The canonical-order command match dispatch.rs:231–… ~180
The trip cascade dispatch.rs DeleteTrip arm ~20
The 21 non-board apply_event call sites sim ×15, web demo ×1, host-core ×5 ~60
Per-host derived-cue plumbing (ride_track_request, nav_preview_missing polling) gui.rs, main.rs, nav.rs ~40

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.
  • serve_catalog's panic!("no catalog refresh intent exists until #1397 S6…") is replaced by a
    real CatalogRead { revision } answer, and mandatory trace obc-render: polygon fill silently drops scanline crossings past MAX_CROSSINGS=256 #3 ("store change during catalog
    refresh") 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's
    shipping path, which this slice does not touch. A diff here means the slice leaked.
  • the_conformance_replay_wake_profile_and_pass_cost re-pinned.

obc-app

  • tests/host_protocol.rs: unchanged and green — the protocol still exists for the board.
  • A pass test that a store-revision fact produces CatalogIntent::RefreshReadCatalog, 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.
measured_resident 320,616 B Must not grow.
resident_ram_max 320,688 B Ceiling.
uninit_max 132,096 B Unchanged.
arena_total / render / nav / usb 131,072 / 131,072 / 97,344 / 131,072 B Unchanged, to the byte.
full_frame_sized_writable_count 2 Unchanged.
poll_frame_measured 9,792 B Limit 12,288 B.
task_frame_measured 1,100 B Limit 8,192 B.
residual_stack_measured 38,808 B Floor.
boot_chain_measured_pinned_host 13,768 B Ceiling 24,576 B.
deep_ride_high_water 37,016 B Margin floor 8,704 B.
measured_flash 1,524,676 B A record, not a gate. Re-pin and state the delta.
Replay wake profile / mean+worst pass time as pinned by #1488 Must not rise.

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 untouched
cd 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 only FinishTrack and ForgetBond, pinned by a
    production assertion equivalent to pass_owned — not a comment.
  • serve_catalog's panic! 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 literal
    situation, and the substitution notes in DC7's table are removed.
  • device_core_legacy_traces.rs is byte-identical; the DC7 disposition table gains no new cell.
  • Every moved UI-snapshot frame is diffed, explained and deliberately re-pinned; none is absorbed.
  • The four invalidated LegacyOwned::deletes_in strings and DeletingSlice::PassCutover's doc
    name their real owners (EPIC — One ride domain: one record, one recorder, one retention rule #1398 Gate 3, Gate 4, S6c).
  • Every resource value re-measured and reported; the board ELF is unchanged.
  • EPIC — App core: one mode machine, one card scheduler, one frame pass #1397's S6 row is replaced by the S6a/S6b/S6c rows; TRACKER — Device architecture refactor: progress, gates, and next work #1448 §1, §2.1 and the App-core workstream
    row name S6b as the next frontier.

Open questions for the implementer to settle in review

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions