Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 137 additions & 0 deletions docs/adrs/20260727000000_events_are_objective_facts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
---
semantic-links:
related-artifacts:
- docs/adrs/index.md
- docs/issues/open/1136-1978-configurable-udp-connection-id-validation-policy.md
- packages/udp-core/src/event.rs
- packages/udp-server/src/event.rs
- packages/http-core/src/event.rs
- packages/swarm-coordination-registry/src/event.rs
---

# Events Are Objective Facts

## Description

The tracker uses a pub/sub event system across multiple packages. Each event bus
has its own `event.rs` module that defines an `Event` enum. Multiple listeners
(ban handler, statistics, metrics, …) subscribe to these events and react
independently.

During the implementation of the configurable UDP connection ID validation policy
(issue [#1136][1136]), a design mistake was made:

A new `UdpCookieErrorObserved` event variant was created specifically so that the
ban handler would **not** react to it when the validation policy was `Disabled`.
The reasoning was: "if we emit a different event, the existing ban listener won't
see it as a ban-worthy error."

This is the wrong pattern.

## Agreement

**Event variants must be objective facts** about what happened in the system.
They must not be designed around what a particular consumer should or should not
do in response to them.

### The wrong pattern

Creating a new event variant (e.g. `UdpCookieErrorObserved`) that is structurally
identical to an existing one (`UdpError { ConnectionCookie }`) but named
differently so a specific listener silently ignores it.

```rust
// WRONG — variant exists purely to prevent the ban handler from reacting
Event::UdpCookieErrorObserved { context, kind, error }
```

Problems:

- Couples the event schema to the internal behaviour of one consumer.
- Hides a policy decision (ban enforcement on/off) inside the event layer.
- Any new consumer that subscribes to `UdpError` but not `UdpCookieErrorObserved`
will silently miss the observation entirely.
- Forces every future listener to duplicate the routing logic.

### The right pattern

Emit the same objective event regardless of the active policy. Gate enforcement
at the **enforcement point**, not at the event definition.

```rust
// RIGHT — objective fact: a cookie error occurred
Event::UdpError {
context: ConnectionContext::new(client_socket_addr, server_service_binding),
kind: Some(UdpRequestKind::Announce { .. }),
error: ErrorKind::ConnectionCookie(cookie_error.to_string()),
}
```

The ban handler receives the event and increments the counter (observability
data). The main server loop — the **enforcement point** — decides whether to act:

```rust
// Enforcement is gated on the active policy, not on the event type
let ban_enforcement_active = connection_id_validation == ConnectionIdValidationPolicy::Strict;

if ban_enforcement_active && ban_service.is_banned(&req.from.ip()) {
// block request
}
```

This keeps three concerns cleanly separated:

| Concern | Owner | Behaviour when policy = Disabled |
| ------- | --------------------------- | -------------------------------- |
| Observe | event emitter (handler) | always emits `UdpError` |
| Count | ban listener | always increments counter |
| Enforce | main loop `is_banned` check | **skipped** — no enforcement |

### Naming heuristic

A well-named event variant:

- Uses past tense, from the system's perspective (`UdpError`, `UdpRequestBanned`).
- Does **not** embed a policy or mode (`UdpCookieErrorInLenientMode` — bad).
- Does **not** mirror a consumer's internal decision (`UdpCookieErrorObserved`
as a synonym for "ignore this error" — bad).

**Red flag**: if you find yourself adding a new variant whose name includes a
policy name, mode name, or whose sole purpose is to make a listener ignore it —
stop and move the policy to the consumer or the enforcement point instead.

**Structural red flag**: if a proposed new variant has the same fields as an
existing one, ask "why not reuse the existing event and change the consumer?"
Almost always the answer is: change the consumer.

### Alternatives Considered

**Keep `UdpCookieErrorObserved` and teach each listener to ignore it.**

Rejected because it scales poorly: every new consumer must know which variants
to skip, the event enum becomes a leaky log of consumer decisions, and the
intent is hidden from new contributors.

**Skip event emission entirely in `Disabled` mode.**

Rejected because it breaks observability — the connection ID error counter would
no longer reflect real traffic when validation is disabled, defeating the purpose
of the metric.

### Consequences

#### Positive

- Event consumers remain fully decoupled from policy decisions.
- Observability is preserved regardless of the active policy.
- Adding a new consumer requires no knowledge of existing consumers' reactions.
- The design principle is explicit and co-located with all event definitions via
the ADR link in each `event.rs` module.

#### Negative

- Enforcement logic is spread between the event emitter (which still emits the
event) and the enforcement point (which decides to act or not). This split must
be documented — which it now is, in the module-level doc of each `event.rs`.

[1136]: https://github.com/torrust/torrust-tracker/issues/1136
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
semantic-links:
related-artifacts:
- docs/adrs/index.md
- packages/udp-core/src/container.rs
- packages/udp-core/src/services/banning.rs
- packages/tracker-core/src/container.rs
- packages/configuration/src/v3_0_0/udp_tracker_server.rs
- src/container.rs
---

# Shared Services Across Tracker Instances

## Description

The tracker can run multiple UDP and HTTP tracker listeners in a single process.
Each listener binds to a different address/port but shares core infrastructure:

- **Peer repository** (`TrackerCoreContainer`) — all instances share the same
swarm data (torrents, peers, statistics). This is the primary reason to run
multiple listeners: they serve the same swarm.
- **Ban service** (`BanService` in `UdpTrackerCoreServices`) — all UDP instances
share the same IP-ban state. An IP banned on one UDP listener is banned on all.
- **Event buses** — core-layer events (`UdpTrackerCoreServices`) are shared;
server-layer events (`UdpTrackerServerContainer`) are per-instance.

This ADR documents the shared-services design and the rationale for keeping
certain services global rather than per-instance.

## Agreement

### Shared services

The following services are created once and shared across all instances of the
same type:

| Service | Location | Shared? | Rationale |
| --------------------------------------------- | --------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------ |
| Peer repository | `TrackerCoreContainer` | Yes | All listeners serve the same swarm |
| Swarm coordination registry | `SwarmCoordinationRegistryContainer` | Yes | Single source of truth for swarm state |
| UDP ban service | `UdpTrackerCoreServices::ban_service` | Yes | Resource protection: an attacker should not be able to consume N× resources by attacking N listeners independently |
| UDP core event bus | `UdpTrackerCoreServices::event_bus` | Yes | Core events (connect, announce, scrape) are objective facts about the swarm, not about a specific listener |
| UDP core services (connect, announce, scrape) | `UdpTrackerCoreServices` | Yes | Stateless service objects; they read from the shared peer repository |
| UDP server event bus | `UdpTrackerServerContainer::event_bus` | **No** | Per-instance; server events (request accepted, banned, error) are specific to one listener |
| UDP server stats repository | `UdpTrackerServerContainer::stats_repository` | **No** | Per-instance; each listener has its own statistics |

### Why the ban service is shared

The ban service protects server resources by rate-limiting misbehaving IPs.
If each UDP listener had its own independent ban service, an attacker could
send `max_connection_id_errors_per_ip` invalid requests to each listener
independently, consuming N× the allowed error budget. A shared ban service
ensures that the total error rate across all listeners is bounded.

This is consistent with the principle that the tracker is a single logical
service, even when it exposes multiple network endpoints.

### Consequences for per-listener configuration

Settings that affect shared services must themselves be global. For example:

- `connection_id_validation` (issue #1136) controls whether the shared ban
service's enforcement is active. It must be a global setting because the
ban service is global — a per-instance policy would create an inconsistency
where one listener's traffic pollutes the shared ban counter that another
listener enforces against.

Settings that are inherently per-listener (bind address, cookie lifetime,
public URL, network topology) remain on the per-instance config struct.

### Alternatives Considered

**Per-instance ban service.**

Rejected because it allows an attacker to multiply resource consumption by
the number of listeners. It also complicates the operator's mental model:
"why did I ban this IP on port 6969 but not on port 6970?"

**Per-instance peer repository.**

Rejected because the primary reason to run multiple listeners is to serve
the same swarm through different protocols or addresses. Isolated peer
repositories would defeat this purpose.

### Consequences

#### Positive

- Resource protection scales with the number of listeners.
- Operators have a single ban list to reason about.
- Configuration for shared services is naturally global, avoiding
per-instance inconsistencies.

#### Negative

- Per-listener policies that interact with shared services (like
`connection_id_validation`) must be global, reducing flexibility.
- A misconfigured listener on one port can affect the ban state for all
listeners.
2 changes: 2 additions & 0 deletions docs/adrs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ semantic-links:
| [20260721000000](20260721000000_make_network_configuration_per_tracker_instance.md) | 2026-07-21 | Make network configuration per tracker instance | Schema v3 uses an optional `network` block on each tracker and removes global `core.net` and flat tracker networking fields without fallback. |
| [20260721100000](20260721100000_use_newtypes_for_constrained_configuration_field_types.md) | 2026-07-21 | Use newtypes for domain-constrained configuration field types | Configuration fields whose value space is smaller than the raw primitive (e.g. scheme-constrained URLs) must use typed newtypes that encode the invariant in the type, validated once at deserialization and never re-checked in consumers. |
| [20260723184019](20260723184019_separate_configuration_value_invariants_from_consistency_validation.md) | 2026-07-23 | Separate configuration value invariants from consistency validation | Validate a single constrained value with a typed newtype; reserve `Validator` for multi-option consistency and bootstrap checks for environment-dependent validity. |
| [20260727000000](20260727000000_events_are_objective_facts.md) | 2026-07-27 | Events are objective facts | Event variants must describe _what happened_ — a neutral, observable fact. Policy and mode decisions belong in the consumer or the enforcement point, never in the event definition. |
| [20260727180000](20260727180000_shared_services_across_tracker_instances.md) | 2026-07-27 | Shared services across tracker instances | Peer repository and ban service are shared across all listener instances. Per-listener settings that affect shared services must be global to avoid inconsistency. |

## ADR Lifecycle

Expand Down
Loading
Loading