Request for Comments: Modular AirStack — module repos, slot contracts, marketplace, and distributed CI/docs #379
Replies: 2 comments
Design history: the slot/resolver design that Rev 1 proposed, and why it was rejectedThe RFC body above is Rev 2 (2026-08-19), rewritten as a standalone after an architecture audit. The original Rev 1 (2026-08-04, full text in this post's edit history) proposed a substantially different integration design. This comment records it so readers know what was already considered and rejected — please read it before proposing something similar. What Rev 1 proposed
Why it was rejected
What Rev 1's ideas became in Rev 2
Rev 2 also added things absent from Rev 1: the explicit CI matrix policy, agent-skill updates in Phase 1, a hand-build-before-tooling pilot, and measurable success metrics. Validation next: internal lab discussion + the hand-built pilot; the time-based metrics will be measured with coding agents as reproducible proxy developers under a separate internal study protocol. |
Changelog: consistency pass (2026-08-20)A cross-review of #379 / #380 / #385 surfaced a few wording-level inconsistencies between the documents and four spec gaps. The body above was updated in place; no design decisions changed. What moved:
Matching edits landed in #380 (pin-resolution rule, external stack repos) and #385 (trees updated) — see their changelogs. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
RFC: Modular AirStack — module repos, stack folders, a module marketplace, and distributed CI/docs
Output of a design session on making AirStack modular rather than monolithic. Written to be self-contained: a reader (human or AI agent) should be able to implement from this document alone. Repo facts current as of
develop@1c41f8c0(VERSION0.19.0-alpha.16).Scope: this RFC covers how modules are packaged, integrated, tested, distributed, and documented. Its companion, RFC #380: Heterogeneous AirStack, builds on this machinery to configure heterogeneous deployments — vehicle variants, mixed multi-robot fleets, top-level configuration, and (future) cross-embodiment platforms. A visual companion, #385: Directory Atlas, shows the resulting directory layouts for every repo shape both RFCs define.
The design in one paragraph: a module is a thin repo — code, a small manifest for deps and identity, no wiring metadata. A stack is a self-contained folder that composes modules into a running system: a pinned list of module repos to pull, plain ROS 2 launch files defining the node topology, and a compose file assembling the Docker images. Wiring legibility comes from authoring conventions plus a CI-generated wiring diagram snapshotted from the running graph and committed beside the launch files; correctness claims come from running trunk's existing system-test suite against published, signed base images. A registry makes modules and stacks discoverable; governance keeps the compatibility claims true.
1. Problem statement
Projects branched from AirStack build features we want as optional trunk capabilities, but today the only paths are merging into the monolith or living in a fork. Motivating cases: DFM2's Isaac Sim disturbance library (buried in a whole-repo fork), an OptiTrack motion-capture state-estimation integration with its own tests, and a custom global planner.
Pain points to solve:
local.launch.xml,local_droan_cpu.launch.xml,local_macvo_obstacle_avoidance.launch.xmlare near-copies with alternate wirings commented out).Design principles (decided)
ros2 launch, readable XML, andros2 topic infomust be enough.doctordiffs reality against it. An observed artifact can't lie, and an observational check works on any topology, however unconventional.2. Module anatomy: a thin repo with a small manifest
A module repo contains its packages, a
test_stack/(§5), and amodule.yamlthat is deliberately small — deps, identity, and test metadata only. Wiring does not live here.Deliberately absent: wiring metadata. The manifest carries no slot, role, or topic annotations. A module's interface is its launch file's declared args (
ros2 launch <pkg> <file> --show-args) plus the interface conventions spec (§4); its wiring lives in whatever stack includes it.The one interface convention modules follow (and it does the plug-and-play work): a module's launch file exposes every topic endpoint as a launch arg and defaults it to the canonical name from the conventions spec. In a conventional stack, including the module therefore requires zero remaps; only deviations appear in stack files — which is what makes them skimmable (§4).
Mapping the motivating cases: OptiTrack →
ros_packagewhose launch defaults its output to/$ROBOT_NAME/odometry; custom planner →ros_packagedefaulting to/$ROBOT_NAME/global_plan; DFM2 →isaac_extension(Isaac supports extension search paths — lowest-coupling pilot).3. Stacks: topology as self-contained folders
A stack is a folder — plain files, no schema beyond a required layout:
airstack syncreads the active stack'smodules.repos(vcstool format — not submodules) into a gitignoredmodules/dir; overlay plumbing places each module bytype/targets: ROS packages symlinked intorobot/ros_ws/src/modules/(colcon picks them up), Isaac extensions onto the extension path, compose fragments merged viaCOMPOSE_FILE. Trunk never carries module code..reposfiles are non-negotiable. A branch ref rots silently;airstack sync --updateis the deliberate act that moves pins. Consequence: a stack with a pinned.reposis a release set, localized — "these modules at these versions, tested together" is a property every stack has for free, not registry machinery (§7 shrinks accordingly).launch/holds the stack's entry points — one or more. An unsplit stack has exactlystack.launch.xml; a split stack (#380 §2) has one entry file per host role plus an explicitbridge.yaml. The single-locus rule and lint (§4) apply across all of a stack's entry files together.modules.reposmay carry a top-levelairstack_compat:key — a semver range, sibling torepositories:; vcstool ignores it, our tooling reads it.syncwarns when the checkout's active release falls outside the range (observe, don't gate); the verified claim remains a registered stack's CI-stamped compat matrix (§5, §7). Without this, a third-party stack's trunk binding lives nowhere but its owner's memory.airstack.yamlandsyncfetches it (RFC: Heterogeneous AirStack — vehicle configs, fleets, top-level configuration, and cross-embodiment platforms #380 §3). Trunk ships a small set of reference stacks (full_default,lite_default, plus presets that collapse today'slocal_*.launch.xmlvariant explosion into named stacks differing by a few include lines); everything else lives with its owner.wiring.mdis generated the same way, and the same harness flies it.<include>(and any non-default args) in a launch file you own and can read — transparent when it works and when it breaks.airstack stack diff <a> <b>compares two stacks' generatedwiring.md(normalized, CI-verified), so the diff shows topology differences, not XML formatting noise.airstack module add|remove|list|sync|create|extract|search,airstack stack list|new|diff(stack newcopies a reference stack),airstack init --release <X>,module create --in-tree/doctor --drift(§11).4. Launch legibility: conventions + observed wiring
Traditional ROS launch trees are illegible for identifiable reasons — remaps scattered across a deep include tree, defaults indistinguishable from overrides, runtime logic in Python launch, and no picture — so a developer ends up in
rqt_grapharchaeology. Each cause gets a countermeasure; together they make a stack's topology readable at a glance from source:<include>block reads "this module, these connections." Enforced by a ~50-line CI lint: no<remap>outsidestacks/*/launch/, every topic arg declared with adescription=.docs/robot/autonomy/integration_checklist.mdupgraded into a versioned spec: for each interchange point (sensors/*naming convention,odometry,global_map,global_plan, thetrajectorygroup,control_setpoint, theinterface_statusgroup,tasks/*,safety,gossip) — canonical topic names, message types, QoS profiles, TF frames/units, and rate class. QoS and frames are named explicitly because they're the classic silent failures (best-effort-vs-reliable receives nothing; ENU vs NED). The spec is documentation that modules default to and conformance tests check — it is never input to any wiring machinery.wiring.md. The system-test harness already brings up every reference stack in sim; a harness step snapshots the live graph (ros2 node list+ros2 topic info --verboseper topic — which also captures QoS) and renders a mermaid dataflow diagram, nodes grouped by module, edges labeled topic/type/QoS. It's committed atstacks/<name>/wiring.md; CI fails if the committed diagram differs from the observed graph. Properties: generated from the running system, so it cannot lie or rot; renders on GitHub and MkDocs; and a PR that changes wiring must regenerate it, so the review diff shows the topology change visually. (Prior art to vendor or imitate:ros2_graph; most of the plumbing exists intests/harness/.) Onewiring.mdper stack, split or not: for a split stack the harness brings up every entry point and the diagram additionally groups nodes by host, withbridge.yamledges (RFC: Heterogeneous AirStack — vehicle configs, fleets, top-level configuration, and cross-embodiment platforms #380 §2) drawn as boundary crossings. Sensor-driver nodes are normalized to their vehicle-manifest sensorids — RFC: Heterogeneous AirStack — vehicle configs, fleets, top-level configuration, and cross-embodiment platforms #380 §1 pairs each sensor's real driver with its sim representation, and that declared pairing is what licenses treating them as the same graph endpoint — so a sim-generated baseline diffs cleanly against a hardware bring-up. A stack that cannot run in CI (hardware-only sensors, no sim asset) still gets an observedwiring.md:doctor --snapshotruns the identical capture against a real bring-up and commits it with a provenance line ("observed on hardware, , "); the CI drift check then reports it as unverified-in-CI rather than silently passing — an artifact observed on hardware still can't lie, it's just refreshed by hand instead of by CI.grep -r global_plan stacks/my_stack/answers "who touches this" — there is no layer that could materialize a connection that isn't written down.doctor, entirely observational:doctor --livediffs the running graph againstwiring.md(both endpoints present, QoS compatible, types match — ROS 2 type-hash mismatches fail silently as no-connection, so this check is load-bearing);doctorat compose time runs the dep-conflict checks (§6) and the lint;doctor --driftserves the researcher workflow (§11). Nothing indoctorgenerates or infers wiring. Where observe-only hardens into a gate — two enumerated places, no others:doctor's default posture everywhere is report-and-step-aside (§11's drift report is the extreme case: informs, never blocks). It errors only where flagging loudly is indistinguishable from launching a broken or unsafe system: (1) dep conflicts that would compose a broken image (§6), and (2) safety-placement violations — control-setpoint or trajectory-group topics appearing in anybridge.yaml(#380 §2). Anything added to this list needs the same RFC process as a conventions change (§8).Safety floor, as convention + visibility: command authority flows through the trajectory controller / interface layer, where arming, safety monitoring, and takeover live. A module emitting
trajectory_overrideinherits the whole safety apparatus free — a selling point, not a constraint. No manifest field polices bypasses; insteadwiring.mdmakes any direct edge into the interface layer visible, anddoctor --liveflags publishers of control setpoints that aren't the blessed controller. Loudly.Layer role names (
state_estimator,local_planner,local_world_model,global_planner,global_world_model,controller,behavior) are vocabulary — the docs taxonomy and the conformance-mark groupings in §5 — never configuration keys.5. Version compatibility and test separation
Trunk's
docker-build.ymlalready pushes cosign-signed images tagged byVERSION— published images are the compatibility contract.Reusable workflow trunk owns (
.github/workflows/module-system-tests.yml). A module repo's entire CI:It: (1) checks out trunk at
airstack_ref; (2) checks out the module and runsairstack module add ./; (3) brings up the module'stest_stack/— a stack folder in the module repo, copied from a trunk reference stack with the module wired in (scaffolded bymodule create/module extract); (4) runs the existing suite unchanged viaairstack test -m "<marks>". The module repo never copies a test — and itstest_stack/doubles as the module's living usage example: the install documentation is a working stack, by construction.System tests double as conformance tests.
tests/waypoint_checker.pyjudges the odometry track regardless of which planner produced it — passing it is the behavioral definition of a working global planner. Guidance table by module category: global planner →waypoint_flight,autonomy; state estimator →liveliness,sensors,takeoff_hover_land; world model / perception →liveliness,sensors; sim extension →livelinesson the affected sim. Badge semantics: a compat badge reads "module M @ vM passes marks {…} in a test stack derived from reference stack S, on AirStack vX" — conformance to a stack, demonstrated by flying it.CI matrix policy (decided — stated now, before it explodes): stacks × modules × sims × robot counts is unbounded, so: trunk CI tests reference stacks only; a module's CI tests that module in its
test_stack/via the reusable workflow; custom stacks are tested by their owners with the same workflow. Anything less explicit and either CI cost balloons or coverage quietly becomes fiction.GPU runners (system tests need GPU + sim license; trunk uses OSMO-scheduled ephemeral runners via
.github/orchestrator/):repository_dispatch {module_repo, module_ref, airstack_ref}; trunk runs on its own runners, posts a check-run back via a GitHub App. Secrets and licenses never leave trunk; bench time is gated/rate-limited.Cost ladder: every push →
unit+build_packagesin the published image (minutes, no GPU); PR/nightly →liveliness(+sensorswhere relevant),msairsimis the cheap bring-up; release/compat claim → full conformance set on GPU (stamps the badge); trunk-side nightly canary runs registered modules againstdevelopso breakage surfaces the day it lands.Metrics: module runs'
metrics.jsondiff against trunk's baseline via existingtests/parse_metrics.py— badges can carry e.g. "cross-track RMSE vs. default planner."6. Docker images: base + composed module layers
Never publish permutations. Trunk publishes one signed base per host type per version; composed images are built where used, from cached layers. Stacks never carry monolithic Dockerfiles — a stack's compose file references the module layer chain below; per-stack images are compositions of cached per-module layers. (This is why dep declarations live in
module.yamlwhile wiring doesn't: dependencies must be per-module for layers to cache and compose across stacks.)Three dependency tiers (declared in
module.yaml):package.xml+deps:; no Dockerfile in the module repo.Dockerfile.modulewritten againstARG BASE_IMAGE, never a fixed base.ghcr.io/…/<module>-overlay:<airstack_version>builtFROM ${BASE_IMAGE}; developers pull instead of build.Composition chain (
airstack sync, deterministic order, grouped by target host):Module code changes rebuild nothing (source stays volume-mounted; dev loop =
bwsin the container, unchanged). A module's dep change rebuilds only its layer and above.modules.lockhashes dep declarations + records image digests →airstack upknows exactly when a rebuild is needed ("optitrack deps changed, rebuilding 2/4 layers") and compat badges record the digest chain they were earned with. CI uses the identical path with a registry-backed buildx cache (runners are ephemeral).Failure modes: (1) dep conflicts between modules →
doctorrunspip check/apt dry-run at compose time and names the fighting modules; (2) prebuilt overlays don't merge → a published overlay is used as-is only when it's the sole tier-3 module; otherwise build each from its fragment in chain order (overlay = cache, fragment = source of truth).Side effect: trunk Dockerfiles thin out as deps migrate into the modules that own them (MACVO networks, mocap SDKs).
7. Marketplace / registry
An
airstack-modules-indexrepo (rosdistro'sdistribution.yamlpattern): one YAML per module — name, repo URL, description, maintainer, CI-generated compat matrix.airstack module search|list|addreads it; MkDocs renders a catalog with badges. Getting listed = PR to the index = the quality gate (manifest valid, CI green, README template, license check). Registered stacks get catalog entries too (description, target platform, embeddedwiring.md).Release sets are tagged reference stacks. Since a stack's pinned
modules.reposalready is a tested-together set (§3), a release set needs no machinery of its own: "AirStack 0.20 +full_default@ tag", blessed by one CI run of the whole stack. Individual badges mean "works in a reference stack"; a release-set tag means "this whole stack, these exact pins, passed together." Most users start from a tagged reference stack; à-la-carte is for developers. Release sets also drive versioned docs (§9).8. Governance (most likely to decide success)
airstack_msgsruthlessly, as its own package. ROS 2 type hashes make msg mismatches fail silently (no connection), not gracefully.doctorcompares msg versions across the composed workspace, anddoctor --livecatches the resulting silent non-connections at runtime.9. Docs
Hybrid, embedding automated. Pure link-outs to repo READMEs lose search, theming, and versioning (a GitHub README shows
main— wrong for a user on 0.19). Module docs ride the existingmikemachinery:test_stack/, source link); reference stack pages with theirwiring.mddiagrams embedded; the interface conventions spec; authoring guide; deprecation RFCs.docs/), declared viadocs:and validated against the README template at registration. The docs deploy workflow shallow-clones each registered module at a pinned ref intomodules/<name>/and splices nav (a fetch loop driven by the registry index — simpler thanmkdocs-multirepo-plugin; extends the existingsame-dirpattern).developembeds module default branches.repository_dispatch+ existing triggers + nightly. "Within a day, exact at releases."10. Smaller items (all decided)
assets: [{url, sha256, dest}], fetched to cache byairstack sync. No Git LFS in module repos.AGENTS.md+.agents/skills/— ifwrite-launch-file,integrate-module-into-layer, andadd-ros2-packagestill teach the monolithic bringup pattern the day stacks land, every agent session will faithfully reintroduce the monolith. Update those skills in the same PR as each phase's machinery; addcreate-stackandextract-moduleskills.airstack module creategenerates the template repo (manifest, CI stub, launch with canonical defaults,test_stack/, README).11. The researcher workflow: research in a fork, graduate to a module
Principle: research happens in a fork; modularity is a graduation step, not an entry fee. Demand manifests and CI during exploration and researchers will ignore the process — the design's job is making the fork cheap to extract from, which is decided on day one.
airstack init --release 0.20(= a tagged reference stack, §7) +module addwhat the experiments need. Stay pinned through the research.Dockerfile.robot), not moving files. One cheap discipline:airstack module create --in-tree my_plannerscaffoldsrobot/ros_ws/src/modules/my_planner/(normal colcon package, stub manifest, own launch with canonical defaults) in the researcher's fork — zero CI/registry obligations, only the directory boundary from day one.airstack module doctor --driftdiffs the fork against the pinned release and classifies changes: contained in the module dir (fine) vs. trunk edits (extraction debt, listed).airstack module extract my_plannermoves the directory into a fresh template repo (manifest, CI pinned to their release, README skeleton,test_stack/scaffolded from the release's reference stack with the module wired in) and emits the drift report as the task list. Then: conformance marks against the pinned version (same code — should pass immediately), then against current trunk (the deferred compat work, done once), then register..agents/skills/extract-moduleskill takes the manifest schema, conventions spec, template, and drift report; the agent converts mechanically and verifies withdoctor+ the module's conformance marks (it flies the drone until it passes). A second skill guides agents during Stage 1 to keep changes inside the module dir — cheap prevention that makes Stage 2 nearly free.12. Phased implementation plan + success metrics
module.yamlschema + JSON Schema validation; reusablemodule-system-tests.yml; extract DFM2 disturbances as pilot (sim-side, lowest coupling); agent-skill updates land with the machinery (§10).modules/overlay + pinnedmodules.repos;module add|remove|sync|doctor; Docker composition chain +modules.lock; extract OptiTrack and the custom planner. Researcher tooling (§11):create --in-tree,doctor --drift,extract,extract-moduleskill.local_*.launch.xmlvariants into named stacks); interface conventions spec (topics + types + QoS + frames); single-locus lint; CI wiring snapshot →wiring.md+ drift check;doctor --live;stack new|diff. This phase is also where #380's vehicle/fleet/top-level configuration layer lands — its config surface consumes stack folders.Heterogeneous deployment configuration and cross-embodiment platforms are specified in RFC #380; its cross-embodiment part is sequenced after phases 1–4 are proven.
Success metrics (measured, not vibes):
(1) and (2) are measured empirically with coding agents as reproducible proxy developers — fixed versioned prompts, pinned commits, harness-judged milestones, multiple trials per condition — under an internal study protocol, alongside human baselines from lab pilots. (3) is an ecosystem-health number tracked in the registry.
Ranked success factors: (1) the CI-against-published-images loop, (2) conventions governance/deprecation policy, (3) maintainer lifecycle policy. Everything else is buildable later; these determine whether the ecosystem stays trustworthy.
Appendix: trunk facts an implementer needs
.envVERSION="0.19.0-alpha.16"tags docker images;check-version-increment.ymlgates PRs on semver bump..github/workflows/docker-build.ymlbuilds, pushes, cosign-signs all compose images on merge tomain/developwhenVERSIONchanges; unchanged services registry-retag via content fingerprints (docker_image_plan.py).tests/system/(marks intests/pytest.ini:unit,build_docker,build_packages,integration,liveliness,sensors,takeoff_hover_land,autonomy,waypoint_flight); harness intests/harness/+tests/conftest.py; unit-test packages listed intests/colcon_unit_test_packages.yaml; metrics viatests/parse_metrics.py(diff-vs-baseline, exits 1 on regression); waypoint pass/fail via standalonetests/waypoint_checker.py.runs-on: [self-hosted, airstack-ephemeral]→ OSMO-scheduled ephemeral pods via.github/orchestrator/(spawn loop, JIT tokens, reap loop; privileged DinD with non-overlayfs data-root). Fork PRs blocked from runners.robot/ros_ws/src/autonomy_bringup/launch/robot.launch.xml, role-dispatched byAUTONOMY_ROLE(full|onboard|offboard); per-layer bringups likerobot/ros_ws/src/local/local_bringup/launch/(the variant explosion:local.launch.xml,local_droan_cpu.launch.xml,local_macvo_obstacle_avoidance.launch.xml).deploy.replicas: ${NUM_ROBOTS};ROBOT_NAME/ROS_DOMAIN_IDresolved at container start byrobot/docker/.bashrc+robot/docker/robot_name_map/resolve_robot_name.py./{robot}/odometry,/{robot}/global_plan,/{robot}/trajectory_controller/{trajectory_override,trajectory_segment_to_add,look_ahead,tracking_point,set_trajectory_mode},/{robot}/tasks/{navigate,takeoff,land,fixed_trajectory},/{robot}/interface/*— seedocs/robot/autonomy/integration_checklist.md.mikeversioning,same-dirplugin for out-of-tree READMEs, deploy workflows per branch.simulation/), OptiTrack integration (natnet_ros2exists in trunk), custom global planner.ros2_graph(live-graph → mermaid).From a design session (2026-08-04), revised after an architecture audit (2026-08-19); consistency pass 2026-08-20 (entry-point layout,
doctorgate enumeration, stackairstack_compat, hardwarewiring.mdprovenance — changelog in comments). An earlier draft explored a different integration design — slot taxonomy,stack.yaml, wiring resolver — rejected for reasons summarized in the design-history comment below. Companion: RFC #380 — Heterogeneous AirStack. Comments and pushback welcome — especially from prospective module authors and stack owners.All reactions