ADR-0028: rSkill action-contract slot dispatch + robot.yaml gripper-joint convention
- Status: Proposed (split: 0028a + 0028b + 0028c)
- Date: 2026-05-27
- Related: ADR-0007 (per-checkpoint
action contract — the field this ADR extends);
ADR-0013 (closed
EmbodimentTagliteral — the same closure model applies to actioncontrol_mode); ADR-0018 §F1 (typedActionChunkwire format already carriescontrol_mode— this ADR makes it actually load-bearing); ADR-0019 (ActionContract.dimas dataset-bridge contract); ADR-0024 (skill_runner is the dispatch layer); ADR-0025 (panda_mobile HAL acceptsJOINT_POSITION+BODY_TWIST— this ADR adds the remaining surfaces); ADR-0027 (state-side layout adapters — this ADR is the symmetric action-side pattern); CLAUDE.md §1.3 (types are the contract), §1.4 (explicit beats implicit), §3 (layer boundaries — Skill ↔ Safety ↔ HAL).
Forward-reference (2026-06-14): the Status line above predates the final split. This decision was realised across four files — this one (the "0028a" foundation), ADR-0028b (slot dispatch), ADR-0028c (cartesian/gripper handlers), and ADR-0028d (joint-velocity + composite-mode handlers). See the clustered ADR index for the full family.
Context
The trace below E-stops within 22 seconds of dispatch:
[reasoner] dispatch: execute_skill rskill_id='OpenRAL/rskill-pi05-robocasa365-human300-nf4' prompt='pick up kettle'
[skill_runner] policy_adapter.skill_built skill='…pi05-robocasa365-human300-nf4' joint_units=radians perm=None is_gripper=[]
[skill_runner] policy_step step=1 raw_policy_action=['+0.014', '+0.000', '-0.003', '+0.001', '-0.000', '+0.000', '-0.989', '+0.001', '-0.000', '+0.000', '+0.000', '-0.991']
[safety_kernel] safety.envelope_violation kind=controller field=n_dof joint=65535 step=65535 value=12 limit=10
[hal_panda_mobile] openral_hal.estop_received; ignoring further commands until reset.
The policy emits a 12-D action vector. The robot manifest declares 10 joints (3 holonomic base + 7 Franka arm). The safety kernel correctly rejects the mismatch.
The 12-D layout is verified from python/sim/src/openral_sim/backends/robocasa.py:244-264
and matches the per-channel unnormalizer stats across four RoboCasa-family
pi0.5 checkpoints (OpenRAL/rskill-pi05-robocasa365-human300-nf4,
RoMALab/pi05_robocasa-MG_300, RoMALab/pi05_robocasa-MG_1000_v1,
ruiname/pi05-robocasa-10tasks-200k):
[arm_osc(6) + gripper(2) + base(3) + torso(1)]
↑ ↑ ↑ ↑
cartesian gripper body placeholder
delta width twist (always -1)
This is a mixed-semantics action vector — not 12 joint positions. Two gaps are responsible:
-
The rSkill manifest cannot describe the layout. Today
action_contractcarries onlydim(and an optional opaquerepresentationliteral). The runner has no field to read that says "slot 0–5 is cartesian delta, slot 6 is gripper width, slot 8–10 is body twist, drop the rest."actuators_requireddeclareskind: joint_position— factually wrong for this checkpoint — and there is no validation against ground truth. -
The HAL has no slot dispatch. Even with a correct layout declared,
python/runner/src/openral_runner/ros_publishing_hal.py:240-257rejects every non-jointControlModewithROSConfigError, andpackages/openral_hal_panda_mobile/lifecycle_node.py:752only whitelistsJOINT_POSITION+BODY_TWIST.CARTESIAN_DELTAandGRIPPER_POSITIONexist as enum values inopenral_core.ControlModebut have no consumer.
Adjacent to (1), the robot.yaml fleet is inconsistent about whether the gripper is a joint:
| Robot | Gripper as joint | Gripper in end_effectors: |
Status |
|---|---|---|---|
franka_panda, widowx, so100_follower, google_robot, aloha_bimanual, openarm |
✓ (with position_limits) |
✓ (parallel_gripper) |
canonical |
panda_mobile |
✗ (only in end_effectors) |
✓ (parallel_gripper) |
outlier |
sawyer |
✗ (only in end_effectors) |
✓ (parallel_gripper) |
outlier |
Two robots haven't been ported to the convention the rest of the fleet
already follows. This blocks any future joint-position-with-gripper VLA
on panda_mobile and sawyer, and creates latent footguns in
rskills/pi05-robocasa365-human300-nf4/rskill.yaml:91 which binds
gripper_qpos_joints: ["panda_finger_joint1", "panda_finger_joint2"]
— neither joint declared in robots/panda_mobile/robot.yaml. The bind
only works today because the state assembler reads from live
JointState (which the MJCF emits), not from the robot.yaml manifest.
Without a structural way to identify the gripper channel, the runner
also resorts to "gripper" in name.lower() substring sniffing
(packages/openral_rskill_ros/openral_rskill_ros/rskill_runner_node.py:1258)
— fragile across naming conventions, breaks on any joint with "gripper"
in the name that isn't the gripper.
Decision (split into three sub-ADRs)
0028a — robot.yaml gripper-joint convention + JointSpec.role (THIS ADR)
The cheap, independently-valuable foundation. Lands first.
- Schema (
openral_core.JointSpec): add optionalrole: Literal["arm","base","gripper","torso","leg","head","neck","wheel","unknown"] = "unknown". Structural identification of joint purpose without name-substring matching. Default"unknown"so existing manifests load unchanged. - Schema (
openral_core.EndEffectorSpec): formalize the previously silently-ignoredactuated: bool = True. Two existing yamls already set it; Pydantic'sextra="forbid"would have rejected them on a strict load. - Robot manifests: add a
panda_gripperjoint topanda_mobile(1-DoF prismatic,[0.0, 1.0], matchingfranka_pandaexactly). Add aright_gripperjoint tosawyer. Result: every parallel-gripper embodiment in the fleet declares the gripper as a joint. - Role annotations: tag every existing gripper joint across the
fleet with
role: "gripper". Tag base joints onpanda_mobileandpusht_2dwithrole: "base". Other joints stay"unknown"(the default) — explicit re-annotation deferred per CLAUDE.md §6 ("don't refactor across all layers in one PR"). - Invariant test:
tests/unit/test_rskill_action_dim_invariant.pyasserts that for every rskill manifest withactuators_requiredofkind: joint_position,action_contract.dim == len(robot.joints)for every embodiment_tag it claims (or the rskill carries a slot layout — see 0028b). The check would have caught the panda_mobile gap at fixture load.
This ADR does not:
- Fix the failing demo trace. The 12-D RoboCasa pi0.5 dispatch still E-stops after 0028a — that's 0028b's job. 0028a only makes the fleet uniform so 0028b's slot dispatch has a clean target.
- Change the rskill manifest schema beyond what's needed to declare the invariant the test enforces.
- Touch the runner, the safety kernel, or any HAL.
0028b — action_contract.slots schema + generic slot dispatch (LATER)
Sketch only — full ADR follows after 0028a merges.
# rskills/pi05-robocasa365-human300-nf4/rskill.yaml
action_contract:
dim: 12
slots:
- {range: [0, 5], control_mode: "cartesian_delta", ee: "panda_hand", frame: "panda_link0"}
- {range: [6, 6], control_mode: "gripper_position", ee: "panda_gripper"}
- {range: [7, 7], discard: true} # paired gripper channel (training artifact)
- {range: [8, 10], control_mode: "body_twist", frame: "base_link"}
- {range: [11, 11], discard: true} # torso placeholder (dataset always -1)
Runner reads slots, emits one typed ActionChunk per non-discard slot
(all sharing the same trace_id), ros_publishing_hal._action_to_chunk
serialises any ControlMode, safety kernel grows per-mode envelopes.
Robots whose rskills emit pure joint_position write a one-slot block —
zero behavioural change for them.
0028c — panda_mobile HAL grows CARTESIAN_DELTA + GRIPPER_POSITION handlers (LATER)
Routes the typed chunks 0028b emits onto robosuite's OSC controller
(for arm cartesian deltas) and the gripper actuator (for gripper width).
The BODY_TWIST path is already wired (Nav2 uses it). Single-robot
PR.
After 0028a → 0028b → 0028c the trace at the top of this document runs to completion: 12-D vector splits into one cartesian-delta arm chunk, one gripper width chunk, one base-twist chunk; safety validates each; HAL applies each on its native channel; the kettle gets picked up.
Schema (this ADR — 0028a only)
# python/core/src/openral_core/schemas.py
JointRole: TypeAlias = Literal[
"arm", # manipulator joint contributing to EE pose
"base", # planar base DoF (x/y/yaw for holonomic; theta for diff-drive)
"gripper", # parallel-gripper width / single mimicked DoF
"torso", # trunk / waist on humanoids
"leg", # locomotion joint on humanoids / quadrupeds
"head",
"neck",
"wheel", # rotational wheel joint on diff-drive bases
"unknown",
]
class JointSpec(BaseModel):
...
role: JointRole = "unknown" # NEW — structural tag, not derived from name
class EndEffectorSpec(BaseModel):
...
actuated: bool = True # NEW (formalised) — False for passive tools / inert flanges
role is "unknown" by default so existing manifests load without
edits. Annotating is a follow-up commit per CLAUDE.md §1.15 (drive-by
fixes get their own commit).
Robot manifest updates (this ADR — 0028a only)
robots/panda_mobile/robot.yaml — add to joints::
- name: "panda_gripper"
joint_type: "prismatic"
parent_link: "panda_link7"
child_link: "panda_finger_pair"
position_limits: [0.0, 1.0]
velocity_limit: 0.1
effort_limit: 70.0
has_torque_sensor: false
actuator_kind: "servo"
role: "gripper"
# MJCF expands this 1-D abstraction into the franka two-finger mimic
# at the simulator layer; the safety kernel + the rskill action
# contract see a single width DoF.
sim_joint_name: "gripper0_finger_joint1"
robots/sawyer/robot.yaml — add to joints::
- name: "right_gripper"
joint_type: "prismatic"
parent_link: "right_hand"
child_link: "right_finger_pair"
position_limits: [0.0, 0.041]
velocity_limit: 0.1
effort_limit: 35.0
actuator_kind: "servo"
role: "gripper"
Base joints on panda_mobile get role: "base". Existing gripper
joints on franka_panda / widowx / so100_follower / google_robot
/ aloha_bimanual / openarm get role: "gripper". Arm joints on
all manipulators get role: "arm". Annotations only; no value changes.
Invariant test
# tests/unit/test_rskill_action_dim_invariant.py
def test_joint_position_rskills_action_dim_matches_robot_joints() -> None:
"""An rskill declaring only joint_position actuators must have
action_contract.dim == len(robot.joints) for every embodiment_tag.
This is the structural check that would have caught the
panda_mobile / RoboCasa pi0.5 dim mismatch at fixture load
(the trace at the top of ADR-0028).
rskills carrying mixed control surfaces declare an
action_contract.slots block (ADR-0028b) and are exempted.
"""
Reads every rskills/*/rskill.yaml, looks up each embodiment_tag in
openral_sim.registry.ROBOTS, validates the dim invariant. Fails loudly
with the manifest path + the expected/observed dims.
Consequences
Positive
panda_mobileandsawyermatch the fleet convention. Any future joint-position-with-gripper VLA on either robot lands without E-stopping on the n_dof envelope check.JointSpec.roleremoves the"gripper" in name.lower()heuristic inrskill_runner_node.py:1258. Structural identification across naming conventions (RoboCasagripper0_…, ALOHA*_gripper, openarm*_gripper, future fleet additions).- The latent state-side bug in
rskills/pi05-robocasa365-human300-nf4/rskill.yaml:91(binding to joints not declared in robot.yaml) is documented; the binding stays pointed at MJCF JointState names by design (state observations live on a different layer than control surfaces — the binding records sim/HW JointState names, not robot.yaml joint names). - The invariant test makes the structural contract enforceable. Adding a new rskill whose action_contract doesn't match the embodiment's joint count fails at fixture load — not at runtime via E-stop.
- 0028b lands on a uniform fleet, no per-robot exceptions in the slot dispatcher.
Negative / cost
- One new schema field on the most-touched Pydantic model in the
codebase (
JointSpec). Default"unknown"keeps the change additive; no existing fixture rewrite required. - Two robot.yaml files gain one joint each. The base-frame URDFs for
panda_mobileandsawyeralready model the gripper at the MJCF layer — the change is metadata, not kinematics. - The
EndEffectorSpec.actuatedformalisation reveals two existing yamls (panda_mobile,gr1) that already set the field; their current behaviour was a silent no-op. After this ADR the field is load-bearing — future readers can rely on it (e.g. for safety bounds on un-actuated tool flanges that should never receive grip commands).
Out of scope
- The full slot-dispatch machinery. 0028b. Mentioned here only because
ADR splits need to be telegraphed up-front — a reader of 0028a in
isolation would not understand why we add
rolewithout using it yet. - Dexterous-hand finger joints on
gr1. Different problem (>5 DoF per hand, separate per-finger control). Separate ADR when the GR-1 rskills land. - Real-hardware HAL ports. 0028c only updates the sim HAL.
- Per-mode safety envelope declarations on robot.yaml. Required by 0028b, deferred until then.
Implementation sequence
Per CLAUDE.md §4.2 (smallest viable PR; each commit independently reviewable):
docs(adr): ADR-0028— this file only.feat(schemas): JointSpec.role + EndEffectorSpec.actuated— Pydantic fields,JointRoleliteral, docstrings.docs/METHODS.mdupdated in the same commit (per CLAUDE.md §1.13). Round-trip + Hypothesis tests on each model.feat(robots): panda_mobile + sawyer gripper joints— addpanda_gripperandright_gripper. Update the panda_mobile robot description comment block ("10-D…plus 1-D gripper" → "11-D"). No other yaml changes.feat(robots): annotate role: tags across fleet— gripper / base / arm tags on existing joints. Pure metadata.test(unit): action_contract.dim matches robot.joints invariant— new test asserting the structural contract. Fails today on panda_mobile + sawyer rskills; passes after step 3.docs: repo-state-map + METHODS.md— reflect the schema field addition + ADR-0028a status. CLAUDE.md §4.3.
Each commit independently reviewable; full sequence merges as one PR.