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.
Permissions
Section titled “Permissions”| 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.
Row data formats
Section titled “Row data formats”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_until1305,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, andper_minute_costare 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
selectormust be a literal tag (your rate-deck label or a jurisdiction value).!alland!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
rejectionslist (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).
Inspecting tables
Section titled “Inspecting tables”# Catalogue: every table with active version, row estimate, last swapcurl -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 onlycurl -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"vsscli lcr table listvsscli lcr table show us-rates # summary + version historyvsscli lcr version list us-ratesvsscli lcr route show us-rates 1305 interstate carrier-avsscli lcr route show us-rates 1305 interstate carrier-a --version 3Loading a deck
Section titled “Loading a deck”For decks up to 10,000 rows, one request replaces the table wholesale (and creates the table on first use):
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":"…"}}vsscli lcr bulk replace us-rates us-rates.csvcat us-rates.csv | vsscli lcr bulk replace us-rates --stdin --format csvFormat is auto-detected from the file extension (.csv / .json);
--format settles ambiguous cases like stdin.
Note new_version in the result — that number (and prior_version, your
instant rollback target) is worth logging in any automation.
Changing rates
Section titled “Changing rates”Bulk patch
Section titled “Bulk patch”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.
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.csvvsscli lcr bulk patch us-rates corrections.csvThere 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.
Single rows (in-place)
Section titled “Single rows (in-place)”# Upsert: body carries the costs, the URL carries the keycurl -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"vsscli lcr route set us-rates 1305 interstate carrier-a 105 --priority 10vsscli lcr route delete us-rates 1305 interstate carrier-a--setup-cost, --effective-on, and --valid-until are available on
set.
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).
Validity windows
Section titled “Validity windows”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_onarrives, and the switch starts using them at the boundary with no operator action; - auto-expiring rates — a promotional or short-term rate with
valid_untilset 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.
Large decks: upload sessions
Section titled “Large decks: upload sessions”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.
# 1. OpenUPLOAD=$(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 abortcurl -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 sessionscurl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/uploads" -H "Authorization: Bearer $TOKEN"stream drives the whole lifecycle against one local file, splitting it
into batches for you:
vsscli lcr upload stream us-rates full-deck.csv --mode replacevsscli lcr upload stream us-rates full-deck.csv --mode replace --batch-size 2000A failed batch auto-aborts the session (pass --keep-on-error to keep it
open and resume by hand). The manual verbs are there too:
vsscli lcr upload open us-rates --mode replace # prints the upload idvsscli lcr upload append us-rates <upload-id> chunk-01.csvvsscli lcr upload commit us-rates <upload-id>vsscli lcr upload abort us-rates <upload-id>vsscli lcr upload list us-ratesRollback
Section titled “Rollback”Every bulk write leaves the prior version intact, so rollback is a pointer flip — the same ~30-second propagation as any other swap:
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"vsscli lcr version list us-rates # active version is starredvsscli lcr version activate us-rates 3Unlike 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).
Housekeeping
Section titled “Housekeeping”Vacuum
Section titled “Vacuum”Historical versions are your rollback net, but each one is a full copy of the deck — prune on a schedule:
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]}vsscli lcr version vacuum us-rates --keep-recent 5vsscli lcr version vacuum us-rates --keep-before 2026-06-01T00:00:00ZThe 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.
Removing a carrier
Section titled “Removing a carrier”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:
# 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 themcurl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/us-rates/trunks/old-carrier/routes" \ -H "Authorization: Bearer $TOKEN"vsscli lcr trunk routes us-rates old-carrier --all # auto-paginatevsscli lcr trunk delete us-rates old-carrierThis 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.
Deleting a table
Section titled “Deleting a table”curl -s -X DELETE "$VSS/v1/ns/$NSID/lcr/tables/old-rates" \ -H "Authorization: Bearer $TOKEN"vsscli lcr table delete old-rates # prompts before wiping all versionsRefused 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:
curl -s "$VSS/v1/ns/$NSID/lcr/tables/us-rates/lookup?prefix=1305&selector=interstate" \ -H "Authorization: Bearer $TOKEN"vsscli lcr lookup us-rates --prefix 1305 --selector interstatevsscli lcr lookup us-rates --prefix 1305 --version 3 # historicalresolve 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:
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.
vsscli lcr resolve us-rates 13055551234 \ --prefix-match prefix --selector '!match-on:call-type' \ --route-count 3 --assumed-duration 180 \ --called-state FL --called-lata 460
# LRN-keyed table: supply the routing number the lookup keys onvsscli lcr resolve us-rates 13055551234 \ --prefix-match prefix --prefix-source lrn --routing-e164 13055550000
# Replay a captured call from a JSON file (call_data +# lcr_block_override, same shapes as the request body)vsscli lcr resolve us-rates 13055551234 --file call.json --route-count 5Flags default to the server’s defaults. The full call-detail snapshot is
available as flags: the --called-*, --calling-*, and --routing-*
families (OCN, LATA, state, ratecenter — plus --routing-e164 for the
LRN) and --string-summary. With --file, the file is the base and any
flag you set explicitly overrides its matching field, so a saved scenario
can be re-run with one knob turned.
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.
Troubleshooting
Section titled “Troubleshooting”| 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 |