Skip to content

M6 — Mobility: CHO + DAPS handover #260

Open

Description

@josephnef

Part of the 5G-NR RAN EPIC #254. Phase B, the payoff capability.

Goal: a moving UE's schedule migrates cell-to-cell with no scan / no re-association / no clock re-acquisition — make-before-break, riding the network-wide synced clock.

5G mapping

CHO (Conditional Handover — pre-staged A3-style condition) and DAPS (Dual Active Protocol Stack — dual-cell active slots + uplink duplication during the crossing).

Deliverables (tools/ran-controller/ + src/cell/)

  • measurement aggregation — each DU tags UE-uplink RxAtrib RSSI/SNR/EVM (from M0's per-UE attribution, filtered — never raw RSSI) and reports to the CU over F1.
  • CHO — a pre-staged A3-style condition (offset margin + time-to-trigger hysteresis) evaluated at the DU/CU.
  • make-before-break slot migration — the UE's grant appears in cell B's beacon and disappears from A; shared clock ⇒ no re-sync; FastRetune for the channel; security context pre-staged from ap_wpa2.cpp (802.11r-style).
  • DAPS soft handover — same-channel first (from the review). With one radio a UE cannot listen to A while tuned to B, so different-channel DAPS is sequential duplication (deaf during retune), not simultaneous dual connectivity. First demo = same-channel dual-cell duplication + sequence dedup over Xn/F1. The different-channel variant must budget retune time+variance, RF settling, missed beacons/ACKs, and a deadline to return to the serving channel.
  • shared sequence handling + duplicate suppression for HARQ and DAPS, and state cleanup during handover.

Reuses

M5 CU + F1/Xn, M4 synced TSF (the enabler — TSF continuity across cells), FastRetune, ap_wpa2.cpp security context, M0 per-UE attribution.

Risks (from EPIC)

  • A3/CHO tuning — raw RSSI ping-pongs at the edge; hysteresis + time-to-trigger is the classic pitfall (drive off filtered quality).
  • Single-radio deaf-time — different-channel DAPS is sequential; ~ms FastRetune bounds it.

Dependencies

M5 (#254 tracks).

Test — tests/ran_handover.sh

Move a UE A→B: the grant migrates after the time-to-trigger window with TSF continuity, no re-association, vs the ~100 ms roam "ghost bar." Kill one path mid-crossing → the duplicated UL still arrives via B (DAPS).

Status (2026-11-05)

  • Done (cell M6 core: L3 filter, A3 (CHO) trigger, DAPS dedup, F1 v2 measurements #527, bed7fb0):
    • src/cell/Handover.h — L3Filter (3GPP layer-3 filter, stale = absent), A3Trigger (offset, hysteresis, per-neighbour time-to-trigger, dwell), MeasTable, SeqDedup (DAPS dedup, 2^32 wrap).
    • F1 v2: each DU reports its own UEs' and the heard UEs' filtered RSSI and SNR. On the rig the CU logs them (ran.meas).
    • EVM is not reported yet.
  • Selftest: a noisy edge crossing (6 dB shadowing, 100 draws). Raw-RSSI triggering averages 30.6 handovers; the filtered A3 makes exactly one in 100/100, always after the midpoint.
  • Next:
    • evaluating the A3 at the CU;
    • make-before-break slot migration over F1;
    • same-channel DAPS with dedup;
    • the rig handover test (the crossing emulated with per-packet TX attenuation).

Status (2026-11-05, later)

Activity

  1. josephnef commented on Nov 5, 2026

    @josephnef
    CollaboratorAuthor

    CU phase planning merged (#534, 75715f0).

    The CU now places a joining DU's TBTT (src/cell/PhasePlan.h plan_phase) so the cells' UEs can measure each other in gaps (#533):

    • Another channel: both ways, every capacity position.
    • Same channel: disjoint active spans.
    • Running cells keep their phases.
    • The exchange: a DU with CELL_PHASE_AUTO=1 asks before starting its beacon and gets a tagged F1 Phase (v8).
    • UE gap bound: now 26 + 2 ms, the measured 23.5–23.7 ms retune plus ~10 %. At 30 + 3 ms no phase works both ways on the rig grid.

    Rig (testrig m6_phaseplan.sh): 6/6 pass.

    • DU2 (ch157) placed at 51200 µs, score 2/2, inside the [48500, 53900] window.
    • Both UEs heard the other cell in nearly every gap (≈57 per UE per 60 s run), with no gap skipped and no uplink lost.
    • Gap deaf time: 54.1 ms.
    • m6_measgap.sh still passes at the new bound.

    Found: a UE that locked to the auto-phase DU while it was still waiting for its phase (beacon already on) took a ~1.3 ms jump in that beacon's timing as skew. Its TsfSync fit is cumulative, and its gaps then missed. The DU now asks before StartBeacon, and the rig starts the UEs only once the DUs serve.

    Open:

    • the jump's source is not established;
    • a UE-side fit reset on a discontinuity;
    • phase planning for 3+ cells on different channels (no layout works for every pair at 26 ms).
  2. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    UE clock-fit step detection merged (#535, 476641f).

    The UE's TsfSync fit is cumulative, so one jump of the DU's TSF used to read as skew (seen in the #534 runs). Now:

    • a beacon more than 200 µs off the fit is held out of it;
    • 6 in a row that agree are a step, and the remote axis is re-anchored by it;
    • held beacons that never agree are dropped.

    Six beacons outlast the DU's own 500 ms discipline pass, so a jump the DU undoes itself is never followed.

    Rig (testrig m6_fitstep.sh, 6/6 pass):

    • DU restart under an attached UE (~−31 s jump): followed. UE skew stayed ≤ 10 ppm and DL arrival was 100 % in the DL area. With detection off, skew read ~10⁶ ppm and the DL frames scattered.
    • 1300 µs jump the DU undoes: ignored, with 0 uplinks off-slot. With confirm 3 it was followed out and back, putting 4–5 of 20 uplinks off-slot.

    Open:

    • after a DU restart the UE's grant state doesn't follow (the new maps restart epoch and superframe count);
    • one inter-frequency handover fell back in regression (no target beacon heard within 1 s; detection not involved; cause not established; 3 re-runs pass).
  3. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    UE re-attachment after a DU restart merged (#536, 27e5928).

    The UE's clock already followed a restarted DU (#535), but the DU's grant maps restart at a low epoch on a new superframe count. The UE refused them, and the restarted DU never heard it again. Now:

    • for a while after the UE's clock fit confirms a step of the DU's TSF, a map at a strictly lower epoch that is due near its beacon's own superframe starts the UE's grant state over (GrantMapReceiver::offer_after_restart);
    • the UE then resets what belonged to the old DU instance, as on a handover switch.

    Rig (testrig m6_fitstep.sh, DU1 killed and restarted under its UE, 3/3):

    • one re-attach per run;
    • the restarted DU hears the UE in 99.8–100 % of superframes from its service start, 99.8 % in slot;
    • UE DL 100 % in its granted slot.

    No spurious re-attach in the regression runs (handover, fallback, measurement gaps, phase planning, a transient TSF jump).

    Also fixed: the UE's DL in-slot log judged the slot matching its id, not its grant's. A CU move showed DL out of slot although the frames were right.

    Open:

    • grant maps carry no tag, so a forged map could already redirect slots, and within the re-attach window it can also reset the UE's per-DU state; authentication is a wire change;
    • RACH-joined UEs across a DU restart.
  4. 2 remaining items

  5. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    RACH re-join after a DU restart merged (#538, 7e73826).

    A UE that joined through the RACH holds an id a restarted DU doesn't know. Now a joined UE that the authentic map in force hasn't granted for CELL_UE_REJOIN_SF (30) superframes drops the id and joins again:

    • its uplink beacon is stopped first (a failed stop defers the re-join);
    • its ACK responder and the old id's HARQ space are cleared;
    • it uses a new RACH client with a fresh nonce, because a DU answers a repeated (MAC, nonce) from its cache.

    This covers a DU that restarted and one that released the UE. Static UEs never re-join.

    Kestrel driver bug found and fixed: StopBeacon cleared the beacon-TX enable but left the TBTT protection StartBeacon sets (TBTT_PROHIB_EN, BRK_SETUP). The port kept holding injected frames to its old beacon grid, so every re-join msgA arrived ~29.9 ms into the superframe, past the RACH slot (an A/B with the beacon left on landed in slot).

    Rig (testrig m6_rejoin.sh, keyed, 3/3): re-attach, one re-join, the second join after 4–8 attempts, and the new id heard 100 % in slot by the restarted DU. With re-join off, the UE is never heard. Regression of the beacon-stop users (inter-frequency handover and fallback, measurement gaps, clock fit) passes.

    Open: 802.11r-style per-cell security context; a moving-UE crossing test.

  6. josephnef commented on Nov 6, 2026

    @josephnef
    CollaboratorAuthor

    Emulated crossing merged (#539, 408e03f): the first handovers with nothing forced.

    The DUs ramp their TX power against each other on one wall-clock zero (CELL_DU_TX_RAMP, beacons and DL). One UE on one channel measures both beacons, and the CU's A3 decides at its defaults.

    Rig (testrig m6_crossing.sh, keyed, final code):

    • Crossing 1 → 2 → 1, 3/3: exactly two handovers per run, from the UE's own measurements.
      • margin entry → switch: 40–55 sf (24 of it the handover lead);
      • trigger margin: 9–15 dB (the 1 s reporting chain at ~2 dB/s);
      • interruption: 1–2 sf; the target heard the UE in slot, the source released it, TSF continuity held.
    • Hover just under the entry, 3/3: 0 handovers. The criterion is at most one, never back.
    • Source killed mid-crossing: with DAPS, 0/0 uplinks missing in the crossing window; without, 11/13.

    CU bug found and fixed: the CU planned from the DUs' reported positions, which lag a schedule by RAN_LEAD_SF (more than a plan round). With two equally good layouts it re-issued them in turn every round, one off-slot uplink per swap. It now plans from its own issued schedule. The coordination, handover, DAPS, phase-planning and re-join regressions pass.

    Also found: the rig's power-to-RSSI relation isn't linear (−8 dB of offset reads about −9 dB at the UE, −9 dB about −12 dB; cause not established).

    Open: a real moving UE (uplink path loss, fading, several UEs); 802.11r-style per-cell security context.

  7. josephnef commented on Nov 7, 2026

    @josephnef
    CollaboratorAuthor

    Per-cell keys and the handover security context merged (#540, 6bf572d).

    Every cell, DU and UE used to share one key. Now all keys derive from one master that only the CU and provisioning hold (src/cell/Keys.h):

    • Cell key K_c tags a cell's control: grant map, handover IE, DL body, the UE beacon's tag, MR, RACH. An insider can forge one cell.
    • Area key K_A tags what neighbours check: the neighbour list and a new area tag on UE beacons. A neighbour DU now checks that tag before counting a UE as heard or forwarding it for DAPS.
    • UE key of the UE's home identity.

    At each handover the CU wraps the target cell's key for that UE alone (24 bytes, a per-handover pad plus a key check). The source DU relays it in its handover IE without being able to read it. The UE unwraps it and refuses a context that isn't its own, switches key at the switch, and goes back on a fallback. Without CELL_AREA_KEY, everything works with one shared key, as before.

    Rig (testrig m6_cellkeys.sh):

    • keyed handover 2/2, the target hearing the UE 99.8 % in slot;
    • a UE holding cell 2's key in cell 1 takes 0 of 879 maps;
    • a UE holding another UE's key refuses every command and stays;
    • coordination with area-tagged "heard" passes.

    The full M6 suite passes under per-cell keys: inter-frequency handover and fallback, DAPS, gaps, phase planning, crossing and source failure, DU restart and re-join.

    Open:

    • RACH-joined UEs have no provisioned UE key, so under per-cell keys they don't hand over;
    • an area-key holder can fake measurement provenance and heard/DAPS presence, never a cell's control.
  8. josephnef commented on Nov 7, 2026

    @josephnef
    CollaboratorAuthor

    UE keys for RACH-joined UEs merged (#541, ed922d0). A joiner now hands over under per-cell keys.

    The key. A joiner's UE key is bound to the MAC it joins with, K_j = HMAC(master, 'J' mac), provisioned out of band like a SIM's. Claiming another's MAC at the join gains nothing, because only K_j's holder can unwrap the context.

    Where homes come from. Each DU records a UE's home when it admits it: a static UE its (cell, UE), a RACH joiner its authenticated MAC, and a handover arrival the home its command carries (F1 v10 HoCommand::home). The DU reports those homes alongside its UE list (Report.homes). The CU keeps none: it wraps for the home in the source DU's current report, so a CU restart or a reused id can't pick a stale one.

    Rig (testrig m6_joinerkeys.sh):

    • the joiner hands over 2/2;
    • holding another MAC's key it refuses every command and stays, 2/2;
    • in a crossing there and back with the CU killed and restarted between the two handovers, the new CU still wrapped the return for the joiner.

    Also: both F1 receive buffers are now 8192 bytes. A report at every list bound is 3673 bytes, more than the old 2048-byte buffers could hold.

  9. josephnef commented on Nov 7, 2026

    @josephnef
    CollaboratorAuthor

    Per-UE area credentials merged (#542, 1612994).

    A UE beacon's area tag, which neighbour DUs check before counting a UE as heard or forwarding it for DAPS, is now keyed by the UE's own area key, A_u = HMAC(K_u, 'V'), derived from its UE key with no new provisioning:

    • the CU derives every A_u from the homes the DUs report;
    • each plan round it sends every DU the other cells' UEs' keys (F1 v11 AreaKeys), each wrapped under that DU's cell key with a fresh nonce;
    • in-flight handover target identities are included from the command onwards, for the source's DAPS window after the switch;
    • under per-cell keys, holding the area key is no longer enough to forge another UE's heard or DAPS presence.

    Configuration guards:

    • a per-cell UE without CELL_UE_KEY is refused;
    • a CU without RAN_MASTER_KEY that hears from a per-cell DU exits with ran.config_error.

    Rig (testrig m6_ueareakeys.sh):

    • heard from per-UE tags 2/2;
    • a UE with the area key but the wrong UE key is never counted as heard by the other DU, while its own cell serves it, 2/2;
    • DAPS 0 vs 14–15 missing; a source failure mid-crossing 0 vs 10.

    Remaining residual: the area key now only tags the neighbour list, so an area-key holder can fake the provenance of a measured neighbour beacon, nothing else.

  10. josephnef commented on Nov 7, 2026

    @josephnef
    CollaboratorAuthor

    Signed neighbour lists merged (#543, 1a11dc7). A UE now checks each neighbour DU's own signature.

    An HMAC tag a UE can check is one it could forge, so the area-key-tagged neighbour list let any UE fake any cell's list. Now:

    • each DU signs its NL with its own Ed25519 key and airs a certificate for that key, signed by the area CA;
    • a UE holds only the CA's public key (CELL_CA_PUB), checks CA → cell key → NL, and can forge nothing;
    • verified lists are cached by their exact bytes, so a signature is checked once per list.

    Qodo found a real hole during review: the verifier checked the first NL IE but used the last. A beacon with more than one list for a cell is now refused, and the list used is exactly the bytes verified.

    Rig (testrig m6_nlsign.sh):

    • with both DUs certified, each UE reports the other cell in every report;
    • a DU holding the area key and every cell key but an uncertified signing key is never counted by any UE (its own included), while it still serves its UE;
    • the per-cell suite passes with signing on.

    Also documented: since #542, a UE with a wrong UE key is invisible to other DUs, so co-channel coordination can't protect its slot (seen: ~45 % of uplinks lost).

  11. josephnef commented on Nov 8, 2026

    @josephnef
    CollaboratorAuthor

    Replay checks for signed neighbour lists — merged (#544).

    The residual left by #543 was a genuine signed beacon recorded and re-aired, which shows up as a ghost cell. Now:

    • Signed window: the NS IE (v2) signs a network-superframe window (64 superframes, ~6.5 s). A UE takes a neighbour only if that neighbour's window is within ±1 of its serving cell's. The serving window must keep advancing and never go back.
    • TBTT phase: on the shared PTP timebase, a neighbour beacon must land within 300 µs of serving phase + the offset the serving list names. A serving copy off its reference is refused and cannot drag the reference.
    • The test-only CELL_ROLE=replayer records cell 2's beacon and re-airs it after DU2 is killed.
    case (testrig m6_replay.sh) runs UE1's reports with cell 2: early last 200 (only the replay on air)
    checks on 2 100 % 0 %
    checks off 2 100 % 100 % (the ghost is measured)

    The regression found a real bug: a missing brace cleared the UE's serving-phase reference every superframe, so genuine neighbours were refused on phase (74.8 % → 99.9 % after the fix).

    The regression passes on the final code:

    • nlsign;
    • the per-cell signed suite: measgap, crossing, phaseplan, handover (normal and fault), DAPS;
    • cellkeys, joinerkeys, ueareakeys;
    • a shared-key handover.

    Still open: a real-time repeater on the right phase, which is the real cell, only louder; only distance bounding would stop it.

    Results: testrig results/2026-11-08-m6-replay.txt. Docs: the replay section in docs/cell-m6.md.

  12. josephnef commented on Nov 8, 2026

    @josephnef
    CollaboratorAuthor

    DAPS downlink duplication — merged (#545).

    The downlink now has a CU-anchored stream:

    • CU: originates each UE's DL PDUs over F1 Downlink (v12). Each stream has an epoch, so replays are refused and a re-created stream starts cleanly.
    • DU: airs up to 8 PDU sns per DL frame, under the tag (cell protocol v4).
    • UE: delivers each sn once, whichever DU it came through (DlStreamRx).
    • Duplication: from the handover command until ran.ho_done, plus the stream's last 16 PDUs at the command (data forwarding), every PDU also goes to the target. The target buffers them and airs them after the switch.

    testrig m6_daps_dl.sh, 16 runs, all pass. UE1's DL PDUs missing in the handover windows:

    case DAPS no DAPS
    source DU killed when the UE accepts (3) 0 / 0 / 0 21 / 23 / 33
    plain handover (3) 0 / 0 / 0 8 / 18 / 19
    emulated crossing, A3 at defaults, 2 handovers (2) 0 / 0 57 / 62
    • DL silence: DAPS recovers the PDUs, not the time; the silence while the source fades or is dead remains (crossing 3.6–4.3 s).
    • Cost: 58–104 duplicates per run (36–52 without).
    • Every run: TSF continuity at each switch, the target hearing the UE in slot ≥ 99 %, no re-association, and with DAPS 0 uplinks lost through the target.
    • What the crossing found: duplicating only from the command lost 6 and 9 PDUs to a source link fading before the A3 fired; hence the backlog. One crossing still lost 6 PDUs more than 1.6 s before its command. This is documented, not tuned away.

    Results: testrig results/2026-11-08-m6-daps-dl.txt. Docs: the downlink duplication section in docs/cell-m6.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions