Removes the old planner/decision-critic/role-agents/domain-agents system (step-injector scripts, stale scopes, sprint-era workflow) and replaces it with 16 ground-truth-verified skills under .claude/skills/: change control, debugging playbook, failure archaeology, architecture contract, domain reference, OCR/Gemini pipeline, config and secrets, build and env, run and operate, diagnostics (with tested scripts), validation and QA, docs and writing, launch readiness, deploy-safety campaign, proof and analysis toolkit, and research frontier. RULE 0/1/2, the temporal-contamination rule, and the decision stress-test protocol are carried forward into the new skills; the retired content remains in git history. Co-Authored-By: Claude Fable 5 <[email protected]>
20 KiB
name, description
| name | description |
|---|---|
| mvp-vehicle-domain-reference | Vehicle domain theory as implemented in MotoVaultPro. Load when working on VIN validation, VIN decode, check digits, model-year codes, "wrong year decoded", OCR VIN misreads (I/O/Q), fuel efficiency math, MPG or km/L calculations, unit conversion, imperial/metric, fuel grades, DATE vs TIMESTAMP column semantics, subscription tiers, vehicle limits, pro-gated features, or the year/make/model/trim dropdown catalog. This is the WHAT and WHY of the domain rules; live symptom triage (dates off by one day, TIER_REQUIRED 403s) starts in mvp-debugging-playbook. |
MotoVaultPro Vehicle Domain Reference
Authored 2026-07-07. All claims verified against repo code on that date. Where any doc contradicts this file or the code, the code wins.
When to use / When NOT to use
Use this skill when you need the domain rules themselves: VIN structure and check-digit math, model-year resolution, fuel efficiency formulas and edge cases, DATE vs TIMESTAMP semantics, the tier model, or how VIN-decode results map onto the vehicle catalog.
Do NOT use this skill for:
- OCR/Gemini pipeline mechanics (engines, WIF auth, timeouts, SDK sharp edges) ->
mvp-ocr-gemini-pipeline - Debugging a live symptom on staging/prod ->
mvp-debugging-playbook - Past incidents and settled battles in depth ->
mvp-failure-archaeology - Architecture invariants and capsule layout ->
mvp-architecture-contract - Adding a config knob or tier key ->
mvp-config-and-secrets
1. VIN theory as implemented
A VIN (Vehicle Identification Number) is exactly 17 characters for model year 1981+. The letters I, O, and Q are never valid in a VIN (they look like 1 and 0). Both validators in this repo enforce the same pattern:
^[A-HJ-NPR-Z0-9]{17}$
- Python:
ocr/app/validators/vin_validator.py(MODERN_VIN_PATTERN; a legacy 11-17 char pattern exists behindallow_legacy=Truewith a -0.2 confidence penalty) - Backend:
backend/src/features/vehicles/api/vehicles.controller.ts(VIN_REGEX, rejects with 400INVALID_VINbefore calling the OCR service)
Position semantics
| Positions | Name | Meaning |
|---|---|---|
| 1-3 | WMI | World Manufacturer Identifier (country + manufacturer) |
| 4-8 | VDS | Vehicle Descriptor Section (model, body, engine) |
| 9 | Check digit | Computed from all other positions (algorithm below) |
| 10 | Model year code | 30-year cycle table (below) |
| 11 | Plant | Assembly plant code |
| 12-17 | Serial | Sequential production number |
OCR confusion corrections
VinValidator.TRANSLITERATION in ocr/app/validators/vin_validator.py maps only characters that are INVALID in VINs to their likely intended values: I -> 1, O -> 0, Q -> 0 (plus lowercase i/o/q and lowercase l -> 1). Deliberate constraint documented in the code: B and S are valid VIN characters and must NOT be transliterated (a naive "8 vs B, 5 vs S" correction would corrupt real VINs). Spaces and dashes are stripped first.
The extractor (ocr/app/extractors/vin_extractor.py + extract_candidates() in the validator) also handles OCR fragmentation (concatenating adjacent fragments) and spurious inserted characters (sliding 17-char windows plus one- and two-character deletion over 18-19 char strings), using the check digit to filter false candidates. This candidate machinery is a settled battle -- see mvp-failure-archaeology before "simplifying" it.
Check-digit algorithm (position 9)
Implemented in VinValidator.calculate_check_digit():
- Transliterate each character to a value: digits map to themselves; letters map via
CHAR_VALUES: A=1 B=2 C=3 D=4 E=5 F=6 G=7 H=8, J=1 K=2 L=3 M=4 N=5 P=7 R=9, S=2 T=3 U=4 V=5 W=6 X=7 Y=8 Z=9. (No I/O/Q; note P=7 with no 6, and R=9 with no 8 in that row -- this is per the standard, not a typo.) - Multiply each position's value by its weight from
CHECK_WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2](position 9 itself has weight 0 and is skipped). - Sum, take modulo 11. Remainder 0-9 is the check digit as a character; remainder 10 is the letter
X.
A failed check digit does NOT reject the VIN in validate() -- it returns is_valid=True with confidence_adjustment=-0.15 (some real VINs, especially non-North-American, do not comply). A passing check digit adds +0.1 confidence.
Model-year code table (position 10)
_VIN_YEAR_CODES in ocr/app/engines/gemini_engine.py maps the position-10 character to a base year in the first cycle (1980-2009). The letters I, O, Q, U, Z and the digit 0 are not valid year codes.
| Code | Base | Code | Base | Code | Base |
|---|---|---|---|---|---|
| A | 1980 | L | 1990 | 1 | 2001 |
| B | 1981 | M | 1991 | 2 | 2002 |
| C | 1982 | N | 1992 | 3 | 2003 |
| D | 1983 | P | 1993 | 4 | 2004 |
| E | 1984 | R | 1994 | 5 | 2005 |
| F | 1985 | S | 1995 | 6 | 2006 |
| G | 1986 | T | 1996 | 7 | 2007 |
| H | 1987 | V | 1997 | 8 | 2008 |
| J | 1988 | W | 1998 | 9 | 2009 |
| K | 1989 | X | 1999 | ||
| Y | 2000 |
The 30-year cycle ambiguity. Codes repeat every 30 years (A = 1980 or 2010 or 2040). Disambiguation uses position 7, per NHTSA FMVSS No. 115 (MY2010+ vehicles must have an alphabetic position 7):
- Position 7 alphabetic -> add 30 (2010-2039 cycle)
- Position 7 numeric -> 1980-2009 cycle, or 2040-2069 if that year is no more than 2 years in the future (
datetime.now().year + 2)
This is implemented in resolve_vin_year() in ocr/app/engines/gemini_engine.py. It was implemented backwards once (numeric position 7 mapped to the 2010+ cycle) and fixed in commit 936753f; the test suite ocr/tests/test_resolve_vin_year.py locks in the correct behavior. Do not re-derive this logic from memory -- read the function.
The year is NEVER trusted from the LLM. GeminiEngine.decode_vin() computes the year deterministically via resolve_vin_year(), passes it into the prompt as already-resolved, and overrides Gemini's returned year with the deterministic value (logging a warning on mismatch). Gemini is only trusted for make/model/trim/engine/transmission/body/drive/fuel, which require manufacturer knowledge.
VIN decode path (for orientation only; details in mvp-ocr-gemini-pipeline)
POST /api/vehicles/decode-vin (pro-gated) -> vehicles.controller.ts regex validation -> backend/src/features/ocr/external/ocr-client.ts:decodeVin() -> OCR container POST /decode/vin (ocr/app/routers/decode.py) -> GeminiEngine.decode_vin() with Google Search grounding -> back through vehiclesService.mapVinDecodeResponse() (section 5). Frontend axios timeout for this call is 120s (frontend/src/features/vehicles/api/vehicles.api.ts) -- an older doc note saying 60s is stale. There is deliberately NO VIN decode cache (removed in 283ba6b after race/staleness bugs).
2. Fuel math as implemented
Domain services: backend/src/features/fuel-logs/domain/ -- efficiency-calculation.service.ts, unit-conversion.service.ts, fuel-grade.service.ts, orchestrated by fuel-logs.service.ts.
Units model
UnitSystem = 'imperial' | 'metric' (defined in backend/src/shared-minimal/utils/units.ts, re-exported by unit-conversion.service.ts). The user's unit system comes from user settings (fuel-logs/external/user-settings.service.ts, default imperial).
Critical property: stored values are unit-agnostic numbers in whatever system the user entered. There is no conversion at write time; the unit system only selects labels and interpretation:
| unitSystem | fuelUnits means | distance means | efficiency label |
|---|---|---|---|
| imperial | gallons | miles | MPG |
| metric | liters | kilometers | km/L |
Consequence: if a user switches unit systems, historical rows are NOT converted -- old numbers get reinterpreted under the new labels. This is the current implemented behavior, not an oversight to silently "fix" in passing; treat any change as a feature decision.
Efficiency formula
UnitConversionService.calculateEfficiency(distance, fuelUnits) = distance / fuelUnits -- the same formula serves MPG (miles/gallon) and km/L (km/liter). The BACKEND domain efficiencyLabel for metric is km/L (an unused MPG_TO_L100KM_FACTOR and a convertEfficiency method exist -- convertEfficiency has no production callers as of 2026-07-07). The FRONTEND, however, diverges for metric users: FuelLogForm.tsx:352 labels the calculated-efficiency field "L/100km" while the value it displays is computed as distance/units (i.e. km/L -- a live label/value mismatch), and FuelLogsList.tsx:148 computes a local fallback as (liters/km)*100 labeled "L/100km" (the settings screens also describe metric as L/100km). So a metric user sees "X km/L" when the API supplies efficiency but "Y L/100km" via the form and the list fallback. This backend/frontend divergence is a known inconsistency, NOT a settled convention -- do not flatly "correct" either side without treating it as a feature decision.
EfficiencyCalculationService.calculateEfficiency(currentLog, previousOdometerReading, unitSystem) picks the distance:
- If
tripDistance > 0: use it (calculationMethod: 'trip_distance'). - Else if
odometerReadingpresent AND a previous odometer reading exists AND the delta is positive: use the delta (calculationMethod: 'odometer'). - Else return
null(no efficiency shown).
Edge cases that fall out of this, all intentional:
- First fill-up with odometer method: no previous log ->
previousOdometerReadingis null -> efficiency is null. Efficiency is never stored; it is computed on read (the legacympgcolumn was dropped inmigrations/003_drop_mpg_column.sql). - Odometer rollback or duplicate reading (delta <= 0): efficiency null, not negative.
fuelUnits <= 0: null.- Vehicle stats (
getVehicleStatsinfuel-logs.service.ts) computes per-log efficiencies withpreviousOdometerReading=null, so odometer-method logs contribute 0 and are filtered out ofaverageEfficiency-- only trip-distance logs count toward the average. Know this before "fixing" a low average.
Odometer vs tripDistance: XOR
Frontend zod schema (frontend/src/features/fuel-logs/components/FuelLogForm.tsx) enforces exactly-one via two .refine() calls: at least one of odometerReading/tripDistance must be > 0, AND both may not be set. The database enforces only the at-least-one half (distance_required_check in migrations/002_enhance_fuel_logs_schema.sql, recreated in 004). odometer was made nullable and trip_distance widened to DECIMAL(10,3) in migration 004 to allow trip-only, fractional-distance logs.
Dual legacy/enhanced schema -- which fields are canonical
One table (fuel_logs), two column generations, one repository (data/fuel-logs.repository.ts) with two APIs:
| Canonical (enhanced) column | Legacy column (dual-written) | API field |
|---|---|---|
date_time TIMESTAMPTZ |
date DATE |
dateTime (ISO string) |
fuel_units DECIMAL(8,3) |
gallons |
fuelUnits |
cost_per_unit DECIMAL(6,3) |
price_per_gallon |
costPerUnit |
trip_distance DECIMAL(10,3) |
-- | tripDistance |
odometer (nullable) |
same column | odometerReading |
fuel_type, fuel_grade, location_data |
-- | fuelType, fuelGrade, locationData |
createEnhanced dual-writes gallons = fuelUnits and price_per_gallon = costPerUnit for "legacy support". The enhanced fields are canonical; treat gallons/price_per_gallon/date as backfill/compatibility only. The enhanced response shape is EnhancedFuelLogResponse in domain/fuel-logs.types.ts (includes computed efficiency? and efficiencyLabel).
Convention break to know: mapEnhancedRow deliberately returns snake_case rows with numerics coerced (parseFloat), and toEnhancedResponse in the service does the camelCase mapping -- unlike the standard repository mapRow() pattern. This arrangement caused two incidents (#47 raw-row decision, #244 missing coercion); do not return raw pg rows and do not remove the coercion.
Fuel type and grade
fuel_type is one of gasoline | diesel | electric (CHECK constraint). Valid grades per type, enforced both by FuelGradeService and a database trigger (validate_fuel_grade() in migration 002):
- gasoline:
87,88,89,91,93(default 87) - diesel:
#1,#2 - electric: grade must be null
3. DATE vs TIMESTAMP semantics
The rule. A Postgres DATE column is a calendar date with no timezone (fuel_logs.date, maintenance_records.date, next_due_date, purchase_date, ...). It must flow as a plain YYYY-MM-DD string end-to-end: pg's DATE parser is overridden to return the raw string (types.setTypeParser(1082, ...) in backend/src/core/config/database.ts -- never remove this), APIs pass the string through, the frontend displays it with dayjs, and sorting is lexicographic (safe for ISO dates). Never write new Date(dateString), toISOString(), or toLocaleDateString() against a DATE value -- each one applies a UTC or local-midnight conversion that shifts the date by a day for some timezone. A TIMESTAMP WITH TIME ZONE column (fuel_logs.date_time, created_at, updated_at) is a real instant: new Date() and ISO serialization are correct there (toEnhancedResponse does exactly this for dateTime).
History (2026-03-23, three distinct traps fixed in one day, issue #237). (a) pg returned DATE as a local-midnight Date object, which toISOString() shifted a day -- fixed by the type-parser override (f0fc427); (b) frontend new Date("YYYY-MM-DD") parses as UTC midnight, so toLocaleDateString() shifted it back -- fixed by dayjs display (1e056f0); (c) the OCR date parser used toISOString().split('T')[0] -- fixed with local-time formatting (087f7b9). This battle is settled; a "dates off by one day" symptom means someone reintroduced one of these three patterns.
4. Tier and subscription domain model
Source: backend/src/core/config/feature-tiers.ts (registry) + backend/src/features/subscriptions/ (Stripe sync).
Tier hierarchy (TIER_LEVELS): free(0) < pro(1) < enterprise(2). Higher tiers inherit lower-tier access via numeric comparison.
Gated feature keys and vehicle limits: the registry (FEATURE_TIERS) and VEHICLE_LIMITS (enforced via canAddVehicle(tier, currentCount)) live in backend/src/core/config/feature-tiers.ts; the current key catalog, the limit literals, and the gating mechanics are canonical in mvp-config-and-secrets section 3.
Fail-open trap: canAccessFeature() returns true for any feature key NOT in the registry -- a typo silently ungates the route -- and two parallel gating mechanisms exist with different failure behavior. Details in mvp-config-and-secrets section 3; read it before adding a gated route.
Source of truth for a user's tier: user_profiles.subscription_tier (Postgres enum free|pro|enterprise, default free; backend/src/features/user-profile/migrations/002_add_subscription_and_deactivation.sql). The auth plugin reads it into request.userContext.subscriptionTier on every authenticated request; all gating reads from userContext, never from Stripe directly. The subscriptions capsule keeps it synced from Stripe: syncTierToUserProfile() in subscriptions/domain/subscriptions.service.ts is called on subscription create/change/cancel and webhook events; adminOverrideTier() updates subscriptions.tier and user_profiles.subscription_tier atomically in one transaction; the grace-period job (subscriptions/jobs/grace-period.job.ts) downgrades lapsed users to free.
The TIER_REQUIRED 403 contract (what the frontend keys upgrade prompts on):
{
"error": "TIER_REQUIRED",
"requiredTier": "pro",
"currentTier": "free",
"upgradePrompt": "Upgrade to Pro to ..."
}
Emitted by both require-tier.ts and tier-guard.plugin.ts. Keep this shape stable; do not invent per-route variants.
5. Vehicle catalog domain (platform capsule)
The platform capsule (backend/src/features/platform/) owns the year -> make -> model -> trim -> engine/transmission dropdown cascade used by vehicle forms. All routes are authenticate-only (not tier-gated).
- Seeding:
CatalogSeedService.seedIfEmpty()(platform/domain/catalog-seed.service.ts) runs at backend startup (backend/src/index.ts), loadingengines.sql,transmissions.sql,vehicle_options.sqlfromfeatures/platform/data/(container path/app/migrations/features/platform/data) only ifvehicle_optionsis empty. Seed failure is logged and startup continues (data can be imported later via admin UI) -- so an empty catalog is a possible runtime state, and VIN-decode matching degrades to all-noneconfidence when it happens. - Caching: dropdown results cache in Redis with 6-hour TTL per level (
platform/domain/platform-cache.service.ts,ttl = 6 * 3600). - VIN decode integration:
vehiclesService.mapVinDecodeResponse()(backend/src/features/vehicles/domain/vehicles.service.ts) takes Gemini's raw strings and matches each field against the catalog dropdown options, cascading: year (alwayshighconfidence if present, since it is deterministic) -> make -> model -> trim -> engine/transmission. Each downstream match only runs if every upstream field matched (make.valueetc.). Per-field result is{ value, sourceValue, confidence }where confidence ishigh(exact case-insensitive match),medium(alphanumeric-normalized, prefix, contains, or longest reverse-contains match), ornone(unmatched --value: null, raw Gemini string preserved insourceValuefor display).bodyType,driveType,fuelTypeare display-only: never matched, alwaysconfidence: 'none'.
Practical implication: a correct Gemini decode can still return value: null fields if the catalog lacks that year/make/model row. That is a catalog-coverage problem, not a decode bug -- check vehicle_options before touching the OCR side.
Provenance and maintenance
Authored 2026-07-07 by direct inspection of the files named above, plus git log/git show for incident commits (936753f, 1add6c8, 56df5d4, 283ba6b, f0fc427, 1e056f0, 087f7b9, 574acf3, 0d90829). Volatile facts and how to re-verify each:
| Fact | Re-verify with |
|---|---|
| VIN regex / transliteration / weights / CHAR_VALUES | sed -n '20,80p' ocr/app/validators/vin_validator.py |
| Year-code table and cycle logic | sed -n '41,93p' ocr/app/engines/gemini_engine.py |
| Year never trusted from LLM | grep -n "resolved_year" ocr/app/engines/gemini_engine.py |
| Frontend VIN decode timeout (120s, 2026-07-07) | grep -n timeout frontend/src/features/vehicles/api/vehicles.api.ts |
| Efficiency formula and null cases | cat backend/src/features/fuel-logs/domain/efficiency-calculation.service.ts backend/src/features/fuel-logs/domain/unit-conversion.service.ts |
| XOR zod refinements | grep -n -A3 refine frontend/src/features/fuel-logs/components/FuelLogForm.tsx |
| fuel_logs schema, constraints, grades | cat backend/src/features/fuel-logs/migrations/*.sql |
| Dual-write legacy columns | grep -n "legacy" backend/src/features/fuel-logs/data/fuel-logs.repository.ts |
| DATE type-parser override | grep -n "setTypeParser" backend/src/core/config/database.ts |
Tier levels (key/limit catalog: mvp-config-and-secrets section 3) |
grep -n "minTier|VEHICLE_LIMITS" -A3 backend/src/core/config/feature-tiers.ts |
| TIER_REQUIRED 403 shape | grep -rn -A4 "TIER_REQUIRED" backend/src/core/middleware/require-tier.ts backend/src/core/plugins/tier-guard.plugin.ts |
| Tier sync from Stripe | grep -n "syncTierToUserProfile" backend/src/features/subscriptions/domain/subscriptions.service.ts |
| Catalog seed + 6h cache | grep -n "seedIfEmpty|6 \* 3600" backend/src/features/platform/domain/*.ts |
| matchField confidence ladder | grep -n -A45 "private matchField" backend/src/features/vehicles/domain/vehicles.service.ts |