Docs › E911 dispatchable location

E911 dispatchable location — SpatiumDDI as a Location Information Server

Issue #972. Behind the network.e911 feature module (group Network), which ships disabled — turn it on under Settings → Features. Like the other vertical registries it is inert until populated, and only a site with an E911 obligation wants five more sidebar entries (#1069).

Given a phone’s IP, MAC, or LLDP chassis+port, answer “which room is this device in, right now?” as a dispatchable location — a civic address plus the building / floor / room detail a dispatcher needs to find the caller.

What this is, and emphatically what it is not

SpatiumDDI is not your 911 service provider. It does no call routing, no ALI database upload, no ELIN provisioning, and it never talks to a PSAP. It is a location source: the thing your PBX or E911 provider asks when it needs to know where a calling device physically is.

Installing this does not make you compliant. RAY BAUM’S Act §506 (47 CFR §9.16(b)) places the dispatchable-location duty on the enterprise operating the phone system, not on the carrier and not on us. What this feature does is make the duty achievable and auditable with data you already collect — and tell you, loudly, where you are not meeting it yet.

SpatiumDDI never decides that an address is valid. Address validation is a lookup against the MSAG / NG911 Location Validation Function, and the E911 providers (RedSky, Intrado, Bandwidth) are the ones who can perform it. This feature records their verdict. An ERL reads unvalidated until somebody tells it otherwise, and unvalidated means “nobody has confirmed this”, not “this is wrong”.

Why it needs no new data collection

Every input a Location Information Server needs is already in the database, collected for IPAM. Nothing joined them:

Input Where it already lives
IP ↔ MAC, live dhcp_lease (Kea + Windows)
IP ↔ MAC, historical ip_mac_history (#459)
MAC ↔ switch + port network_fdb_entry (bridge-MIB poll)
Phone ↔ port, self-announced network_neighbour (LLDP — a Cisco phone’s chassis-id is its MAC)
Port ↔ description network_interface.alias, where most shops already keep Bldg-A-Fl3-Rm312
Subnet / VLAN ↔ site subnet.site_id, network_device.site_id, site.parent_site_id
Which subnets are phones subnet.subnet_role = 'voice'

This is the same tracking Cisco Emergency Responder performs with its own SNMP pollers against the same switches. The difference is that here it is a by-product of data collected for other reasons, and any consumer can read it — a non-Cisco PBX, RedSky, Bandwidth, Intrado, a Teams or Zoom deployment, or an ops script.

Emergency Response Locations

An ERL (the NENA term) is one dispatchable location. The civic address is stored as the 31 separate RFC 5139 elements, not as one string:

country  US          bld   A
a1       NY          flr   3
a3       New York    room  312
hno      1234        seat  14
rd       Broadway    loc   "east wing, behind reception"
pc       10001

Separate elements because PIDF-LO, HELD and every provider validation API want them apart, and because a single address string cannot be decomposed later. The one-line rendering you see in the UI and in copilot answers is assembled from these at read time, so it can never drift from them.

An ERL also carries:

Editing any civic element resets the verdict. A provider validated the old address; keeping validated would leave your estate reporting an address nobody has ever checked, and would keep the conformity check quiet about it.

Is it actually dispatchable?

RAY BAUM’S asks for “room number, floor number, or similar information necessary to adequately identify the location of the calling party”. An ERL carrying only a street address is an address, not a dispatchable location. The API and UI derive is_dispatchable from whether any of bld / flr / unit / room / seat / loc is set, and you can filter on it to find the gap.

Bindings: which ERL applies to which device

A binding maps a network identity to an ERL. There are seven kinds, and their precedence is fixed in code — most specific wins:

# Kind Matches on Typical granularity
1 switch_port a network_interface the room
2 wireless_ap a BSSID the area (no data source yet — see Deferred)
3 mac a MAC address one handset
4 ip an IPAM address row one handset
5 subnet a subnet the floor or wing
6 vlan a VLAN row the floor or wing
7 site_default a site the front door

There is no priority column, deliberately. An operator able to reorder these could put site_default above switch_port and send every ambulance to the front door while the UI still showed a rule for the room. For the same reason there is exactly one ERL per target per kind, enforced by a unique constraint: a tie would otherwise be resolved by whichever row the database happened to return first, which is not a decision anybody made.

Note for anyone comparing against the issue: #972’s Design section numbers the manual pin below subnet and vlan. That is wrong, and the shipped order differs. Every device carrying a pin is also on some subnet, so a subnet rule would win every time and the pin could never fire — yet the pin exists precisely for the phone on a port nothing polls. It sits below switch_port, because a live port observation is measured truth where a pin is a standing assertion.

The resolver, and the one rule that matters most

A stale precise answer is worse than a fresh coarse one.

A phone unplugged from port 3/0/12 and re-patched on another floor stays in the switch’s forwarding table on the old port until the entry ages out — and stays in our copy of it until the next SNMP poll. Returning “Room 312” in that window sends an ambulance to the wrong floor. That is the failure this entire feature exists to prevent, so the resolver refuses a port-level answer whose evidence has gone stale, falls back to the next-coarser rule, and says so.

Every answer carries confidence:

confidence Meaning
observed the most specific applicable rule fired
degraded a more precise answer existed and was refused; degraded_reason says why
none no rule matched at any level

There is deliberately no shape in which the API returns an address with no provenance.

The freshness window

Derived per device: its own poll_interval_seconds × 2, so one missed poll is tolerated and two are not. A fixed global number would be too tight for a 15-minute poller and uselessly loose for a 60-second one.

Two independent staleness signals

  1. Age — the evidence is older than the window.
  2. Disagreement — an LLDP neighbour on the same port announces a different chassis-id than the forwarding table puts there. LLDP is the device’s own announcement, so when the two disagree the FDB row is the one to distrust. This fires immediately, where the age test must wait out the whole window — a phone swapped for a different phone on the same port is exactly the case age cannot catch quickly.

Resolution order

identity (ip | mac | chassis+port)
   │
   ├─ ip → mac        via dhcp_lease (preferred: the binding the DHCP server
   │                  is currently honouring), else ip_mac_history for
   │                  statically addressed phones
   │
   ├─ mac → port      via network_neighbour (LLDP: the phone's own claim),
   │                  else network_fdb_entry (the switch's memory)
   │
   └─ walk the binding precedence, refusing any port-level match whose
      evidence is stale, and report which rule won

API

All endpoints are under /api/v1/e911 and gated on the e911_location permission (GET→read, POST/PATCH→write, DELETE→delete; superadmin always passes).

Method Path Purpose
GET /location the lookup?ip= / ?mac= / ?chassis_id=&port_id=
GET POST /erls list / create
GET PATCH DELETE /erls/{id} read / update / delete
POST /erls/{id}/validation record a provider’s verdict
GET POST /bindings list (precedence order) / create
PATCH DELETE /bindings/{id} update / delete
GET /export.csv every ERL + bindings as CSV
GET /export/ios-lldp-med.txt LLDP-MED stanzas to review and apply

Plus two at the application root, outside /api/v1:

Method Path Purpose
POST /held HELD locationRequest → PIDF-LO (authenticated)
POST /held/self device self-query (opt-in, unauthenticated)

A binding’s kind and target are immutable: PATCH accepts only erl_id, is_active and notes. Repointing a target would silently move every device it covers, so that is a delete plus a create, and leaves two audit rows.

Giving a PBX or E911 provider access

Use an existing API token (#74) scoped to allowed_paths: ["/api/v1/e911"], owned by an account holding only e911_location: read. Read-only, revocable, audited, and no new credential mechanism. The resolution log records which token asked, not merely which user owns it.

Every lookup is logged

e911_resolution_log records each /location call: the identity asked about, the actor (user or token), the source IP, the ERL returned, the rule that matched, the confidence, and the age of the evidence. A lookup tells the caller which desk a named person sits at — the trail is not optional. Rows are written whether or not anything was found, because “nobody could tell me where this phone was” is exactly the query an after-action review needs.

Compliance checks

Three policies seeded into the conformity engine (#106). They are the first in SpatiumDDI that carry a real regulatory citation rather than framework: custom:

Check Target Severity Fails when
e911_voice_subnet_unbound subnet critical a voice subnet reaches no ERL at subnet, VLAN or site level
e911_erl_validated platform critical an in-use ERL has never been validated, or was rejected
e911_port_binding_evidence_fresh platform warning a switch carrying port-level bindings has stopped being polled

Two of these deserve a note on what they deliberately tolerate:

e911_port_binding_evidence_fresh is keyed on the switch’s own poll state, not on stale FDB rows — an unpolled switch eventually has no FDB rows, so a check looking for stale ones would pass on the worst case. When it fires, the resolver is already degrading every lookup for those ports; this is the difference between “our room-level locations work” and “they stopped working in March”.

Operator Copilot

Three read-only tools, all default-enabled:

There are no propose_* write tools, an explicit decision under non-negotiable #13: a wrong ERL binding misroutes an ambulance, which is the broad-blast-radius shape that guidance keeps off the copilot.

HELD — the protocol your PBX already speaks

POST /held takes an RFC 5985 locationRequest and returns PIDF-LO (RFC 4119 + RFC 5139 civic + RFC 5491 geodetic). This is what makes the feature usable with no phone-side change — CUCM, Cisco MPP firmware, the Webex app, RedSky “HELD+”, Intrado ERS and Bandwidth DLR all speak it.

It is mounted at the application root, not under /api/v1, because a HELD client is configured with a whole URL and the protocol names the path.

Name the device with an RFC 6155 identity — ip, mac, or chassisId + portId:

<?xml version="1.0"?>
<locationRequest xmlns="urn:ietf:params:xml:ns:geopriv:held">
  <locationType>civic</locationType>
  <device><ip>10.20.3.44</ip></device>
</locationRequest>

Authenticate it like any other API call — an API token scoped to /held with e911_location: read.

What comes back, and what it says about itself

The refusal that matters

A request with no identity is refused, not answered. RFC 5985’s default is to answer from the requester’s own address, so falling through would hand a PBX the location of its own server in response to a question about a phone — a perfectly-formed answer that is completely wrong, which is worse than an error.

For the same reason, a recognised-but-unresolvable identity (msisdn, imsi, fqdn) is reported rather than ignored: treating one as “no identity given” would take that same fall-through.

Untrusted XML

/held parses XML that arrived over the network, so three layers stand between a request and the parser: the body is size-capped at 64 KiB before anything reads it, a DOCTYPE declaration is refused outright, and the parser itself does not resolve entities (lxml with resolve_entities=False, plus no_network, load_dtd and huge_tree off).

An earlier version parsed with Python’s stdlib ElementTree on the stated reasoning that it “supports no DTD and no external entities at all”. Measured, half of that was false: ET refuses an external entity — so XXE really was unavailable — but it expands internal ones, and a four-level billion-laughs payload expanded to 50,000 characters. The DOCTYPE regex was the only thing in the way, on an endpoint that can be made unauthenticated. CodeQL caught it.

The guard is kept, but the safety no longer rests on it: a guard that is the sole defence is one regex away from being none. Tests now go at the parser directly, bypassing the guard, for both an expansion bomb and an XXE payload.

Device self-query — opt-in, and off by default

POST /held/self is RFC 5985 §6: a phone asks about itself and is answered from its TCP source address, with no authentication.

It is disabled unless you set E911_SELF_QUERY_ENABLED=true, and you should firewall the path to your voice VLANs as well. Four properties make it defensible when you do turn it on:

Property Why
404 when disabled, not 403 a disabled surface should not advertise itself to a scanner
identity is always the TCP source address a body-supplied <ip> is discarded, including one that matches — accepting it when it matches would leak by timing whether a guessed address is the caller’s
rate-limited per source IP, failing closed unlike the login throttle, this throttle is the protection; with Redis down a caller inside the voice VLAN could otherwise walk the estate
the response carries the location only no MAC, no port, no hostname, and not even the confidence headers — those describe our internal evidence and a phone has no need for them

Verification status

The structure, namespaces, element naming, wire order, escaping and axis order are pinned by tests. Interop with a live CUCM, RedSky or Intrado endpoint has not been exercised, and that is stated here rather than implied away. If a real client rejects a document, PIDF_ELEMENT_ORDER in app/services/e911/pidf_lo.py is the single place the element sequence is defined.

DHCP options 99 and 123

A phone that supports RFC 4776 (civic) or RFC 6225 (geodetic) learns its location at lease time, with no HELD exchange and no LLDP-MED. Few handsets do, so this is a bonus rather than the headline — but SpatiumDDI already runs the DHCP server and already holds the address as separate elements, so it is nearly free.

Automatic, per scope. When a subnet resolves to an ERL — by its own binding, its VLAN’s, or its site’s default — the options appear in that scope’s rendered Kea configuration. Nothing to switch on.

This is the floor-level answer, never the room. DHCP can know which subnet a request came from and nothing finer. A switch_port or mac binding is deliberately not consulted: a DHCP option is written once per scope for every client in it, so honouring a device rule would hand every phone on the floor the location of one desk.

Everything here fails closed to “emit no option”, because the blast radius of a malformed option is not a bad location — it is Kea refusing the whole configuration and DHCP stopping for every client on the server. A value that will not fit is dropped rather than truncated; an address with no two-letter country yields nothing at all (RFC 4776 puts it in a fixed field, so without one the option is unparseable rather than merely incomplete); an out-of-range coordinate yields nothing, since a wrapped fixed-point value is a valid-looking location somewhere else entirely.

Measured, not assumed. Against kea-dhcp4 3.0.3: Kea ships its own definition for option 99 and refuses any attempt to override it, which fails the entire configuration. So 99 is emitted under Kea’s own name, geoconf-civic, while 123 — which has no standard definition — rides on ours. The first implementation got this backwards and would have stopped DHCP on every server that rendered it.

Exports

Two generated artefacts. Neither makes an outbound connection and neither writes to a device — SpatiumDDI does not push switch configuration (#60 is closed as not planned).

GET /e911/export.csv

Every ERL with its civic elements, ELINs, validation verdict and the bindings pointing at it. For bulk review, for the spreadsheet an auditor asked for, and as the thing you map into Cisco Emergency Responder’s own ERL bulk load — CER’s columns differ between versions, so you do the mapping rather than us guessing it.

Values that look like spreadsheet formulas are prefixed with a single quote, because a building named =cmd|' /C calc'!A0 executes when the file is opened in Excel.

GET /e911/export/ios-lldp-med.txt

location civic-location and location elin-location stanzas, plus the per-interface location civic-location-id lines for ERLs reached by a switch_port binding. LLDP-MED is the one delivery mechanism that needs no HELD, no DHCP option and no phone-side configuration at all — the switch announces the location to the handset per port.

Read it before you apply it. The file says so in its own header: it is generated text, SpatiumDDI has not applied it to any device, and the command shape follows Cisco’s LLDP-MED location documentation rather than having been tested against an IOS image.

A value that cannot be expressed safely as an IOS argument is omitted and listed under its stanza, not shortened. A room named 312\nno logging console is arbitrary configuration pasted into a switch by someone who trusted us, and a room silently truncated to 312 is a phone in the wrong place that looks correct. Elements with no IOS keyword at all — seat, for instance — are reported the same way, because the phone will not announce them and you should know that rather than assume it was sent.

Not in this release

Writing switch configuration is permanently out of scope.