ReviewOS

also looking at this

stacks/drone

feat: model phase-bound cargo fleet failures

#104
Merged chris wants to merge codex/cargo-fleet-phase-faults into main
25 files +1844 -13

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

README.mdmodified+2-1
Changes to README.md
@@ -127,7 +127,8 @@ and power declarations, captured release/custody, redundant links and post-relea
127127Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing.
128128The [two-carrier model](docs/cargo-fleet.md) and `./buddy cargo:fleet` now execute actual
129129finite station handover and the receiver's second delivery under separate sensor/authority
130scopes. Six bounded cases retain radio/host loss and uncertain receipt. General fleet
130scopes. Eleven bounded cases include radio/host loss, uncertain receipt, phase-bound kill,
131GNSS loss, pad-motion interlocks and modeled fence-breach supervision. General fleet
131132fault/recovery, electrical/measured endurance and physical/operational qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
132133
133134## Agriculture sensing
docs/cargo-delivery.mdmodified+1-1
Changes to docs/cargo-delivery.md
@@ -216,7 +216,7 @@ receiving body in its own raw-sensor, estimator and authority runtime. With ACK
216216it stays at the declared pad with zero actual motor thrust and no phantom parcel mass.
217217A caller-invented ACK is not proof of mechanical transfer. The
218218[two-carrier model](cargo-fleet.md) now binds its receipt port to actual intake, same-epoch
219receiver authority and six bounded fleet cases through `cargo:fleet`. Its broader
219receiver authority and eleven bounded fleet cases through `cargo:fleet`. Its broader
220220cancellation, fault/recovery and operational matrix remains open under #25.
221221
222222## BVLOS operations evidence
docs/cargo-fleet.mdmodified+23-3
Changes to docs/cargo-fleet.md
@@ -18,6 +18,11 @@ The [single-carrier guide](cargo-delivery.md) retains the wider operations evide
1818./buddy cargo:fleet --scenario receiver-host-loss-before-intake
1919./buddy cargo:fleet --scenario intake-receipt-loss
2020./buddy cargo:fleet --scenario receiver-joint-shift-before-intake
21./buddy cargo:fleet --scenario receiver-navigation-loss-during-rail
22./buddy cargo:fleet --scenario source-kill-after-station-receipt
23./buddy cargo:fleet --scenario receiver-kill-after-intake
24./buddy cargo:fleet --scenario receiver-pad-motion-during-rail
25./buddy cargo:fleet --scenario receiver-containment-breach-after-intake-receipt
2126```
2227
2328The command emits one JSON line, at most 16 KiB. Exit 0 means the modeled routes and
@@ -92,6 +97,21 @@ station reports mechanical work; each vehicle's reserve remains the shared longe
9297| `receiver-host-loss-before-intake` | Incomplete | Host stops 4 ms after the actual grant; no physical intake, independent board expiry and station retention |
9398| `receiver-joint-shift-before-intake` | Incomplete | A 0.4 N force for one 4 ms step shifts the actual joint before intake; strict coincidence refuses it, the loose point falls, and B lands its counted alternate empty |
9499| `intake-receipt-loss` | Incomplete | Actual intake occurs but its receipt fields are withheld; B retains the mass and lands its counted alternate without claiming custody or making a second delivery |
100| `receiver-navigation-loss-during-rail` | Incomplete | GNSS capture stops 4 ms after actual rail start; receiver navigation expires, station brakes and retains the point, and A continues |
101| `source-kill-after-station-receipt` | Incomplete | Source kill follows its captured station receipt; A inhibits while B completes intake, final delivery and return. B's success does not make the whole fleet complete |
102| `receiver-kill-after-intake` | Incomplete | Kill follows actual intake by 4 ms, before receipt capture; B retains the attached mass with uncertain custody and no final release |
103| `receiver-pad-motion-during-rail` | Incomplete | A declared constant 4 N northward force moves B; native receiving interlocks stop its mission before fence breach or intake, and the station brakes |
104| `receiver-containment-breach-after-intake-receipt` | Incomplete | After captured intake, an 8 N northward force ramps over 2 s. B's qualified estimated position breaches its 12 m fence; authority fails and outputs neutral while attached mass and received custody remain |
105
106The loaded containment case explicitly opts into the existing `fixture-imu` position
107prediction model, including its assumed initial north heading and synthetic uncertainty.
108It uses the actual raw IMU/GNSS feed and the native estimator/fence checks, with no
109substitution of plant position into policy. The receiver force is a declared world-NED
110model input, retained as `forceN` and `forceRampMs` in the fault report. It continues
111through the independent neutral tail. A neutral command cannot stop momentum or an
112ongoing external force; this case demonstrates fence-breach detection and inhibition,
113not guaranteed physical containment. The pad-motion case uses the same real body-force
114path and is stopped earlier by receiving contact/speed/anchor interlocks.
95115
96116Radio observations retain capture, receipt, sequence and scope, with 1 ms clock uncertainty
97117and 20/40 ms primary/backup delays. Failure stops new packets rather than restamping a
@@ -106,12 +126,12 @@ native finite graph/reserve budget; the world is bounded by the 180 s host limit
106126source/receiver scopes, same-epoch admission, captured receipt ordering, retained mass,
107127backup continuation, both-link braking, the host/grant gap and uncertain loaded diversion.
108128`tests/unit/cargo-fleet-cli.test.ts` executes the native Buddy command and its bounded wire.
109`scripts/qualify-cargo-fleet-cli.ts` exercises all six cases through a copied standalone
129`scripts/qualify-cargo-fleet-cli.ts` exercises all eleven cases through a copied standalone
110130binary outside the checkout and retains output hashes and factual outcomes.
111131
112132These cases do not cover a complete fleet fault envelope. Receiver/source kill or manual
113intervention at every phase, navigation degradation and containment across the transfer,
114late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable
133intervention at every phase, broader navigation degradation and containment campaigns,
134wider late/foreign/replayed receipt campaigns, explicit operator cancellation/recovery, durable
115135whole-world checkpoints, general rendezvous, collision avoidance and richer fleet planning
116136remain open under [#25](https://github.com/stacksjs/drone/issues/25) and its dependencies.
117137The wider measured hardware, endurance and operational evidence requirements remain open.
docs/roadmap/feature-coverage.mdmodified+1-1
Changes to docs/roadmap/feature-coverage.md
@@ -29,7 +29,7 @@ means there is no native implementation yet. A protocol-compatible transport is
2929| Spraying, spreading, prescription maps and implement interlocks | Owned inert-material physics, separate implement leases, quantized meters, exact coverage and shutdown audit | Physical calibration, delivery, field dose/drift, local requirements and HIL remain open | [#22](https://github.com/stacksjs/drone/issues/22) |
3030| SAR, fire/disaster support and authorized police observation | Owned collection/runtime, authenticated incident roles, source-bound annotations/review, private datasets, recipient-confirmed handover, authorized workspace with live synthetic execution and deliberate source admission, and separately accepted default-disabled local purge | Sector/team responsibility and full physical/operational qualification remain open | [#23](https://github.com/stacksjs/drone/issues/23) |
3131| Perimeter patrol, scheduling, docks and charging | Partial: retained simulation, selected-pad contact, explicit partial charging, observed isolation, finite native recurrence, bounded input/summary replay and owner-only authenticated review/consumption metadata | `patrol:simulate`, `patrol:replay` with fresh consent and [four explicit review endpoints](../patrol-review.md); production scheduling, runtime handoff/recovery, weather and physical docking remain open | [#24](https://github.com/stacksjs/drone/issues/24) |
32| Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and six model cases; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) |
32| Cargo, long-range/BVLOS cases and redundant links | Partial: configured owned delivery with actual mass/CG dynamics, captured one-release/receiver custody, counted alternate landing, declared reserve budgets, scoped primary/backup links and post-release/post-receipt failure cases; bounded two-carrier station/intake handover with captured custody and eleven model cases including phase-bound navigation, kill, pad-motion and fence-breach supervision; general fleet fault/recovery and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.md), plus the bounded [two-carrier model](../cargo-fleet.md); physical control disabled, no operational authorization | [#25](https://github.com/stacksjs/drone/issues/25) |
3333| Gimbal/camera control, smooth paths and broadcast timecode | Partial: owned hover POI angular reframe, lagged/quantized gimbal, exposure-bound modeled stills and latched target/camera/authority faults; translational paths, physical integration, lens/video and handoff remain open | Native `cinema:simulate` [simulation semantics and limits](../cinema.md); no physical/broadcast timing qualification | [#26](https://github.com/stacksjs/drone/issues/26) |
3434| Environment, wildlife, anti-poaching observation and atmospheric samples | Partial: owned two-height synthetic cell sampling, calibration/geotags, conditional uncertainty, collection-policy faults and unsigned five-file provenance/export; physical samples, wildlife/authority and actual geofence qualification remain open | Native `environment:simulate` [semantics and limits](../environment.md); human review required | [#27](https://github.com/stacksjs/drone/issues/27) |
3535| Deep feature audit, refresh and conformance gates | Pinned directory accounting and sampled behavior audit: 89 cases, including ten transport and ten camera-feedback/capture gaps; deeper review and implementation remain open | [Audit integrity and source provenance](../parity.md); 26 bounded claims, no general parity claim | [#28](https://github.com/stacksjs/drone/issues/28) |
resources/functions/drone/cargo/fleet.tsmodified+34-6
Changes to resources/functions/drone/cargo/fleet.ts
@@ -17,7 +17,7 @@ type Station = ReturnType<typeof createCargoStationHandler>
1717type Release = ReturnType<ReturnType<typeof createReceivingRigidBody>['separateCarriedLoad']>
1818type Departure = Extract<ReturnType<Station['takeReadyParcel']>, { outcome: 'departed' }>
1919type Owner = 'source-body' | 'station-actor' | 'receiver-body' | 'final-parcel-actor' | 'loose-parcel'
20export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake'] as const
20export const cargoFleetScenarios = ['nominal', 'receiver-host-loss-before-intake', 'intake-receipt-loss', 'receiver-primary-link-loss', 'receiver-both-links-loss', 'receiver-joint-shift-before-intake', 'receiver-navigation-loss-during-rail', 'source-kill-after-station-receipt', 'receiver-kill-after-intake', 'receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'] as const
2121export type CargoFleetScenario = typeof cargoFleetScenarios[number]
2222function captureScenario(input: unknown): CargoFleetScenario {
2323 try {
@@ -58,7 +58,7 @@ function hasAuthority(p: Progress | null, command: VehiclePayloadCommand, nowMs:
5858export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
5959 if (extra.length) throw new TypeError('cargo-fleet-arguments')
6060 const scenario = captureScenario(input)
61 const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null }
61 const faultInjection: { scenario: Exclude<CargoFleetScenario, 'nominal'>, anchoredAtMs: number | null, triggeredAtMs: number | null, forceN?: SensorVector, forceRampMs?: number } | null = scenario === 'nominal' ? null : { scenario, anchoredAtMs: null, triggeredAtMs: null, ...(['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(scenario) ? { forceN: [scenario === 'receiver-pad-motion-during-rail' ? 4 : 8, 0, 0] as SensorVector, ...(scenario === 'receiver-containment-breach-after-intake-receipt' ? { forceRampMs: 2000 } : {}) } : {}) }
6262 const bodyA = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: point })
6363 const bodyB = createReceivingRigidBody({ positionM: [...receiverPadM], groundDownM: -.1, expectedPointLoad: point })
6464 const initialA = bodyA.loadSnapshot(), initialB = bodyB.loadSnapshot()
@@ -115,13 +115,28 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
115115 const profileA = createMultirotorProfile(), profileB = createMultirotorProfile(), createA = profileA.createSession, createB = profileB.createSession
116116 profileA.mission = planCargoDelivery({ version: 1, id: 'cargo-fleet-source', delivery: { origin, site: stationSite, releaseHeightM: 1 }, load: { massKg: point.massKg, forwardM: point.forwardM }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }).mission
117117 profileB.mission = receiverMission()
118 if (scenario === 'receiver-containment-breach-after-intake-receipt') profileB.positionPrediction = 'fixture-imu'
119 const baseFaultsA = profileA.faults
120 if (scenario === 'source-kill-after-station-receipt') {
121 profileA.scenario = scenario; profileA.scenarios = [scenario]
122 profileA.faults = (selected, atMs, phase) => {
123 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
124 const killed = faultInjection!.anchoredAtMs !== null && atMs > faultInjection!.anchoredAtMs
125 if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
126 return { ...baseFaultsA('nominal', atMs, phase), kill: killed }
127 }
128 }
118129 const baseFaultsB = profileB.faults
119130 profileB.scenario = scenario; profileB.scenarios = [scenario]
120131 profileB.faults = (selected, atMs, phase) => {
121132 if (selected !== scenario) throw new TypeError('cargo-fleet-scenario-changed')
122133 const active = faultInjection?.scenario === 'receiver-host-loss-before-intake' && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 4
123134 if (active && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
124 return { ...baseFaultsB('nominal', atMs, phase), hostLost: active }
135 const navigationLost = faultInjection?.scenario === 'receiver-navigation-loss-during-rail' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs
136 if (navigationLost && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
137 const killed = faultInjection?.scenario === 'receiver-kill-after-intake' && faultInjection.anchoredAtMs !== null && atMs > faultInjection.anchoredAtMs
138 if (killed && phase === 'host') faultInjection!.triggeredAtMs ??= atMs
139 return { ...baseFaultsB('nominal', atMs, phase), hostLost: active, dropPosition: navigationLost, kill: killed }
125140 }
126141 const plantA = createMultirotorPlant(bodyA), adaptedB = createMultirotorPlant(bodyB)
127142 let receiverPhysicalAtMs = 600
@@ -130,7 +145,9 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
130145 receiverPhysicalAtMs += 4
131146 const shift = faultInjection?.scenario === 'receiver-joint-shift-before-intake' && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs === faultInjection.anchoredAtMs + 4
132147 if (shift) faultInjection!.triggeredAtMs = receiverPhysicalAtMs
133 bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift ? { forceN: [.4, 0, 0] as SensorVector } : {}) })
148 const containment = faultInjection && ['receiver-pad-motion-during-rail', 'receiver-containment-breach-after-intake-receipt'].includes(faultInjection.scenario) && faultInjection.anchoredAtMs !== null && receiverPhysicalAtMs > faultInjection.anchoredAtMs
149 if (containment) faultInjection!.triggeredAtMs ??= receiverPhysicalAtMs
150 bodyB.step(actuators as [number, number, number, number], dtS, { windMps: environment.windMps, ...(shift || containment ? { forceN: shift ? [.4, 0, 0] as SensorVector : faultInjection!.forceN!.map(n => n * Math.min(1, (receiverPhysicalAtMs - faultInjection!.anchoredAtMs!) / (faultInjection!.forceRampMs ?? 4))) as SensorVector } : {}) })
134151 return adaptedB.snapshot()
135152 } }
136153 function releaseValues(which: 'source' | 'receiver') {
@@ -164,6 +181,14 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
164181 for (let nowMs = 0; nowMs <= 181000; nowMs += 4) {
165182 if (!progressA?.terminal) progressA = a.step(nowMs)
166183 if (!progressB?.terminal) progressB = b.step(nowMs)
184 if (faultInjection?.scenario === 'source-kill-after-station-receipt' && faultInjection.anchoredAtMs === null) {
185 const receipt = sourceCoordinator.snapshot().delivery
186 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
187 }
188 if (faultInjection?.scenario === 'receiver-containment-breach-after-intake-receipt' && faultInjection.anchoredAtMs === null) {
189 const receipt = intakeCoordinator.snapshot().custody
190 if (receipt.outcome === 'received') faultInjection.anchoredAtMs = receipt.acknowledgedAtMs
191 }
167192 if (station && station.snapshot().atMs < nowMs) station.step(nowMs)
168193 if (recipient && recipient.snapshot().atMs < nowMs) recipient.step(nowMs)
169194 if (loose && loose.snapshot().atMs < nowMs) loose.step(nowMs)
@@ -177,7 +202,10 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
177202 }
178203 }
179204 }
180 if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) station.start()
205 if (station?.snapshot().phase === 'held' && sourceCoordinator.snapshot().delivery.outcome === 'received' && progressB?.mission.payload?.operation === 'material-receive' && hasAuthority(progressB, progressB.mission.payload, nowMs, 102n, 202n)) {
206 station.start()
207 if (faultInjection && ['receiver-navigation-loss-during-rail', 'receiver-pad-motion-during-rail'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs ??= nowMs
208 }
181209 if (station && ['falling', 'held', 'moving', 'ready'].includes(station.snapshot().phase) && progressB && (!progressB.hostAvailable || progressB.authority.state !== 'autonomous')) station.cancel()
182210 if (commands.intake && nowMs > commands.intake.issuedAtMs) {
183211 const grant = commands.intake; commands.intake = null
@@ -189,7 +217,7 @@ export function simulateCargoFleet(input: unknown = {}, ...extra: unknown[]) {
189217 const attachment = bodyB.attachParcel(departure.parcel)
190218 intake = { atMs: nowMs, grant, attachment, source: structuredClone(progressA!), receiver: structuredClone(progressB!) }
191219 intakeEvidence = { atMs: nowMs, eventId: grant.command.eventId, sourceEventId: grant.sourceEventId, outcome: attachment.outcome, massKg: attachment.outcome === 'attached' ? attachment.parcel.massKg : 0 }
192 if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection?.scenario === 'intake-receipt-loss') faultInjection.anchoredAtMs = nowMs }
220 if (attachment.outcome === 'attached') { loose = null; changeOwner('receiver-body', nowMs); if (faultInjection && ['intake-receipt-loss', 'receiver-kill-after-intake'].includes(faultInjection.scenario)) faultInjection.anchoredAtMs = nowMs }
193221 else { loose = createCargoStationHandler({ atMs: nowMs, parcel: departure.parcel, pickup: { centerM: [3, 2, 0], radiusM: 2 }, destinationM: [3, 2, 0], maxSpeedMps: .5, maxAccelerationMps2: 1 }); changeOwner('loose-parcel', nowMs) }
194222 sampleMass()
195223 }
scripts/qualify-cargo-fleet-cli.tsmodified+13-3
Changes to scripts/qualify-cargo-fleet-cli.ts
@@ -17,23 +17,33 @@ for (const [scenario, status, owner, massKg, custody] of [
1717 ['receiver-host-loss-before-intake', 'unqualified', 'station-actor', 1.5, 'uncertain'],
1818 ['intake-receipt-loss', 'unqualified', 'receiver-body', 1.6, 'uncertain'],
1919 ['receiver-joint-shift-before-intake', 'unqualified', 'loose-parcel', 1.5, 'rejected'],
20 ['receiver-navigation-loss-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'],
21 ['source-kill-after-station-receipt', 'unqualified', 'final-parcel-actor', 1.5, 'received'],
22 ['receiver-kill-after-intake', 'unqualified', 'receiver-body', 1.6, 'uncertain'],
23 ['receiver-pad-motion-during-rail', 'unqualified', 'station-actor', 1.5, 'rejected'],
24 ['receiver-containment-breach-after-intake-receipt', 'unqualified', 'receiver-body', 1.6, 'received'],
2025] as const) {
2126 const r = await runBoundedHostProcess([binary, 'cargo:fleet', '--scenario', scenario], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo fleet ' + scenario })
2227 const bytes = Buffer.from(r.stdout, 'utf8')
2328 await Bun.write(join(output, scenario + '.stdout'), bytes); await Bun.write(join(output, scenario + '.stderr'), r.stderr)
2429 if (r.exitCode !== (status === 'completed' ? 0 : 2) || r.stderr !== '' || !r.stdout.endsWith('\n') || r.stdout.split('\n').length !== 2) throw new Error('Unqualified copied fleet command: ' + scenario)
25 const s = JSON.parse(r.stdout)
30 const s = JSON.parse(r.stdout), receiverDelivered = owner === 'final-parcel-actor'
2631 if (s.evidence !== 'owned-cargo-fleet-simulation' || s.scenario !== scenario || s.status !== status || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || s.identityAuthenticated !== false
2732 || s.ownership.owner !== owner || s.ownership.initialMassKg !== 3.1 || s.ownership.finalMassKg !== 3.1 || s.ownership.maximumMassErrorKg > 1e-12 || s.receiver.finalLoad.totalMassKg !== massKg || s.receiver.intake.outcome !== custody
2833 || s.source.scope.bootId !== '101' || s.source.scope.sessionId !== '201' || s.receiver.scope.bootId !== '102' || s.receiver.scope.sessionId !== '202'
29 || s.source.status !== 'completed' || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (status === 'completed' ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario)
34 || s.source.status !== (scenario === 'source-kill-after-station-receipt' ? 'blocked' : 'completed') || s.source.delivery.outcome !== 'received' || s.receiver.delivery.outcome !== (receiverDelivered ? 'received' : 'not-released')) throw new Error('Unqualified copied fleet facts: ' + scenario)
3035 for (const v of [s.source, s.receiver]) if (v.board.physicalActuationEnabled !== false || JSON.stringify(v.board.actuators) !== '[0,0,0,0]' || v.board.expiresAtMs !== null || v.energy.evidence !== 'declared-mission-power-budget' || v.energy.measured !== false || v.energy.electricalModel !== false || v.energy.marginAboveReserveWh <= 0) throw new Error('Unqualified copied fleet authority/budget: ' + scenario)
31 if (status === 'completed' && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario)
36 if (receiverDelivered && (s.transfer.intake.outcome !== 'attached' || s.transfer.intake.massBeforeKg !== 1.5 || s.transfer.intake.massAfterKg !== 1.6 || s.transfer.intake.receiverScope.sessionId !== '202' || s.transfer.intake.atMs >= s.receiver.intake.acknowledgedAtMs || s.transfer.stationDeparture.atMs <= s.transfer.sourceRelease.atMs + 1000 || s.station.ownedMassKg !== 0 || s.recipient.ownedMassKg !== .1 || s.recipient.phase !== 'held')) throw new Error('Unqualified copied physical transfer: ' + scenario)
3237 if (scenario === 'receiver-primary-link-loss' && (s.receiver.links.selectedSource !== 'backup' || s.receiver.links.latchedLoss)) throw new Error('Unqualified copied backup continuation')
3338 if (scenario === 'receiver-both-links-loss' && (!s.receiver.links.latchedLoss || s.station.phase !== 'cancelled' || s.receiver.status !== 'blocked')) throw new Error('Unqualified copied link expiry')
3439 if (scenario === 'receiver-host-loss-before-intake' && (s.transfer.intake !== null || s.receiver.status !== 'host-lost' || s.receiver.board.fault !== 'output-lease-expired' || s.faultInjection.triggeredAtMs !== s.receiver.intake.grantedAtMs + 4 || s.station.ownedMassKg !== .1)) throw new Error('Unqualified copied host grant gap')
3540 if (scenario === 'intake-receipt-loss' && (s.receiver.missionReason !== 'payload-diversion-landed' || s.receiver.payloadFailure.reason !== 'uncertain' || s.receiver.intake.receiptCapturedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied lost receipt')
3641 if (scenario === 'receiver-joint-shift-before-intake' && (s.transfer.intake.outcome !== 'refused' || s.receiver.payloadFailure.reason !== 'rejected' || s.loose.ownedMassKg !== .1 || s.loose.phase !== 'held' || s.loose.parcel.positionM[2] !== 0 || s.loose.impact.atMs <= s.transfer.intake.atMs)) throw new Error('Unqualified copied stale joint')
42 if (scenario === 'receiver-navigation-loss-during-rail' && (s.receiver.fault !== 'estimator-loss' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || s.faultInjection.anchoredAtMs !== s.station.startedAtMs || s.faultInjection.triggeredAtMs !== s.station.startedAtMs + 4)) throw new Error('Unqualified copied navigation loss')
43 if (scenario === 'source-kill-after-station-receipt' && (s.receiver.status !== 'completed' || s.faultInjection.anchoredAtMs !== s.source.delivery.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || s.transfer.intake.atMs <= s.faultInjection.triggeredAtMs)) throw new Error('Unqualified copied source kill')
44 if (scenario === 'receiver-kill-after-intake' && (s.transfer.intake.outcome !== 'attached' || s.faultInjection.anchoredAtMs !== s.transfer.intake.atMs || s.faultInjection.triggeredAtMs !== s.transfer.intake.atMs + 4 || s.receiver.intake.receiptCapturedAtMs !== null || s.receiver.intake.acknowledgedAtMs !== null || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied receiver kill')
45 if (scenario === 'receiver-pad-motion-during-rail' && (s.receiver.fault !== 'material-receive-interlocks' || s.station.phase !== 'cancelled' || s.station.ownedMassKg !== .1 || s.transfer.intake !== null || JSON.stringify(s.faultInjection.forceN) !== '[4,0,0]')) throw new Error('Unqualified copied pad motion')
46 if (scenario === 'receiver-containment-breach-after-intake-receipt' && (s.receiver.fault !== 'mission-fence-breach' || Math.hypot(s.receiver.finalPositionM[0], s.receiver.finalPositionM[1]) <= 12 || s.faultInjection.anchoredAtMs !== s.receiver.intake.acknowledgedAtMs || s.faultInjection.triggeredAtMs !== s.faultInjection.anchoredAtMs + 4 || JSON.stringify(s.faultInjection.forceN) !== '[8,0,0]' || s.faultInjection.forceRampMs !== 2000 || s.transfer.finalRelease !== null)) throw new Error('Unqualified copied containment')
3747 rows.push({ scenario, exit: r.exitCode, status, owner, receiverMassKg: massKg, custody, stdoutBytes: bytes.length, stdoutSha256: hash(bytes) })
3848 console.log(scenario + ' PASS')
3949}
tests/unit/cargo-fleet-cli.test.tsmodified+15-0
Changes to tests/unit/cargo-fleet-cli.test.ts
@@ -48,3 +48,18 @@ test('native fleet failures exit incomplete while retaining the actual parcel ow
4848 expect(invalid.stderr).toContain('Cargo fleet simulation unavailable')
4949 expect(invalid.stderr).not.toContain('unknown-private-marker')
5050}, 100000)
51
52test('a receiver delivery cannot hide a source kill behind a successful fleet exit', async () => {
53 const result = await command(['cargo:fleet', '--scenario', 'source-kill-after-station-receipt']).withTimeout(30000)
54 expect(result.timedOut).toBe(false)
55 expect(result.exitCode).toBe(2)
56 const report = JSON.parse(result.stdout)
57 expect(report.status).toBe('unqualified')
58 expect(report.source.status).toBe('blocked')
59 expect(report.receiver.status).toBe('completed')
60 expect(report.receiver.delivery.outcome).toBe('received')
61 expect(report.ownership.owner).toBe('final-parcel-actor')
62 expect(report.ownership.finalMassKg).toBe(3.1)
63 expect(report.source.board.actuators).toEqual([0, 0, 0, 0])
64 expect(result.stderr).toBe('')
65}, 40000)
tests/unit/cargo-fleet.test.tsmodified+116-0
Changes to tests/unit/cargo-fleet.test.ts
@@ -171,3 +171,119 @@ test('a real receiver joint shift refuses stale rendezvous and the departed poin
171171 expect(run.ownership.finalMassKg).toBe(3.1)
172172 expect(run.ownership.maximumMassErrorKg).toBeLessThan(1e-12)
173173})
174
175test('receiver GNSS loss at actual station rail start expires navigation and brakes transport while its peer continues', () => {
176 const run = simulateCargoFleet({ scenario: 'receiver-navigation-loss-during-rail' })
177 expect(run.status).toBe('unqualified')
178 expect(run.source.runtime.status).toBe('completed')
179 expect(run.source.delivery.outcome).toBe('received')
180 expect(run.receiver.runtime.status).toBe('blocked')
181 expect(run.receiver.runtime.authority.state).toBe('failsafe')
182 expect(run.receiver.runtime.authority.reason).toBe('stale-estimator')
183 expect(run.receiver.runtime.fault).toBe('estimator-loss')
184 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
185 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
186 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.5)
187 expect(run.transfer.stationDeparture).toBeNull()
188 expect(run.transfer.intake).toBeNull()
189 expect(run.station!.phase).toBe('cancelled')
190 expect(run.station!.ownedMassKg).toBe(.1)
191 expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs)
192 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
193 expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
194 expect(run.station!.stoppedAtMs!).toBeGreaterThanOrEqual(run.station!.cancelRequestedAtMs!)
195 expect(Math.hypot(...run.station!.parcel!.velocityMps)).toBe(0)
196 expect(run.ownership.owner).toBe('station-actor')
197 expect(run.ownership.finalMassKg).toBe(3.1)
198})
199
200test('source kill after captured station custody inhibits only that carrier while the receiver finishes the one parcel delivery', () => {
201 const run = simulateCargoFleet({ scenario: 'source-kill-after-station-receipt' })
202 expect(run.status).toBe('unqualified')
203 expect(run.source.runtime.status).toBe('blocked')
204 expect(run.source.runtime.authority.state).toBe('killed')
205 expect(run.source.runtime.board.actuators).toEqual([0, 0, 0, 0])
206 expect(run.source.delivery.outcome).toBe('received')
207 expect(run.receiver.runtime.status).toBe('completed')
208 expect(run.receiver.intake.custody.outcome).toBe('received')
209 expect(run.receiver.delivery.outcome).toBe('received')
210 expect(run.transfer.intake!.atMs).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
211 expect(run.faultInjection!.anchoredAtMs).toBe(run.source.delivery.acknowledgedAtMs)
212 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
213 expect(run.ownership.owner).toBe('final-parcel-actor')
214 expect(run.station!.ownedMassKg).toBe(0)
215 expect(run.recipient!.ownedMassKg).toBe(.1)
216 expect(run.ownership.finalMassKg).toBe(3.1)
217})
218
219test('receiver kill immediately after actual intake retains its real added mass without inventing captured custody', () => {
220 const run = simulateCargoFleet({ scenario: 'receiver-kill-after-intake' })
221 expect(run.status).toBe('unqualified')
222 expect(run.source.runtime.status).toBe('completed')
223 expect(run.source.delivery.outcome).toBe('received')
224 expect(run.receiver.runtime.status).toBe('blocked')
225 expect(run.receiver.runtime.authority.state).toBe('killed')
226 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
227 expect(run.transfer.intake!.attachment.outcome).toBe('attached')
228 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6)
229 expect(run.receiver.finalLoad.properties.phase).toBe('attached')
230 expect(run.receiver.intake.custody.outcome).toBe('uncertain')
231 expect(run.receiver.intake.custody.receiptCapturedAtMs).toBeNull()
232 expect(run.receiver.intake.custody.acknowledgedAtMs).toBeNull()
233 expect(run.faultInjection!.anchoredAtMs).toBe(run.transfer.intake!.atMs)
234 expect(run.faultInjection!.triggeredAtMs).toBe(run.transfer.intake!.atMs + 4)
235 expect(run.transfer.finalRelease).toBeNull()
236 expect(run.recipient).toBeNull()
237 expect(run.station!.ownedMassKg).toBe(0)
238 expect(run.ownership.owner).toBe('receiver-body')
239 expect(run.ownership.finalMassKg).toBe(3.1)
240})
241
242test('receiver pad motion trips receiving interlocks and brakes the station before any intake', () => {
243 const run = simulateCargoFleet({ scenario: 'receiver-pad-motion-during-rail' })
244 expect(run.status).toBe('unqualified')
245 expect(run.source.runtime.status).toBe('completed')
246 expect(run.source.delivery.outcome).toBe('received')
247 expect(run.receiver.runtime.status).toBe('blocked')
248 expect(run.receiver.runtime.authority.state).toBe('disarmed')
249 expect(run.receiver.runtime.fault).toBe('material-receive-interlocks')
250 expect(run.receiver.runtime.finalTruthPositionM[0]).toBeGreaterThan(3)
251 expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeLessThan(12)
252 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
253 expect(run.receiver.finalLoad.properties.phase).toBe('awaiting')
254 expect(run.transfer.intake).toBeNull()
255 expect(run.transfer.stationDeparture).toBeNull()
256 expect(run.station!.phase).toBe('cancelled')
257 expect(run.station!.ownedMassKg).toBe(.1)
258 expect(run.station!.startedAtMs).toBe(run.faultInjection!.anchoredAtMs)
259 expect(run.station!.cancelRequestedAtMs!).toBeGreaterThan(run.faultInjection!.triggeredAtMs!)
260 expect(run.ownership.owner).toBe('station-actor')
261 expect(run.ownership.finalMassKg).toBe(3.1)
262})
263
264test('receiver containment during loaded departure revokes authority on actual fence motion while preserving received custody', () => {
265 const run = simulateCargoFleet({ scenario: 'receiver-containment-breach-after-intake-receipt' })
266 expect(run.status).toBe('unqualified')
267 expect(run.source.runtime.status).toBe('completed')
268 expect(run.source.delivery.outcome).toBe('received')
269 expect(run.receiver.runtime.status).toBe('blocked')
270 expect(run.receiver.runtime.authority.state).toBe('failsafe')
271 expect(run.receiver.runtime.authority.reason).toBe('fence-breach')
272 expect(run.receiver.runtime.fault).toBe('mission-fence-breach')
273 expect(run.receiver.runtime.navigationEvidence.localPositionHealthy).toBe(true)
274 expect(run.receiver.runtime.navigationEvidence.positionPrediction?.mode).toBe('fixture-imu')
275 expect(run.faultInjection).toMatchObject({ forceN: [8, 0, 0], forceRampMs: 2000 })
276 expect(Math.hypot(run.receiver.runtime.finalTruthPositionM[0], run.receiver.runtime.finalTruthPositionM[1])).toBeGreaterThan(12)
277 expect(run.receiver.runtime.board.actuators).toEqual([0, 0, 0, 0])
278 expect(run.receiver.finalLoad.properties.phase).toBe('attached')
279 expect(run.receiver.finalLoad.properties.totalMassKg).toBe(1.6)
280 expect(run.receiver.intake.custody.outcome).toBe('received')
281 expect(run.receiver.intake.custody.acknowledgedAtMs).toBe(run.faultInjection!.anchoredAtMs)
282 expect(run.faultInjection!.triggeredAtMs).toBe(run.faultInjection!.anchoredAtMs! + 4)
283 expect(run.transfer.finalRelease).toBeNull()
284 expect(run.receiver.delivery.outcome).toBe('not-released')
285 expect(run.station!.phase).toBe('departed')
286 expect(run.station!.ownedMassKg).toBe(0)
287 expect(run.ownership.owner).toBe('receiver-body')
288 expect(run.ownership.finalMassKg).toBe(3.1)
289})