Repository navigation
Conversation
… a stabiliser (#414) `wasm/cm/cascade` wrapped `FlightCore`, so the artifact a consumer pulls would hold attitude and position and WOULD NOT STOP FOR ANYTHING. The geofence RTL, the low-battery failsafe, the attitude-runaway motor cut, the relay-fsm mode machine and all 20 pre-arm rows were implemented, tested and in places Kani-proven — and unreachable from the shipped seam. That is ORPHAN-P01's fourth state (implemented, verified, advertised, unreachable) occurring at the seam rather than inside a crate, and it is why this week's failsafe fixes (#562/#563, #564/#565) were absent from what anybody could actually run. This wires the supervisor in and publishes its state and its command channel, seam at pulseengine:falcon-cascade@0.12.0. MEASURED, NOT ARGUED — three measurements changed the design: 1. WHERE THE SUPERVISOR CONFIG GOES. The stakeholder refused growing `sensor-frame` because the canonical ABI's flattening boundary is live in this module and crossing MAX_FLAT_PARAMS=16 silently changes the calling convention (the ABI passes garbage rather than erroring). Counting the real record: `controller#step` flattens to 26 (sensor-frame 22 + waypoint 4), so it is ALREADY INDIRECT by 10 and cannot cross anything; kiln independently lowered the shipped component at 26 flattened args (pulseengine/jess#287), agreeing with the field-by-field count. The record actually near the cliff was the one nobody flagged: `configure(vehicle-config)` is 9 flat params and the draft's six supervisor fields took it to 15 — one field of margin, with clause (4) still wanting more. So the supervisor's configuration went into the new `pilot` interface and `vehicle-config` stays at 9, byte-identical. `sensor-frame` and `vehicle-config` are used by `controller` alone, so the per-stage worlds' baselines (rate#tick 18, ekf#estimate 6) cannot move. 2. BATTERY IS A PREREQUISITE, NOT A FEATURE. `read_battery_v` and `read_battery_i` are the only two backend methods the supervisor calls that `FlightCore` did not, and both DEFAULT to None; the cascade backend overrides 7 of the trait's 12 methods and not those. `update_preflight` then sets `battery_ok = batt_present && ..` with batt_present false, `all_pass()` ANDs it, and `arm_check` returns Blocked(Battery) forever — so wiring the supervisor without `battery-v` in the seam would have shipped a vehicle that can NEVER ARM. Hence the field, cleared as free by (1). 3. THE SUPERVISOR STILL FITS. The component links an 8 KiB shadow stack and the by-value form of `FlightCore::new` already trapped there. FlightSupervisor is 3208 B against the core's 2688 B (1.19x), under the half-stack budget — the existing test from #513 holds that number. Same construct-into-slot mitigation, 19% more pressure, not a blocker. AN UNCONFIGURED SUPERVISOR CANNOT ARM. `FlightSupervisor::new` defaults the first six pre-arm rows TRUE for bring-up, and `update_preflight` never writes the FailsafeConfigured row — so that default is what a host's first `arm` is judged against, and `arm_check_table` is read in `command()`, not in `step()`. The component therefore declares that row on every entry through `pilot`, from `ensure_supervisor`. Unconfigured, the configuration-dependent failsafes are INACTIVE rather than wrong (fence 1e9, low-batt 0.0) because a 0 m geofence about a (0,0,0) home latches an RTL on tick one — and guessing a cell count is not safer: 14 V fires far too late on a 6S pack and reads permanently low on 3S. `target` IS NOW A MISSION LEG, not a position command — the one semantic change a host must notice. The supervisor owns the setpoint and derives one per mode, calling `core.set_position` itself; writing it from the component would be overwritten within the tick. A supervised vehicle sometimes declines to go where it is sent, which is the point. The `pilot` command enum is deliberately NARROWER than relay_fsm::Event: the four withheld variants (reached-altitude, reached-home, touchdown, failsafe) are physical observations the supervisor derives from its own state. A host able to inject `touchdown` could disarm a vehicle in the air. The match is exhaustive with no `_` arm so a new variant on either side must be considered, not absorbed. falcon-core now re-exports relay_fsm and relay_preflight. Its public API already speaks those types (`mode()`, `command()`, `set_check()`), so a consumer had to declare a second path dependency on the same crates and hope the versions agree. The published component is the case that found it: it depends on falcon-core alone, deliberately, so that what ships is what is verified (#388). ORACLE, run as CI runs it: cargo test -p falcon-core --all-targets 73 passed / 0 failed the new seam-contract suite 5 passed / 0 failed shadow-stack budget 2 passed / 0 failed cargo test -p falcon-core --doc 0 failed cargo fmt --all --check clean built for the SHIPPED target, wasm32-unknown-unknown with -zstack-size=8192: 0 errors, component created, and `memory.grow` count 0 (OCI-P02 holds) read back from the ARTIFACT, not the source, per the requirement's own criterion (`wasm-tools component wit`): all three interfaces export at @0.12.0, with mode: u8, failsafe: u32, battery-v: option<f32> and configure-supervisor / command present. FALSIFIED, twice, each in the direction that proves something: - Swapped the declaration order of two FIELDLESS Mode variants (Land/Rtl). ZERO compile errors anywhere — every match stays exhaustive — and the wire values silently renumbered. `mode_discriminants_are_the_published_wire_values` FAILED on "mode 5 is land". That is the exact hazard the pin exists for: relay_fsm::Mode carries no #[repr] and no explicit discriminants, so its wire values ARE its declaration order. - Made `arm_check_table` skip the FailsafeConfigured row. `an_unconfigured_supervisor_refuses_to_arm` FAILED with "it reported Armed". The test also carries its own control: flipping only that row lets the identical arm through, so the refusal is attributable to it. KNOWN GAPS, named rather than left to be discovered: - The HITL wire binding still serves the @0.11.0 shape. SENSOR_PAYLOAD_LEN is 76 and carries no battery element, so a host driving the component through `crates/falcon-hitl/src/wire.rs` gets battery_v None and CANNOT ARM the supervised component. The stakeholder predicted this exact dependency. Changing a Kani-verified wire format is its own slice. - `have_position: true` is hardcoded in `FlightSupervisor::command`, so FSAFE-P01's "never RTL when position is lost" ships unreachable through this seam. A core limitation this interface exposes rather than introduces. - The warm-up count does not resync. `FlightSupervisor::new` hardcodes FlightCore::new(0.5, 1000.0); the loop rate self-corrects because `resync_loop_rate` re-derives it and the gyro LPF and notch bank from the backend's dt past a 2% tolerance, but `warmup = (loop_hz*0.2).max(10)` = 200 does not — at a real 250 Hz that holds the FDI off 0.8 s instead of 0.2 s. Longer, so conservative, but a behaviour change; belongs in falcon-core. SUPERVISOR-P01 moves proposed -> implemented, NOT verified: its criteria require a wasm SITL harness driving geofence breach, a draining pack and an attitude runaway, plus FAULT-005's disarm clause on gz. The two-commit rule caps this at implemented and that promotion is a separate code-free PR. The @0.12.0 design was announced to the consumer BEFORE any tag carries it, as the requirement demands (pulseengine/jess#167), including an open offer to move `battery-v` to `pilot` instead if they would rather measure than accept it. Refs #414, #145, pulseengine/jess#167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
Clause (6)'s owed measurement — run, and it holdsI argued structurally in the PR body that the per-stage worlds share none of the changed records, so their byte-identity baselines could not move. The requirement asks for a measurement, not an argument. Run it: One tree, one toolchain, only the WIT swapped (
Identical length byte-for-byte — what a length-preserving Caveat, stated because it would be easy to over-read the table: this covers the five per-stage components only. Working tree verified clean after the swap-and-restore. |
…arness to arm
Two corrections to the change in the parent commit, both found by continuing to
check rather than by CI telling me.
1. I DOCUMENTED cruise-alt's SIGN BACKWARDS.
The WIT comment said "NED metres — NEGATIVE IS UP, as everywhere else in this
package". It is the one altitude in this seam that is NOT NED-signed:
falcon-core documents the constructor argument as "`cruise_alt` m AGL" and the
supervisor NEGATES it itself when deriving the setpoint — `[p0, p1,
-cruise_alt]` for Takeoff, Loiter, Rtl and Land. Every in-tree call site passes
a positive value.
So a consumer following my comment and passing -30.0 to mean "30 m up" would
have commanded +30.0 NED: thirty metres BELOW THE GROUND, as an RTL cruise
altitude. I inferred the convention from the surrounding fields instead of
checking this one, which is precisely the error the integrator asked me not to
repeat (their AFD-127) — committed about an hour after I agreed.
Prose is not a sufficient fix for a sign error on a safety seam, so the
behaviour is pinned: `a_positive_cruise_alt_commands_a_climb_not_a_descent`
arms, takes off, and asserts pos-d DECREASES and settles near -2.0 m for
cruise_alt = 2.0. FALSIFIED by deleting the negation in falcon-core: the vehicle
flew to +1.87 m NED — 1.87 m underground — and the test caught it.
`sane(c.cruise_alt, .., positive)` also goes false -> true. Zero or negative is
not a conservative cruise altitude; it is a commanded descent into terrain.
2. THE SITL HARNESS COULD NOT HAVE PASSED, and would have taken a required
gate down with it.
`tests/cascade-sitl-wasm` is the only in-tree host that drives the published
component, and it runs in gazebo.yml and soak.yml. It was broken two ways by the
parent commit, and the first would not even have produced a useful error:
- Its inline wasmtime world pinned `controller@0.11.0` / `observer@0.11.0`, so
it would have failed to INSTANTIATE against @0.12.0 — a version mismatch,
not a behaviour report.
- Even past that, it only ever called `step`. `FlightSupervisor::step` returns
through `step_estimate_only` with motors commanded ZERO while the mode is
Disarmed, and the FSM starts Disarmed. The harness would have measured a
vehicle sitting on the ground for 4000 ticks and reported a 2.00 m hold
error, which reads like a control regression rather than a missing arm.
So it now behaves like a real host: `configure_supervisor` (home, fence,
cruise = -down, low-batt), then `command(Arm)` and `command(RequestTakeoff)`
BEFORE the first step — which is where the pre-arm table is actually judged,
since `arm_check_table` is read inside `command`, not inside `step`.
It also supplies a pack voltage, and that is not boilerplate: with
`read_battery_v` defaulting to None, `battery_ok` is false, `all_pass()` ANDs it
and `arm_check` returns Blocked(Battery) — so a frame with no battery cannot arm
the supervised component at all. 16.8 V against a 14.0 V LOW threshold.
The geofence is configured at 100 m rather than the 1e9 "inactive" sentinel. The
harness bounds the horizontal at 1.0 m (HOLD-P01), so 100 m cannot interfere,
and the vehicle under test still flies with a real geofence armed instead of one
switched off for convenience.
ALSO: `docs/TRANSPORT-BINDING.md` now states that it is deliberately one seam
revision behind rather than leaving a header that reads as stale. The HITL v1
binding carries no battery element (SENSOR_PAYLOAD_LEN 76), so a host on that
transport cannot arm the supervised component — #572, a wire revision with its
own Kani oracle.
ORACLE:
cargo test -p falcon-core --all-targets 73 passed / 0 failed
seam contract (now 6) 6 passed / 0 failed
shadow-stack budget 2 passed / 0 failed
cargo fmt --all --check clean
cargo check on tests/cascade-sitl-wasm 0 errors against the @0.12.0 seam
component rebuilt for wasm32-unknown-unknown -zstack-size=8192:
0 errors, memory.grow count 0 (OCI-P02 still holds)
NOT VERIFIED LOCALLY, and I am not going to imply otherwise: the harness's
RUNTIME arming sequence needs the composed wasm and a plant, so the oracle for
it is the Component Model cascade and Gazebo SITL gates. It type-checks against
the real seam and the sequence mirrors the native supervisor tests, but whether
the vehicle actually reaches Loiter and holds 2.00 m through the component is
CI's answer, not mine.
Refs #414, #572, pulseengine/jess#167
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
Two corrections, both found by continuing to check rather than by CI1. I documented
|
…which the seam cannot carry I published "WITHOUT THIS FIELD THE COMPONENT CANNOT ARM" about `battery-v`, in the WIT doc, the commit message, the PR body, #414, #572 and jess#167. It is mis-attributed, and chasing it down found a larger gap plus a defect of my own. WHAT I GOT WRONG. Battery absence does refuse arming — that part holds. But a host that WARMS ITS ESTIMATOR BEFORE ARMING, which is what `step_estimate_only` exists for and what a real GCS does, is refused FIRST by `CalibrationPresent`. Measured: warm 1500 ticks, supply everything the @0.12.0 seam carries, then Arm -> mode = Disarmed, arm_blocked_check = Some(CalibrationPresent) install a CalParams, re-arm (the control) -> mode = Armed, arm_blocked_check = None So battery is NECESSARY BUT NOT SUFFICIENT, and `battery-v` does not make the component armable. THE LARGER GAP. `update_preflight` derives `calibration_present` from `core.calibration() != CalParams::identity()`, and NOTHING IN THIS SEAM CARRIES A CALIBRATION — `vehicle-config` has none, `supervisor-config` has none, and the component never calls `set_calibration` because it has nothing to pass. So the published component can presently only be armed on the ARM-BEFORE-FIRST-STEP path, where the pre-arm table still holds `FlightSupervisor::new`'s deliberate bring-up defaults (the legacy six forced true). That is SUPERVISOR-P01's OWN FALSIFICATION CONDITION: "the supervisor runs against default inputs the host cannot supply." It also means A GREEN SITL GATE IS NOT THE EVIDENCE I IMPLIED IT WAS — it shows the defaults path works end to end through the Component Model ABI, not that the 20-row table gates arming through the seam. The harness now says so in place, so nobody cites it that way at promotion time. WHY MY CENSUS MISSED IT: it enumerated the `read_*` methods of `FlightBackend` and compared them against what the cascade backend overrides. Calibration does not arrive through the backend — it arrives through a SETTER. Censusing one input channel and concluding about arming was the error; the census has to cover every input `update_preflight` consumes, setters included. AND A DEFECT OF MINE, found by the same re-reading. I wrote that "`update_preflight` never writes the `FailsafeConfigured` row". It DOES — as a multi-line `t.set(\n CheckId::FailsafeConfigured,\n ..)` that the single-line grep I used cannot see, sourced from a field that defaults true and is never refreshed. So every tick CLOBBERED the component's declaration. Arming stayed correct (nothing steps between `ensure_supervisor` and `command` reading the table), but the PUBLISHED `failsafe` bit 7 did not: an unconfigured host would have seen `arm-blocked` CLEAR in telemetry while actually being refused — an observer disagreeing with the thing it observes, which is the defect class this seam exists to remove. Now re-declared after `step` and before the state is published. Also re-exports `relay_calib` from falcon-core, for the same reason as relay_fsm/relay_preflight: `set_calibration` is public API that speaks the type. ORACLE: cargo test -p falcon-core --all-targets 73 passed / 0 failed seam contract (now 7) 7 passed / 0 failed shadow-stack budget 2 passed / 0 failed cargo fmt --all --check clean cargo check tests/cascade-sitl-wasm 0 errors component, wasm32-unknown-unknown -zstack-size=8192: 0 errors The new test asserts a LIMITATION with its own control, so it fails usefully in both directions: if the seam gains a calibration path the first assert breaks (delete it and verify the real thing), and if a different row starts refusing first the gap has moved and the justification is stale again. WHAT IS NOW A DECISION RATHER THAN A FIX, and not mine to take alone: either the seam gains a calibration input (a seam change, so the shape is the integrator's call), or the supervisor refreshes preflight at construction so arming is always judged on real signals — which leaves the published component visibly unarmable until the first lands. Asked on #571 rather than chosen here. Refs #414, #571, #572, pulseengine/jess#167 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
Correction: I mis-attributed the arming blocker, and chasing it down found a larger gapI published "without What is actually trueBattery absence does refuse arming — that part holds, and So The larger gap
So the published component can presently be armed only on the arm-before-first-step path, where the pre-arm table still holds That is SUPERVISOR-P01's own falsification condition — "the supervisor runs against default inputs the host cannot supply." And it means a green SITL gate is not the evidence I implied it was. It shows the defaults path works end to end through the Component Model ABI; it does not show that the 20-row pre-arm table gates arming through the seam. The harness now says that in place so nobody cites it that way at promotion time. Two of you flagged the general shape of this before I measured it; the specific row was mine to find and I found it late. Why my census missed itThe census enumerated And a defect of mine, found by the same re-readingI wrote that " So every tick clobbered the component's declaration. Arming stayed correct, because nothing steps between Status of this PRStill The new test asserts a limitation, with its own control, so it fails usefully in both directions: if the seam gains a calibration path the first assert breaks (delete it, verify the real thing), and if a different row starts refusing first then the gap has moved and the justification is stale again. What is a decision rather than a fixNot mine to take alone, so it is not taken:
Either way the SITL harness should end up warming first and arming only once the observer's Auto-merge remains off. |
Decision: land this, calibration as the immediate next sliceMaintainer call taken on the question in the correction above. Rationale recorded so the trade is visible rather than inferred from a merge: This PR is a strict improvement on the current state. Today the published component has no mode machine at all — it will hold attitude and position and will not stop for anything. After this it runs the supervisor, publishes The alternative — refreshing preflight at construction so arming is always judged on real signals — is sound but ships a component no host can arm, and would break both the SITL harness and the in-tree native supervisor tests that arm before stepping. Holding this PR until a calibration input lands would park the whole supervisor wiring behind a seam round-trip. So: calibration input next, shape pending the integrator's answer on pulseengine/jess#167 (my lean is a Auto-merge is now armed, and the gate that matters is a required check. #414 stays open until a tag carries this. SUPERVISOR-P01 stays |
…ht it in 7 minutes
The required Gazebo gate failed on the previous head, and it failed for the
right reason:
commanded : Arm -> RequestTakeoff (supervised; Disarmed commands zero motors)
Error: FAIL: wasm and native disagree by 0.069364190 (tick 6249).
The same Rust reached two ways must compute the same answer.
NOT the failure I predicted. I expected the arming sequence to be the risk; it
worked, and the run reached tick 6249. What broke is the DIFFERENTIAL: this
harness runs the same flight core natively alongside the component and asserts
they agree, and I had switched only the wasm side to `FlightSupervisor`. So it
compared a supervised vehicle against an unsupervised one and reported a real
difference about two different programs rather than about the boundary.
That is the differential doing exactly its job, and it is the second time this
file has had to learn the same lesson — #393 already notes that keeping the old
five-stage mirror "would have compared two different programs and reported a
difference meaning nothing". The module doc now records both instances, because
a mirror is only evidence while both sides run the same code.
MIRRORING NOW TAKES MORE THAN SWAPPING THE TYPE, and each item was load-bearing:
- the supervisor's configuration (home / fence / cruise / low-batt),
- a battery reading, since `read_battery_v` defaults to None and the pre-arm
table ANDs BatteryOk,
- the arm + takeoff commands, because `FlightSupervisor::step` commands ZERO
motors while Disarmed — an unarmed mirror sits on the ground while the wasm
side flies and reports that as a Component Model failure,
- construction through `FlightSupervisor::new`, NOT `FlightCore::new`. This
one is subtle and would have left a residual disagreement: the supervisor
hardcodes `FlightCore::new(0.5, 1000.0)`, and the warm-up count is seeded
`(loop_hz * 0.2).max(10)` = 200. The old mirror built at `1/dt` — 80 at
400 Hz — and warm-up does NOT resync from the backend, only the loop rate
does. Two different FDI/innovation warm-ups is a difference that says
nothing about the boundary.
ALSO, two comments that became false and would have misled the next reader of a
divergence:
- the judged-window rationale said "the component wraps FlightCore, so there
is no failsafe to disarm it". It wraps FlightSupervisor now; the window
stays, but because this harness does not fly long enough or command Land to
reach a touchdown, not because disarming is impossible.
- the rotor-out verdict said there is "deliberately NO disarm term" because
there is "no mode machine to disarm". That reason is gone —
FV-FALCON-FAULT-005's fourth clause becomes REACHABLE on the wasm leg for
the first time. The term is still absent, and I did not add it: putting a
new BAR on a REQUIRED gate in the same change that makes it possible is
ratcheting past the evidence, since nothing here has yet measured a
supervised rotor-out reaching touchdown through the component. It belongs
with SUPERVISOR-P01's promotion. The absence is now a CHOICE rather than an
impossibility, and the comment says so.
Auto-merge was DISARMED before pushing this, then re-armed — arming a branch you
are still pushing to is how #478 squashed at an intermediate commit.
ORACLE:
cargo check tests/cascade-sitl-wasm 0 errors
cargo test -p falcon-core --all-targets 73 passed / 0 failed
seam contract 7 / shadow-stack 2 0 failed
cargo fmt --all --check clean
The runtime answer is still gz's to give: this restores the two sides to the
same program, but whether they now agree to within the gate's tolerance is a
measurement I cannot make locally. `examples/falcon-sitl-gz/src/physics.rs` was
reverted after the harness crate's own rustfmt reordered its imports — that
crate is a separate workspace with a different import-ordering result, and the
churn is unrelated to this change.
Refs #414, #571
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG
The gz gate caught it in 7 minutes, and not where I predictedI flagged the harness's arming sequence as the unverified risk. It worked — the run reached tick 6249. What broke is the differential: this harness runs the same flight core natively alongside the component and asserts they agree, and I had switched only the wasm side to Classified before acting ( This is the second time this file has had to learn the same lesson — #393's note already says keeping the old five-stage mirror "would have compared two different programs and reported a difference meaning nothing". The module doc now records both instances. Mirroring took more than swapping the typeEach of these was load-bearing:
Two comments that had become false
Auto-merge was disarmed before pushing and re-armed after — arming a branch you are still pushing to is how #478 squashed at an intermediate commit. Whether the two sides now agree within tolerance is gz's answer, not mine; the gate is required, so it has to give it before this lands. |
Verify-Filter: (has-tag "supervisor")
Closes the seam half of #414.
wasm/cm/cascadewrappedFlightCore, so the artifact a consumer pulls would hold attitude and position and would not stop for anything — the geofence RTL, the low-battery failsafe, the attitude-runaway motor cut, the mode machine and all 20 pre-arm rows were implemented, tested, in places Kani-proven, and unreachable from the shipped seam. That is why this week's failsafe fixes (#563, #565) are absent from anything anyone can actually run.Seam goes
pulseengine:falcon-cascade@0.11.0→@0.12.0.stepnow gets zero motorsThis is the requirement's own clause (5) and it is not a side effect — it is the correct behaviour, so read this before merging.
FlightSupervisor::stepreturns early throughstep_estimate_only(estimator warm, motors commanded zero) while the mode isDisarmed,ArmedorTerminated. The FSM startsDisarmed. So a host that drivescontroller.stepand nothing else now gets[0,0,0,0]forever until it callspilot.command(arm, ..)and reachesLoiter.That motors-off behaviour must not be weakened, and falcon-core's own comment records why: on the analytic plant the ground quietly held the vehicle so an always-stepping core was invisible, but on the gz plant a post-touchdown
Disarmedkept the altitude loop live and flew the vehicle away — and "Terminate = motors cut" had been an FSM claim with no actuator behind it. An auto-arm insidestepwould make arming unobservable and bypass the 20 pre-arm rows that are the entire point of having a supervisor. So the seam gains a way to arm instead.Affected in-tree consumers, named: the wasm SITL harness that holds 2.00 m, and the rotor-out verdict that isolates in 2 ticks. Both drive
steponly and will need an arm sequence.Three measurements, each of which changed the design
1. Where the supervisor's configuration goes — and the record near the cliff was not the one anyone flagged.
The stakeholder refused growing
sensor-frame(pulseengine/jess#167) because the canonical ABI's flattening boundary is live in this module: crossingMAX_FLAT_PARAMS = 16silently changes the calling convention, and the ABI passes garbage rather than erroring when a caller assumed the other form. Correct in general. So I counted the real record rather than arguing:imu: imu-sampledt-s: f32position-ned: option<vec3>mag-body: option<vec3>heading-rad: option<f32>motor-rpm: option<rotor-rpm>sensor-frametarget: waypointcontroller#stepcontroller#stepis already INDIRECT by 10 — it cannot cross a boundary it is a decade past, andbattery-vtakes it to 28. Independently confirmed: kiln lowered the shipped component at "26 flattened args" (pulseengine/jess#287), agreeing with a field-by-field count off the WIT. Two derivations, different routes.The hazard was elsewhere.
configure(cfg: vehicle-config)is 9 flat params (nine bare f32s), and the draft's six supervisor fields took it to 15 — one field of margin, with clause (4) still wanting RC/datalink liveness. So the supervisor's configuration went into the newpilotinterface andvehicle-configstays at 9, byte-identical.Also:
sensor-frameandvehicle-configare used byinterface controlleralone, which onlyworld cascadeexports. The five per-stage worlds share none of those records, so the two exports the stakeholder measured either side of the line (rate#tick18,ekf#estimate6) cannot be moved by this change at all.2.
battery-vis a prerequisite, not a feature.read_battery_vandread_battery_iare the only two backend methods the supervisor calls thatFlightCoredid not — and both default toNonein a 12-method trait whose doc saysNonemeans "absent, not healthy". The cascade backend overrides 7 and not those. Thenupdate_preflightsetsbattery_ok = batt_present && ..withbatt_present == false,PreflightChecks::all_pass()ANDs it, andarm_checkreturnsBlocked(CheckFail::Battery)forever.So wiring the supervisor without a battery field would have shipped a vehicle that can never arm. Cleared as free by (1).
3. The supervisor still fits the 8 KiB shadow stack.
The by-value form of
FlightCore::newalready trapped there as an out-of-bounds access.FlightSupervisoris 3208 B against the core's 2688 B (1.19×), under the half-stack budget — #513's test holds that number. Same construct-into-slot mitigation, 19% more pressure, not a blocker. It is also entirely stack-allocated ([Vec3; MAX_WAYPOINTS],[KeepoutZone; MAX_KEEPOUT_ZONES], noVec/Box/String), so the arena-as-#[global_allocator]reset at export entry cannot free supervisor state — which is what made #544/#566 a live question here.An unconfigured supervisor cannot arm
FlightSupervisor::newdefaults the first six pre-arm rows true for bring-up back-compat,update_preflightnever writes theFailsafeConfiguredrow, andarm_check_tableis read incommand()— not instep(). So that default is exactly what a host's firstarmwould be judged against. The component therefore declares the row on every entry throughpilot, fromensure_supervisor.Unconfigured, the configuration-dependent failsafes are inactive rather than wrong (fence
1e9, low-batt0.0): a 0 m geofence about a(0,0,0)home latches an RTL on tick one, which is the "worse than no supervisor" case the requirement names. And guessing a cell count is not the safer choice — 14 V fires far too late on a 6S pack and reads permanently low on a 3S one.targetis now a mission leg, not a position commandThe one semantic change a host must notice. The supervisor owns the position setpoint: it derives one per mode (RTL flies home at
cruise-alt, Land descends, Mission flies the active leg) and callscore.set_positionitself at the end of its own step. Writing the core's setpoint from the component would be overwritten inside the same tick, so the honest wiring makes the frame's target the mission. A supervised vehicle sometimes declines to go where it is sent — that is the point.The command enum is deliberately narrower than
relay_fsm::Eventpilot-commandoffers 8 of the 12 variants. The four withheld —reached-altitude,reached-home,touchdown,failsafe— are physical observations the supervisor derives from its own estimated state, not requests. A host able to injecttouchdowncould disarm a vehicle in the air; one able to injectfailsafecould raise a diversion with no cause recorded infailsafe. The seam does not offer a host the ability to lie to the mode machine about physics. The mapping match is exhaustive with no_arm, so a new variant on either side has to be considered rather than silently absorbed.Oracle, run the way CI runs it
Built for the shipped target,
wasm32-unknown-unknownwith-zstack-size=8192asscripts/build-components.shdoes: 0 errors, component created, andmemory.growcount 0 so OCI-P02 still holds.Read back from the artifact, not the source — which is the requirement's own stated criterion (
wasm-tools component wit):Offsets:
failed-rotoru8 at 72 →modeu8 at 73 (padding that already existed, so mode costs zero bytes) →failsafeu32 aligns to 76 →valid80 →tick-tail84 → record 84 → 88. That is the layout the consumer accepted.Falsified, twice, each in the direction that proves something
Modevariants (Land/Rtl). Zero compile errors anywhere — every match stays exhaustive — and the wire values silently renumbered.mode_discriminants_are_the_published_wire_valuesFAILED onmode 5 is land. That is precisely the hazard the pin exists for:relay_fsm::Modecarries no#[repr]and no explicit discriminants, so its wire values are its declaration order, and a consumer has shipped a decoder against them.arm_check_tableskip theFailsafeConfiguredrow.an_unconfigured_supervisor_refuses_to_armFAILED with "it reported Armed". That test also carries its own control: flipping only that row lets the identical arm through, so the refusal is attributable to the row and not to the supervisor being unarmable for some unrelated reason.Known gaps — named, not left to be discovered
SENSOR_PAYLOAD_LENis 76 and the binding carries no battery element, so a host driving the component throughcrates/falcon-hitl/src/wire.rsgetsbattery_v = Noneand cannot arm the supervised component. The stakeholder predicted this exact dependency on jess#167. Changing a Kani-verified wire format is its own slice.have_position: trueis hardcoded inFlightSupervisor::command(crates/falcon-core/plain/src/lib.rs:1805), so FSAFE-P01's "never RTL when position is lost" ships unreachable through this seam. A core limitation this interface exposes rather than introduces.FlightSupervisor::newhardcodesFlightCore::new(0.5, 1000.0). The loop rate self-corrects —resync_loop_ratere-derives it and the gyro LPF and notch bank from the backend's owndtpast a 2% tolerance — butwarmup = (loop_hz * 0.2).max(10)= 200 does not, so at a real 250 Hz frame the FDI is held off 0.8 s instead of 0.2 s. Longer, therefore conservative, but a behaviour change and it belongs in falcon-core.FlightSupervisorreads no liveness input anywhere, so a seam field for it would be a field nothing reads. The clause is satisfied for battery and geofence configuration only.Why this is
implementedand notverifiedSUPERVISOR-P01 moves
proposed→implemented. Its criteria additionally require a wasm SITL harness driving a geofence breach, a draining pack and an attitude runaway with an observed mode change each time, plus FV-FALCON-FAULT-005's disarm clause on gz over three pristine-server trials. None of that is in this PR. The two-commit rule caps an implementation PR atimplemented, and that promotion is a separate code-free PR.Clause (6) is satisfied: the @0.12.0 design was announced to the consumer before any tag carries it, including the measurement above and an open offer to move
battery-vtopilotinstead if they would rather measure than accept it.Still owed to the consumer by clause (6): the byte-identity re-measurement of the five per-stage components across the @0.12.0 bump. Argued structurally above (those worlds share none of the changed records) — but the requirement demands measurement, not assertion, so it is owed as a number.
Refs #414, #145, pulseengine/jess#167
🤖 Generated with Claude Code
https://claude.ai/code/session_01HvusAXYbHLyv3uTzfBcMbG