diff --git a/docs/en/server/development/behaviour-and-timers.md b/docs/en/server/development/behaviour-and-timers.md
index 16af023..1b73668 100644
--- a/docs/en/server/development/behaviour-and-timers.md
+++ b/docs/en/server/development/behaviour-and-timers.md
@@ -25,6 +25,18 @@ The following intervals are part of the current runtime behavior:
If you change one of these intervals, document the operational impact for monitoring, loop behavior, and troubleshooting.
+## Voice contention constants
+
+These constants define per-packet and per-session behaviour. They are
+documented in detail in [Voice routing and contention](routing-and-contention.md).
+
+| Constant | Value | Role |
+|---|---|---|
+| `STREAM_TO` | **0.36 s** | Window to consider a stream "active" (between packets). |
+| `_STALE_PEER_SESSION_TIMEOUT` | **5.0 s** | A per-peer session with no frames is considered dead (lost VTERM). |
+| `GROUP_HANGTIME` | **5 s** (config default, per-system) | Blocking period after a QSO ends before another TG is accepted on that slot. |
+| `DEFAULT_UA_TIMER` | configurable (minutes, per-system) | Duration of dynamic (User Activated) bridges. |
+
## In-band VTERM scope
In-band bridge signalling on voice terminator (VTERM) is intentionally scoped to:
diff --git a/docs/en/server/development/routing-and-contention.md b/docs/en/server/development/routing-and-contention.md
new file mode 100644
index 0000000..89eca0c
--- /dev/null
+++ b/docs/en/server/development/routing-and-contention.md
@@ -0,0 +1,359 @@
+# Voice routing and contention rules
+
+This page documents **how voice frames travel through the server** and the
+**contention / slot rules** that govern who hears what. It is the reference
+for sysops and integrators who need to understand or troubleshoot routing
+behaviour without reading source code.
+
+The server maintains **legacy parity** with `adn-dmr-server` (`bridge_master.py`)
+except for the explicitly documented divergences at the end of this page.
+
+---
+
+## Golden rule: one conversation per TG
+
+**Only one conversation per TG may exist on the server at a time.** This rule
+is **global** — it applies regardless of slot, peer, or traffic origin (OBP or
+HBP). It has the highest priority; all other rules are subordinate to it.
+
+A TG is **busy** when it has an **active** voice stream (frames within
+`STREAM_TO` of the last packet) on any slot, from any source.
+
+| New traffic source | Behaviour |
+|---|---|
+| Another **OBP** sends the same TG | **Reject** — TG already busy |
+| A **hotspot (HBP)** keys the same TG | **Reject**, *or* silent activation (see below) |
+
+The only exception is **silent activation**, which does **not** create a second
+conversation — it only lets the user listen to the existing one.
+
+---
+
+## End-to-end packet flow
+
+```mermaid
+flowchart TD
+ PTT[User keys PTT
on TG 730500 slot 2] --> HBP[Hotspot sends DMRD
over UDP HBP to PROXY]
+ HBP --> INJ[PROXY injects into MASTER 'SYSTEM']
+ INJ --> AUTH{HBP auth OK?}
+ AUTH -->|No| DROP1[Drop: peer not authenticated]
+ AUTH -->|Yes| ACL{ACL for SUB/TG
allows?}
+ ACL -->|No| DROP2[Drop: ACL]
+ ACL -->|Yes| DMRD_RX[dmrd_received
update STATUS slot]
+ DMRD_RX --> CONT{Global contention:
TG busy?}
+ CONT -->|Busy OBP/HBP| SILENT{Silent activation?
see below}
+ CONT -->|Free| SRC[source row ACTIVE?
bridge leg]
+ SRC -->|No| NODRV[No bridge leg
see: dynamic TG]
+ SRC -->|Yes| FWD[forward to to_target]
+ NODRV --> FWD
+ FWD --> FANOUT[Iterate target peers]
+ FANOUT --> P1{For each peer:
OPTIONS has TG?}
+ P1 -->|Yes or dynamic active| SLOT{Target slot
free?}
+ P1 -->|No| SKIP[Skip peer]
+ SLOT -->|Busy| SKIP
+ SLOT -->|Free| REWRITE[Rewrite LC
+ slot mapping]
+ REWRITE --> TX[Send DMRD
to peer on its slot]
+ TX --> NEXT[Next peer / frame]
+
+ style DMRD_RX fill:#bfb,stroke:#333
+ style FWD fill:#bbf,stroke:#333
+ style TX fill:#fbb,stroke:#333
+```
+
+### Decision points in order
+
+1. **HBP authentication** — the peer must be registered with a valid passphrase.
+2. **ACL** — `SUB_ACL`, `TGID_TS1_ACL`, `TGID_TS2_ACL` (when `USE_ACL`).
+3. **`dmrd_received`** — updates `STATUS[slot]`: `RX_TIME`, `RX_TGID`,
+ `RX_STREAM_ID`, `RX_TYPE`, `RX_PEER`.
+4. **Global contention** — if the TG already has an active stream from another
+ source, it is blocked or silently activated.
+5. **Source row ACTIVE** — a bridge leg must exist and be `ACTIVE` for that
+ `system/slot/TG`. If missing, a dynamic one is created.
+6. **Fan-out** — for each peer on the MASTER, the downlink gate decides if it
+ receives the packet.
+7. **LC rewrite + slot mapping** — the destination slot is the peer's, not the
+ source's.
+
+---
+
+## The four contention rules (legacy parity)
+
+Evaluated **packet by packet** in `to_target` against the `STATUS[slot]` of the
+target system. These match `bridge_master.py` lines ~2076–2104.
+
+| Rule | Condition | Action |
+|---|---|---|
+| **1. RX hangtime** | TG ≠ `RX_TGID` **and** `(now - RX_TIME) < GROUP_HANGTIME` | `continue` (do not forward) |
+| **2. TX hangtime** | TG ≠ `TX_TGID` **and** `(now - TX_TIME) < GROUP_HANGTIME` | `continue` |
+| **3. same TG RX active** | TG == `RX_TGID` **and** `(now - RX_TIME) < STREAM_TO` **and** different stream | `continue` |
+| **4. same TG TX, other sub** | TG == `TX_TGID` **and** `(now - TX_TIME) < STREAM_TO` **and** other subscriber | `continue` |
+
+Key facts:
+
+- `RX_TIME`, `RX_TGID`, `TX_TIME`, `TX_TGID` are **not cleared on VTERM**. They
+ keep the last value until another QSO overwrites them — that is why hangtime
+ counts from the last packet.
+- Contention is evaluated per-frame, not per-stream. If a stream was blocked by
+ hangtime and the hangtime then expires, subsequent frames of the same stream
+ **are** forwarded.
+- The `CONTENTION` flag in legacy is only a log debounce; it does not block.
+
+---
+
+## Critical timeouts and constants
+
+These values define observable behaviour. Changing them affects contention,
+hangtime, and reconnect.
+
+| Constant | Value | Location | Role |
+|---|---|---|---|
+| `STREAM_TO` | **0.36 s** | `domain/hbp_protocol.py` | Window to consider a stream "active" (between packets). |
+| `_STALE_PEER_SESSION_TIMEOUT` | **5.0 s** | `routing/helpers.py` | A per-peer session with no frames is considered dead (VTERM lost). |
+| `GROUP_HANGTIME` | **5 s** (config default) | per-system YAML | Blocking period after a QSO ends before another TG is accepted on that slot. |
+| `DEFAULT_UA_TIMER` | configurable (minutes) | per-system YAML | Duration of dynamic (User Activated) bridges. |
+
+See [Behaviour and timers](behaviour-and-timers.md) for the periodic loop
+intervals (`rule_timer`, `stream_trimmer`, etc.).
+
+### Stream states
+
+```mermaid
+stateDiagram-v2
+ [*] --> IDLE: slot free
+ IDLE --> VHEAD: voice header arrives (VHEAD)
+ VHEAD --> ACTIVE: voice frames within STREAM_TO
+ ACTIVE --> ACTIVE: each frame renews RX_TIME/TX_TIME
+ ACTIVE --> VTERM: voice terminator arrives (VTERM)
+ ACTIVE --> TIMEOUT: no frames > STREAM_TO
+ VTERM --> HANGTIME: GROUP_HANGTIME seconds
+ TIMEOUT --> HANGTIME: GROUP_HANGTIME seconds
+ HANGTIME --> IDLE: hangtime expires
+ HANGTIME --> ACTIVE: same TG resumes (renews)
+```
+
+**Lost VTERM:** if the MMDVM never sends a terminator, the per-peer session
+expires after `_STALE_PEER_SESSION_TIMEOUT` (5 s), freeing the slot.
+
+---
+
+## SINGLE=1 vs SINGLE=0 (exclusive listen)
+
+`SINGLE` is set in the hotspot's `OPTIONS` line (e.g. `SINGLE=1;`). It controls
+**how many dynamic TGs a peer can have active per slot**.
+
+```mermaid
+flowchart LR
+ subgraph SINGLE1["SINGLE=1 (exclusive listen)"]
+ S1A[1 exclusive TG per slot]
+ S1B[PTT on new TG
= replaces the lock]
+ S1C[Timer DEFAULT_UA_TIMER
expires → releases]
+ S1D[TG 4000 → clears lock]
+ end
+ subgraph SINGLE0["SINGLE=0 (multi-dynamic)"]
+ S0A[Several dynamic TGs
accumulated per slot]
+ S0B[GROUP_HANGTIME governs
between TGs]
+ S0C[Only 1 downlink stream
at a time per slot]
+ end
+```
+
+| Aspect | SINGLE=1 | SINGLE=0 |
+|---|---|---|
+| Dynamic TGs per slot | **1 exclusive** | **Several accumulated** (`_PEER_UA_MULTI_TGS`) |
+| Storage | `_PEER_UA_SESSIONS[peer][slot]` | `_PEER_UA_MULTI_TGS[peer][slot]` |
+| Switching TG | PTT on new TG replaces the lock | Adds to the set; does not replace |
+| TG 4000 | Clears the slot session | Clears all the peer's dynamics |
+| Timer | `DEFAULT_UA_TIMER` expires → releases | No individual expiry; purged by `GROUP_HANGTIME` |
+| In-band deactivation | Aggressive: OFF/RESET/TG4000/non-matching traffic | Conservative: mainly TG 4000 |
+
+### SINGLE exceptions
+
+- **TG 9990 (echo):** does **not** create a SINGLE lock. Echo always returns to
+ the caller.
+- **TG 4000:** does **not** create a UA session. It is a reset command only.
+- **TG 9991–9999 (on-demand):** do not create a SINGLE lock.
+- **Own UA session:** a SINGLE=1 peer that activated TG T dynamically **must**
+ receive downlink for T (it does not self-block).
+
+---
+
+## Static vs dynamic talkgroups
+
+### Static TG
+
+Defined in the hotspot's `OPTIONS` (`TS1_STATIC` / `TS2_STATIC`). The peer
+always listens to that TG on that slot while connected.
+
+```
+OPTIONS: TS1=730500;TS2=730502,730508;SINGLE=1;TIMER=60;
+```
+
+Origins of static TGs:
+
+- **OPTIONS at login (RPTO)** — the hotspot reports its line.
+- **Self-service (web panel)** — the user changes TGs from the browser; the
+ server sends an updated `RPTO` to the MASTER.
+- **Startup/reload** — `apply_startup_bridges` applies TGs at start and on
+ `SIGHUP`.
+- **D-28 (divergence):** there is **no** periodic 26 s loop
+ (`options_config_loop`). Refresh is event-driven (RPTO, startup, dmrd
+ no-source fallback).
+
+### Dynamic TG (User Activated)
+
+Activated when a user keys a TG that is **not** in their static OPTIONS.
+
+```mermaid
+flowchart TD
+ PTT[PTT on non-static TG] --> CHECK{TG free?}
+ CHECK -->|Yes| ACT1[Activate dynamic TG
+ pass voice in 1 TX
divergence vs legacy]
+ CHECK -->|No, busy| SILENT[Silent activation]
+ ACT1 --> STORE1[_PEER_UA_SESSIONS or
_PEER_UA_MULTI_TGS]
+ STORE1 --> PERSIST[Async upsert
peer_dynamic_tgs MariaDB]
+ ACT1 --> BRIDGE1[Create dynamic bridge leg
ensure_dynamic_relay]
+ BRIDGE1 --> DL[Peer now listens
to that TG]
+```
+
+**Difference vs legacy (`adn-dmr-server`):**
+
+- **Legacy:** two TXs are needed — the 1st activates, the 2nd passes voice.
+- **This server:** the 1st TX does both (activate + pass voice).
+
+See [Dynamic TG persistence (MariaDB)](../user-guide/bridges-and-talkgroups.md#dynamic-tg-persistence-mariadb)
+for reconnect survival.
+
+---
+
+## Silent activation (TG busy with active QSO)
+
+**Intentional divergence.** If a user keys a TG that has an **active**
+conversation, the server:
+
+1. Does **not reject** the TX.
+2. **Activates** that TG as dynamic.
+3. Does **not forward the user's uplink** (does not disturb the active QSO).
+4. **Does deliver the downlink** of the active QSO to the user immediately.
+
+```mermaid
+flowchart TD
+ TX[User TX on busy TG] --> Q1{TG has active
QSO?}
+ Q1 -->|No| NORMAL[Normal flow]
+ Q1 -->|Yes| ACT[Activate dynamic TG]
+ ACT --> NOUL[Suppress uplink
do not forward user voice]
+ NOUL --> DL2[Deliver downlink
of active QSO to user]
+ DL2 --> HANG[Respect GROUP_HANGTIME
for other TGs on that slot]
+```
+
+Applies to:
+
+- **Non-static TG** the user wants to hear.
+- **SINGLE=1** switching from an active TG to another with an ongoing QSO.
+
+---
+
+## Slot mapping on downlink
+
+The slot on which a peer **receives** a TG is determined by its `OPTIONS` (if
+static) or the slot where it activated it (if dynamic). It is **not** the slot
+of the originating transmission.
+
+```mermaid
+flowchart LR
+ OBP1[OBP sends TG 730500
on slot 1] --> MAP{Slot mapping}
+ PEA[HS-A has 730500
on TS2] --> MAP
+ PEB[HS-B has 730500
on TS1] --> MAP
+ MAP --> OUTA[HS-A receives on TS2]
+ MAP --> OUTB[HS-B receives on TS1]
+```
+
+- **OBP always travels on slot 1** of the packet. The slot is informational of
+ the origin; delivery goes to the peer's slot.
+- Two peers with the same TG on different slots can **both** hear the same
+ transmission, each on its own slot.
+- On **simplex (DMO)**, MMDVMHost drops packets with the TS1 bit set; the server
+ delivers downlink voice on **TS2** for simplex peers.
+
+---
+
+## Downlink gate: does a peer receive the packet?
+
+For each peer and each group voice frame, the server evaluates a chain of
+filters. **All** must pass for the packet to be delivered.
+
+```mermaid
+flowchart TD
+ PKT[DMRD toward peer] --> F1{OPTIONS has TG
or dynamic active?}
+ F1 -->|No| DROP[Do not deliver]
+ F1 -->|Yes| F2{Special TG 9990-9999?}
+ F2 -->|Yes| PASS1[Bypass slot contention]
+ F2 -->|No| F3{Target slot
free?}
+ F3 -->|Peer TXing ingress| DROP
+ F3 -->|Bridge hold active
different TG| DROP
+ F3 -->|Active stream
on that slot, different TG| DROP
+ F3 -->|Free| F4{GROUP_HANGTIME
respected?}
+ F4 -->|No| DROP
+ F4 -->|Yes| F5{SINGLE lock
compatible?}
+ F5 -->|Blocked by other TG| DROP
+ F5 -->|OK| SEND[Deliver DMRD to peer]
+ PASS1 --> SEND
+```
+
+### Conditions that block downlink (per-peer)
+
+| Condition | Detail |
+|---|---|
+| **Active ingress** | The peer is transmitting on that slot → no receive until TX ends. |
+| **Bridge hold** | The slot has a `bridge_hold` (own ingress or listener) blocking foreign TGs for `GROUP_HANGTIME`. |
+| **Active stream, different TG** | A downlink stream is already active on that slot with another TG → wait for it to end. |
+| **GROUP_HANGTIME** | If the last TG on that slot was different and is within hangtime → block. |
+| **Incompatible SINGLE lock** | SINGLE=1 with a lock on another TG → block, unless it is the lock TG or the peer activated it. |
+| **Stale session** | If the per-peer session has had no frames for `_STALE_PEER_SESSION_TIMEOUT`, it is purged and the slot frees. |
+
+### Mid-call join
+
+When a downlink stream ends (VTERM or timeout), the peer's slot becomes free.
+The **next frame** of any other TG the peer has active (static or dynamic) and
+that is currently in progress on the network **is delivered without requiring a
+new PTT**.
+
+This does **not relax** any rule: GROUP_HANGTIME, SINGLE, and per-peer
+contention are evaluated exactly as for any stream. It is simply the normal
+behaviour when the slot becomes free.
+
+---
+
+## OpenBridge and dynamic TGs
+
+If a hotspot on server 7302 activated TG 7300 dynamically and a hotspot on
+7301 keys that TG, the OBP traffic must reach the 7302 hotspot even though 7300
+is not in its OPTIONS. The bridge leg is activated by the UA session (via
+`master_dynamic_tg_slots`), which checks both `_PEER_UA_SESSIONS` (SINGLE=1)
+and `_PEER_UA_MULTI_TGS` (SINGLE=0).
+
+See [OpenBridge protocol](../protocols/openbridge.md) for ingress filters,
+BCSQ/BCKA, and DMRE v5 details.
+
+---
+
+## Divergences vs legacy
+
+| Behaviour | Legacy (`adn-dmr-server`) | This server |
+|---|---|---|
+| Activate dynamic TG (free TG) | 2 TXs: 1st activates, 2nd passes voice | 1 TX: activates + passes voice |
+| TX on busy TG | Blocked by contention (rules 1–4) | Silent activation: activates TG, no uplink, delivers downlink |
+| Stream end → join active TG | Peer stays in hangtime; no mid-stream join | When the stream ends, the next active TG is delivered without a new PTT |
+| OPTIONS refresh (26 s loop) | `options_config_loop` every 26 s | **D-28:** event-driven (RPTO, startup, dmrd fallback) — no periodic loop |
+| `GROUP_HANGTIME` | RX + TX, per-system, no reset on VTERM | **Parity** (same behaviour) |
+| `OPTIONS` overrides `GROUP_HANGTIME` | No | **Parity** (cannot) |
+
+---
+
+## See also
+
+- [Bridges and talkgroups](../user-guide/bridges-and-talkgroups.md) — the
+ `BRIDGES` / subscription model.
+- [Special numbers](../user-guide/special-numbers.md) — TG 4000, 999x, echo.
+- [Behaviour and timers](behaviour-and-timers.md) — periodic loop intervals.
+- [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md) — internal model.
+- [OpenBridge protocol](../protocols/openbridge.md) — DMRE v5, ingress filters.
+- [Hotspot proxy](../user-guide/hotspot-proxy.md) — integrated PROXY / self-service.
diff --git a/docs/en/server/user-guide/bridges-and-talkgroups.md b/docs/en/server/user-guide/bridges-and-talkgroups.md
index 5a05de1..83525b2 100644
--- a/docs/en/server/user-guide/bridges-and-talkgroups.md
+++ b/docs/en/server/user-guide/bridges-and-talkgroups.md
@@ -57,4 +57,7 @@ For OpenBridge, the **DMR destination** in the packet may differ from the **TGID
Group voice uses **hang time**, **`STREAM_TO`**, and slot `TX_*` / `RX_*` state to avoid colliding transmissions on the same resources.
+For the full contention rule set, SINGLE behaviour, silent activation, and
+slot mapping, see [Voice routing and contention](../development/routing-and-contention.md).
+
See also: [Special numbers](special-numbers.md), [OpenBridge protocol](../protocols/openbridge.md).
diff --git a/docs/en/server/user-guide/introduction.md b/docs/en/server/user-guide/introduction.md
index 7a49696..34dc643 100644
--- a/docs/en/server/user-guide/introduction.md
+++ b/docs/en/server/user-guide/introduction.md
@@ -34,6 +34,7 @@ Routing, timers, OpenBridge loop control, and protocol handling are implemented
- [Configuration](configuration.md) — files, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACLs, **`DATABASE`**, reports, **`PROXY`**, **`SELF_SERVICE`**, aliases, voice merge.
- [Bridges and talkgroups](bridges-and-talkgroups.md) — how `BRIDGES` works.
+- [Voice routing and contention](../development/routing-and-contention.md) — the full packet flow, contention rules, SINGLE, slot mapping, and divergences.
- [Special numbers](special-numbers.md) — TG 4000, information services, echo.
- [Hotspot proxy](hotspot-proxy.md) — integrated **`PROXY`** / **`SELF_SERVICE`** in `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — dashboard, `adn-monitor.yaml`, self-service UI (separate repo, deployed with the server).
diff --git a/docs/en/server/user-guide/special-numbers.md b/docs/en/server/user-guide/special-numbers.md
index f195bb9..0e3274b 100644
--- a/docs/en/server/user-guide/special-numbers.md
+++ b/docs/en/server/user-guide/special-numbers.md
@@ -98,7 +98,7 @@ The **audio** is sent with **source ID 5000** and **destination TG 9** in the ge
**Purpose:** Bridge rows for **echo** often use **9990** with the **ECHO** system (see `BRIDGES` and options in your YAML).
-**`SINGLE=1`:** Keying **9990** does **not** create an exclusive listen session (same as **4000**). Downlink echo always returns to the calling hotspot even when another TG holds the SINGLE lock. See [Hotspot proxy](hotspot-proxy.md#behaviour-with-multiple-hotspots).
+**`SINGLE=1`:** Keying **9990** does **not** create an exclusive listen session (same as **4000**). Downlink echo always returns to the calling hotspot even when another TG holds the SINGLE lock. See [Hotspot proxy](hotspot-proxy.md#behaviour-with-multiple-hotspots) and [Voice routing and contention — SINGLE exceptions](../development/routing-and-contention.md#single-exceptions).
**Note:** A **standalone echo** is also available as a separate process — [Echo](echo.md).
diff --git a/docs/es/server/development/behaviour-and-timers.md b/docs/es/server/development/behaviour-and-timers.md
index 314098c..c7584bc 100644
--- a/docs/es/server/development/behaviour-and-timers.md
+++ b/docs/es/server/development/behaviour-and-timers.md
@@ -25,6 +25,18 @@ Los siguientes intervalos forman parte del comportamiento actual en ejecución:
Si cambias uno de estos intervalos, documenta el impacto operativo en monitorización, comportamiento de bucles y troubleshooting.
+## Constantes de contención de voz
+
+Estas constantes definen el comportamiento por paquete y por sesión. Están
+documentadas en detalle en [Enrutado de voz y contención](routing-and-contention.md).
+
+| Constante | Valor | Rol |
+|---|---|---|
+| `STREAM_TO` | **0.36 s** | Ventana para considerar un stream "activo" (entre paquetes). |
+| `_STALE_PEER_SESSION_TIMEOUT` | **5.0 s** | Una sesión per-peer sin frames se considera muerta (VTERM perdido). |
+| `GROUP_HANGTIME` | **5 s** (default config, por sistema) | Bloqueo tras fin de QSO antes de aceptar otro TG en ese slot. |
+| `DEFAULT_UA_TIMER` | configurable (minutos, por sistema) | Duración de bridges dinámicos (User Activated). |
+
## Alcance de VTERM in-band
La señalización in-band en voice terminator (VTERM) está acotada a:
diff --git a/docs/es/server/development/routing-and-contention.md b/docs/es/server/development/routing-and-contention.md
new file mode 100644
index 0000000..3ab0af0
--- /dev/null
+++ b/docs/es/server/development/routing-and-contention.md
@@ -0,0 +1,362 @@
+# Enrutado de voz y reglas de contención
+
+Esta página documenta **cómo viajan los paquetes de voz por el servidor** y las
+**reglas de contención / slot** que determinan quién escucha qué. Es la
+referencia para sysops e integradores que necesitan entender o diagnosticar el
+comportamiento de enrutado sin leer el código fuente.
+
+El servidor mantiene **paridad legada** con `adn-dmr-server` (`bridge_master.py`)
+salvo las divergencias explícitamente documentadas al final de esta página.
+
+---
+
+## Regla de oro: una conversación por TG
+
+**Sólo una conversación por TG puede existir en el servidor a la vez.** Esta
+regla es **global** — aplica sin importar el slot, el peer o el origen del
+tráfico (OBP o HBP). Tiene la prioridad más alta; todas las demás reglas se
+subordinan a ella.
+
+Un TG está **ocupado** cuando tiene un stream de voz **activo** (frames dentro
+de `STREAM_TO` del último paquete) en cualquier slot, venga de donde venga.
+
+| Origen del nuevo tráfico | Comportamiento |
+|---|---|
+| Otro **OBP** envía el mismo TG | **Rechazar** — el TG ya está ocupado |
+| Un **hotspot (HBP)** transmite al mismo TG | **Rechazar**, *o* activación silenciosa (ver abajo) |
+
+La única excepción es la **activación silenciosa**, que **no** crea una segunda
+conversación — sólo permite al usuario escuchar la existente.
+
+---
+
+## Flujo de paquetes de extremo a extremo
+
+```mermaid
+flowchart TD
+ PTT[Usuario pulsa PTT
en TG 730500 slot 2] --> HBP[Hotspot envía DMRD
por UDP HBP al PROXY]
+ HBP --> INJ[PROXY inyecta en MASTER 'SYSTEM']
+ INJ --> AUTH{Auth HBP OK?}
+ AUTH -->|No| DROP1[Drop: peer no autenticado]
+ AUTH -->|Sí| ACL{ACL de SUB/TG
permite?}
+ ACL -->|No| DROP2[Drop: ACL]
+ ACL -->|Sí| DMRD_RX[dmrd_received
actualiza STATUS slot]
+ DMRD_RX --> CONT{Contención global:
TG ocupado?}
+ CONT -->|Ocupado OBP/HBP| SILENT{Activación silenciosa?
ver abajo}
+ CONT -->|Libre| SRC[source row ACTIVE?
bridge leg]
+ SRC -->|No| NODRV[No hay bridge leg
ver: TG dinámico]
+ SRC -->|Sí| FWD[forward a to_target]
+ NODRV --> FWD
+ FWD --> FANOUT[Iterar peers destino]
+ FANOUT --> P1{Para cada peer:
¿OPTIONS tiene TG?}
+ P1 -->|Sí o dinámico activo| SLOT{¿Slot destino
libre?}
+ P1 -->|No| SKIP[Skip peer]
+ SLOT -->|Ocupado| SKIP
+ SLOT -->|Libre| REWRITE[Rewrite LC
+ slot mapping]
+ REWRITE --> TX[Enviar DMRD
al peer en su slot]
+ TX --> NEXT[Próximo peer / frame]
+
+ style DMRD_RX fill:#bfb,stroke:#333
+ style FWD fill:#bbf,stroke:#333
+ style TX fill:#fbb,stroke:#333
+```
+
+### Puntos de decisión en orden
+
+1. **Autenticación HBP** — el peer debe estar registrado con passphrase válida.
+2. **ACL** — `SUB_ACL`, `TGID_TS1_ACL`, `TGID_TS2_ACL` (si `USE_ACL`).
+3. **`dmrd_received`** — actualiza `STATUS[slot]`: `RX_TIME`, `RX_TGID`,
+ `RX_STREAM_ID`, `RX_TYPE`, `RX_PEER`.
+4. **Contención global** — si el TG ya tiene un stream activo desde otra
+ fuente, se bloquea o activa silenciosamente.
+5. **Source row ACTIVE** — debe existir una pata de bridge `ACTIVE` para ese
+ `system/slot/TG`. Si no existe, se crea una dinámica.
+6. **Fan-out** — para cada peer del MASTER, el gate de downlink decide si
+ recibe el paquete.
+7. **Rewrite LC + slot mapping** — el slot destino es el del peer, no el de
+ origen.
+
+---
+
+## Las cuatro reglas de contención (paridad legada)
+
+Evaluadas **paquete a paquete** en `to_target` contra el `STATUS[slot]` del
+sistema destino. Coinciden con `bridge_master.py` líneas ~2076–2104.
+
+| Regla | Condición | Acción |
+|---|---|---|
+| **1. RX hangtime** | TG ≠ `RX_TGID` **y** `(now - RX_TIME) < GROUP_HANGTIME` | `continue` (no enruta) |
+| **2. TX hangtime** | TG ≠ `TX_TGID` **y** `(now - TX_TIME) < GROUP_HANGTIME` | `continue` |
+| **3. mismo TG RX activo** | TG == `RX_TGID` **y** `(now - RX_TIME) < STREAM_TO` **y** stream distinto | `continue` |
+| **4. mismo TG TX, otro sub** | TG == `TX_TGID` **y** `(now - TX_TIME) < STREAM_TO` **y** otro suscriptor | `continue` |
+
+Hechos clave:
+
+- `RX_TIME`, `RX_TGID`, `TX_TIME`, `TX_TGID` **no se borran en VTERM**.
+ Conservan el último valor hasta que otro QSO los sobrescribe — por eso el
+ hangtime cuenta desde el último paquete.
+- La contención se evalúa por frame, no por stream. Si un stream fue bloqueado
+ por hangtime y luego el hangtime expira, los siguientes frames del mismo
+ stream **sí** se reenvían.
+- El flag `CONTENTION` del legado es sólo un debounce de log; no bloquea.
+
+---
+
+## Timeouts y constantes críticas
+
+Estos valores definen el comportamiento observable. Cambiarlos afecta
+contención, hangtime y reconexión.
+
+| Constante | Valor | Ubicación | Rol |
+|---|---|---|---|
+| `STREAM_TO` | **0.36 s** | `domain/hbp_protocol.py` | Ventana para considerar un stream "activo" (entre paquetes). |
+| `_STALE_PEER_SESSION_TIMEOUT` | **5.0 s** | `routing/helpers.py` | Una sesión per-peer sin frames se considera muerta (VTERM perdido). |
+| `GROUP_HANGTIME` | **5 s** (default config) | por system en YAML | Bloqueo tras fin de QSO antes de aceptar otro TG en ese slot. |
+| `DEFAULT_UA_TIMER` | configurable (minutos) | por system en YAML | Duración de bridges dinámicos (User Activated). |
+
+Ver [Comportamiento y temporizadores](behaviour-and-timers.md) para los
+intervalos de los bucles periódicos (`rule_timer`, `stream_trimmer`, etc.).
+
+### Estados de un stream
+
+```mermaid
+stateDiagram-v2
+ [*] --> IDLE: slot libre
+ IDLE --> VHEAD: llega voice header (VHEAD)
+ VHEAD --> ACTIVE: frames de voz dentro de STREAM_TO
+ ACTIVE --> ACTIVE: cada frame renueva RX_TIME/TX_TIME
+ ACTIVE --> VTERM: llega voice terminator (VTERM)
+ ACTIVE --> TIMEOUT: sin frames > STREAM_TO
+ VTERM --> HANGTIME: GROUP_HANGTIME segundos
+ TIMEOUT --> HANGTIME: GROUP_HANGTIME segundos
+ HANGTIME --> IDLE: expira hangtime
+ HANGTIME --> ACTIVE: mismo TG reanuda (renueva)
+```
+
+**VTERM perdido:** si el MMDVM nunca envía el terminator, la sesión per-peer
+expira tras `_STALE_PEER_SESSION_TIMEOUT` (5 s), liberando el slot.
+
+---
+
+## SINGLE=1 vs SINGLE=0 (escucha exclusiva)
+
+`SINGLE` se configura en la línea `OPTIONS` del hotspot (ej: `SINGLE=1;`).
+Controla **cuántos TG dinámicos puede tener activos un peer por slot**.
+
+```mermaid
+flowchart LR
+ subgraph SINGLE1["SINGLE=1 (escucha exclusiva)"]
+ S1A[1 TG exclusivo por slot]
+ S1B[PTT en nuevo TG
= reemplaza el lock]
+ S1C[Timer DEFAULT_UA_TIMER
expira → libera]
+ S1D[TG 4000 → borra lock]
+ end
+ subgraph SINGLE0["SINGLE=0 (multi-dinámico)"]
+ S0A[Varios TG dinámicos
acumulados por slot]
+ S0B[GROUP_HANGTIME rige
entre TGs]
+ S0C[Sólo 1 stream downlink
a la vez por slot]
+ end
+```
+
+| Aspecto | SINGLE=1 | SINGLE=0 |
+|---|---|---|
+| TGs dinámicos por slot | **1 exclusivo** | **Varios acumulados** (`_PEER_UA_MULTI_TGS`) |
+| Almacenamiento | `_PEER_UA_SESSIONS[peer][slot]` | `_PEER_UA_MULTI_TGS[peer][slot]` |
+| Cambiar de TG | PTT en nuevo TG reemplaza el lock | Se suma al set; no reemplaza |
+| TG 4000 | Borra la sesión del slot | Borra todos los dinámicos del peer |
+| Timer | `DEFAULT_UA_TIMER` expira → libera | No expira individualmente; purga por `GROUP_HANGTIME` |
+| Desactivación in-band | Agresiva: OFF/RESET/TG4000/tráfico no matching | Conservadora: principalmente TG 4000 |
+
+### Excepciones a SINGLE
+
+- **TG 9990 (eco):** **no** crea lock SINGLE. El eco vuelve siempre al llamante.
+- **TG 4000:** **no** crea sesión UA. Es sólo comando de reset.
+- **TG 9991–9999 (bajo demanda):** no crean lock SINGLE.
+- **UA session propia:** un peer SINGLE=1 que activó el TG T dinámicamente
+ **debe** recibir downlink de T (no se auto-bloquea).
+
+---
+
+## Talkgroups estáticos vs dinámicos
+
+### TG estático
+
+Definido en la línea `OPTIONS` del hotspot (`TS1_STATIC` / `TS2_STATIC`). El
+peer siempre escucha ese TG en ese slot mientras esté conectado.
+
+```
+OPTIONS: TS1=730500;TS2=730502,730508;SINGLE=1;TIMER=60;
+```
+
+Origen de los TG estáticos:
+
+- **OPTIONS al login (RPTO)** — el hotspot reporta su línea.
+- **Self-service (panel web)** — el usuario cambia sus TG desde el navegador;
+ el servidor envía un `RPTO` actualizado al MASTER.
+- **Startup/reload** — `apply_startup_bridges` aplica los TG al arranque y en
+ `SIGHUP`.
+- **D-28 (divergencia):** **no** hay loop periódico de 26 s
+ (`options_config_loop`). El refresco es por evento (RPTO, startup, fallback
+ dmrd sin source).
+
+### TG dinámico (User Activated)
+
+Activado cuando un usuario transmite en un TG que **no** tiene estático.
+
+```mermaid
+flowchart TD
+ PTT[PTT en TG no estático] --> CHECK{TG libre?}
+ CHECK -->|Sí| ACT1[Activar TG dinámico
+ pasar voz en 1 TX
divergencia vs legacy]
+ CHECK -->|No, ocupado| SILENT[Activación silenciosa]
+ ACT1 --> STORE1[_PEER_UA_SESSIONS o
_PEER_UA_MULTI_TGS]
+ STORE1 --> PERSIST[Upsert asíncrono
peer_dynamic_tgs MariaDB]
+ ACT1 --> BRIDGE1[Crear bridge leg dinámica
ensure_dynamic_relay]
+ BRIDGE1 --> DL[Peer queda escuchando
ese TG]
+```
+
+**Diferencia vs legacy (`adn-dmr-server`):**
+
+- **Legacy:** se necesitan 2 TX — la 1ª activa, la 2ª pasa voz.
+- **Este servidor:** la 1ª TX hace ambas cosas (activar + pasar voz).
+
+Ver [Persistencia TG dinámicos (MariaDB)](../user-guide/bridges-and-talkgroups.md#persistencia-tg-dinamicos-mariadb)
+para la supervivencia tras reconexión.
+
+---
+
+## Activación silenciosa (TG ocupado con QSO activo)
+
+**Divergencia intencionada.** Si un usuario transmite en un TG que tiene una
+conversación **activa**, el servidor:
+
+1. **No rechaza** la TX.
+2. **Activa** ese TG como dinámico.
+3. **No sube el uplink** del usuario (no molesta el QSO activo).
+4. **Sí entrega el downlink** del QSO activo al usuario inmediatamente.
+
+```mermaid
+flowchart TD
+ TX[Usuario TX en TG ocupado] --> Q1{TG tiene QSO
activo?}
+ Q1 -->|No| NORMAL[Flujo normal]
+ Q1 -->|Sí| ACT[Activar TG dinámico]
+ ACT --> NOUL[Suprimir uplink
no reenviar voz del usuario]
+ NOUL --> DL2[Entregar downlink
del QSO activo al usuario]
+ DL2 --> HANG[Respetar GROUP_HANGTIME
para otros TGs en ese slot]
+```
+
+Aplica a:
+
+- **TG no estático** que el usuario quiere escuchar.
+- **SINGLE=1** cambiando de TG activo a otro con QSO en curso.
+
+---
+
+## Mapeo de slot en downlink
+
+El slot por el que un peer **recibe** un TG lo determina su `OPTIONS` (si es
+estático) o el slot donde lo activó (si es dinámico). **No** es el slot de
+origen de la transmisión.
+
+```mermaid
+flowchart LR
+ OBP1[OBP envía TG 730500
en slot 1] --> MAP{Slot mapping}
+ PEA[HS-A tiene 730500
en TS2] --> MAP
+ PEB[HS-B tiene 730500
en TS1] --> MAP
+ MAP --> OUTA[HS-A recibe en TS2]
+ MAP --> OUTB[HS-B recibe en TS1]
+```
+
+- **OBP siempre viaja en slot 1** del paquete. El slot es informativo del
+ origen; la entrega se hace al slot del peer destino.
+- Dos peers con el mismo TG en slots distintos pueden **ambos** escuchar la
+ misma transmisión, cada uno en su slot.
+- En **simplex (DMO)**, MMDVMHost descarta paquetes con el bit TS1; el servidor
+ entrega voz de downlink en **TS2** para peers simplex.
+
+---
+
+## Gate de downlink: ¿un peer recibe el paquete?
+
+Para cada peer y cada frame de voz de grupo, el servidor evalúa una cadena de
+filtros. **Todos** deben pasar para que el paquete se entregue.
+
+```mermaid
+flowchart TD
+ PKT[DMRD hacia peer] --> F1{OPTIONS tiene TG
o dinámico activo?}
+ F1 -->|No| DROP[No entrega]
+ F1 -->|Sí| F2{TG especial 9990-9999?}
+ F2 -->|Sí| PASS1[Bypass slot contention]
+ F2 -->|No| F3{¿Slot destino
libre?}
+ F3 -->|Peer TXing ingress| DROP
+ F3 -->|Bridge hold activo
TG distinto| DROP
+ F3 -->|Stream activo
en ese slot, TG distinto| DROP
+ F3 -->|Libre| F4{GROUP_HANGTIME
respeta?}
+ F4 -->|No| DROP
+ F4 -->|Sí| F5{SINGLE lock
compatible?}
+ F5 -->|Bloqueado por otro TG| DROP
+ F5 -->|OK| SEND[Entregar DMRD al peer]
+ PASS1 --> SEND
+```
+
+### Condiciones que bloquean downlink (per-peer)
+
+| Condición | Detalle |
+|---|---|
+| **Ingress activo** | El peer está transmitiendo en ese slot → no recibe hasta terminar TX. |
+| **Bridge hold** | El slot tiene un `bridge_hold` (ingress propio o listener) que bloquea TGs ajenos por `GROUP_HANGTIME`. |
+| **Stream activo, TG distinto** | Ya hay un stream downlink activo en ese slot con otro TG → espera a que termine. |
+| **GROUP_HANGTIME** | Si el último TG en ese slot fue distinto y está dentro del hangtime → bloquea. |
+| **SINGLE lock incompatible** | SINGLE=1 con lock en otro TG → bloquea salvo que sea el TG del lock o el peer lo activara. |
+| **Sesión stale** | Si la sesión per-peer lleva `_STALE_PEER_SESSION_TIMEOUT` sin frames, se purga y el slot se libera. |
+
+### Mid-call join
+
+Cuando un stream downlink termina (VTERM o timeout), el slot del peer queda
+libre. El **siguiente frame** de cualquier otro TG que el peer tenga activo
+(estático o dinámico) y que esté en curso en la red **se entrega sin requerir
+nuevo PTT**.
+
+Esto **no relaja** ninguna regla: GROUP_HANGTIME, SINGLE y contención per-peer
+se evalúan igual que para cualquier stream. Es simplemente el comportamiento
+normal cuando el slot queda libre.
+
+---
+
+## OpenBridge y TGs dinámicos
+
+Si un hotspot en el servidor 7302 activó el TG 7300 dinámicamente y un
+hotspot en 7301 transmite ese TG, el tráfico OBP debe llegar al hotspot 7302
+aunque 7300 no esté en sus OPTIONS. La pata de bridge se activa por la sesión
+UA (vía `master_dynamic_tg_slots`), que consulta tanto `_PEER_UA_SESSIONS`
+(SINGLE=1) como `_PEER_UA_MULTI_TGS` (SINGLE=0).
+
+Ver [Protocolo OpenBridge](../protocols/openbridge.md) para filtros de ingreso,
+BCSQ/BCKA y detalles de DMRE v5.
+
+---
+
+## Divergencias vs legacy
+
+| Comportamiento | Legacy (`adn-dmr-server`) | Este servidor |
+|---|---|---|
+| Activar TG dinámico (TG libre) | 2 TX: 1ª activa, 2ª pasa voz | 1 TX: activa + pasa voz |
+| TX en TG ocupado | Se bloquea por contención (reglas 1–4) | Activación silenciosa: activa TG, no sube uplink, entrega downlink |
+| Fin de stream → join TG activo | El peer queda en hangtime; no hay join mid-stream | Al terminar el stream, el siguiente TG activo se entrega sin nuevo PTT |
+| OPTIONS refresh (loop 26 s) | `options_config_loop` cada 26 s | **D-28:** por evento (RPTO, startup, fallback dmrd) — sin loop periódico |
+| `GROUP_HANGTIME` | RX + TX, por sistema, sin reset en VTERM | **Paridad** (mismo comportamiento) |
+| `OPTIONS` sobrescribe `GROUP_HANGTIME` | No | **Paridad** (no se puede) |
+
+---
+
+## Ver también
+
+- [Bridges y talkgroups](../user-guide/bridges-and-talkgroups.md) — el modelo
+ `BRIDGES` / subscriptions.
+- [Números especiales](../user-guide/special-numbers.md) — TG 4000, 999x, eco.
+- [Comportamiento y temporizadores](behaviour-and-timers.md) — intervalos de
+ bucles periódicos.
+- [BRIDGES vs Subscriptions](bridges-vs-subscriptions.md) — modelo interno.
+- [Protocolo OpenBridge](../protocols/openbridge.md) — DMRE v5, filtros de
+ ingreso.
+- [Proxy hotspot](../user-guide/hotspot-proxy.md) — PROXY / self-service
+ integrados.
diff --git a/docs/es/server/user-guide/bridges-and-talkgroups.md b/docs/es/server/user-guide/bridges-and-talkgroups.md
index 6c8fb65..f33e495 100644
--- a/docs/es/server/user-guide/bridges-and-talkgroups.md
+++ b/docs/es/server/user-guide/bridges-and-talkgroups.md
@@ -57,4 +57,7 @@ En OpenBridge, el **destino DMR** en el paquete puede diferir del **TGID** en un
La voz de grupo usa **hang time**, **`STREAM_TO`** y estado de slot `TX_*` / `RX_*` para evitar transmisiones simultáneas en los mismos recursos.
+Para el conjunto completo de reglas de contención, comportamiento SINGLE,
+activación silenciosa y mapeo de slot, ver [Enrutado de voz y contención](../development/routing-and-contention.md).
+
Ver también: [Números especiales](special-numbers.md), [Protocolo OpenBridge](../protocols/openbridge.md).
diff --git a/docs/es/server/user-guide/introduction.md b/docs/es/server/user-guide/introduction.md
index a9a5758..ddb985b 100644
--- a/docs/es/server/user-guide/introduction.md
+++ b/docs/es/server/user-guide/introduction.md
@@ -34,6 +34,7 @@ Enrutado, temporizadores, control de bucle OpenBridge y manejo de protocolo est
- [Configuración](configuration.md) — ficheros, `GLOBAL`, **MASTER** / **PEER** / **OPENBRIDGE**, ACL, **`DATABASE`**, informes, **`PROXY`**, **`SELF_SERVICE`**, alias, fusión de voz.
- [Bridges y talkgroups](bridges-and-talkgroups.md) — cómo funciona `BRIDGES`.
+- [Enrutado de voz y contención](../development/routing-and-contention.md) — el flujo completo de paquetes, reglas de contención, SINGLE, mapeo de slot y divergencias.
- [Números especiales](special-numbers.md) — TG 4000, servicios de información, eco.
- [Proxy hotspot](hotspot-proxy.md) — **`PROXY`** / **`SELF_SERVICE`** integrados en `adn-server.yaml`.
- [ADN Monitor](../../monitor/index.md) — panel, `adn-monitor.yaml`, UI self-service (repo aparte, desplegado con el servidor).
diff --git a/docs/es/server/user-guide/special-numbers.md b/docs/es/server/user-guide/special-numbers.md
index ccabdf4..3934847 100644
--- a/docs/es/server/user-guide/special-numbers.md
+++ b/docs/es/server/user-guide/special-numbers.md
@@ -98,7 +98,7 @@ El **audio** se envía con **ID de fuente 5000** y **TG de destino 9** en el flu
**Propósito:** las filas de bridge para **eco** suelen usar **9990** con el sistema **ECHO** (ver `BRIDGES` y opciones en tu YAML).
-**`SINGLE=1`:** pulsar **9990** **no** crea sesión de escucha exclusiva (igual que **4000**). El downlink del eco vuelve siempre al hotspot llamante aunque otra TG tenga el bloqueo SINGLE. Ver [Proxy hotspot](hotspot-proxy.md#comportamiento-con-varios-hotspots).
+**`SINGLE=1`:** pulsar **9990** **no** crea sesión de escucha exclusiva (igual que **4000**). El downlink del eco vuelve siempre al hotspot llamante aunque otra TG tenga el bloqueo SINGLE. Ver [Proxy hotspot](hotspot-proxy.md#comportamiento-con-varios-hotspots) y [Enrutado de voz y contención — Excepciones a SINGLE](../development/routing-and-contention.md#excepciones-a-single).
**Nota:** Un **echo independiente** también está disponible como proceso aparte — [Echo](echo.md).
diff --git a/mkdocs.es.yml b/mkdocs.es.yml
index f5e97fa..d4a444c 100644
--- a/mkdocs.es.yml
+++ b/mkdocs.es.yml
@@ -75,6 +75,7 @@ nav:
- Rendimiento (2.x): server/development/performance.md
- BRIDGES vs Subscriptions: server/development/bridges-vs-subscriptions.md
- Comportamiento y temporizadores: server/development/behaviour-and-timers.md
+ - Enrutado de voz y contención: server/development/routing-and-contention.md
- Contribuir:
- Traducciones: server/contributing/translations.md
- Monitor:
diff --git a/mkdocs.yml b/mkdocs.yml
index f817ca8..8113f63 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -75,6 +75,7 @@ nav:
- Performance (2.x): server/development/performance.md
- BRIDGES vs Subscriptions: server/development/bridges-vs-subscriptions.md
- Behaviour and timers: server/development/behaviour-and-timers.md
+ - Voice routing and contention: server/development/routing-and-contention.md
- Testing: server/development/testing.md
- Contributing:
- Translations: server/contributing/translations.md