# Changelog

All notable changes to SIPhon are documented here. The format loosely follows
[Keep a Changelog](https://keepachangelog.com/). Versioning is lockstep across
the `siphon-sip` crate and the `siphon-sip` Python SDK, driven by the git tag.

## [Unreleased]

## [1.2.0] — 2026-07-07

### Added
- **`@rtpengine.on_media_timeout` script hook.** The media engine reaps a call
  whose media went dead (no packets past its inactivity window) and pushes a
  media-timeout event; a handler decorated with `@rtpengine.on_media_timeout`
  (optionally filtered by `call_id` / `from_tag`, same shape as
  `@rtpengine.on_dtmf`) now receives `(call_id, from_tag)` so the script can
  release the per-call state no BYE will clear — Rx/N5 QoS sessions, offline
  charging, dialog/session-store entries. The event is still logged; the hook is
  additive. Delivered by the native **siphon-rtp** backend, which pushes the
  event over its control connection — the rtpengine backend does not emit
  media-timeout events (its NG event log carries only DTMF), so the hook is a
  no-op under rtpengine today. Mirrored in the `siphon-sip` SDK mock
  (`on_media_timeout` + a `fire_media_timeout` test helper).
- **Native `siphon-rtp` media backend (JSON-over-TCP) — experimental.** siphon
  can now drive the in-house `siphon-rtp` media engine over its native control
  protocol — a persistent TCP connection carrying length-prefixed JSON frames —
  as an alternative to the rtpengine NG/bencode-over-UDP engine. The siphon-rtp
  engine is pre-release, so this backend is **experimental**; rtpengine remains
  the recommended production backend. Select it per deployment:
  ```yaml
  media:
    backend: siphon-rtp            # default: rtpengine
    siphon_rtp:
      address: "127.0.0.1:8080"
      control_secret: "${SIPHON_RTP_CONTROL_SECRET}"   # optional
      timeout_ms: 2000
  ```
  - Reliable transport with request/response correlation, an optional
    shared-secret auth handshake, and automatic reconnect with backoff (siphon
    boots even when the engine is down; commands issued before the connection is
    up wait for it, up to their timeout).
  - **Server-pushed events** (DTMF, media-timeout) arrive on the same control
    connection and flow through the existing event path, so
    `@rtpengine.on_dtmf` handlers work unchanged regardless of backend.
  - The Python `rtpengine` scripting API and all media profiles are **unchanged**
    — only the transport underneath differs.
  - **Full HA / load-balancing parity with rtpengine:** `media.siphon_rtp`
    accepts either a single `address` or an `instances:` list with weights, using
    weighted round-robin plus per-call-id connection affinity (every command for
    a call stays on one connection). Per-instance health is probed and exported
    alongside the existing rtpengine health metrics.
  - **Backward compatible:** the default backend remains `rtpengine`; existing
    `media.rtpengine` configs are untouched. SIPREC/MPTY subscriptions are not
    yet implemented on `siphon-rtp` and surface a clear engine error there.
  - Depends on the published `siphon-rtp-proto` crate (the shared wire contract).
- **Classic `rtpproxy` media backend (text-over-UDP).** siphon can now drive a
  classic `rtpproxy` relay (the Sippy/Kamailio/OpenSIPS media proxy) as a third
  media-control backend — for migrating an existing deployment to siphon while
  keeping its in-place rtpproxy. Select it per deployment:
  ```yaml
  media:
    backend: rtpproxy             # default: rtpengine
    rtpproxy:
      address: "127.0.0.1:22222"  # rtpproxy -s udp:<addr>
      timeout_ms: 1000
      retries: 2                  # UDP retransmits (same cookie); default 2
  ```
  - Speaks the classic cookie-prefixed `U`/`L`/`D`/`V` protocol over UDP, with
    cookie-keyed request/response correlation and **idempotent retransmits** for
    reliability over UDP (rtpproxy de-duplicates by cookie).
  - rtpproxy only allocates a relay port, so **siphon rewrites the SDP itself**
    (`c=`/`m=`), per media stream, including multi-stream offers (media-id tag
    suffix) and held media (`m=… 0`, left untouched).
  - The Python `rtpengine` scripting API and media profiles are **unchanged** —
    `rtpengine.offer/answer/delete/ping` and `call.media` map onto rtpproxy. The
    profile's NAT `direction` (e.g. `["internal","external"]`) and `asymmetric`
    flag map to rtpproxy bridge/symmetry modifiers; IPv6 is detected per stream.
  - **HA / load-balancing parity with rtpengine:** `media.rtpproxy` accepts a
    single `address` or an `instances:` list with weights (weighted round-robin
    plus per-call-id affinity); per-instance health is probed (`V`) and exported
    alongside the existing rtpengine health metrics.
  - **Backward compatible:** the default backend remains `rtpengine`. The
    rtpengine-only verbs (announcements, DTMF injection, gating, SIPREC/MPTY) are
    not available on rtpproxy and surface a clear engine error there; rtpproxy
    pushes no async events, so the `media.events` listener is unused.
- **B2BUA `call.set_from_host()` / `call.set_to_host()`** — pin the host part of
  the B-leg From / To URI, mirroring `set_from_user` / `set_to_user`. By default
  the B2BUA rewrites the B-leg From host to its own advertised address (topology
  hiding) and the To host to the dial-target host. `set_from_host()` opts a leg
  out of the From host-rewrite so the original domain survives — needed for a
  multitenant SBC whose downstream selects the tenant from the From domain (a
  domainless call would otherwise land in an unauthenticated/default routing
  context). `set_to_host()` pins the To host declaratively (replaces the raw
  `set_header("To", "<sip:…>")` idiom). Only the host changes; scheme/user/port/
  params and tags are preserved. Applies to both `call.dial()` and `call.fork()`.
  Mirrored in the `siphon-sip` SDK mock; new SIPp acceptance scenario
  (`sipp/b2bua_set_host_uas.xml`).
- **Kernel firewall (`security.firewall`).** Mirror SIPhon's bans — the
  confidence-weighted `failed_auth_ban` store and the APIBAN blocklist — into a
  kernel nf_tables set, so abusive sources are dropped in the kernel before they
  reach SIPhon's socket instead of only in the userspace ACL. Self-contained:
  SIPhon programs the set directly over netlink (no `nft` shell-out, no daemon, no
  new dependencies), and the kernel auto-expires each ban via a per-element timeout
  matching the in-memory TTL. Opt-in, Linux-only, needs `CAP_NET_ADMIN`; falls back
  to the userspace ACL with a warning when it's missing. Zero-touch by default:
  SIPhon owns the whole ruleset (table, sets, base chain, and the `saddr @banned
  drop` rules), so `firewall: {}` is all that's needed; set `manage_rule: false` to
  have SIPhon maintain only the sets and reference them from your own ruleset. Two
  new counters make the runtime failure modes observable:
  `siphon_firewall_command_failures_total` (a ban did not reach the kernel — alert
  on it) and `siphon_firewall_commands_dropped_total` (a ban storm outran the
  netlink actor's queue; the userspace ACL still enforces every ban). Also expands
  the security cookbook with the ban-scoring model and adds a Kernel firewall page
  covering `CAP_NET_ADMIN` per runtime, container behaviour, and the
  nftables-vs-XDP tradeoff.
- **Admin API ban management** — `GET /admin/bans` lists the sources currently
  auto-banned by `failed_auth_ban` (with remaining TTL), and
  `DELETE /admin/bans/{ip}` lifts a ban early for an operator clearing a false
  positive. The unban clears the userspace ban and, when the kernel firewall is
  enabled, removes the matching nf_tables element in lockstep so the in-kernel
  drop is lifted too.
- **Outbound TLS client certificate (mutual TLS).** New `tls.client_certificate`
  and `tls.client_private_key` (PEM chain + key). When set, siphon presents that
  client certificate on outbound TLS connections whose peer requests one — for
  upstream SIP trunks that require client-certificate / mutual TLS (e.g.
  Microsoft Teams Direct Routing). Previously the outbound pool presented no
  client certificate, so such peers aborted the handshake with
  `CertificateUnknown`. Both fields must be set together (or neither); a
  one-sided setting or an unreadable/unparseable file is a hard startup error
  (fail closed). Server-certificate verification is unchanged (still permissive).
- **Hostname-based outbound TLS SNI.** Outbound TLS handshakes now present the
  resolved target hostname as SNI / certificate name instead of the destination
  IP literal. RFC 6066 forbids SNI for an IP literal, so IP-based next hops
  emitted none and hostname-vhost front-ends could not route the handshake; the
  hostname now flows from the resolved SIP URI (relay, fork, and gateway TLS
  health probe) through to the connection pool. Bare-IP next hops are unchanged
  (still no SNI).
- **Gateway source-membership predicate — `request.from_gateway(group)` /
  `call.from_gateway(group)`.** Returns `True` when the message's source IP is
  one of the resolved addresses of the named gateway group (configured under
  `gateway.groups`). siphon's equivalent of Kamailio `ds_is_from_list()` /
  OpenSIPS `ds_is_in_list()` — a routing-direction / trust predicate that
  replaces hardcoded source CIDRs. Matches on IP only (source port ignored)
  against every resolved A/AAAA candidate of every destination in the group, so
  a hostname that round-robins across many IPs (e.g. Teams'
  `sip`/`sip2`/`sip3.pstnhub.microsoft.com`) matches on any of them. The member
  set is cached lock-free and refreshed at startup and on each health-probe
  cycle, so the predicate never resolves DNS on the request path. Infallible —
  returns `False` (never raises) for an unknown group, no configured gateway, or
  an unparseable source IP. Security note: on connection-oriented transports
  (TCP/TLS/WS/WSS) the source IP is handshake-verified and trustworthy as an
  authorization signal; on UDP it is spoofable, so `from_gateway` there is a
  best-effort direction hint, not an auth gate.
- **Automatic CDR generation (`cdr.auto_emit`).** With `cdr.auto_emit: true`,
  siphon now writes one CDR per call automatically on the call lifecycle — no
  `cdr.write()` in the script — for both the proxy and B2BUA datapaths. The
  record carries `timestamp_start` (INVITE), `timestamp_answer` (200),
  `timestamp_end` (BYE), `duration_secs`, `response_code`, and
  `disconnect_initiator` (`caller`/`callee`/`timeout`/`error`). Every teardown
  is covered: answered→BYE (either side), B-leg failure, answer-timeout (408),
  and caller CANCEL (487). Default **off**, so manual-only deployments are
  unchanged; manual `cdr.write()` still works and stacks on top. The previously
  **inert** `cdr.include_register` flag is now wired: with `auto_emit`, each
  registrar state change emits a REGISTER CDR (`reg_event` = registered /
  refreshed / deregistered / expired). New `siphon_cdr_sessions` gauge exposes
  the live per-call tracking count (drains to 0 between calls; a steady climb
  is a teardown-hook leak). Per-call state is bounded by the orphan sweep.

### Fixed
- **`cdr.write()` now accepts a B2BUA `Call`, not just a proxy `Request`.**
  Calling `cdr.write(call, extra=…)` from a `@b2bua.on_answer` / `on_bye` /
  `on_failure` / `on_early_media` / `on_cancel` handler previously raised
  `TypeError: 'Call' object is not an instance of 'Request'` — the method was
  typed for `Request` only, so B2BUA scripts had no way to write a CDR. It is
  now polymorphic: a `Call` produces the same record shape as a `Request`
  (method `INVITE`, Call-ID / From / To / R-URI / source IP off the A-leg
  INVITE, plus the same Rf `rf_session_id` / `rf_result_code` auto-stamp), with
  the A-leg's arrival transport threaded through so the `transport` field is
  correct. Passing any other object now raises a clear `TypeError`. Mirrored in
  the SDK mock (`cdr.write(call)`).

## [1.1.1] — 2026-07-02

### Security
- **Bump `quick-xml` 0.37 → 0.41** to address RUSTSEC-2026-0194 (quadratic
  runtime when checking a start tag for duplicate attribute names) and
  RUSTSEC-2026-0195 (unbounded namespace-declaration allocation in `NsReader`,
  a memory-exhaustion DoS). siphon parses XML on the presence (PIDF/reginfo),
  iFC, SIPREC-metadata, and Sh paths — some of it from remote peers — so the
  parser hardening matters. No API or behavioural change (the reginfo / iFC /
  SIPREC parsers keep identical decode + entity-unescape semantics).

## [1.1.0] — 2026-07-02

### Added
- **Supply-chain documentation + `SECURITY.md`.** A new **Supply chain & SBOM**
  docs page documents the per-release SBOM (SPDX 2.3 + CycloneDX 1.4, attached to
  each GitHub Release), how to consume it with Grype / Trivy / Dependency-Track,
  how to reproduce it with `cargo sbom`, and the scheduled `cargo-deny` advisory /
  license / source audit. A root `SECURITY.md` adds a private vulnerability-
  reporting policy (GitHub private reporting, coordinated disclosure) — previously
  absent. No behavioural change; documents supply-chain artifacts that already
  ship at release.
- **SDK mocks for the extension namespaces (`smpp`, `http`).** The `siphon-sip`
  Python SDK now mocks the namespaces injected by the opt-in extensions, so
  `from siphon import smpp` / `from siphon import http` resolve under pytest and
  carry full type hints + docstrings for script authoring. Two new harnesses —
  `siphon_sdk.smpp_testing.SmppTestHarness` and
  `siphon_sdk.http_testing.HttpTestHarness` — dispatch mock binds / PDUs and
  HTTP requests into a script's handlers and capture the results, mirroring
  `SipTestHarness`. This lets SMPP/HTTP scripts be unit-tested with a single
  `pip install siphon-sip`, no running SMSC or listener required. The mocks
  track the extension runtimes (siphon-smpp, siphon-http), which each ship a CI
  check that fails if their namespace surface drifts from these mocks. The docs
  **Extensions** page and nav now link the per-extension documentation sites.
- **HTTP extension wired into `siphon-bin` (`--features http`).** The second
  opt-in extension module alongside SMPP: when `extensions.http` in `siphon.yaml`
  points at an `http.yaml`, `siphon-bin` registers the scriptable `http`
  namespace and the HTTP runtime, so scripts can serve inbound HTTP
  (`@http.route`, `@http.middleware`, `@http.on_startup`) and make outbound calls
  (`http.Client`) from the same asyncio loop they use for SIP. `http.Client` is a
  **pooled, Rust-backed (reqwest) client whose calls run entirely on siphon's
  Tokio runtime and yield the asyncio driver loop while in flight** — so a script
  that only needs outbound HTTP on the hot path (a REST lookup per INVITE, a
  provisioning callback) should enable this feature and use `http.Client` rather
  than a synchronous Python client that blocks its driver loop for the whole
  round-trip. A new `full` aggregate feature (`--features full`) enables every
  extension module at once. The HTTP module is pinned to **siphon-http v1.0.1**;
  with the feature off, an `extensions.http` block still parses and is skipped
  with a loud warning (same contract as SMPP and the `sctp` feature). Documented
  under **Extensions** in the docs site.
- **Opt-in extension binary (`siphon-bin`)** — a new standalone package that
  builds a drop-in `siphon` binary composing optional extension modules behind
  cargo features (all off by default). The first module is **SMPP 3.4**
  (`--features smpp`): when `extensions.smpp` in `siphon.yaml` points at an
  `smpp.yaml`, it registers the scriptable `smpp` namespace and the SMPP runtime
  so scripts can handle `@smpp.on_pdu` / `@smpp.on_bind`. With a module's feature
  off, its `extensions.<name>` block still parses and is skipped with a loud
  warning (same contract as the `sctp` feature). The plain `siphon` binary from
  `cargo install siphon-sip` is unchanged; operators who want extensions build
  `siphon-bin` (e.g. `cargo build -p siphon-bin --release --features smpp`, or
  the `siphon-bin/Dockerfile` image). Documented under **Extensions** in the
  docs site. The `ext/` layer is structured so further modules (HTTP, …) plug in
  behind their own features. The SMPP module is pinned to **siphon-smpp v1.2.1**,
  which adds a per-ESME-session inbound ingress rate cap (`server.max_msg_per_sec`
  with a `pace` / `reject` over-rate action).
- **`siphon::install_allocator!()` — one-line jemalloc + page-decay setup.** A
  `#[global_allocator]` and jemalloc's `_rjem_malloc_conf` config symbol only
  take effect in the final binary crate (the language honors `#[global_allocator]`
  only in the binary root, and jemalloc's weak `_rjem_malloc_conf = NULL` default
  means a library-provided definition isn't reliably linked), so both must be
  emitted in `main.rs`. The new macro does exactly that in one line:
  `siphon::install_allocator!();` installs jemalloc as the global allocator plus
  siphon's page-decay tuning
  (`background_thread:true,dirty_decay_ms:1000,muzzy_decay_ms:0`), with siphon
  owning the `tikv-jemallocator` version (re-exported, so there's no
  `links = "jemalloc"` version skew and no separate dependency to add). Pass a
  literal to override the decay config
  (`siphon::install_allocator!("dirty_decay_ms:0")`). A read-only boot probe
  (`siphon::metrics::jemalloc_is_active`) now logs a loud WARN at startup if
  jemalloc isn't the active allocator — so the system allocator running
  unexpectedly (RSS bloat, `siphon_memory_*` gauges reading jemalloc's idle
  footprint) shows up in logs rather than a memory post-mortem. See
  `examples/embed_with_allocator.rs`. siphon's own binary is unchanged.
- **ISDN-AddressString AVPs decode to E.164 in scripts** — MSISDN (701),
  SC-Address (3300), SGSN-Number (1489) and MME-Number-for-MT-SMS (1645) are
  now dictionary-typed `ISDNAddressString` (3GPP TS 29.002 §17.7.8) instead of
  raw `OctetString`. `req.get_avp("MSISDN")` now returns the decoded E.164
  digit string (e.g. `"31612345678"`) rather than raw `0x91`+TBCD bytes, and
  setting one of these AVPs from a digit string (`set_avp` / the generic
  `diameter.send_request(msisdn=…)` kwargs) now TBCD-encodes it correctly on
  the wire — previously the generic path shipped raw ASCII, which conformant
  HSSes rejected. Two new script helpers cover raw/unknown AVPs and
  hand-built messages: `diameter.decode_isdn_address(value)` (accepts bytes or
  an already-decoded str — idempotent) and
  `diameter.encode_isdn_address(digits, ton_npi=0x91)`.
- **Generic Diameter server mode** — the Diameter stack was client-only
  (originate toward HSS/PCRF); it now also accepts inbound Diameter from
  authenticated peers, runs the CER/CEA handshake and the DWR/DWA watchdog, and
  dispatches each inbound request to Python. Transport direction is independent
  of request direction (RFC 6733 §2.1): incoming **and** outgoing connections,
  TCP + SCTP, and a node that dials out (`diameter.connect_to`) can still serve
  inbound requests over that connection. New Python server API:
  `@diameter.on_inbound_cer` (advertise CEA identity), `@diameter.on_request`
  with optional `"App:CMD"` filter (`req.answer(code)` / `req.reject(code)` /
  `await req.forward_to(peer)`; unhandled → `3002`), `@diameter.on_reply`
  (central answer-AVP rewrite — topology hiding, Origin / Result-Code mapping),
  `@diameter.on_request_completed` (post-answer event hook), and
  `diameter.peer_pool(target)` (round-robin / weighted / sticky with
  Route-Record loop detection → `3005` and per-call timeout). Two Rust-only
  admission gates run before any Python: source-IP CIDR ACL + Origin-Host
  validation. A lossless AVP tree (`DiameterMsg` / `Avp`) sits alongside the
  JSON decode path for byte-faithful relay that preserves unknown AVPs and flags
  verbatim. Config is flat single-domain
  (`diameter.{listen, origin_host, clients, servers, connect_to}`) or an
  explicit per-domain map; `diameter.event_sink` writes per-transaction events
  (file / none; clickhouse / kafka feature-gated). Ships an **S6a dictionary**
  (TS 29.272: command codes 316–324, AVPs 1400–1450 / 1635, AIR / ULR / PUR
  builders + parsers) and examples (`examples/diameter_server.{py,yaml}`,
  `examples/hss_s6a.py`).
- **glibc allocator instrumentation** — new `siphon_glibc_*` Prometheus gauges
  (`system_bytes`, `in_use_bytes`, `free_bytes`, `mmap_bytes`, `arena_count`)
  sourced from `malloc_info(3)`, aggregated across all arenas. This surfaces the
  C-side / CPython-raw-domain memory pool (incl. `libsctp`) that jemalloc
  (`siphon_memory_*`) and CPython's mimalloc (`siphon_python_allocated_blocks`)
  cannot see; because Rust runs on jemalloc, glibc's arenas hold only the C
  side, so the gauges isolate it cleanly. Deliberately uses `malloc_info` rather
  than `mallinfo2`, which reports the main arena only. Sampled on the dispatcher
  cleanup tick; no-op off glibc. `SIGUSR2` dumps the full `malloc_info` XML to
  the log for call-site attribution.
- **`memory:` config block** for allocator runtime tuning:
  `memory.glibc.arena_max` (`mallopt(M_ARENA_MAX)`, caps the number of arenas)
  and `memory.glibc.trim_interval_secs` (periodic `malloc_trim(0)`). The gauges
  above are always-on; both knobs default off — measure first, bound only if the
  pool proves to be arena retention rather than a leak.
- **`siphon_sbi_npcf_app_sessions_active` gauge** — active N5/Npcf app-sessions
  created by this NF and not yet deleted (a steady climb under flat call rate is
  a stranded-session leak), backed by a new per-replica app-session registry on
  `NpcfClient` that inserts on create and removes on delete.
- **HTTP admin API is now served**, behind a new optional `admin.listen`. It was
  implemented but never started, so only `/metrics` was exposed at runtime.
  Endpoints: `/admin/health` (liveness), `/admin/ready` (readiness — returns 503
  while the process is draining on SIGTERM, so a load balancer / Kubernetes
  deschedules it before it stops accepting new INVITEs), `/admin/stats`,
  `/admin/registrations[/{aor}]` (inspect / force-unregister), and `/metrics`.
  Off by default (no `admin.listen` ⇒ unchanged behaviour).
- **Operator documentation for scaling, redundancy and deployment** (`docs/`):
  `scaling-and-redundancy.md` (what state is node-local vs. Redis-shared, what the
  Redis backend actually provides, and why SIPhon ships no clusterer/DMQ-style
  replication engine), `deployment.md` (single-node / redundant-pair / N-node
  with a front LB + DNS SRV / IMS topologies, an operations runbook, and a light
  Kubernetes shape), and `migrating-from-kamailio-opensips.md`.
- **Reference deployments** (`deploy/`): a front-LB + 2-backend + Redis HA demo
  (docker-compose + a host-binary `validate.sh` that proves restart recovery from
  Redis), and Kubernetes manifests with a `kind` kill-a-pod failover drill
  (`validate-kind.sh`).
- **Release-cut HA failover gate** — `cut-release.sh` now runs the Redis-registrar
  failover validation as a mandatory gate (skip with `FAILOVER_OK=1`), alongside
  the existing perf/mem and criterion regression gates.

### Changed
- **Synchronous Python executor pool ceiling is now memory-aware by default.**
  The pool's default `max`/`core` worker counts were derived only from the host
  CPU count (`core = max(8, 2×CPUs)`, `max = max(32, 4×core)`), which scaled the
  pool's memory ceiling with the *box's* core count rather than the NF's memory
  budget. Combined with a per-worker heap that is ~8 MB on free-threaded CPython
  3.14t (not the ~2 MB the comment assumed), an un-cpu-limited NF on a 16-core
  host defaulted to `core=32/max=128` ≈ 1 GB of pool heap, so memory-constrained
  IMS NFs hit their cgroup limit under churn. The default ceiling is now the
  **minimum** of that CPU-derived cap and a memory budget (~30 % of the
  container's cgroup memory limit — v2 `memory.max`, v1 `memory.limit_in_bytes`,
  falling back to host RAM — divided by the ~10 MB conservative per-worker heap),
  and `core` is capped the same way so the pool no longer *starts* at 32 workers
  on a big box. On a 512 MB NF the ceiling resolves to ~15 (was 32/128); on
  256 MB to ~7. The resolved `core`/`max` and which bound won (`cpu`/`memory`/
  `override`) are logged at startup. The `script.sync_pool_size` /
  `script.sync_pool_max` overrides still take precedence when set.
- **SCTP is now an opt-in build feature, off by default.** SIP-over-SCTP
  (RFC 4168) and Diameter-over-SCTP link the `libsctp` system library, which
  only exists on Linux. Moving them behind the `sctp` Cargo feature lets the
  default build — including the official Docker image and the prebuilt release
  packages (`.deb` / `.rpm` / tarball) — drop the `libsctp-dev` / `libsctp1`
  dependency and build cleanly on macOS and Windows.
  - **To enable SCTP:** build with `--features sctp` (on Linux, install
    `libsctp-dev` first). The official Docker image and release binaries do
    **not** include SCTP — you must build it yourself.
  - **No config or scripting-API breakage:** the `Transport::Sctp` variant and
    the `listen.sctp` config block still exist, so existing configs parse
    unchanged whether or not the feature is enabled.
  - **When built without SCTP:** a configured `listen.sctp` listener is skipped
    with a warning, and a Diameter peer set to `transport: sctp` fails at
    connect with `ErrorKind::Unsupported` (no silent fallback to TCP).
  - CI builds and tests both configurations (default and `--features sctp`).

### Removed
- **Dropped the no-op `nat.force_rport` and `nat.fix_register` config keys.** Both
  were accepted but never consumed by the runtime. Their intended behaviour is
  already covered: responses are always routed symmetrically to the request's
  source address (RFC 6314, so rport is effectively unconditional), and every
  `registrar.save()` records the observed source (`Contact.received` /
  `Contact.flow`) for NAT/MT routing. REGISTER-side fixups remain available as the
  explicit script methods `request.fix_nated_register()` / `fix_nated_contact()`.
  Removal is backward-compatible — existing `siphon.yaml` files carrying either
  key still parse (the keys are ignored, exactly as before). `nat.fix_contact`,
  `nat.keepalive`, and `nat.crlf_keepalive` are unchanged.

### Fixed
- **Premature `100 Trying` on non-INVITE transactions over UDP (RFC 4320 §4.2).**
  The non-INVITE auto-100 (MESSAGE/SUBSCRIBE/OPTIONS/BYE) fired after the short
  INVITE-style delay (~200ms), violating RFC 4320 §4.2, which forbids a 100 to a
  non-INVITE over an unreliable transport before the UAC's Timer E is reset to T2
  (≈3.5s with default timers). The most visible symptom was a `100 Trying` for an
  in-dialog BYE that the peer answers in milliseconds. The auto-100 delay over
  UDP is now derived from T1/T2 (Timer E → T2); over a reliable transport, where
  RFC 4320 permits a 100 at any time, the configured
  `transaction.auto_emit_100_trying_delay_ms` still applies. INVITE 100 Trying
  behavior is unchanged.

### Performance
- `SipHeaders` now stores one `IndexMap<String, (String, Vec<String>)>` (lowercase
  key → original-cased name + values) instead of two parallel maps. This removes a
  per-header key-clone + hash-insert on the parse path, halves the copy-on-write
  clone, and serializes in a single pass. Criterion microbenches: SIP parse −30%,
  serialize −50%, full parse→serialize roundtrip −33%, first header write −20%.
  No public API change; serialized output is byte-identical (RFC 4475 + proptest
  roundtrips unchanged).

### Internal
- Per-module steady-state memory-leak guards for the control-plane paths the
  SIP mem-leak test never exercised, each gating on the production store
  draining back to baseline: rtpengine (`pending` correlation map on the success
  and timeout paths), diameter (`pending` map through the real connection
  reader, sequential and under concurrent in-flight load), and SBI/N5
  (`NpcfClient` app-session store across create → delete).
- Criterion microbenchmarks for the per-message / per-call hot paths, one bench
  file per path: `sip_hot_path` (parse/serialize/header/txn-key), `sdp_hot_path`
  (parse/filter/serialize), `diameter_codec` (AVP encode + message decode),
  `rtpengine_bencode` (NG offer encode/decode), and `crypto` (Milenage AKA +
  digest response assembly). They isolate the individual costs the SIPp
  throughput baseline averages over.
- Release-cut regression gate (`scripts/bench_regression.sh`, wired into
  `scripts/cut-release.sh`): fails on >10% slowdown vs the committed
  `benches/baseline.json`. Self-contained (reads criterion's own `estimates.json`,
  no `critcmp`/`jq`). CI proves the benches compile; the hard timing gate runs at
  release cut on fixed hardware, where absolute timings are meaningful.

### Documentation
- Added a **Transports & networking** guide (docs site, under *Running in
  production*): transport listeners (UDP/TCP/TLS/WS/WSS/SCTP), WebSocket/WebRTC
  (RFC 7118) and the signaling-vs-media split, RFC 5626 flow tokens and
  connection reuse, `advertised_address` for behind-NAT / load-balancer
  deployments, client NAT traversal, inter-transport routing, and IPv4/IPv6
  interworking.

## [1.0.0] — 2026-06-26

First stable release. A love letter to Kamailio and OpenSIPS — their proven
architecture, rebuilt with a Rust core and free-threaded Python 3.14t scripting.
The developer writes business logic; SIPhon owns the protocol.

### Core
- RFC 3261 SIP parser (RFC 4475 torture tests, proptest roundtrips, fuzzing)
- Stateful proxy (§16) with parallel/sequential forking (§16.7)
- Transaction state machines (§17), dialog tracking, Record-Route / loose routing
- First-class, scriptable B2BUA (§6) — proxy and B2BUA in a single binary

### Transports
- UDP, TCP, TLS 1.3, WebSocket (WS/WSS), SCTP
- NAT traversal (rport, RFC 3581), Outbound / flow tokens (RFC 5626)

### Registrar & auth
- AoR store with memory / Redis / PostgreSQL backends, GRUU, Service-Route
- Digest auth (RFC 2617 / 7616) with timestamp-bound nonces and AoR-to-user binding
- AKAv1-MD5 / Milenage (RFC 3310, 3GPP TS 33.203 / 35.206)

### IMS & 5G
- Diameter Cx / Rx / Ro / Rf / Sh; Initial Filter Criteria (iFC) with ISC routing
- IPsec SA management for P-CSCF; 5G SBI Npcf (N5) + Nbsf PCF discovery
- Runnable P-CSCF / I-CSCF / S-CSCF examples

### Media & routing
- RTPEngine NG media anchoring, SDP codec filtering, media injection
- Gateway load balancing with health probing, DNS SRV/NAPTR (RFC 3263), ENUM
- Presence (SUBSCRIBE/NOTIFY, PIDF, RLS), outbound REGISTER

### Observability & compliance
- Prometheus metrics (built-in + custom), HEP/Homer tracing, CDR, admin HTTP API
- Lawful Intercept (ETSI X1/X2/X3) + SIPREC (RFC 7865 / 7866), graceful shutdown

### Scripting
- Free-threaded Python 3.14t (no GIL), hot-reload, sync + async handlers
- `siphon-sip` mock SDK on PyPI for unit-testing scripts (imported as `siphon_sdk`)

### Performance
- Design targets — Proxy 10k cps, B2BUA 5k cps (8-core). Stays clean past
  31.9k cps on the reference box with zero failures and zero retransmits across
  all 16 baseline rows.

[1.0.0]: https://github.com/siphon-project/siphon-sip/releases/tag/v1.0.0
