Files
motovaultpro/.claude/skills/mvp-vehicle-domain-reference/SKILL.md
T
Eric GullicksonandClaude Fable 5 c239bb9347
Deploy to Staging / Build Images (push) Successful in 5m32s
Deploy to Staging / Deploy to Staging (push) Successful in 44s
Deploy to Staging / Verify Staging (push) Successful in 5s
Deploy to Staging / Notify Staging Ready (push) Successful in 4s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
chore: replace AI skill library with 16 verified mvp-* skills
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]>
2026-07-09 20:21:42 -05:00

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 behind allow_legacy=True with a -0.2 confidence penalty)
  • Backend: backend/src/features/vehicles/api/vehicles.controller.ts (VIN_REGEX, rejects with 400 INVALID_VIN before 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():

  1. 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.)
  2. 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).
  3. 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:

  1. If tripDistance > 0: use it (calculationMethod: 'trip_distance').
  2. Else if odometerReading present AND a previous odometer reading exists AND the delta is positive: use the delta (calculationMethod: 'odometer').
  3. Else return null (no efficiency shown).

Edge cases that fall out of this, all intentional:

  • First fill-up with odometer method: no previous log -> previousOdometerReading is null -> efficiency is null. Efficiency is never stored; it is computed on read (the legacy mpg column was dropped in migrations/003_drop_mpg_column.sql).
  • Odometer rollback or duplicate reading (delta <= 0): efficiency null, not negative.
  • fuelUnits <= 0: null.
  • Vehicle stats (getVehicleStats in fuel-logs.service.ts) computes per-log efficiencies with previousOdometerReading=null, so odometer-method logs contribute 0 and are filtered out of averageEfficiency -- 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), loading engines.sql, transmissions.sql, vehicle_options.sql from features/platform/data/ (container path /app/migrations/features/platform/data) only if vehicle_options is 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-none confidence 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 (always high confidence if present, since it is deterministic) -> make -> model -> trim -> engine/transmission. Each downstream match only runs if every upstream field matched (make.value etc.). Per-field result is { value, sourceValue, confidence } where confidence is high (exact case-insensitive match), medium (alphanumeric-normalized, prefix, contains, or longest reverse-contains match), or none (unmatched -- value: null, raw Gemini string preserved in sourceValue for display). bodyType, driveType, fuelType are display-only: never matched, always confidence: '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