ReviewOS

also looking at this

stacks/drone

feat: run configured modeled cargo delivery and phase failures

#97
Merged chris wants to merge codex/cargo-delivery-runtime into main
38 files +2114 -30

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.

.github/workflows/ci.ymlmodified+13-0
Changes to .github/workflows/ci.yml
@@ -275,6 +275,19 @@ jobs:
275275 test "$actual" -eq "$expected"
276276 bun -e 'const [file, scenario] = process.argv.slice(1); const raw = await Bun.file(file).text(); const s = JSON.parse(raw); if (Buffer.byteLength(raw) > 16384 || s.scenario !== scenario || s.status !== (scenario === "link-loss" ? "unqualified" : "characterized") || s.physicalFlightEnabled !== false || s.boardOutputsInhibited !== true || Object.values(s.capabilities).some(Boolean) || s.final.landingPerformed !== false) throw new Error("Invalid compiled cargo characterization")' "$RUNNER_TEMP/cargo-$scenario.json" "$scenario"
277277 done
278 - name: Qualify copied cargo delivery, custody and phase failures
279 run: bun scripts/qualify-cargo-cli.ts "$GITHUB_WORKSPACE/storage/runtime-build/cli/drone" "$RUNNER_TEMP/cargo-delivery-cli"
280 - uses: actions/upload-artifact@v4
281 if: always()
282 with:
283 name: cargo-delivery-cli-evidence
284 if-no-files-found: ignore
285 retention-days: 7
286 path: |
287 ${{ runner.temp }}/cargo-delivery-cli/*.stdout
288 ${{ runner.temp }}/cargo-delivery-cli/*.stderr
289 ${{ runner.temp }}/cargo-delivery-cli/receipt.json
290 ${{ runner.temp }}/cargo-delivery-cli/request.json
278291 - name: Qualify compiled environmental datasets outside the checkout
279292 working-directory: ${{ runner.temp }}
280293 shell: bash
README.mdmodified+13-3
Changes to README.md
@@ -114,9 +114,19 @@ our existing multirotor sensor, estimator, control and authority loop.
114114./buddy cargo:load-bench --scenario link-loss
115115```
116116
117Link loss is deliberately unqualified and exits 1. This is a [bounded physics prerequisite](docs/cargo-load.md)
118for [#25](https://github.com/stacksjs/drone/issues/25); delivery, physical release, custody and endurance
119qualification remain open.
117Link loss in this [bounded load characterization](docs/cargo-load.md) is deliberately unqualified
118and exits 1. Its capability flags remain separate from the modeled delivery mission:
119
120```sh
121./buddy cargo:mission examples/cargo-delivery.json
122./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss
123```
124
125The [cargo delivery guide](docs/cargo-delivery.md) covers explicit mass/CG, receiver, alternate
126and power declarations, captured release/custody, redundant links and post-release failures.
127Exit 2 retains a configured mission that did not complete a matched delivery and carrier landing.
128Distinct-vehicle fleet handover, electrical/measured endurance and physical/operational
129qualification remain open in [#25](https://github.com/stacksjs/drone/issues/25).
120130
121131## Agriculture sensing
122132
app/Commands/CargoMission.tsadded+25-0
Changes to app/Commands/CargoMission.ts
@@ -0,0 +1,25 @@
1import { defineCommand } from '@stacksjs/cli'
2import { printAsync, printErrorAsync } from '../../resources/functions/cli/output'
3import { cargoDeliveryScenarios, cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
4import { readMissionFile } from '../../resources/functions/drone/missions/files'
5
6export default defineCommand({
7 name: 'cargo:mission <request>',
8 description: 'Execute an explicitly configured modeled cargo mission without physical dispatch',
9 options: {
10 '--scenario <name>': { description: cargoDeliveryScenarios.join(', '), default: 'nominal' },
11 },
12 async handle(options, path) {
13 try {
14 const input = await readMissionFile(path, { maxBytes: 16384, preserveBom: true })
15 const summary = cargoDeliverySummary(simulateCargoDelivery({ configuration: JSON.parse(input), scenario: options.scenario }))
16 if (!summary.demonstrationMatched || summary.status !== 'completed' || summary.delivery.outcome !== 'received' || !summary.landingContact) process.exitCode = 2
17 await printAsync(JSON.stringify(summary))
18 }
19 catch {
20 process.exitCode = 1
21 try { await printErrorAsync('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure') }
22 catch { /* Failed output remains failure even if its diagnostic pipe is closed. */ }
23 }
24 },
25})
bin/drone.tsmodified+2-0
Changes to bin/drone.ts
@@ -12,6 +12,7 @@ import AuthoritySimulate from '../app/Commands/AuthoritySimulate'
1212import ControlsSimulate from '../app/Commands/ControlsSimulate'
1313import CargoLoadBench from '../app/Commands/CargoLoadBench'
1414import CargoDeliver from '../app/Commands/CargoDeliver'
15import CargoMission from '../app/Commands/CargoMission'
1516
1617import PayloadsBench from '../app/Commands/PayloadsBench'
1718import PayloadsCapture from '../app/Commands/PayloadsCapture'
@@ -94,6 +95,7 @@ EstimatorReplay(app)
9495ControlsSimulate(app)
9596CargoLoadBench(app)
9697CargoDeliver(app)
98CargoMission(app)
9799AuthoritySimulate(app)
98100DriversBench(app)
99101PayloadsBench(app)
config/docs.tsmodified+1-0
Changes to config/docs.ts
@@ -107,6 +107,7 @@ const config: BunPressOptions = {
107107 { text: 'Owned prescribed application', link: '/application' },
108108 { text: 'Synthetic airborne environmental sampling', link: '/environment' },
109109 { text: 'Simulated carried-load characterization', link: '/cargo-load' },
110 { text: 'Modeled cargo delivery and custody', link: '/cargo-delivery' },
110111 { text: 'Simulated POI camera reframe', link: '/cinema' },
111112 ],
112113 },
docs/adr/0034-modeled-cargo-custody.mdadded+49-0
Changes to docs/adr/0034-modeled-cargo-custody.md
@@ -0,0 +1,49 @@
1# 0034: Cargo missions use captured custody and the shared vehicle runtime
2
3## Decision
4
5Build cargo delivery on `VehicleMissionPlan`, `VehiclePayloadPort`, the owned quad-X
6plant and the existing raw sensor/estimator/control/authority/watchdog loop. Keep
7point-mass dynamics and independent parcel motion in the model, and put release and
8custody policy behind a separate modeled coordinator. Plant truth cannot mutate
9policy custody directly.
10
11The public simulator accepts an explicit configuration for load/CG, receiver, release
12height, alternate and declared power budget. A pure planner validates the complete
13normal/alternate graph before allocating a world. Native Buddy commands provide the
14application entrypoint; the standalone CLI registers the same command implementation.
15No framework package abstraction is introduced for cargo-specific policy.
16
17Release is a single scoped operation after qualified captured dwell. Intent, mechanism
18open/jam, receiver receipt and carrier completion are distinct observations. Unknown
19dispatch remains uncertain. Terminal uncertainty cancels before discarding its event;
20authority loss and close retain the same cancellation path. Receipt requires matching
21event/container/site/recipient evidence and a qualified finite capture-time ACK pose.
22
23The shared mission engine's optional cargo payload failure edge is a finite, one-way
24alternate approach and landing. Its budget includes that path. It cannot retry release,
25replace the authority fence or bypass a source/authority failure.
26
27Two captured link sources belong to the same vehicle/boot/session scope. Switching
28between them does not transfer mission authority or parcel custody to another vehicle.
29Foreign/replayed/stale packets and complete source loss cannot silently renew authority.
30
31Post-release and post-receipt fault fixtures bind to observed policy events. Independent
32settling continues only board/plant physics after host processing stops. Later physical
33parcel contact cannot create a host receipt, and an earlier receipt cannot fabricate
34carrier landing completion.
35
36## Limits and further work
37
38The actual executable world remains a flat local fixture with the existing synthetic
39GNSS origin and initial-north heading assumption. Its point parcel has no drag or
40collision scene. The current power calculation is a declared ceiling budget, separate
41from measured or modeled electrical consumption. Physical and authenticated-identity
42flags remain false.
43
44Distinct-vehicle fleet handover requires actual separate vehicle/boot/session sources,
45fresh authority and custody observations, explicit transfer/acceptance and failure
46behavior. It is not implemented by changing a scope label or copying a successful
47summary. Durable cargo/runtime reconciliation, physical release/receiver validation and
48the separate BVLOS operational evidence case remain issue #25 work. The
49[cargo guide](../cargo-delivery.md) records the current command and library contracts.
docs/api.mdmodified+19-0
Changes to docs/api.md
@@ -38,6 +38,25 @@ forgetting a lease, not a guaranteed real-time deadline. Do not automatically re
3838without independently checking operator authority. `close()` is idempotent. The SITL example has
3939a hard 30-second duration and a deliberately artificial target.
4040
41## Modeled cargo missions
42
43The cargo modules expose the owned runtime directly, without an external-autopilot dependency:
44
45- `planCargoDelivery(configuration)` in `resources/functions/drone/cargo/plan.ts` captures a
46 complete declaration and validates the normal/alternate mission graph and declared reserve.
47- `simulateCargoDelivery({ configuration, scenario? })` in `cargo/delivery.ts` under the same
48 drone folder runs its actual loaded rigid body through the raw sensors, estimator, mission,
49 controls, authority and watchdog. Omitting configuration selects only the closed named fixture.
50- `cargoDeliverySummary(run)` produces a bounded compact projection, preserving actual mission,
51 custody and host-loss outcomes. A matched failure demonstration is not delivery completion.
52- `createCargoDeliveryCoordinator(declaration, modeledMechanism)` in `cargo/coordinator.ts`
53 implements `VehiclePayloadPort`, captured readiness and idempotent cancellation. Its adapter
54 contract is synchronous and explicitly modeled, with no built-in hardware I/O.
55
56The [cargo guide](cargo-delivery.md) defines the flat synthetic origin, finite load/route bounds,
57one-release and receipt semantics, phase-bound faults, native CLI exit codes and remaining
58fleet/physical/BVLOS work. Budgets are declared power ceilings, not electrical measurements.
59
4160## Target sources
4261
4362`createGpsTargetSource({ targetId, origin, maxSpeedMps?, now? })` converts trusted phone or GNSS fixes
docs/cargo-delivery.mdadded+184-0
Changes to docs/cargo-delivery.md
@@ -0,0 +1,184 @@
1# Modeled cargo delivery
2
3The owned quadcopter runtime can execute a delivery mission with a carried point mass,
4captured release observations, receiver confirmation, a finite alternate landing and two
5modeled communication sources. The raw IMU/GNSS feed, estimator, controls, authority and
6independent output watchdog are the same paths used by the other owned vehicle profiles.
7ArduPilot and PX4 are optional drivers and are not used by this simulation.
8
9This is software simulation. Physical outputs remain inhibited and identity is not
10authenticated. A modeled receiver receipt does not establish a real recipient, hardware
11release, package condition, safe aircraft operation or authorization for BVLOS transport.
12
13## Run a declared mission
14
15```sh
16./buddy cargo:mission examples/cargo-delivery.json
17./buddy cargo:mission examples/cargo-delivery.json --scenario site-blocked
18./buddy cargo:mission examples/cargo-delivery.json --scenario after-release-host-loss
19```
20
21The standalone `drone cargo:mission` command has the same arguments. The request file is
22required. It contains a complete `CargoDeliveryConfiguration`, as shown in the repository's
23[fictional example](https://github.com/stacksjs/drone/blob/main/examples/cargo-delivery.json).
24There is no inferred destination, parcel, alternate, power declaration or farm location.
25
26| Field | Declaration |
27|---|---|
28| `version`, `id` | Version `1` and an ASCII mission identifier, at most 40 characters |
29| `delivery.origin` | The synthetic feed's fixed WGS84 origin: latitude/longitude `0,0`, ellipsoid height `100` m |
30| `delivery.site` | Explicit site, recipient and container identifiers, `positionM` and capture radius |
31| `delivery.releaseHeightM` | Height above the receiver plane, from 0.3 to 3 m |
32| `load` | Point mass greater than zero and at most 0.25 kg; body-forward offset from -0.1 to 0.1 m |
33| `alternateM` | An explicit LOCAL_NED landing point on the modeled zero-down ground plane |
34| `power` | `availableWh`, conservative `maxW` and `reservePercent` |
35
36Down is positive. The receiver and alternate down coordinate must be zero. The carrier
37starts and returns at the local origin. Its modeled structural datum rests 0.1 m above
38the ground; qualified contact, rather than an invented exact zero position, completes
39landing. Sites and routes must fit the existing 12 m horizontal and -5 to 0 m down fence.
40This flat local world does not implement real terrain, arbitrary geodetic origins or
41long-range navigation.
42
43Site radius is 0.1 to 2 m. Site and recipient identifiers use lowercase ASCII letters,
44digits and hyphens, beginning with a letter, at most 64 characters; the container is
45limited to 40 characters by the carried-body model. Records reject extra fields,
46accessors and missing values. Tuples require three finite own data elements.
47
48The complete owned mission validator checks normal and alternate graph duration and
49reserve before a plant is allocated. The graph includes takeoff, destination, one
50material-drop request, return/landing, and a one-way alternate approach/landing after
51payload failure. No failure path retries release. Its fixed task deadlines give a worst
52case of 81,520 ms. The declared power ceiling must be at least 600 W; native mission
53capacity, reserve and power ceilings still apply.
54
55The example's 150 g parcel is 0.04 m aft of the structural datum. Its destination is
56`[2, 1, 0]`, release height 1.2 m, alternate `[-1, 1, 0]`, and budget 50 Wh/700 W/35%
57reserve. These are modeled declarations, not a recommended physical aircraft load.
58
59## Outcomes and command delivery
60
61`cargo:mission` returns one compact JSON document, at most 16,384 UTF8 bytes including
62its final LF. It retains the admitted configuration, actual outcome, load, parcel,
63custody, link events, reserve budget and final board/authority observations.
64
65- Exit 0 requires a matched scenario, received custody and actual modeled carrier
66 landing completion with neutral outputs.
67- Exit 2 retains a modeled diversion, blocked/incomplete mission, host loss or an
68 unmatched scenario. Received custody can coexist with a later carrier failure.
69- Exit 1 covers invalid input, rejected admission, unavailable results and output
70 failures. The handler attempts one fixed diagnostic without caller data.
71
72The request is a regular file bounded to 16,384 bytes, decoded with strict UTF8. A final
73symlink is refused; parent paths are followed. Reading is not path authorization or an
74atomic snapshot of concurrent file mutation. Accept results only after both streams
75reach EOF and the actual exit status is known. A failed output write can leave a prefix.
76JSON parsing uses native last-key-wins semantics. The CLI does not persist a full runtime
77journal, canonical signed request or authenticated output.
78
79The existing `cargo:deliver --scenario <name>` command remains a set of closed
80demonstrations with its original 100 g forward load. It exits 0 for matched fault
81demonstrations as well as the matched nominal route; it does not mean a parcel was
82delivered. Invalid input, unmatched demonstrations and output failures exit 1.
83The separate [load bench](cargo-load.md) retains its original characterization contract.
84
85## Release and custody
86
87The shared payload port observes qualified delayed measurements, not current plant
88truth. Release requires current authority, matching site/recipient evidence, attached
89load, stable geometry and 26 distinct fresh captures. Both received observations and
90source captures must span at least 500 ms. Retained packets, retiming, clock/reference
91changes, stale gyros or scheduler gaps break readiness. Attitude, position, covariance,
92issue-time position, task, reference and bounded command lifetime are checked.
93
94The mechanism is dispatched at most once. Command intent, captured open/jammed state,
95receiver confirmation and carrier completion remain different observations. A policy
96refusal cannot become received custody through ambient receipt fields. Dispatch failure
97remains uncertain; uncertain/rejected terminal acknowledgements cancel the scoped
98operation before clearing it. Authority loss and close also cancel it idempotently.
99
100The model separates the actual point mass without resetting the carrier's motion,
101estimator or controller. The parcel follows a ballistic point trajectory to a plane.
102A receiver within the declared radius produces a fictional delayed receipt, which is
103then captured and transported. This model has no parcel drag, wind drift, deformation,
104collision scene, receiver authentication or measured release mechanism.
105
106## Links and failures
107
108`createRedundantVehicleLinks` selects primary then backup for one captured vehicle,
109boot and session scope. It rejects foreign scope, replay, stale/future captures and
110invalid clock uncertainty. Age includes uncertainty and preserves the original capture
111epoch. Loss of both sources latches, including across an unobserved gap. A late packet
112does not silently restore authority. Labels are correlations, not authentication.
113The cargo fixture remains one carrier with boot 55/session 123; failover between its
114links is not fleet handover.
115
116Named cases include recipient absence, site blockage, mechanism jam, receipt loss,
117foreign receipt, primary/both-link loss, independent containment and faults during
118the alternate. Position, IMU, vehicle-sensor, RC, kill and host-loss cases also remain.
119The command's help lists every exact name.
120
121`after-release-` cases cover host, position, IMU, vehicle-sensor, RC, kill and both-link
122loss. `after-receipt-` cases cover host, kill and position loss. Injection starts 20 ms
123after the policy first observes the matching mechanism-open or received-custody state,
124rather than at a guessed mission time. `faultInjection` retains the observation, source capture, event, actual
125trigger and final physical epochs. An unreached anchor stays null and unmatched.
126
127These phase cases add a 1,000 ms independent settling probe after host execution ends.
128Only the board and physical plant continue; sensors, estimator, mission, payload and
129authority do not run. A parcel can reach the receiver in that interval while custody
130stays uncertain because no host received its receipt. A prior confirmed receipt remains
131received if the carrier later fails. Ground contact in a settling probe is not controlled
132carrier landing. After host loss the reported authority is its last stale snapshot,
133while the independent board expires its output lease.
134
135## Library interface and energy limits
136
137Import `planCargoDelivery` from `resources/functions/drone/cargo/plan.ts`,
138`simulateCargoDelivery` and `cargoDeliverySummary` from `cargo/delivery.ts`, and
139`createCargoDeliveryCoordinator` from `cargo/coordinator.ts` under the same drone folder.
140The simulator accepts `{ configuration, scenario? }`; omitting configuration selects
141only the named demonstration fixture. The planner requires a complete declaration.
142The coordinator implements the existing `VehiclePayloadPort` over an explicitly modeled,
143synchronous mechanism adapter and returns detached custody/readiness snapshots.
144Full simulation results retain the complete runtime path and replayable mission trace.
145
146Energy is the existing declared maximum-power budget, including the worst normal or
147alternate graph and reserve. It is not a motor/battery electrical model, measured Wh,
148temperature limit, voltage-sag prediction or Pi performance result. Changing mass changes
149the actual modeled dynamics; it does not identify an electrical load law. A successful
150budget is conditional on that ceiling and is not measured endurance qualification.
151
152## BVLOS operations evidence
153
154Country and intended operation must be established separately from library support.
155Sources below were checked October 24, 2026; inspect their current requirements for the
156actual operation. This software issues no authorization.
157
158The FAA's [Part 107 waiver guidance](https://www.faa.gov/uas/commercial_operators/part_107_waivers)
159identifies BVLOS and multiple-aircraft operation as distinct waiver subjects. Its
160[package-delivery guidance](https://www.faa.gov/uas/advanced_operations/package_delivery_drone)
161describes Part 135 certification, airspace authorization and applicable exemption/waiver
162work for BVLOS package delivery, with operating-area, communication and collision-avoidance
163evidence. A source-code license or simulated receipt supplies none of those approvals.
164
165EASA's [specific-category guidance](https://www.easa.europa.eu/en/the-agency/faqs/operational-authorisation-requirements-specific-category)
166distinguishes applicable standard-scenario declarations from national-authority operational
167authorizations supported by risk assessment, including SORA/PDRA paths and separate LUC
168privileges. A declaration route has its own aircraft and operational conditions.
169
170Before any proposed long-range operation, the operator's evidence case must bind the actual
171aircraft, crew, route, containment/alternate volumes, ground risks and authority approvals.
172It must address qualified detect-and-avoid and non-cooperative traffic, communication and
173navigation coverage/latency/loss, measured endurance and weather, release hardware and
174recipient/site procedures, fleet responsibility transfer, emergency response, maintenance
175and retained actual logs. These are pending engineering/operations deliverables here.
176Two modeled links, selected-person camera tracking and a range obstacle simulation do not
177establish an airspace detect-and-avoid system.
178
179## Remaining issue scope
180
181[#25](https://github.com/stacksjs/drone/issues/25) remains open for actual distinct-vehicle
182fleet handover, richer endurance models/measurements, durable cargo/runtime reconciliation,
183physical release/recipient validation and the separate operational evidence case.
184The original acceptance scope is not reduced to passing named demonstrations.
docs/cargo-load.mdmodified+7-2
Changes to docs/cargo-load.md
@@ -1,6 +1,6 @@
11# Simulated carried-load characterization
22
3This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. Delivery missions and physical release are still unimplemented.
3This is the first airborne physics prerequisite for [cargo issue #25](https://github.com/stacksjs/drone/issues/25). It characterizes a carried point mass and one prescribed split through our existing multirotor sensor, estimator, controller and authority loop. This bench does not implement delivery missions; the separate [modeled cargo mission](cargo-delivery.md) provides configured delivery and custody. Physical release remains unimplemented.
44
55```sh
66./buddy cargo:load-bench
@@ -35,6 +35,11 @@ The retained source qualification passed 38 tests and 5,553 assertions across co
3535
3636## Remaining roadmap
3737
38Issue #25 remains open, with its original acceptance criteria unchecked. Next work must add a delivery mission and site/recipient confirmation, verified release hardware and ACKs, custody, measured energy reserves and alternates. Redundant communications, degraded navigation, containment, diversion and fleet handover also remain open. Operational authorization, detect-and-avoid and physical/HIL evidence require their own documented qualification.
38Issue #25 remains open. The separate [modeled cargo mission](cargo-delivery.md) now implements
39configured delivery, captured mechanism/receiver evidence and custody, declared reserve budgets,
40alternates, two modeled links and phase-bound fault cases. Those capabilities do not change this
41bench's characterization result or flags. Distinct-vehicle fleet handover, measured/electrical
42endurance, release hardware, operational authorization, detect-and-avoid and physical/HIL
43qualification remain open.
3944
4045The result keeps `physicalFlightEnabled` and `identityAuthenticated` false, with physical board outputs inhibited. Its six delivery, mechanism verification, custody, recipient, mission and energy-margin capability flags remain false. This characterization does not authorize physical operation or long-range transport.
docs/index.mdmodified+1-1
Changes to docs/index.md
@@ -84,7 +84,7 @@ See the [scenario commands and expected outcomes](vehicles-vtol.md) before compa
8484| Prescribed application | Owned ground simulation for inert water or granules, with explicit implement authority | [Application](application.md) |
8585| Public safety | Synthetic incident collection, authenticated records, review and local dataset management | [Public safety](public-safety.md) |
8686| Patrol and docking | Retained modeled vehicle, explicit decisions, finite recurrence and charge opt-in | [Patrol CLI](patrol-cli.md) |
87| Cargo and cinema | Bounded carried-load characterization and a simulated POI angular reframe | [Cargo](cargo-load.md), [cinema](cinema.md) |
87| Cargo and cinema | Configured modeled delivery, captured release/custody, alternates and redundant links; fleet handover remains open. Separate carried-load and POI reframe experiments | [Cargo delivery](cargo-delivery.md), [load bench](cargo-load.md), [cinema](cinema.md) |
8888| Environmental sampling and antenna pointing | Synthetic sampling and a selected-vehicle mount model | [Environment](environment.md), [antenna](antenna.md) |
8989
9090Each guide distinguishes implemented behavior, recorded evidence and remaining
docs/roadmap/README.mdmodified+4-0
Changes to docs/roadmap/README.md
@@ -22,6 +22,10 @@ companion application and simulator, not a native board autopilot.
2222 pause/resume, recorded checkpoint recovery, conditional branches, native CLI and stx viewer.
2323 Whole-runtime checkpoint correlation, payload ACK reconciliation, driver translation and hardware
2424 evidence remain open in [#10](https://github.com/stacksjs/drone/issues/10).
25- Configured modeled cargo delivery uses captured release/receiver custody, finite alternate landing,
26 declared reserves, scoped redundant links and post-release failures on the shared runtime.
27 Distinct-vehicle fleet handover and physical/electrical/operational qualification remain in
28 [#25](https://github.com/stacksjs/drone/issues/25). See [cargo contracts](../cargo-delivery.md).
2529- TypeScript follow/behavior library, kinematic simulator, controller leases and envelope checks.
2630- Pi AI Camera acquisition bridge and explicit image-space target selection/loss policy, pending Pi hardware validation.
2731- An optional visual guard that holds metric follow logic on lost, unconfirmed, stale or changed visual selection.
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: owned carried-point-mass/CG physics, prescribed split and native link-loss characterization; delivery mission, release ACK, custody, energy margin and redundant links remain open | Native `cargo:load-bench` [simulation evidence and limits](../cargo-load.md); no physical or BVLOS qualification | [#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; distinct-vehicle fleet handover and electrical/measured endurance remain open | Native `cargo:mission` and `cargo:deliver` [simulation contracts and BVLOS evidence gates](../cargo-delivery.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) |
examples/cargo-delivery.jsonadded+18-0
Changes to examples/cargo-delivery.json
@@ -0,0 +1,18 @@
1{
2 "version": 1,
3 "id": "operator-cargo-route",
4 "delivery": {
5 "origin": { "latitudeDeg": 0, "longitudeDeg": 0, "ellipsoidHeightM": 100 },
6 "site": {
7 "id": "declared-site",
8 "recipientId": "declared-recipient",
9 "containerId": "parcel-150g",
10 "positionM": [2, 1, 0],
11 "radiusM": 0.4
12 },
13 "releaseHeightM": 1.2
14 },
15 "load": { "massKg": 0.15, "forwardM": -0.04 },
16 "alternateM": [-1, 1, 0],
17 "power": { "availableWh": 50, "maxW": 700, "reservePercent": 35 }
18}
resources/functions/artifacts/native-cli/process.tsmodified+2-1
Changes to resources/functions/artifacts/native-cli/process.ts
@@ -5,6 +5,7 @@ export interface BoundedProcessOptions {
55 stdoutBytes: number
66 stderrBytes: number
77 label: string
8 strictUtf8?: boolean
89}
910
1011export interface BoundedProcessResult {
@@ -46,7 +47,7 @@ export async function runBoundedProcess(command: string[], options: BoundedProce
4647 try {
4748 while (true) {
4849 const { done, value } = await reader.read()
49 if (done) return Buffer.concat(chunks, bytes).toString('utf8')
50 if (done) return new TextDecoder('utf-8', { fatal: options.strictUtf8 === true, ignoreBOM: true }).decode(Buffer.concat(chunks, bytes))
5051 if (value.byteLength > limit - bytes) {
5152 const error = new Error(`${options.label} ${label} exceeds byte limit`)
5253 stop(error, `${label} exceeds byte limit`)
resources/functions/drone/cargo/coordinator.tsmodified+2-2
Changes to resources/functions/drone/cargo/coordinator.ts
@@ -37,7 +37,7 @@ function record(input: unknown, fields: string[]): Record<string, unknown> {
3737 }
3838 return result
3939}
40function captureDeclaration(input: unknown): CargoDeliveryDeclaration {
40export function captureCargoDeliveryDeclaration(input: unknown): CargoDeliveryDeclaration {
4141 const top = record(input, ['origin', 'site', 'releaseHeightM']), site = record(top.site, ['id', 'recipientId', 'containerId', 'positionM', 'radiusM'])
4242 const origin = record(top.origin, ['latitudeDeg', 'longitudeDeg', 'ellipsoidHeightM'])
4343 if (!['id', 'recipientId', 'containerId'].every(key => typeof site[key] === 'string' && /^[a-z][a-z0-9-]{0,63}$/.test(site[key] as string))
@@ -59,7 +59,7 @@ function finiteTuple(value: unknown, size: number) {
5959
6060/** Captured modeled release/custody policy behind the shared vehicle payload port. No physical IO. */
6161export function createCargoDeliveryCoordinator(input: unknown, mechanism: ModeledCargoMechanism) {
62 const declaration = captureDeclaration(input), { site } = declaration
62 const declaration = captureCargoDeliveryDeclaration(input), { site } = declaration
6363 if (!mechanism || mechanism.evidence !== 'modeled-cargo-mechanism' || typeof mechanism.request !== 'function' || typeof mechanism.cancel !== 'function') throw new TypeError('cargo-delivery-modeled-mechanism')
6464 const delivery: CargoDeliveryCustody = { outcome: 'not-released', containerId: site.containerId, siteId: site.id, recipientId: site.recipientId, requestedAtMs: null, mechanismCapturedAtMs: null, receiptCapturedAtMs: null, acknowledgedAtMs: null, reason: null, mechanismOutcome: 'not-requested' }
6565 const measurements = createVehicleMeasurementQualifier('quadcopter')
resources/functions/drone/cargo/delivery.tsmodified+71-36
Changes to resources/functions/drone/cargo/delivery.ts
@@ -1,20 +1,37 @@
11import type { SensorVector } from '../sensors/types'
22import type { CargoBodySeparation, CargoLoadSnapshot } from './types'
3import type { VehicleControllerContext, VehicleMissionPlan, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
3import type { VehicleControllerContext, VehiclePayloadCommand, VehiclePlant, VehicleSession } from '../vehicles/types'
44import { createRigidBody } from '../controls/physics'
55import { multirotorModel } from '../controls/model'
66import { createMultirotorPlant, createMultirotorProfile } from '../vehicles/multirotor/profile'
77import { runVehicleSimulation } from '../vehicles/runtime'
88import { createRedundantVehicleLinks } from '../vehicles/links'
9import type { CargoDeliveryConfiguration } from './plan'
10import { planCargoDelivery } from './plan'
911import type { CargoDeliveryCustody } from './coordinator'
1012import { createCargoDeliveryCoordinator } from './coordinator'
1113
1214const flightFaults = ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'host-loss'] as const
13export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults] as const
15const phaseFaultDefinitions = {
16 'after-release-host-loss': { phase: 'after-release', fault: 'hostLost' },
17 'after-release-position-loss': { phase: 'after-release', fault: 'dropPosition' },
18 'after-release-imu-loss': { phase: 'after-release', fault: 'dropImu' },
19 'after-release-vehicle-sensor-loss': { phase: 'after-release', fault: 'dropVehicleSensor' },
20 'after-release-rc-override': { phase: 'after-release', fault: 'rcOverride' },
21 'after-release-kill': { phase: 'after-release', fault: 'kill' },
22 'after-release-both-links-loss': { phase: 'after-release', fault: 'links' },
23 'after-receipt-host-loss': { phase: 'after-receipt', fault: 'hostLost' },
24 'after-receipt-kill': { phase: 'after-receipt', fault: 'kill' },
25 'after-receipt-position-loss': { phase: 'after-receipt', fault: 'dropPosition' },
26} as const
27const phaseFaults = Object.keys(phaseFaultDefinitions) as (keyof typeof phaseFaultDefinitions)[]
28function phaseFault(scenario: string) { return Object.hasOwn(phaseFaultDefinitions, scenario) ? phaseFaultDefinitions[scenario as keyof typeof phaseFaultDefinitions] : null }
29export const cargoDeliveryScenarios = ['nominal', 'recipient-absent', 'site-blocked', 'mechanism-jam', 'receipt-loss', 'foreign-receipt', 'primary-link-loss', 'both-links-loss', 'authority-containment', 'diversion-position-loss', 'diversion-kill', ...flightFaults, ...phaseFaults] as const
1430type CargoDeliveryScenario = typeof cargoDeliveryScenarios[number]
1531interface Parcel { massKg: number, positionM: SensorVector, velocityMps: SensorVector, onSurface: boolean, capturedAtMs: number }
1632type DeliveryRelease = CargoBodySeparation & { atMs: number, eventId: string }
1733type DeliveryLinkSnapshot = ReturnType<ReturnType<typeof createRedundantVehicleLinks>['snapshot']>
34interface DeliveryFaultInjection { phase: 'after-release' | 'after-receipt', anchoredAtMs: number | null, sourceCapturedAtMs: number | null, eventId: string | null, triggeredAtMs: number | null, independentTailMs: number, finalPhysicalAtMs: number }
1835export interface CargoDeliveryResult {
1936 version: 1
2037 evidence: 'owned-cargo-delivery-simulation'
@@ -23,6 +40,8 @@ export interface CargoDeliveryResult {
2340 physicalFlightEnabled: false
2441 boardOutputsInhibited: true
2542 identityAuthenticated: false
43 faultInjection?: DeliveryFaultInjection
44 configuration?: CargoDeliveryConfiguration
2645 delivery: CargoDeliveryCustody
2746 initialLoad: CargoLoadSnapshot
2847 finalLoad: CargoLoadSnapshot
@@ -32,35 +51,24 @@ export interface CargoDeliveryResult {
3251 linkEvents: DeliveryLinkSnapshot[]
3352 runtime: ReturnType<typeof runVehicleSimulation>
3453}
35const site = Object.freeze({ id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', northM: 3, eastM: 0, downM: 0, radiusM: .35 })
36function inputScenario(input: unknown): CargoDeliveryScenario {
37 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario')) throw new TypeError('cargo-delivery-input')
38 const d = Object.getOwnPropertyDescriptor(input, 'scenario')
39 if (d === undefined) return 'nominal'
40 if (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) throw new TypeError('cargo-delivery-input')
41 return d.value
42}
43function deliveryMission(): VehicleMissionPlan {
44 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
45 return { version: 2, vehicle: 'quadcopter', id: 'cargo-shared-delivery', frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
46 origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -1],
47 power: { availableWh: 40, maxW: 600, reservePercent: 30 }, start: 'start', tasks: [
48 { id: 'start', kind: 'start', next: 'depart' },
49 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -1], ...spatial, next: 'site' },
50 { id: 'site', kind: 'waypoint', positionM: [site.northM, site.eastM, -1], ...spatial, settleMs: 800, next: 'release' },
51 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
52 { id: 'home', kind: 'return', ...spatial, next: 'land' },
53 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
54 { id: 'alternate-approach', kind: 'waypoint', positionM: [1.5, -2, -1], ...spatial, next: 'alternate-land' },
55 { id: 'alternate-land', kind: 'land', positionM: [1.5, -2, 0], ...spatial },
56 ] }
54const fixtureConfiguration: CargoDeliveryConfiguration = { version: 1, id: 'cargo-shared-delivery', delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'fixture-receiver-site', recipientId: 'fixture-recipient', containerId: 'parcel-100g', positionM: [3, 0, 0], radiusM: .35 }, releaseHeightM: 1 }, load: { massKg: .1, forwardM: .08 }, alternateM: [1.5, -2, 0], power: { availableWh: 40, maxW: 600, reservePercent: 30 } }
55function captureInput(input: unknown) {
56 if (!input || typeof input !== 'object' || Object.getPrototypeOf(input) !== Object.prototype || Reflect.ownKeys(input).some(key => key !== 'scenario' && key !== 'configuration')) throw new TypeError('cargo-delivery-input')
57 const d = Object.getOwnPropertyDescriptor(input, 'scenario'), config = Object.getOwnPropertyDescriptor(input, 'configuration')
58 if (d && (!d.enumerable || !Object.hasOwn(d, 'value') || !cargoDeliveryScenarios.some(value => value === d.value)) || config && (!config.enumerable || !Object.hasOwn(config, 'value') || config.value === undefined)) throw new TypeError('cargo-delivery-input')
59 return { scenario: (d?.value ?? 'nominal') as CargoDeliveryScenario, explicit: !!config, planned: planCargoDelivery(config ? config.value : fixtureConfiguration) }
5760}
5861
5962/** Fictional local delivery with real modeled mass change and received, delayed evidence. No physical transport. */
6063export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult {
61 const scenario = inputScenario(input), flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal'
64 const captured = captureInput(input), { scenario, planned } = captured, configuration = planned.configuration
65 const selectedSite = configuration.delivery.site
66 const site = { ...selectedSite, northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], downM: selectedSite.positionM[2] }
67 const flightScenario = flightFaults.find(value => value === scenario) ?? 'nominal'
68 const selectedFault = phaseFault(scenario)
69 const faultInjection: DeliveryFaultInjection | null = selectedFault ? { phase: selectedFault.phase, anchoredAtMs: null, sourceCapturedAtMs: null, eventId: null, triggeredAtMs: null, independentTailMs: 1000, finalPhysicalAtMs: 0 } : null
6270 const profile = createMultirotorProfile(flightScenario), baseSession = profile.createSession, baseFaults = profile.faults
63 profile.mission = deliveryMission()
71 profile.mission = planned.mission
6472 profile.scenario = scenario
6573 profile.scenarios = [...cargoDeliveryScenarios]
6674 profile.name = 'Owned modeled cargo delivery'
@@ -69,15 +77,19 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
6977 if (selected !== scenario) throw new TypeError('cargo-delivery-scenario-changed')
7078 const base = baseFaults(flightScenario, atMs, phase), failureAtMs = observed?.mission.payloadFailure?.triggeredAtMs
7179 const diversionFault = failureAtMs !== undefined && atMs >= failureAtMs + 200
72 return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault, kill: base.kill || scenario === 'diversion-kill' && diversionFault }
80 if (phase === 'host' && faultInjection && faultInjection.anchoredAtMs !== null && atMs >= faultInjection.anchoredAtMs + 20) faultInjection.triggeredAtMs ??= atMs
81 const phaseActive = faultInjection !== null && faultInjection.triggeredAtMs !== null && atMs >= faultInjection.triggeredAtMs
82 const fault = phaseActive ? selectedFault?.fault : null
83 return { ...base, dropPosition: base.dropPosition || scenario === 'diversion-position-loss' && diversionFault || fault === 'dropPosition', kill: base.kill || scenario === 'diversion-kill' && diversionFault || fault === 'kill',
84 hostLost: base.hostLost || fault === 'hostLost', dropImu: base.dropImu || fault === 'dropImu', dropVehicleSensor: base.dropVehicleSensor || fault === 'dropVehicleSensor', rcOverride: base.rcOverride || fault === 'rcOverride' }
7385 }
74 const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, massKg: .1, forwardM: .08 } })
86 const body = createRigidBody({ positionM: [0, 0, -.1], groundDownM: -.1, carriedPointLoad: { id: site.containerId, ...configuration.load } })
7587 const initialLoad = body.loadSnapshot(), adapted = createMultirotorPlant(body)
7688 let physicalAtMs = 600, parcel: Parcel | null = null, release: DeliveryRelease | null = null
7789 let armed: VehiclePayloadCommand | null = null, observed: VehicleControllerContext | null = null
7890 let receiptAtMs: number | null = null
7991 let mechanismAttempt: { eventId: string, atMs: number, jammed: boolean } | null = null
80 const coordinator = createCargoDeliveryCoordinator({ origin: profile.mission.origin, site: { id: site.id, recipientId: site.recipientId, containerId: site.containerId, positionM: [site.northM, site.eastM, site.downM], radiusM: site.radiusM }, releaseHeightM: 1 }, {
92 const coordinator = createCargoDeliveryCoordinator(configuration.delivery, {
8193 evidence: 'modeled-cargo-mechanism',
8294 request(command) { armed = structuredClone(command) },
8395 cancel(eventId) { if (armed?.eventId === eventId) armed = null },
@@ -120,7 +132,8 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
120132 linkObservation(nowMs) {
121133 linkSequence++
122134 for (const sourceId of ['primary', 'backup'] as const) {
123 if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')) continue
135 if (nowMs >= 5000 && (scenario === 'both-links-loss' || sourceId === 'primary' && scenario === 'primary-link-loss')
136 || selectedFault?.fault === 'links' && faultInjection && faultInjection.triggeredAtMs !== null && nowMs >= faultInjection.triggeredAtMs) continue
124137 linkQueue.push({ sourceId, sequence: linkSequence, capturedAtMs: nowMs, receivedAtMs: nowMs + (sourceId === 'primary' ? 20 : 40) })
125138 }
126139 linkQueue.sort((a, b) => a.receivedAtMs - b.receivedAtMs)
@@ -147,23 +160,37 @@ export function simulateCargoDelivery(input: unknown = {}): CargoDeliveryResult
147160 siteClear: scenario !== 'site-blocked', recipientPresent: scenario !== 'recipient-absent' && !scenario.startsWith('diversion-'), mechanismOpen: release?.outcome === 'separated', mechanismJammed: mechanismAttempt?.jammed ?? false, mechanismEventId: mechanismAttempt?.eventId ?? '',
148161 receiptEventId: receiptCaptured && scenario !== 'receipt-loss' ? release?.eventId ?? '' : '', receiptContainerId: site.containerId } }
149162 },
150 createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) } }),
163 createPayload: () => ({ ...coordinator.port, observe(context) { observed = structuredClone(context); coordinator.port.observe!(context) }, poll(nowMs) {
164 const ack = coordinator.port.poll(nowMs)
165 if (faultInjection && faultInjection.anchoredAtMs === null) {
166 const custody = coordinator.snapshot().delivery
167 const capturedAtMs = faultInjection.phase === 'after-receipt' ? custody.outcome === 'received' ? custody.receiptCapturedAtMs : null : custody.mechanismOutcome === 'open' ? custody.mechanismCapturedAtMs : null
168 const eventId = observed?.measurement?.values[faultInjection.phase === 'after-receipt' ? 'receiptEventId' : 'mechanismEventId']
169 if (capturedAtMs !== null && typeof eventId === 'string' && eventId.length > 0) {
170 faultInjection.anchoredAtMs = nowMs; faultInjection.sourceCapturedAtMs = capturedAtMs; faultInjection.eventId = eventId
171 }
172 }
173 return ack
174 } }),
151175 }
152176 }
153 const runtime = runVehicleSimulation(profile, { seconds: 100 })
177 const runtime = runVehicleSimulation(profile, { seconds: 100, neutralTailMs: faultInjection?.independentTailMs ?? 0 })
154178 const delivery = coordinator.snapshot().delivery
155179 return structuredClone({ version: 1, evidence: 'owned-cargo-delivery-simulation' as const, scenario, status: runtime.status === 'completed' && runtime.mission.payloadFailure ? 'diverted' as const : runtime.status, physicalFlightEnabled: false as const, boardOutputsInhibited: true as const, identityAuthenticated: false as const,
156 delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime })
180 ...(captured.explicit ? { configuration } : {}), ...(faultInjection ? { faultInjection: { ...faultInjection, finalPhysicalAtMs: physicalAtMs } } : {}), delivery, initialLoad, finalLoad: body.loadSnapshot(), release, parcel, links, linkEvents, runtime })
157181}
158182
159183/** A bounded command projection. Matches describe these model cases, never physical delivery. */
160184export function cargoDeliverySummary(run: CargoDeliveryResult) {
185 const configuration = run.configuration ?? fixtureConfiguration, selectedSite = configuration.delivery.site
186 const site = { northM: selectedSite.positionM[0], eastM: selectedSite.positionM[1], radiusM: selectedSite.radiusM }
161187 const rt = run.runtime, power = rt.trace.plan.power, measurements = rt.path.flatMap(row => row.measurement ? [row.measurement] : [])
162188 const landingContact = rt.mission.state === 'completed' && rt.trace.rows.at(-1)?.observation.onGround === true
163189 const neutral = rt.board.actuators.length === 4 && rt.board.actuators.every(n => n === 0) && rt.board.expiresAtMs === null
164190 && (rt.authority.state === 'disarmed' || rt.authority.state === 'killed' || rt.authority.state === 'failsafe' || rt.status === 'host-lost' && rt.board.fault === 'output-lease-expired')
165191 const separationSeen = run.release?.outcome === 'separated' && run.finalLoad.properties.phase === 'separated' && measurements.some(m => m.values.loadAttached === false && m.values.loadMassKg === 1.5)
166192 const receiptEvent = run.release?.eventId
193 const selectedFault = phaseFault(run.scenario)
167194 let worldMatched = false
168195 if (run.scenario === 'nominal' || run.scenario === 'primary-link-loss') worldMatched = separationSeen && run.delivery.outcome === 'received' && run.status === 'completed'
169196 && (run.scenario !== 'primary-link-loss' || run.linkEvents.some(event => event.selectedSource === 'backup') && run.links.selectedSource === 'backup' && !run.links.latchedLoss)
@@ -180,12 +207,20 @@ export function cargoDeliverySummary(run: CargoDeliveryResult) {
180207 else if (run.scenario === 'rc-override') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.to === 'manual' && event.reason === 'rc-override')
181208 else if (run.scenario === 'kill') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.state === 'killed'
182209 else if (run.scenario === 'host-loss') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.lastCheckedAtMs === 4980 && rt.board.fault === 'output-lease-expired'
210 else if (selectedFault) {
211 const injection = run.faultInjection, fault = selectedFault.fault
212 const observedFault = fault === 'hostLost' ? rt.board.fault === 'output-lease-expired' && rt.mission.state === 'running'
213 : rt.authority.events.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rcOverride' ? event.to === 'manual' : fault === 'links' ? event.reason === 'link-loss' : fault === 'dropPosition' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks')
214 worldMatched = separationSeen && run.parcel?.onSurface === true && observedFault && !!injection && injection.anchoredAtMs !== null && injection.triggeredAtMs !== null && injection.triggeredAtMs >= injection.anchoredAtMs + 20
215 && injection.eventId === receiptEvent && injection.sourceCapturedAtMs !== null && injection.sourceCapturedAtMs >= run.release!.atMs && injection.sourceCapturedAtMs <= injection.anchoredAtMs
216 && (selectedFault.phase === 'after-receipt' ? run.delivery.outcome === 'received' && run.delivery.receiptCapturedAtMs !== null : run.delivery.outcome === 'uncertain' && run.delivery.receiptCapturedAtMs === null)
217 }
183218 else if (run.scenario === 'authority-containment') worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.authority.events.some(event => event.reason === 'invalid-target-or-lease') && rt.path.every(row => Math.hypot(row.truthPositionM[0], row.truthPositionM[1]) <= 2)
184219 else if (run.scenario.startsWith('diversion-')) worldMatched = run.release === null && run.delivery.outcome === 'not-released' && rt.mission.payloadFailure?.reason === 'rejected'
185220 && rt.authority.events.some(event => event.atMs !== null && event.atMs >= rt.mission.payloadFailure!.triggeredAtMs + 200 && (run.scenario === 'diversion-kill' ? event.to === 'killed' : ['estimator-loss', 'stale-estimator'].includes(event.reason)))
186 const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' ? 'host-lost' : run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted'
221 const expectedStatus = run.scenario === 'nominal' || run.scenario === 'primary-link-loss' ? 'completed' : run.scenario === 'host-loss' || selectedFault?.fault === 'hostLost' ? 'host-lost' : selectedFault || run.scenario === 'both-links-loss' || run.scenario === 'authority-containment' || run.scenario.startsWith('diversion-') || flightFaults.some(value => value === run.scenario) ? 'blocked' : 'diverted'
187222 const summary = structuredClone({ version: 1, evidence: run.evidence, scenario: run.scenario, status: run.status, demonstrationMatched: worldMatched && run.status === expectedStatus && (!['completed', 'diverted'].includes(expectedStatus) || landingContact) && neutral,
188 physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, delivery: run.delivery, landingContact,
223 physicalFlightEnabled: false, boardOutputsInhibited: true, identityAuthenticated: false, ...(run.configuration ? { configuration } : {}), ...(run.faultInjection ? { faultInjection: run.faultInjection } : {}), delivery: run.delivery, landingContact,
189224 load: { initial: run.initialLoad.properties, final: run.finalLoad.properties },
190225 release: run.release ? { atMs: run.release.atMs, eventId: run.release.eventId, outcome: run.release.outcome, reason: run.release.reason } : null,
191226 parcel: run.parcel, links: run.links, linkEvents: run.linkEvents,
@@ -194,7 +229,7 @@ export function cargoDeliverySummary(run: CargoDeliveryResult) {
194229 worstCaseMs: rt.budget.worstCaseMs, worstCaseRequiredWh: rt.budget.requiredWh, marginAboveReserveWh: power.availableWh * (1 - power.reservePercent / 100) - rt.budget.requiredWh },
195230 board: { actuators: [...rt.board.actuators], expiresAtMs: rt.board.expiresAtMs, fault: rt.board.fault, physicalActuationEnabled: rt.board.physicalActuationEnabled },
196231 authority: { hostAvailable: rt.status !== 'host-lost', state: rt.authority.state, reason: rt.authority.reason, lastCheckedAtMs: rt.authority.lastCheckedAtMs, leaseUntilMs: rt.authority.leaseUntilMs },
197 model: { loadMassKg: .1, loadForwardM: .08, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' },
232 model: { loadMassKg: configuration.load.massKg, loadForwardM: configuration.load.forwardM, separation: 'zero-ejection-point-mass', parcel: 'ballistic-point-with-plane-capture', receiver: 'fictional-100ms-receipt', measurementDelayMs: 40, headingAlignment: 'initial-simulation-north' },
198233 })
199234 if (new TextEncoder().encode(JSON.stringify(summary)).byteLength + 1 > 16384) throw new RangeError('cargo-delivery-summary-unavailable')
200235 return summary
resources/functions/drone/cargo/plan.tsadded+58-0
Changes to resources/functions/drone/cargo/plan.ts
@@ -0,0 +1,58 @@
1import type { SensorVector } from '../sensors/types'
2import type { VehicleMissionPlan } from '../vehicles/types'
3import type { CargoDeliveryDeclaration } from './coordinator'
4import { captureCargoDeliveryDeclaration } from './coordinator'
5import { validateVehicleMission } from '../missions/vehicle'
6
7export interface CargoDeliveryConfiguration {
8 version: 1
9 id: string
10 delivery: CargoDeliveryDeclaration
11 load: { massKg: number, forwardM: number }
12 alternateM: SensorVector
13 power: VehicleMissionPlan['power']
14}
15function record(value: unknown, fields: string[]) {
16 if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype || Reflect.ownKeys(value).length !== fields.length) throw new TypeError('cargo-delivery-configuration')
17 const result: Record<string, unknown> = {}
18 for (const field of fields) {
19 const descriptor = Object.getOwnPropertyDescriptor(value, field)
20 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value')) throw new TypeError('cargo-delivery-configuration')
21 result[field] = descriptor.value
22 }
23 return result
24}
25
26/** Explicit modeled route admission, before allocating a plant or transport. */
27export function planCargoDelivery(input: unknown) {
28 const top = record(input, ['version', 'id', 'delivery', 'load', 'alternateM', 'power'])
29 const delivery = captureCargoDeliveryDeclaration(top.delivery), load = record(top.load, ['massKg', 'forwardM']), power = record(top.power, ['availableWh', 'maxW', 'reservePercent'])
30 if (top.version !== 1 || typeof top.id !== 'string' || !/^[a-z][a-z0-9-]{0,39}$/.test(top.id)
31 || delivery.origin.latitudeDeg !== 0 || delivery.origin.longitudeDeg !== 0 || delivery.origin.ellipsoidHeightM !== 100 || delivery.site.positionM[2] !== 0 || delivery.site.containerId.length > 40
32 || typeof load.massKg !== 'number' || !Number.isFinite(load.massKg) || load.massKg <= 0 || load.massKg > .25
33 || typeof load.forwardM !== 'number' || !Number.isFinite(load.forwardM) || load.forwardM < -.1 || load.forwardM > .1
34 || !Object.values(power).every(n => typeof n === 'number' && Number.isFinite(n)) || (power.maxW as number) < 600
35 || !Array.isArray(top.alternateM) || top.alternateM.length !== 3 || Reflect.ownKeys(top.alternateM).length !== 4) throw new TypeError('cargo-delivery-configuration')
36 const alternateM = [0, 1, 2].map(i => {
37 const descriptor = Object.getOwnPropertyDescriptor(top.alternateM, String(i))
38 if (!descriptor?.enumerable || !Object.hasOwn(descriptor, 'value') || typeof descriptor.value !== 'number' || !Number.isFinite(descriptor.value)) throw new TypeError('cargo-delivery-configuration')
39 return descriptor.value
40 }) as SensorVector
41 if (alternateM[2] !== 0) throw new TypeError('cargo-delivery-configuration')
42 const configuration: CargoDeliveryConfiguration = { version: 1, id: top.id, delivery, load: { massKg: load.massKg, forwardM: load.forwardM }, alternateM, power: power as unknown as VehicleMissionPlan['power'] }
43 const height = delivery.releaseHeightM, site = delivery.site.positionM
44 const spatial = { acceptanceM: .18, maxArrivalSpeedMps: .15, settleMs: 300, timeoutMs: 20000 }
45 const proposed: VehicleMissionPlan = { version: 2, vehicle: 'quadcopter', id: configuration.id, frame: 'LOCAL_NED', altitude: 'ORIGIN_RELATIVE_DOWN', terrain: 'DECLARED_MODEL_SURFACE',
46 origin: delivery.origin, fence: { radiusM: 12, minDownM: -5, maxDownM: 0 }, rallyM: [0, 0, -height], power: configuration.power, start: 'start', tasks: [
47 { id: 'start', kind: 'start', next: 'depart' },
48 { id: 'depart', kind: 'takeoff', positionM: [0, 0, -height], ...spatial, next: 'site' },
49 { id: 'site', kind: 'waypoint', positionM: [site[0], site[1], -height], ...spatial, settleMs: 800, next: 'release' },
50 { id: 'release', kind: 'payload', operation: 'material-drop', containerId: delivery.site.containerId, timeoutMs: 1500, next: 'home', onFailure: 'alternate-approach' },
51 { id: 'home', kind: 'return', ...spatial, next: 'land' },
52 { id: 'land', kind: 'land', positionM: [0, 0, 0], ...spatial },
53 { id: 'alternate-approach', kind: 'waypoint', positionM: [alternateM[0], alternateM[1], -height], ...spatial, next: 'alternate-land' },
54 { id: 'alternate-land', kind: 'land', positionM: [...alternateM], ...spatial },
55 ] }
56 const { plan, worstCaseMs, requiredWh } = validateVehicleMission(proposed)
57 return structuredClone({ evidence: 'modeled-cargo-delivery-plan' as const, physicalFlightEnabled: false as const, identityAuthenticated: false as const, configuration, mission: plan, budget: { worstCaseMs, requiredWh } })
58}
scripts/qualify-cargo-cli.tsadded+51-0
Changes to scripts/qualify-cargo-cli.ts
@@ -0,0 +1,51 @@
1import { chmod, copyFile, mkdir } from 'node:fs/promises'
2import { join, resolve } from 'node:path'
3import { runBoundedHostProcess } from '../resources/functions/host/process'
4
5const args = process.argv.slice(2)
6if (args.length !== 2) throw new Error('Usage: qualify-cargo-cli <binary> <new-output-directory>')
7const source = resolve(args[0]!), output = resolve(args[1]!)
8await mkdir(output, { mode: 0o700 })
9const binary = join(output, 'drone')
10await copyFile(source, binary)
11await chmod(binary, 0o755)
12const request = join(output, 'request.json')
13await copyFile(new URL('../examples/cargo-delivery.json', import.meta.url), request)
14const cases: [string, string, string][] = [
15 ['nominal', 'completed', 'received'], ['recipient-absent', 'diverted', 'not-released'], ['site-blocked', 'diverted', 'not-released'],
16 ['mechanism-jam', 'diverted', 'not-released'], ['receipt-loss', 'diverted', 'uncertain'], ['foreign-receipt', 'diverted', 'uncertain'],
17 ['primary-link-loss', 'completed', 'received'], ['both-links-loss', 'blocked', 'not-released'], ['authority-containment', 'blocked', 'not-released'],
18 ['diversion-position-loss', 'blocked', 'not-released'], ['diversion-kill', 'blocked', 'not-released'],
19 ['position-loss', 'blocked', 'not-released'], ['imu-loss', 'blocked', 'not-released'], ['vehicle-sensor-loss', 'blocked', 'not-released'],
20 ['rc-override', 'blocked', 'not-released'], ['kill', 'blocked', 'not-released'], ['host-loss', 'host-lost', 'not-released'],
21 ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-release-position-loss', 'blocked', 'uncertain'], ['after-release-imu-loss', 'blocked', 'uncertain'],
22 ['after-release-vehicle-sensor-loss', 'blocked', 'uncertain'], ['after-release-rc-override', 'blocked', 'uncertain'], ['after-release-kill', 'blocked', 'uncertain'], ['after-release-both-links-loss', 'blocked', 'uncertain'],
23 ['after-receipt-host-loss', 'host-lost', 'received'], ['after-receipt-kill', 'blocked', 'received'], ['after-receipt-position-loss', 'blocked', 'received'],
24]
25const rows: Record<string, unknown>[] = []
26const hash = (bytes: ArrayBuffer | Uint8Array) => new Bun.CryptoHasher('sha256').update(bytes).digest('hex')
27async function run(name: string, command: string[], expectedExit: number, scenario: string, status: string, custody: string, configured: boolean) {
28 const result = await runBoundedHostProcess([binary, ...command], { cwd: output, timeoutMs: 30000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Compiled cargo command ' + name })
29 const stdout = Buffer.from(result.stdout, 'utf8'), stderr = Buffer.from(result.stderr, 'utf8'), exit = result.exitCode
30 await Bun.write(join(output, name + '.stdout'), stdout)
31 await Bun.write(join(output, name + '.stderr'), stderr)
32 if (exit !== expectedExit || stderr.byteLength !== 0 || stdout.byteLength > 16384) throw new Error('Unqualified compiled cargo command: ' + name)
33 const text = new TextDecoder('utf-8', { fatal: true }).decode(stdout)
34 if (!text.endsWith('\n') || text.split('\n').length !== 2) throw new Error('Invalid compiled cargo wire: ' + name)
35 const report = JSON.parse(text)
36 if (report.evidence !== 'owned-cargo-delivery-simulation' || report.scenario !== scenario || report.status !== status || report.delivery.outcome !== custody || report.demonstrationMatched !== true
37 || report.physicalFlightEnabled !== false || report.identityAuthenticated !== false || report.board.physicalActuationEnabled !== false || report.board.expiresAtMs !== null || JSON.stringify(report.board.actuators) !== '[0,0,0,0]'
38 || report.landingContact !== ['completed', 'diverted'].includes(status) || report.authority.hostAvailable !== (status !== 'host-lost') || report.energy.measured !== false || report.energy.electricalModel !== false || report.energy.marginAboveReserveWh <= 0) throw new Error('Invalid compiled cargo outcome: ' + name)
39 if (custody === 'received' && (![report.delivery.receiptCapturedAtMs, report.delivery.mechanismCapturedAtMs, report.delivery.requestedAtMs, report.delivery.acknowledgedAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || !(report.delivery.receiptCapturedAtMs >= report.delivery.mechanismCapturedAtMs && report.delivery.mechanismCapturedAtMs > report.delivery.requestedAtMs && report.delivery.receiptCapturedAtMs <= report.delivery.acknowledgedAtMs))
40 || custody !== 'received' && report.delivery.receiptCapturedAtMs !== null) throw new Error('Invalid compiled cargo custody: ' + name)
41 if (scenario.startsWith('after-') && (!report.faultInjection || ![report.faultInjection.triggeredAtMs, report.faultInjection.anchoredAtMs, report.faultInjection.sourceCapturedAtMs, report.faultInjection.finalPhysicalAtMs].every(value => Number.isSafeInteger(value) && value >= 0) || report.faultInjection.triggeredAtMs !== report.faultInjection.anchoredAtMs + 20 || report.faultInjection.sourceCapturedAtMs > report.faultInjection.anchoredAtMs || report.faultInjection.sourceCapturedAtMs < report.release.atMs || report.faultInjection.eventId !== report.release.eventId || report.faultInjection.finalPhysicalAtMs < report.faultInjection.triggeredAtMs + 1000 || report.faultInjection.independentTailMs !== 1000 || report.parcel.onSurface !== true)) throw new Error('Invalid compiled cargo phase: ' + name)
42 if (configured && (report.configuration.id !== 'operator-cargo-route' || report.load.initial.totalMassKg !== 1.65 || report.model.loadMassKg !== .15 || report.model.loadForwardM !== -.04)) throw new Error('Invalid compiled cargo declaration: ' + name)
43 if (scenario === 'primary-link-loss' && (report.links.selectedSource !== 'backup' || report.links.latchedLoss) || scenario.endsWith('both-links-loss') && !report.links.latchedLoss) throw new Error('Invalid compiled cargo link evidence: ' + name)
44 rows.push({ name, command, exit, scenario, status, custody, stdoutBytes: stdout.byteLength, stdoutSha256: hash(stdout), configured })
45 console.log(name + ' PASS')
46}
47for (const [scenario, status, custody] of cases) await run(scenario, ['cargo:deliver', '--scenario', scenario], 0, scenario, status, custody, false)
48for (const [scenario, status, custody] of cases.filter(([name]) => ['nominal', 'site-blocked', 'after-release-host-loss', 'after-receipt-kill'].includes(name))) {
49 await run('configured-' + scenario, ['cargo:mission', request, '--scenario', scenario], scenario === 'nominal' ? 0 : 2, scenario, status, custody, true)
50}
51await Bun.write(join(output, 'receipt.json'), JSON.stringify({ evidence: 'copied-cargo-cli-consistency', binarySha256: hash(await Bun.file(binary).arrayBuffer()), requestSha256: hash(await Bun.file(request).arrayBuffer()), physicalFlightEnabled: false, identityAuthenticated: false, rows }, null, 2))
tests/tsconfig.runtime.jsonmodified+5-0
Changes to tests/tsconfig.runtime.json
@@ -24,8 +24,13 @@
2424 "../resources/functions/drone/cargo/**/*.ts",
2525 "./unit/cargo-load.test.ts",
2626 "./unit/cargo-delivery.test.ts",
27 "./unit/cargo-delivery-config.test.ts",
28 "./unit/cargo-delivery-phase-faults.test.ts",
2729 "./unit/cargo-delivery-coordinator.test.ts",
2830 "./unit/cargo-delivery-cli.test.ts",
31 "./unit/cargo-mission-cli.test.ts",
32 "./unit/cargo-qualification-process.test.ts",
33 "../scripts/qualify-cargo-cli.ts",
2934 "./unit/cargo-load-cli.test.ts",
3035 "./fixtures/estimation/**/*.ts",
3136 "./unit/estimation.test.ts",
tests/unit/cargo-delivery-config.test.tsadded+92-0
Changes to tests/unit/cargo-delivery-config.test.ts
@@ -0,0 +1,92 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3import { replayVehicleMission } from '../../resources/functions/drone/missions/vehicle'
4import { planCargoDelivery } from '../../resources/functions/drone/cargo/plan'
5
6const configuration = {
7 version: 1,
8 id: 'operator-cargo-route',
9 delivery: { origin: { latitudeDeg: 0, longitudeDeg: 0, ellipsoidHeightM: 100 }, site: { id: 'declared-site', recipientId: 'declared-recipient', containerId: 'parcel-150g', positionM: [2, 1, 0], radiusM: .4 }, releaseHeightM: 1.2 },
10 load: { massKg: .15, forwardM: -.04 },
11 alternateM: [-1, 1, 0],
12 power: { availableWh: 50, maxW: 700, reservePercent: 35 },
13}
14
15// Existing public simulation seam: catches declared routes, actual loaded-body
16// and sensor/authority/payload integration; misses physical flight qualification.
17test('an explicit cargo configuration drives its own load, destination, release and return on the shared runtime', () => {
18 const run = simulateCargoDelivery({ configuration })
19 expect(run.status).toBe('completed')
20 expect(run.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' })
21 expect(run.initialLoad.properties.totalMassKg).toBe(1.65)
22 expect(run.initialLoad.properties.cgBodyM[0]).toBeLessThan(0)
23 expect(run.finalLoad.properties.totalMassKg).toBe(1.5)
24 expect(run.release?.outcome).toBe('separated')
25 expect(run.parcel?.massKg).toBe(.15)
26 expect(run.parcel?.onSurface).toBe(true)
27 expect(Math.hypot(run.parcel!.positionM[0] - 2, run.parcel!.positionM[1] - 1)).toBeLessThanOrEqual(.4)
28 expect(run.runtime.trace.plan.id).toBe('operator-cargo-route')
29 expect(run.runtime.trace.plan.power).toEqual(configuration.power)
30 expect(run.runtime.trace.plan.tasks.find(task => task.id === 'site')).toMatchObject({ positionM: [2, 1, -1.2] })
31 expect(run.runtime.trace.plan.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] })
32 expect(replayVehicleMission(run.runtime.trace).snapshot()).toEqual(run.runtime.mission)
33 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
34 const summary = cargoDeliverySummary(run)
35 expect(summary.model).toMatchObject({ loadMassKg: .15, loadForwardM: -.04 })
36 expect(summary.energy).toMatchObject({ capacityWh: 50, maximumDeclaredW: 700, reservePercent: 35, measured: false, electricalModel: false })
37 expect(summary.demonstrationMatched).toBe(true)
38 expect(run.physicalFlightEnabled).toBe(false)
39 expect(run.identityAuthenticated).toBe(false)
40}, 30000)
41
42test('a blocked configured receiver retains its actual mass and lands at the explicitly declared alternate', () => {
43 const run = simulateCargoDelivery({ configuration, scenario: 'site-blocked' })
44 expect(run.status).toBe('diverted')
45 expect(run.delivery.outcome).toBe('not-released')
46 expect(run.release).toBeNull()
47 expect(run.finalLoad.properties.totalMassKg).toBe(1.65)
48 expect(run.runtime.mission.payloadFailure?.landingTaskId).toBe('alternate-land')
49 expect(run.runtime.trace.rows.at(-1)!.observation.onGround).toBe(true)
50 const landed = run.runtime.path.at(-1)!.truthPositionM
51 expect(Math.hypot(landed[0] + 1, landed[1] - 1)).toBeLessThan(.18)
52 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
53 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
54}, 30000)
55
56test('cargo planning captures a complete route and rejects unsupported geometry and unsafe budgets before execution', () => {
57 const input = structuredClone(configuration), planned = planCargoDelivery(input)
58 input.delivery.site.positionM[0] = 11; input.alternateM[0] = 10; input.load.massKg = .2
59 expect(planned.configuration.delivery.site.positionM).toEqual([2, 1, 0])
60 expect(planned.configuration.load.massKg).toBe(.15)
61 expect(planned.mission.tasks.find(task => task.id === 'alternate-land')).toMatchObject({ positionM: [-1, 1, 0] })
62 expect(planned.budget.worstCaseMs).toBe(81520)
63 expect(planned.physicalFlightEnabled).toBe(false)
64 expect(planned.identityAuthenticated).toBe(false)
65 const lowBudget = structuredClone(configuration); lowBudget.power.availableWh = 1
66 expect(() => planCargoDelivery(lowBudget)).toThrow()
67 const farSite = structuredClone(configuration); farSite.delivery.site.positionM = [13, 0, 0]
68 expect(() => planCargoDelivery(farSite)).toThrow()
69 const farAlternate = structuredClone(configuration); farAlternate.alternateM = [13, 0, 0]
70 expect(() => planCargoDelivery(farAlternate)).toThrow()
71 const unmodeledOrigin = structuredClone(configuration); unmodeledOrigin.delivery.origin.latitudeDeg = 50
72 expect(() => planCargoDelivery(unmodeledOrigin)).toThrow('cargo-delivery-configuration')
73 const unmodeledSurface = structuredClone(configuration); unmodeledSurface.delivery.site.positionM[2] = -.2
74 expect(() => planCargoDelivery(unmodeledSurface)).toThrow('cargo-delivery-configuration')
75 const understatedPower = structuredClone(configuration); understatedPower.power.maxW = 50
76 expect(() => planCargoDelivery(understatedPower)).toThrow('cargo-delivery-configuration')
77 expect(() => simulateCargoDelivery({ configuration: undefined })).toThrow('cargo-delivery-input')
78 let reads = 0
79 const getter = { ...configuration, get alternateM() { reads++; return [-1, 1, 0] } }
80 expect(() => planCargoDelivery(getter)).toThrow('cargo-delivery-configuration')
81 expect(reads).toBe(0)
82})
83
84test('a configured route that never reaches the selected containment failure retains its actual completed outcome', () => {
85 const near = structuredClone(configuration); near.delivery.site.positionM = [1, 0, 0]
86 const run = simulateCargoDelivery({ configuration: near, scenario: 'authority-containment' })
87 expect(run.status).toBe('completed')
88 expect(run.delivery.outcome).toBe('received')
89 expect(run.runtime.authority.events.some(event => event.reason === 'invalid-target-or-lease')).toBe(false)
90 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(false)
91 expect(cargoDeliverySummary(run).landingContact).toBe(true)
92}, 30000)
tests/unit/cargo-delivery-phase-faults.test.tsadded+59-0
Changes to tests/unit/cargo-delivery-phase-faults.test.ts
@@ -0,0 +1,59 @@
1import { expect, test } from 'bun:test'
2import { cargoDeliverySummary, simulateCargoDelivery } from '../../resources/functions/drone/cargo/delivery'
3
4test('host loss after observed separation leaves uncertain custody while the independent parcel and board keep evolving', () => {
5 const run = simulateCargoDelivery({ scenario: 'after-release-host-loss' })
6 expect(run.status).toBe('host-lost')
7 expect(run.release?.outcome).toBe('separated')
8 expect(run.finalLoad.properties.totalMassKg).toBe(1.5)
9 expect(run.delivery.outcome).toBe('uncertain')
10 expect(run.delivery.receiptCapturedAtMs).toBeNull()
11 expect(run.parcel?.onSurface).toBe(true)
12 expect(run.runtime.board.fault).toBe('output-lease-expired')
13 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
14 expect(run.runtime.authority.state).toBe('autonomous')
15 expect(run.runtime.mission.state).toBe('running')
16 expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
17 const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)!
18 expect(run.faultInjection).toMatchObject({ phase: 'after-release', anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20, independentTailMs: 1000 })
19 expect(run.faultInjection!.finalPhysicalAtMs).toBeGreaterThan(run.runtime.authority.lastCheckedAtMs! + 1000)
20 const summary = cargoDeliverySummary(run)
21 expect(summary.authority.hostAvailable).toBe(false)
22 expect(summary.demonstrationMatched).toBe(true)
23 expect(summary.landingContact).toBe(false)
24}, 30000)
25
26for (const fault of ['position-loss', 'imu-loss', 'vehicle-sensor-loss', 'rc-override', 'kill', 'both-links-loss']) test(`a post-separation ${fault} preserves uncertain custody and independent parcel motion`, () => {
27 const run = simulateCargoDelivery({ scenario: 'after-release-' + fault })
28 expect(run.status).toBe('blocked')
29 expect(run.release?.outcome).toBe('separated')
30 expect(run.delivery.outcome).toBe('uncertain')
31 expect(run.delivery.receiptCapturedAtMs).toBeNull()
32 expect(run.parcel?.onSurface).toBe(true)
33 expect(run.parcel!.capturedAtMs).toBeGreaterThan(run.runtime.trace.rows.at(-1)!.observation.nowMs)
34 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
35 expect(run.runtime.mission.state).not.toBe('completed')
36 expect(run.runtime.trace.rows.some(row => row.result.reason === 'payload-confirmed')).toBe(false)
37 const firstOpen = run.runtime.path.find(row => row.measurement?.values.mechanismOpen === true)!
38 expect(run.faultInjection).toMatchObject({ anchoredAtMs: firstOpen.atMs, sourceCapturedAtMs: firstOpen.measurement!.capturedAtMs, eventId: run.release!.eventId, triggeredAtMs: firstOpen.atMs + 20 })
39 const observed = run.runtime.authority.events
40 expect(observed.some(event => fault === 'kill' ? event.to === 'killed' : fault === 'rc-override' ? event.to === 'manual' : fault === 'both-links-loss' ? event.reason === 'link-loss' : fault === 'position-loss' ? ['estimator-loss', 'stale-estimator'].includes(event.reason) : event.reason === 'stale-interlocks')).toBe(true)
41 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
42}, 30000)
43
44for (const fault of ['host-loss', 'kill', 'position-loss']) test(`a post-receipt ${fault} preserves received custody without inventing carrier completion`, () => {
45 const run = simulateCargoDelivery({ scenario: 'after-receipt-' + fault })
46 expect(run.status).toBe(fault === 'host-loss' ? 'host-lost' : 'blocked')
47 expect(run.delivery.outcome).toBe('received')
48 expect(run.delivery.receiptCapturedAtMs).not.toBeNull()
49 expect(run.release?.outcome).toBe('separated')
50 expect(run.parcel?.onSurface).toBe(true)
51 expect(run.runtime.trace.rows.filter(row => row.observation.payloadAck?.outcome === 'confirmed')).toHaveLength(1)
52 const acknowledged = run.runtime.trace.rows.find(row => row.observation.payloadAck?.outcome === 'confirmed')!
53 expect(run.faultInjection).toMatchObject({ phase: 'after-receipt', anchoredAtMs: acknowledged.observation.nowMs, sourceCapturedAtMs: acknowledged.observation.payloadAck!.capturedAtMs, eventId: acknowledged.observation.payloadAck!.eventId, triggeredAtMs: acknowledged.observation.nowMs + 20 })
54 expect(run.delivery.receiptCapturedAtMs!).toBeGreaterThan(run.parcel!.capturedAtMs)
55 expect(run.runtime.mission.state).not.toBe('completed')
56 expect(run.runtime.board.actuators).toEqual([0, 0, 0, 0])
57 expect(cargoDeliverySummary(run).landingContact).toBe(false)
58 expect(cargoDeliverySummary(run).demonstrationMatched).toBe(true)
59}, 30000)
tests/unit/cargo-mission-cli.test.tsadded+73-0
Changes to tests/unit/cargo-mission-cli.test.ts
@@ -0,0 +1,73 @@
1import { expect, test } from 'bun:test'
2import { command } from '@stacksjs/testing'
3import { mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
4import { tmpdir } from 'node:os'
5import { join } from 'node:path'
6import { spawn } from 'node:child_process'
7
8test('native Buddy executes an explicit cargo mission file and reports the configured modeled delivery', async () => {
9 const result = await command(['cargo:mission', 'examples/cargo-delivery.json']).withTimeout(30000)
10 expect(result.timedOut).toBe(false)
11 expect(result.exitCode).toBe(0)
12 expect(result.stderr).toBe('')
13 const report = JSON.parse(result.stdout)
14 expect(report.status).toBe('completed')
15 expect(report.delivery).toMatchObject({ outcome: 'received', containerId: 'parcel-150g', siteId: 'declared-site', recipientId: 'declared-recipient' })
16 expect(report.configuration.id).toBe('operator-cargo-route')
17 expect(report.load.initial.totalMassKg).toBe(1.65)
18 expect(report.load.final.totalMassKg).toBe(1.5)
19 expect(report.landingContact).toBe(true)
20 expect(report.board.actuators).toEqual([0, 0, 0, 0])
21 expect(report.physicalFlightEnabled).toBe(false)
22 expect(report.identityAuthenticated).toBe(false)
23 expect(result.stdout.trim().split('\n')).toHaveLength(1)
24 expect(Buffer.byteLength(result.stdout)).toBeLessThanOrEqual(16384)
25}, 40000)
26
27test('native configured cargo reports alternate and post-release outcomes with exit2', async () => {
28 for (const [scenario, status, custody] of [['site-blocked', 'diverted', 'not-released'], ['after-release-host-loss', 'host-lost', 'uncertain'], ['after-receipt-kill', 'blocked', 'received']]) {
29 const result = await command(['cargo:mission', 'examples/cargo-delivery.json', '--scenario', scenario!]).withTimeout(30000)
30 expect(result.timedOut).toBe(false)
31 expect(result.exitCode).toBe(2)
32 expect(result.stderr).toBe('')
33 const report = JSON.parse(result.stdout)
34 expect(report.status).toBe(status)
35 expect(report.delivery.outcome).toBe(custody)
36 expect(report.demonstrationMatched).toBe(true)
37 expect(report.configuration.id).toBe('operator-cargo-route')
38 expect(report.board.actuators).toEqual([0, 0, 0, 0])
39 }
40}, 100000)
41
42test('native configured cargo refuses malformed, oversized and symlink requests without caller details', async () => {
43 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-mission-'))
44 try {
45 const invalid = join(root, 'invalid.json'), oversized = join(root, 'oversized.json'), unicode = join(root, 'unicode.json'), link = join(root, 'link.json')
46 await writeFile(invalid, '{"private-sentinel":"/operator/private"}')
47 await writeFile(oversized, ' '.repeat(16385))
48 await writeFile(unicode, new Uint8Array([255]))
49 await symlink(invalid, link)
50 for (const path of [invalid, oversized, unicode, link, root, join(root, 'private-missing')]) {
51 const result = await command(['cargo:mission', path]).withTimeout(30000)
52 expect(result.timedOut).toBe(false)
53 expect(result.exitCode).toBe(1)
54 expect(result.stdout).toBe('')
55 expect(result.stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n')
56 }
57 }
58 finally { await rm(root, { recursive: true, force: true }) }
59}, 190000)
60
61test('a closed configured cargo stdout retains failure and awaits its fixed diagnostic', async () => {
62 const child = spawn(process.execPath, ['bin/drone.ts', 'cargo:mission', 'examples/cargo-delivery.json'], { stdio: ['ignore', 'pipe', 'pipe'] })
63 child.stdout.destroy()
64 let stderr = '', timedOut = false
65 child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8'); if (Buffer.byteLength(stderr) > 8192) child.kill('SIGKILL') })
66 const guard = setTimeout(() => { timedOut = true; child.kill('SIGKILL') }, 30000)
67 let code: number | null
68 try { code = await new Promise<number | null>((resolve, reject) => { child.once('error', reject); child.once('close', resolve) }) }
69 finally { clearTimeout(guard) }
70 expect(timedOut).toBe(false)
71 expect(code).toBe(1)
72 expect(stderr).toBe('Cargo mission simulation unavailable: invalid declaration, rejected route or output failure\n')
73}, 40000)
tests/unit/cargo-qualification-process.test.tsadded+43-0
Changes to tests/unit/cargo-qualification-process.test.ts
@@ -0,0 +1,43 @@
1import { expect, test } from 'bun:test'
2import { chmod, mkdtemp, readFile, rm } from 'node:fs/promises'
3import { tmpdir } from 'node:os'
4import { join, resolve } from 'node:path'
5import { runBoundedHostProcess } from '../../resources/functions/host/process'
6
7// The real qualifier/child seam catches unbounded capture and leaked processes;
8// it does not qualify the modeled vehicle or CI runner memory consumption.
9for (const stream of ['stdout', 'stderr'] as const) {
10 test(`the cargo qualifier rejects ${stream} overflow and reaps its executable`, async () => {
11 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-qualifier-'))
12 const binary = join(root, 'fixture'), pidFile = join(root, 'child.pid')
13 try {
14 await Bun.write(binary, `#!/usr/bin/env bun\nawait Bun.write(${JSON.stringify(pidFile)}, String(process.pid));\nprocess.${stream}.write('x'.repeat(32768)); await Bun.sleep(60000)\n`)
15 await chmod(binary, 0o755)
16 const result = await runBoundedHostProcess([process.execPath, resolve('scripts/qualify-cargo-cli.ts'), binary, join(root, 'output')], { timeoutMs: 5000, stdoutBytes: 4096, stderrBytes: 16384, label: 'Cargo qualifier fixture' })
17 expect(result.exitCode).not.toBe(0)
18 expect(result.stderr).toContain(`${stream} exceeds byte limit`)
19 const pid = Number(await readFile(pidFile, 'utf8'))
20 expect(Number.isSafeInteger(pid)).toBe(true)
21 expect(() => process.kill(pid, 0)).toThrow()
22 expect(await Bun.file(join(root, 'output/receipt.json')).exists()).toBe(false)
23 expect(await Bun.file(join(root, 'output/nominal.stdout')).exists()).toBe(false)
24 }
25 finally { await rm(root, { recursive: true, force: true }) }
26 }, 10000)
27}
28
29test('strict bounded process capture rejects malformed UTF8 after reaping the child and preserves valid bytes', async () => {
30 const root = await mkdtemp(join(tmpdir(), 'drone-cargo-encoding-')), pidFile = join(root, 'child.pid')
31 const options = { timeoutMs: 5000, stdoutBytes: 16384, stderrBytes: 1024, strictUtf8: true, label: 'Cargo encoding fixture' }
32 try {
33 await expect(runBoundedHostProcess([process.execPath, '-e', `await Bun.write(${JSON.stringify(pidFile)}, String(process.pid)); await Bun.write(Bun.stdout, new Uint8Array([255]))`], options)).rejects.toThrow()
34 const pid = Number(await readFile(pidFile, 'utf8'))
35 expect(Number.isSafeInteger(pid)).toBe(true)
36 expect(() => process.kill(pid, 0)).toThrow()
37 const result = await runBoundedHostProcess([process.execPath, '-e', `await Bun.write(Bun.stdout, new Uint8Array([239,187,191,195,169,10]))`], options)
38 expect(result.exitCode).toBe(0)
39 expect(Buffer.from(result.stdout, 'utf8')).toEqual(Buffer.from([239, 187, 191, 195, 169, 10]))
40 expect(result.stderr).toBe('')
41 }
42 finally { await rm(root, { recursive: true, force: true }) }
43}, 15000)