PLSS, Oklahoma counties, and county land records
How the platform turns a place on a map into a legal description, and a legal description into a county clerk's recorded instrument.
- The survey grid
- Why counties are a separate layer
- The county dataset
- Resolving a county from geometry
- The county-records proxy
- Aliquot caveats
- What this is not
- Resolving a description server side
- The local snapshot and its ingest
- Texas: abstracts, not PLSS
- Deep links into AXES
- Still open
The survey grid
The Public Land Survey System divides most of the United States into six-mile-square townships, each subdivided into 36 one-mile sections, each further divided into quarters and quarter-quarters. A legal description like SW/4 NW/4 of Sec. 11, T16N, R2W, Indian Meridian names one forty-acre parcel.
The gated AXES page at GET /axes (also served at GET /gis) (web/axes.html)
queries the BLM's national CadNSDI service live — nothing is ingested for
it:
https://gis.blm.gov/arcgis/rest/services/Cadastral/BLM_Natl_PLSS_CadNSDI/MapServer
It uses the PLSS Intersected layer, BLM's atomic-level polygon set, where every township, section, quarter and quarter-quarter attribute is denormalized onto a single feature. That is what makes "what does this sketch touch" one query instead of a walk down the PLSS hierarchy.
The layer is discovered by name (/intersect/i against the service's
?f=json layer list) rather than by index. BLM's national service puts it
at layer 3; agency mirrors number differently — Illinois has it at 2.
Texas is not a PLSS state. Sweetwater, Colorado City and Abilene return nothing from BLM, correctly. Texas uses the abstract/survey/block system, resolved separately against the Railroad Commission's grid — see Texas: abstracts, not PLSS.
Why counties are a separate layer
A PLSS legal description does not imply a county. Townships and ranges cross county lines freely, so T16N R2W Sec. 11 is not enough on its own to know whose courthouse holds the instrument.
That matters because OKCountyRecords.com — the land-records service the
map page searches — indexes county by county. Its search endpoint
requires a county parameter unless you are running a free-text sweep.
Without a county attached to a selection there is nothing to search.
So the map draws all 77 Oklahoma counties in their real locations, and resolves the county for a selection from the selection's own geometry.
The county dataset
web/ok-counties.json, regenerated by scripts/gen_ok_counties.py.
| Field | Meaning |
|---|---|
name |
Census BASENAME, e.g. Le Flore |
fips |
5-digit GEOID, e.g. 40079 |
code |
3-digit county code |
lat / lon |
Census internal point — guaranteed inside the county, unlike a centroid |
bbox |
[west, south, east, north], used to zoom to a county |
okcr |
The county name string OKCountyRecords.com accepts, or null |
rings |
Boundary rings, WGS84 |
Geometry comes from the US Census Bureau's keyless, public TIGERweb
service, generalized to 44 m and rounded to 4 decimal places (11 m) —
about 180 KB for the whole state. That service publishes the same
Counties layer once per scale band, so the generator discovers it by
name and picks the most detailed band rather than pinning an index.
Two authorities are joined here, and the join is not on equality. The
Census says Le Flore; OKCountyRecords.com says LeFlore. Both the
generator and the proxy normalize to lowercase-letters-only before
matching, which is also why the proxy accepts either spelling from a
caller.
Eleven counties are not on OKCountyRecords.com at all: Caddo,
Canadian, Cleveland, Creek, Garfield, Oklahoma, Payne, Rogers, Tulsa,
Wagoner and Woods. They keep okcr: null rather than being dropped — the
map still draws them (dashed, dimmer, daggered in the dropdown) and still
resolves them. Only the search is disabled, with the reason shown. A
county silently missing from the map would be a worse bug than one you
can see but cannot search.
Resolving a county from geometry
Point-in-polygon, in the browser, against the embedded rings. Even-odd ray casting across every ring at once: flattening a MultiPolygon's rings into one list is safe precisely because of the even-odd rule, since an interior ring flips parity back and a lake island reads as outside without the outer/hole distinction being preserved.
Each matched PLSS polygon is assigned a county from its own centroid, not the sketch's. A rectangle drawn across a county line genuinely contains quarter-quarters in two counties, and each one's records live in its own courthouse — collapsing them to a single county would silently search the wrong one for half the selection. The panel defaults to the county holding most of the selection, says so, and clicking any result row moves the search to that parcel's county.
An explicit choice from the county dropdown outranks an inferred one; a later sketch will not silently move the search out from under it.
The county-records proxy
api/app/api/v1/routers/county_records.py, mounted at
/api/v1/county-records. Both routes require a portal session.
| Route | Cost |
|---|---|
GET /county-records/counties |
Free — served from the local allowlist |
GET /county-records/search |
Billable upstream |
Upstream authenticates with HTTP Basic, the API key in the username
field and an empty password. The key is read from OKCR_API_KEY (or
AXIOM_OKCR_API_KEY) in the environment only — never accepted from a
client and never echoed back. Any design that lets the browser call
upstream directly puts a billable credential in a page's network tab.
Until the key is set, /county-records/search returns 503 with a
clear message and the rest of the map works normally.
county is validated against the 66-county allowlist before spending a
request upstream, so an unsupported county costs nothing and gets a
specific message instead of an opaque upstream 404. results_per_page is
capped at upstream's own maximum of 15 at this service's edge.
Upstream 401/403 are remapped to 503, not passed through: they mean our key is bad or disabled, not that the caller is unauthenticated, and a 401 reaching the browser would bounce a perfectly valid portal session to the login page.
Parameter mapping from a selected quarter-quarter:
| Selection | Parameter | Example |
|---|---|---|
| resolved county | county |
Logan |
FRSTDIVNO |
section |
11 |
TWNSHPNO + TWNSHPDIR |
township |
16N |
RANGENO + RANGEDIR |
range |
2W |
SECDIVLAB, unpacked |
quarter |
SW NW |
Note that quarter wants bare quadrants separated by spaces, not the
/4-suffixed prose form used in the on-screen legal description. A
government lot has no quarter at all, so it sends nothing rather than
Lot 7, which would match nothing.
Township and range are sent with their direction suffix, as upstream's
parameter documentation shows (11E, 6W), even though the instrument
records it returns store them as bare numbers. If a county straddling the
Indian Meridian ever returns nothing for a range that clearly exists, the
suffix is the first thing to try dropping.
Aliquot caveats
Aliquots are packed without separators. SECDIVLAB of NENE means the
NE quarter of the NE quarter. Split on 2-character boundaries; the packed
order already reads smallest-unit-first, same as a written legal
description, so no reversal is needed. "Sec" also matches SE if your
regex lacks a trailing guard — that bug produced phantom third aliquot
levels.
Government lots are not aliquots. Second Division mixes both. Lot
numbers can repeat within a section, distinguished only by SECDIVSUF
(A/B). Rendering both as "Lot 7" merges two parcels a title examiner
treats separately.
BLM scale thresholds. Sections do not draw above ~1:500,000; townships above ~1:4,000,000. Labels should honour the same thresholds or they promise detail the service will not render. The query is unaffected — you can select quarter-sections you cannot currently see.
basemap: "dark-gray-vector" fails silently. Named Esri basemaps
resolve through the API-key-gated basemap styles service; with no key you
get a blank grey canvas and no error. The page addresses the underlying
tile services directly, which serve anonymously. Esri marks them "in
mature support"; if they are ever retired the fix is an API key plus the
modern basemap service, not a different URL.
simple-marker cannot render icons. It only draws primitive shapes.
Per-asset iconography needs picture-marker with a data-URI SVG.
Map is shadowed inside the AMD callback. require([...], function(Map, MapView, ...)) binds esri/Map to the name Map, so new Map() builds an
ArcGIS map, not a JS Map — .set() writes an Accessor property and
.size is undefined. Use a plain object for keyed counts. Set is not
shadowed.
What this is not
Every derived description is authoritative: false. BLM's own
metadata says the CadNSDI is a graphical representation, not a survey
substitute. It is good for routing and screening; the county clerk's
recorded instrument governs anything conveying an interest in land.
The county boundaries carry the same caveat, doubly so at ~44 m generalization: near a county line, confirm against the recorded instrument rather than the map.
Resolving a description server side
The map reads and writes legal descriptions in the browser. Everything that
is not the map — the WHMCS module, asset enrichment, a customer integration,
a batch job — goes through /api/v1/geo/legal-description
(api/app/api/v1/routers/legal.py, app/services/plss_service.py), gated on
GIS access, like the map page itself.
| Route | Cost |
|---|---|
GET /geo/legal-description/parse |
Free — local, no survey service |
POST /geo/legal-description/resolve |
One BLM query |
POST /geo/legal-description/reverse |
One BLM query |
parse reads what people actually write — SW/4 NW/4 of Sec. 11, T16N, R2W, Indian Meridian, NE/4 11-16N-2W, SEC 4-T11N-R3W IM, Lot 7, Sec. 3, T1N, R1E — into its pieces, the canonical written form, and the parameters
/county-records/search wants. Section, township and range are all
required: without the three there is no place on earth to resolve, and
guessing one returns a confident answer about the wrong forty acres.
resolve goes to ground, and reverse reads a description back off a
point. Both read the local snapshot where it covers the place asked about
and query the live BLM CadNSDI service — the same one the page uses —
everywhere else. from_snapshot on the response says which answered. See
The local snapshot and its ingest for
why the local copy is partial and how that stays safe.
Two things the server side has to get right that the page already knew:
- The layer is found by name. BLM numbers "PLSS Intersected" 3 on the national service and differently on agency mirrors.
- Numbers are zero-padded, inconsistently —
FRSTDIVNOtwo digits,TWNSHPNO/RANGENOthree — so every numeric comparison is anINover all the spellings, or the query silently matches nothing.
A township, range and section are only unique within a principal
meridian. T17N R2W Sec. 30 is a real parcel under the Indian Meridian and
another under the 6th PM, and the 6th PM alone spans five states — live,
that description with no meridian matches nine parcels in different states.
resolve therefore groups what comes back by meridian and state, and when
there is more than one group it answers found: false, ambiguous: true with
the candidates listed, rather than unioning them into a parcel that exists
nowhere with an acreage and a centroid belonging to none of them. Passing a
meridian or a state resolves it; the county page never hits this because a
click already knows where on earth it is.
A half is expanded, not computed. N/2 SE/4 has no polygon of its own in
quarter-quarter data. Rather than split a section geometrically — sections
are not rectangles, and a bounding-box split invents a boundary — the half
becomes the quarters it contains (N/2 → NE, NW) and the quarter-quarters
are unioned. divisions in the response names exactly which ones, each with
its own county, because a description can straddle a county line.
found: false with a reason is an answer, not an error: the grid has no
such division. A survey service that cannot be reached is a 502, so
"BLM is down" never reads as "no such parcel". Every response carries
authoritative: false.
The local snapshot and its ingest
The resolver reads a local copy of the grid before it asks BLM. The copy is deliberately partial, and the shape of that compromise is the interesting part.
Four tables (api/app/models/plss.py, DDL in api/app/db/schema.sql):
plss_townships, plss_sections, plss_second_divisions — the grid, each
row carrying its state and meridian — and plss_coverage, one row per
township actually ingested.
Why coverage is a table and not a count. Oklahoma alone is roughly 1.5
million second divisions, far more than this deployment's database tier
holds, so the snapshot only ever has part of the state. Partial data is
dangerous without a record of what is missing: an empty result from a table
that was never filled looks exactly like "no such parcel". So the resolver
consults plss_coverage first.
- No coverage for the township → ask BLM. Never "not found".
- Coverage whose section or division rows are not actually there (a window that clipped the township) → ask BLM. A clipped answer is worse than a slow one.
- Coverage present and populated → answer locally, and say so with
from_snapshot: true. - Two coverage rows for one township and range under different meridians →
ambiguous, the same rule the live path enforces, for the same reason.
The ingest is api/scripts/ingest_plss_ok.py, run from api/:
python -m scripts.ingest_plss_ok --county Logan # the normal invocation
python -m scripts.ingest_plss_ok --county Logan --dry-run # fetch and count, write nothing
python -m scripts.ingest_plss_ok --township 17N --range 2W --meridian "Indian Meridian"
python -m scripts.ingest_plss_ok --report # what the snapshot holds
Everything is an upsert on the natural key, so re-running over a county
refreshes it rather than duplicating it. A scope wider than
--max-townships (40) is refused without --force, and the row estimate
is printed first — the guard exists because "just load the state" is the
one instruction this database cannot survive.
Township and section geometry is the union of the divisions ingested for
it. BLM's Intersected layer serves atomic polygons only, so deriving the
parents is one query instead of three and guaranteed to agree with the
children. The cost is that a clipped window yields a clipped parent, which
is what the section counts in --report are for: a township showing fewer
than 36 sections was cut, and the resolver treats it as unable to answer
rather than answering partially.
Measured, one real township (T17N R2W, Indian Meridian): 574 divisions
across 36 sections in 1.6 seconds, 21,981 acres. Resolving
SW/4 SE/4 of Sec. 30, T17N, R2W from it returns 39.62 acres in Logan
County — the same answer the live service gives, which is the check that
matters.
Texas: abstracts, not PLSS
Texas kept its public land when it joined the Union, so the rectangular survey never reached it. A Texas parcel is named by county and abstract — and, in the railroad-block country of West Texas and parts of South Texas, a block and section:
A-427, H&GN RR CO Survey, Block 5, Section 13, Reeves County
It is handled exactly the way PLSS is: queried live, no local ingest, on the map and on the server.
| Where | What |
|---|---|
web/axes.html |
A TX surveys layer (amber, labelled with the abstract past zoom 13) in the same reference band as the PLSS grid. A sketch queries both grids; Texas hits list as their own section. |
GET /geo/legal-description/texas/parse |
Free, local. A county is required. |
POST /geo/legal-description/texas/resolve |
One RRC query → geometry, GIS acres, the polygons it is made of. |
POST /geo/legal-description/texas/reverse |
Two RRC queries → the survey(s) under a point, and the point's county. |
/axes/tx-counties.json, /gis/tx-counties.json |
Texas's 254 counties by FIPS, behind the page's gate (as ok-counties.json). |
The routes require GIS access, like the PLSS ones. Code:
app/services/texas_survey_service.py, app/api/v1/routers/texas_survey.py;
tests: tests/test_texas_survey.py.
Source: the Railroad Commission, never the GLO. The General Land Office's
Original Texas Land Survey is licensed from IFS/Tobin and the GLO states it is
not authorized to distribute it; serving it through this API would be
redistribution. The RRC's public Surveys layer (~311,000 polygons) is what
both the map and the resolver read. The two grids disagree in places, which is
why every answer names its source.
An abstract is unique within a county, not statewide — A-427 exists in
nearly every one of the 254 counties — so everything requires the county. The
RRC encodes it: ABSTRACT_NUMBER is the county's three-digit FIPS followed by
the abstract (389427 = Reeves A-427, 3291167 = Midland A-1167).
web/tx-counties.json (from scripts/gen_tx_counties.py) turns a typed county
name into that prefix without a network call.
The datum. The RRC stores the layer in NAD27; asked for WGS84 with no datum
transformation, ArcGIS can reproject without the shift — 40–100 m off, no error.
Measured 2026-10-10 on Reeves A-427: the server's default output sits 46 m from
an unshifted projection and matches NADCON (datumTransformation=1241), and
its Web Mercator output (what the map draws) agrees with its WGS84 output to the
centimetre. The resolver still pins 1241 on every query so a change of server
default cannot move every parcel silently.
Point queries must return geometry. With returnGeometry=false this server
filters a point query by bounding box: a point in downtown Austin came back "in"
Hays County as well as Travis, and in six surveys instead of the two that hold
it. The resolver and the map both request geometry, and reverse keeps only
polygons that actually contain the point.
Overlapping grants are real. Original surveys conflicted on the ground, so a
point can sit in more than one abstract (downtown Austin: A-316 and A-7).
reverse returns all of them, smallest first; it does not pick one.
Ambiguity is an answer. "Section 13, Reeves County" names 91 surveys.
resolve returns ambiguous: true with the candidates rather than unioning
them — the Texas counterpart of the principal-meridian rule above.
Every answer is authoritative: false. The RRC layer is a mapping aid compiled
from GLO records, not a survey; the recorded instrument governs.
Deep links into AXES
/axes (and /gis) opens on a parcel when the URL names one. The page
reads its query string once at load, finds the parcel with the same BLM and
RRC layers a sketch queries (by fields instead of shape), and passes the
result to the same render. The selection, the search county, the
county-records form and AXES's filter all follow, as if the parcel had been
drawn. The SuiteCRM Account's Land Desk button
(integrations/suitecrm/msp-fields) builds these links from the record's
Legal Description: State, County, and the fields shown for that state.
/axes?state=OK&county=Logan§ion=11&township=16N&range=2W&aliquots=SW/4+NW/4
/axes?state=TX&county=Reeves&abstract=A-427
/axes?state=WV&county=Kanawha&parcel=01-05-0002-0021-0004
/axes?lat=35.4676&lon=-97.5164&zoom=15
| Parameter | For | Accepts |
|---|---|---|
state |
all | Two-letter code or full name. Inferred as OK from section/township/range and as TX from abstract/survey when absent. |
county |
all | County name, with or without "County". In Oklahoma it pins the records-search county and settles a township and range that exists on both meridians. Required for Texas and for a WV map-and-parcel. |
section |
OK | 1–36 (11, Sec 11). |
township |
OK | 16N, T16N, T016N. |
range |
OK | 2W, R2W, or with the meridian: 3W IM, 3W CM. |
aliquots |
OK | SW/4 NW/4, SW NW, SWNW, NE/4, N/2 SE/4, Lots 3 & 4. A half or a whole quarter selects every quarter-quarter inside it; government lots must be named. |
abstract |
TX | A-427, Abst. 427, 427. Exact within the county. |
survey |
TX | Survey name, prefix-matched within the county (H&GN RR CO). |
parcel |
WV | Tax map parcel: CC-DD-MMMM-PPPP-SSSS, unpadded parts, the 16–20-digit clean ID, or map-parcel(-suffix) with county. PA has no statewide parcel layer, so a PA parcel is ignored (logged). |
lat, lon |
all | Decimal degrees. The map centres there at once, before any parcel resolves; with no parcel, that is where it stays. |
zoom |
with lat/lon |
1–22; default 15. |
Section, township and range go together. A link with only some of them
selects nothing. With no parcel, an Oklahoma county alone zooms to that
county.
Nothing in a link is an error. An unknown parameter, a value that does
not parse, a field for the wrong state, or a parcel the grid does not hold
is logged in Diagnostics as a link: line and skipped. The page loads
either way.
Escaping. A link is untrusted input, so each value is parsed into its
expected shape first (digits and a direction for a township, and so on).
Before anything reaches an ArcGIS where clause, it must also pass
DeepLink.sqlLiteral: every character has to fit the field's allowlist,
or the query is refused, and apostrophes are doubled. This is the same rule
as _literal in plss_service.py and texas_survey_service.py. Values
logged or written into the page go through esc(). Tests:
api/tests/test_axes_deep_link.mjs.
Still open
Nothing from the original list. The resolver, the models, the ingest, the
schema.sql additions and the county-records replay handler are all merged.
For the record, in case the archived handoff is read on its own:
| Was open | Now |
|---|---|
app/services/plss_service.py, app/schemas/legal.py, app/api/v1/routers/legal.py |
Merged. /geo/legal-description/{parse,resolve,reverse} |
app/models/plss.py, schema.sql additions |
Merged. Four tables, DDL in api/app/db/schema.sql |
scripts/ingest_plss_ok.py — BLM ingest + coverage report |
Merged. --county, --township/--range, --dry-run, --report |
/county-records/search missing from REPLAYABLE |
Merged, with a replay handler that requires the same GIS permission every other county-records route does |
An API_REFERENCE.md section for the legal-description routes |
Written |
What is deliberately not done, so nobody treats it as missing work:
- The state is not ingested, and should not be. The snapshot is filled a
county at a time on purpose;
--max-townshipsrefuses a wider scope without--force. Everywhere not ingested is answered live. - Texas has no snapshot either, for the same reason. It does have a resolver — abstracts, not PLSS, queried live from the Railroad Commission — and no local copy. Giving it one would need its own tables: an abstract is unique within a county, not within a meridian, so not one of the coverage keys here fits it.