Skip to content

LCR Deck Administration

This is the companion to the Routes guide: that page covers how a route block consults an LCR table at dial time (prefix matching, selectors, effective-cost ranking, fallback); this one covers the tables themselves — loading rate decks, versioning them, rolling back, and verifying what a call would do. The examples use the same conventions as Managing Configuration Versions: $VSS, $TOKEN, $NSID on the curl side, an active namespace profile on the vsscli side.

The model — and how it differs from namespace configuration

Section titled “The model — and how it differs from namespace configuration”

An LCR table is a named, versioned rate deck. Each row is keyed by (prefix, selector, trunk) and carries a per-minute cost, a setup cost, a priority, and an optional validity window (effective_on / valid_until). Costs are integers in a unit you choose — only relative magnitudes matter within a table.

Tables live outside your configuration document, with their own lifecycle:

Namespace configuration LCR tables
Identity ULID versions Integer versions per table (1, 2, 3, …)
Staging latest can run ahead of running None — a successful bulk write goes live
Promotion Explicit apply step Automatic: every bulk write flips the active pointer
Rollback Roll-forward push (or force-apply) activate a historical version — a plain pointer flip
Reach The whole dial plan One table’s rates only

The split is deliberate: rate updates are frequent, large, and usually automated, so they shouldn’t ride the config change-control train. A route block in your configuration names a table (lcr: tablename: us-rates) and sets lookup policy; which rows exist and what they cost is managed through the endpoints on this page, and a change propagates to call processing within about 30 seconds — no config version, no apply.

A table is created implicitly by its first bulk write — there is no separate “create table” call. Deleting a table wipes every version and is refused (409) while a route block in your running configuration still references it. Tables are strictly per-namespace: they are never inherited, so each namespace that runs LCR maintains (or automates) its own decks.

Two write paths with different version semantics

Section titled “Two write paths with different version semantics”
  • Bulk operations (replace, patch, upload sessions, trunk delete) cut a new version and flip the pointer. The prior version stays readable and re-activatable — this is your rollback safety net.
  • Single-row operations (route set, route delete) edit the active version in place. No new version is cut, so there is nothing to roll back to for that edit. They’re the quick-fix path for one bad rate; anything systematic should go through a bulk patch.
Operations Action Held by
List tables/versions/uploads, read rows, lookup, resolve read-level All admin and switch-ops roles, ratedeck-admin, viewer, noc-viewer
Row writes, bulk replace/patch, upload sessions, version activation write-level partition-admin, namespace-admin, ratedeck-admin, operator, editor
Table delete, vacuum, delete-by-trunk admin-level partition-admin, namespace-admin, ratedeck-admin

ratedeck-admin is the purpose-built grant for carrier-tariff staff and rate automation: full LCR control, nothing else — see the role catalog. Note that version activation is deliberately write-level, not admin-level: promoting an already-staged deck is an operations action, so operator and editor can cut a deck over without holding config:apply. The destructive table operations stay admin-only.

Bulk endpoints accept CSV (Content-Type: text/csv) or JSON (application/json), carrying the same fields:

prefix,selector,trunk,per_minute_cost,setup_cost,priority,effective_on,valid_until
1305,interstate,carrier-a,110,0,10,,
1305,interstate,carrier-b,95,200,10,,
1786,interstate,carrier-a,110,0,10,2026-08-01T00:00:00Z,
{"rows": [
{"prefix": "1305", "selector": "interstate", "trunk": "carrier-a", "per_minute_cost": 110, "priority": 10}
]}
  • prefix, selector, trunk, and per_minute_cost are required; setup_cost, priority, and the window stamps are optional. Column order is irrelevant in CSV; timestamps are RFC 3339.
  • Prefixes are bare digits (1305, not +1305) — the dial-time lookup strips a leading + from the key before matching.
  • The selector must be a literal tag (your rate-deck label or a jurisdiction value). !all and !match-on: are lookup policy and live in the route block, never in rows.
  • Duplicate (prefix, selector, trunk) keys within one body are allowed — the last occurrence wins, so corrections can be appended in place.
  • Every trunk named must exist in the namespace; unknown trunks fail the whole request with 422 and the offender list in trunks.
  • A body is all-or-nothing: any malformed row fails the request with a per-line rejections list (line numbers for CSV, array positions for JSON) and nothing is written.
  • Single-request limits: 10,000 rows / 25 MiB. Larger decks use upload sessions (below).
Terminal window
# Catalogue: every table with active version, row estimate, last swap
curl -s "$VSS/v1/ns/$NSID/lcr/tables" -H "Authorization: Bearer $TOKEN"
# One table incl. version history (active version is flagged)
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates" -H "Authorization: Bearer $TOKEN"
# Version history only
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/versions" -H "Authorization: Bearer $TOKEN"
# One row by its logical key (append ?version=3 for a historical read)
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/routes/1305/interstate/carrier-a" \
-H "Authorization: Bearer $TOKEN"

For decks up to 10,000 rows, one request replaces the table wholesale (and creates the table on first use):

Terminal window
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/replace" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: text/csv" \
--data-binary @us-rates.csv
# → {"success":true,"result":{"new_version":4,"prior_version":3,
# "accepted_rows":8500,"swapped_at":"…"}}

Note new_version in the result — that number (and prior_version, your instant rollback target) is worth logging in any automation.

A patch body contains only the rows you’re changing: rows sharing a (prefix, selector, trunk) key with the body are replaced, every other row is copied forward into the new version. Prefixes the patch doesn’t mention survive untouched.

Terminal window
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/patch" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: text/csv" \
--data-binary @corrections.csv

There is no key-level delete in a patch — a patch only replaces and carries forward. To remove rows, use route delete for individual keys, trunk delete for a carrier, or a full replace.

Terminal window
# Upsert: body carries the costs, the URL carries the key
curl -s -X PUT "$VSS/v1/ns/$NSID/lcr/tables/us-rates/routes/1305/interstate/carrier-a" \
-H "Authorization: Bearer $TOKEN" \
-d '{"per_minute_cost":105,"setup_cost":0,"priority":10}'
curl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/us-rates/routes/1305/interstate/carrier-a" \
-H "Authorization: Bearer $TOKEN"

Remember: these edit the active version directly — no new version, no rollback for the edit itself. The table must already exist (single-row writes never create version 1).

Rows outside their effective_on / valid_until window are ignored at call time, which makes windows the tool for:

  • pre-loading a new carrier or destination set — load the rows any time before the contract starts; they won’t be dialed until effective_on arrives, and the switch starts using them at the boundary with no operator action;
  • auto-expiring rates — a promotional or short-term rate with valid_until set stops being offered at the deadline even if nobody remembers to remove it.

One boundary to respect: a deck holds one row per (prefix, selector, trunk) key. A duplicate key inside one bulk body is collapsed silently (last occurrence wins), and a patch or route set replaces the existing row outright. So a price change on an existing key cannot be staged as a coexisting future row — loading the future-windowed copy early replaces the current row and leaves that key unpriced until the window opens. For same-key rate changes, schedule the bulk write at the cutover time; the old version remains one activate away if the new deck misbehaves.

Datasets past the single-request cap stream in through a session: open → append (batches) → commit, with abort as the escape hatch. Each append batch obeys the same 10,000-row/25 MiB limits and the same all-or-nothing validation; the session as a whole has no row ceiling. Sessions expire after 24 hours of inactivity (each successful append re-arms the clock); an expired session answers 410 and its staged rows are abandoned.

Opened in replace mode the committed version contains exactly what you appended; in patch mode untouched rows are copied forward at commit, same as a bulk patch. Nothing is visible to call processing until the commit flips the pointer.

Terminal window
# 1. Open
UPLOAD=$(curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads" \
-H "Authorization: Bearer $TOKEN" -d '{"mode":"replace"}' \
| jq -r .session.upload_id)
# 2. Append batches (repeat per chunk)
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads/$UPLOAD/rows" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: text/csv" \
--data-binary @chunk-01.csv
# 3. Commit (flips the active pointer) — or abort
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads/$UPLOAD/commit" \
-H "Authorization: Bearer $TOKEN"
curl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads/$UPLOAD" \
-H "Authorization: Bearer $TOKEN" # abort: discards staged rows
# Inspect sessions
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads" -H "Authorization: Bearer $TOKEN"

Every bulk write leaves the prior version intact, so rollback is a pointer flip — the same ~30-second propagation as any other swap:

Terminal window
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/versions" -H "Authorization: Bearer $TOKEN"
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/versions/3/activate" \
-H "Authorization: Bearer $TOKEN"

Unlike a configuration revert there is no descendancy guard and no force flag — any version that still exists can be activated. A version removed by vacuum is gone for good and refuses activation (404).

Historical versions are your rollback net, but each one is a full copy of the deck — prune on a schedule:

Terminal window
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/vacuum" \
-H "Authorization: Bearer $TOKEN" \
-d '{"keep_recent":5,"keep_before":"2026-06-01T00:00:00Z"}'
# → {"success":true,"deleted_versions":[1,2,3]}

The active version is always kept. keep_recent protects the N newest versions; keep_before restricts deletion to versions created at or before the timestamp; a version must clear every supplied protection to be dropped.

When a trunk is decommissioned, take its rows out of the deck in one atomic, rollback-able step — a new version is cut without them:

Terminal window
# What references the trunk? (paginated; pass ?cursor= from next_cursor)
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/trunks/old-carrier/routes" \
-H "Authorization: Bearer $TOKEN"
# Cut the version without them
curl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/us-rates/trunks/old-carrier/routes" \
-H "Authorization: Bearer $TOKEN"

This is belt-and-braces rather than urgent: at dial time a row whose trunk no longer exists in the running config is simply skipped. Do it anyway — dead rows distort the top-N truncation and the table’s row estimates.

Terminal window
curl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/old-rates" \
-H "Authorization: Bearer $TOKEN"

Refused with 409 while any route block in the running configuration names the table — repoint or remove the route (and apply) first. Deletion removes every version; there is no undo.

Verifying behavior before (and after) go-live

Section titled “Verifying behavior before (and after) go-live”

Two read tools, one distinction: lookup shows you rows, resolve shows you what a call would do.

lookup is a point read of one stored prefix — the value must be a prefix exactly as it appears in the table (it is not matched longest-prefix), with optional selector/trunk filters and historical version:

Terminal window
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/lookup?prefix=1305&selector=interstate" \
-H "Authorization: Bearer $TOKEN"

resolve runs the same resolver the dial path uses against a hypothetical call: longest-prefix walk, selector resolution, validity windows, effective-cost ranking, top-N truncation. Pass your route block’s policy as the override (otherwise server defaults apply: exact / dialednum / !all / route-count 10), and — for !match-on: selectors or enrichment-keyed lookups — the call-detail fields a real call would carry:

Terminal window
curl -s -X POST "$VSS/v1/ns/$NSID/lcr/tables/us-rates/resolve" \
-H "Authorization: Bearer $TOKEN" -d '{
"dialed_number": "13055551234",
"lcr_block_override": {
"prefix_match_logic": "prefix",
"selector": "!match-on:call-type",
"route_count": 3,
"assumed_call_duration_seconds": 180
},
"call_data": {"called_state": "FL", "called_lata": "460"}
}'
# → {"ranked":["carrier-b","carrier-a"],"rows":[…],"version":4}

ranked is the trunk order the route would dial; rows are the raw candidates it ranked. call_data also accepts routing_e164 (the LRN), OCN/ratecenter fields, and their calling-/routing-side counterparts.

Run a handful of representative numbers through resolve after every deck load — it’s the cheapest possible regression test for a tariff import. For calls already placed, the route-forensics log records the LCR consultation the real call made.

Response Meaning Do
404 table not found Table has no versions yet (or was deleted) First write must be a bulk replace or a replace-mode upload
400 with rejections Malformed rows; nothing was written Fix the listed lines/positions and resend the whole body
422 unknown trunks Rows name trunks absent from the namespace Fix the trunk names, or add the trunks to config and apply first
422 row limit exceeded Body over 10,000 rows Switch to vsscli lcr upload stream / the session flow
413 Body over 25 MiB Same — use the session flow
409 deck locked (lcr-deck-locked) Another writer holds the table — an open upload session or an in-flight write Wait for held_until (or commit/abort the session), then retry
409 session state Session isn’t open (committed, aborted, or mid-commit) Open a new session
410 session expired Over 24 h since the last append Open a new session and re-stream
409 table in use Delete refused: running config references the table Repoint/remove the route block, apply, retry
404 version not found on activate Version never existed or was vacuumed Pick one from version list