Description
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
RxAtribRSSI/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;
FastRetunefor the channel; security context pre-staged fromap_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
FastRetunebounds 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)
- CHO: done.
- ran-controller: observe-only A3 (CHO) handover evaluation #528: the CU evaluates the A3 per UE from filtered reports.
- cell M6: handover command + beacon IE + TSF continuity (F1 v4) #529: the command, the tagged beacon IE, the TSF delta.
- cell M6: handover execution — two-phase command, TSF continuity, conditional fallback #530: execution — two-phase commit (target, then source), cancels, a UE switch with TSF continuity (
TsfSync::ShiftRemote), and a conditional fallback to the source. - Rig (testrig
m6_handover.sh, 8 runs, all pass): interruption 1/1/1 superframe, CU done latency 5/6/16, target-crash fallback 10/10/10.
- DAPS, same channel: done (cell M6: DAPS — same-channel uplink duplication + CU dedup #531). Uplinks are deduplicated at the CU on a network-time sequence. With the source DU killed mid-crossing (testrig
m6_daps.sh, 3 runs each), the crossing UE's stream misses 0/0/0 uplinks with DAPS, against 14/15/18 without. - Open:
- Slot migration: done on the same channel. Still missing:
FastRetunefor another channel, and pre-staging a per-cell security context (the rig cells share one key). - Sequence handling: DAPS dedup and handover state cleanup are done. HARQ sequence continuity across the switch is not: the target's DL HARQ starts fresh.
- Test realism: the rig's UEs are stationary, so the crossing is forced with a negative A3 offset.
- Slot migration: done on the same channel. Still missing:
Activity
cell M6 core: L3 filter, A3 (CHO) trigger, DAPS dedup, F1 v2 measurem…
ran-controller: observe-only A3 (CHO) handover evaluation (#528)
cell M6: handover execution — two-phase command, TSF continuity, cond…
cell M6: DAPS — same-channel uplink duplication + CU dedup (#531)
cell M6: inter-frequency handover (UE retunes at the switch) (#532)
cell M6: UE measurement gaps — the A3 on the UE's own measurements (i…
cell M6: CU phase planning — a joining DU placed so the cells can mea…
josephnef commentedon Nov 5, 2026
CU phase planning merged (#534, 75715f0).
The CU now places a joining DU's TBTT (
src/cell/PhasePlan.hplan_phase) so the cells' UEs can measure each other in gaps (#533):CELL_PHASE_AUTO=1asks before starting its beacon and gets a tagged F1Phase(v8).Rig (testrig
m6_phaseplan.sh): 6/6 pass.m6_measgap.shstill 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
TsfSyncfit is cumulative, and its gaps then missed. The DU now asks beforeStartBeacon, and the rig starts the UEs only once the DUs serve.Open:
cell M6: the UE's clock fit survives a jump of its DU's TSF (TsfSync …
josephnef commentedon Nov 6, 2026
UE clock-fit step detection merged (#535, 476641f).
The UE's
TsfSyncfit is cumulative, so one jump of the DU's TSF used to read as skew (seen in the #534 runs). Now: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):Open:
cell M6: UE re-attachment after a DU restart (#536)
josephnef commentedon Nov 6, 2026
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:
GrantMapReceiver::offer_after_restart);Rig (testrig
m6_fitstep.sh, DU1 killed and restarted under its UE, 3/3):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:
cell M6: handover command + beacon IE + TSF continuity (F1 v4) (OpenI…
cell M6: grant-map authentication (GM IE v2, the cell key's tag) (#537)
2 remaining items
josephnef commentedon Nov 6, 2026
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:This covers a DU that restarted and one that released the UE. Static UEs never re-join.
Kestrel driver bug found and fixed:
StopBeaconcleared the beacon-TX enable but left the TBTT protectionStartBeaconsets (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.
cell M6: an emulated crossing (DU TX-power ramps), there and back, an…
josephnef commentedon Nov 6, 2026
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):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.
cell M6: per-cell keys and the handover security context (802.11r-sty…
josephnef commentedon Nov 7, 2026
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):K_ctags a cell's control: grant map, handover IE, DL body, the UE beacon's tag, MR, RACH. An insider can forge one cell.K_Atags 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.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):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:
cell M6: UE keys for RACH-joined UEs — a joiner hands over under per-…
josephnef commentedon Nov 7, 2026
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 onlyK_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):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.
cell M6: per-UE area credentials — a UE beacon's area tag is the UE's…
josephnef commentedon Nov 7, 2026
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:A_ufrom the homes the DUs report;AreaKeys), each wrapped under that DU's cell key with a fresh nonce;Configuration guards:
CELL_UE_KEYis refused;RAN_MASTER_KEYthat hears from a per-cell DU exits withran.config_error.Rig (testrig
m6_ueareakeys.sh):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.
cell M6: signed neighbour lists — a UE checks each neighbour DU's own…
josephnef commentedon Nov 7, 2026
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:
CELL_CA_PUB), checks CA → cell key → NL, and can forge nothing;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):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).
cell M6: replay checks for signed neighbour lists — signed window + T…
josephnef commentedon Nov 8, 2026
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:
CELL_ROLE=replayerrecords cell 2's beacon and re-airs it after DU2 is killed.m6_replay.sh)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:
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 indocs/cell-m6.md.cell M6: DAPS downlink duplication — a CU DL stream, duplicated to th…
josephnef commentedon Nov 8, 2026
DAPS downlink duplication — merged (#545).
The downlink now has a CU-anchored stream:
Downlink(v12). Each stream has an epoch, so replays are refused and a re-created stream starts cleanly.DlStreamRx).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:Results: testrig
results/2026-11-08-m6-daps-dl.txt. Docs: the downlink duplication section indocs/cell-m6.md.