Santo
An Elixir library for VIN and chassis-number parsing, decoding, and identity resolution — the decode layer beneath a provenance registry.
Generic VIN decoders (ISO 3779 split + an NHTSA API call) are commodity. Santo's value is the layer they skip: marque-level semantics, pre-VIN chassis grammars, and chassis identity resolution. Porsche is the first marque adapter, chosen because it exercises every hard case: pre-1981 chassis numbering, US/RoW VDS divergence, serial-range-encoded model semantics, and the 30-year model-year ambiguity.
Installation
Santo requires Elixir 1.14 or later and has no runtime dependencies.
For local service development, keep Santo as a sibling project:
defp deps do
[
{:santo, path: "../santo"}
]
end
After the first Hex release:
defp deps do
[
{:santo, "~> 0.2.0"}
]
end
The package contains only the runtime library, its compiled reference tables, license, changelog, and package metadata. The corpus and vPIC Oracle remain maintainer tooling and are not shipped to consumers.
Design commitments
- Pure functions over compiled data. No processes, no ETS, no HTTP
in the decode path. Reference tables (
priv/data/*.csv, every row with asourcecolumn) compile into function heads and map literals. Works in a script, a test, or a Livebook with zero setup. - Binary pattern matching is the dispatch mechanism. Marque routing, market detection, and era detection are function heads over binary prefixes and shapes.
- Ambiguity is a first-class return value. The library never
guesses. A VIN that admits two model years returns both. A chassis
number that could be two eras returns both, with the evidence that
would settle it (
:kardex,:coa,:engine_number) named innotes. - VIN ≠ identity.
Santo.Parseddescribes a string;Santo.Identitynames a physical chassis. Re-stamps, replicas, pre-VIN cars, and factory crossover-era cars make identity a superset of VIN.
Usage
# Modern US-market: real check digit, model code + plant disambiguation
{:ok, d} = Santo.decode("WP0CA298X5L001502")
d.model #=> {:carrera_gt, "980"}
d.years #=> [2005]
d.check_digit #=> :valid (position 9 is literally "X" — remainder 10)
# Modern RoW: ZZZ filler *is* the market signal; no check digit applies
{:ok, d} = Santo.decode("WP0ZZZ95ZJS905016")
d.model #=> {:"959", "959"}
d.attributes.variant #=> :sport (905xxx serial block — semantics in the serial range)
d.check_digit #=> :not_applicable
# Pre-VIN ten-digit grammar (and marque-aware normalization)
{:ok, d} = Santo.decode("911.360.0471")
d.model #=> {:carrera_rs_27, "911"}
d.years #=> [1973]
d.notes #=> [{:evidence_required, :option_package, [:kardex, :coa]}]
# (M471 Lightweight vs M472 Touring is *not* in the chassis number)
# 356-era crossover: ambiguity is data
{:ambiguous, [pre_a, a]} = Santo.decode("81192")
Santo.Identity.key("81192")
#=> {:ok, {:disputed,
# [{:chassis, :porsche, :"356_pre_a", "81192"},
# {:chassis, :porsche, :"356_a", "81192"}],
# [:kardex, :engine_number]}}
# Dirty sources: repairs are proposed, never applied
{:error, invalid} = Santo.decode("WP0ZZZ95ZJS90015") # 16 chars, as printed by Bonhams
invalid.repaired #=> nil (multiple plausible repairs — never guess)
invalid.candidates #=> ["WP0ZZZ95ZJS900015", ...] (all decode as in-block 959s)
{:error, invalid} = Santo.decode("WP0CA29845L0O1561") # OCR'd O for 0
invalid.repaired #=> "WP0CA29845L001561" (unique repair passes the check digit)
Return convention throughout:
{:ok, %Santo.Decoded{}}
| {:ambiguous, [%Santo.Decoded{}]} # multiple valid readings
| {:error, %Santo.Invalid{}} # reasons + repair proposals
Layout
lib/santo.ex # Public API: parse/1, decode/1, validate/1, identify/1, normalize/1
lib/santo/
parsed.ex # positional decomposition (no semantics)
decoded.ex # semantic decode struct
invalid.ex # diagnosis + repair proposals
check_digit.ex # transliteration, weights; market-conditional at the call site
model_year.ex # 30-year cycle; candidate lists; pos-7 rule (NA post-1981 only)
wmi.ex # marque routing: compiled heads (hot) + map literal (long tail)
normalize.ex # lossless canonicalization
identity.ex # VIN ⊃ chassis; :disputed registry keys
marque.ex # adapter behaviour (:pass falls through to generic)
marque/porsche.ex # dispatcher: routes by shape/era
marque/porsche/ # modern.ex (17-char) / mid.ex (ten-digit) / early.ex (356)
priv/data/ # vendored tables; every row carries a source column
test/fixtures/corpus.exs # golden corpus of real, documented cars
oracle/ # maintainer-only vPIC differential harness
Test corpus
The golden corpus (test/fixtures/corpus.exs) landed before the decoder
did — frozen benchmark first. Real, publicly documented cars: six
Carrera GTs sharing a VDS stem with distinct check digits, 959 Komfort
vs Sport serial blocks, the H/J year-code pair, 1973 Carrera RS 2.7
ten-digit chassis numbers, and 356 Speedsters spanning the Pre-A/A
factory transition.
mix test # zero dependencies
Status / roadmap
- Phase 0 — skeleton + corpus,
Parsed, check digit (incl.X), model-year candidates - Phase 1 — WMI routing + codegen (compiled heads + long-tail map)
- Phase 2 — Porsche modern adapter (US/RoW split, market-conditional check digit, serial blocks)
- Phase 3 — pre-VIN grammars (ten-digit Mid, 356 Early), normalization, crossover ambiguity
- Phase 5 — identity layer (
identify/1,Santo.Identity.key/1,:disputedvalues) - Phase 4 —
mix vin.oraclevPIC differential harness (design; initial snapshot captured 2026-07-30) - Pre-1970 911/912 schemes ('65–'69 30xxxx/35xxxx/45xxxx blocks) — fixtures first
- More marques, only after the behaviour has survived Porsche's full weirdness
Explicit non-goals for the library: persistence, HTTP clients in the decode path, VIN generation beyond test support.