# Check a ride-share driver's vehicle


![Hollin: a driver's 2023 Honda Accord checked rule by rule and placed in the Green ride tier, beside the pane that lists the Cardog calls behind it](https://cardog.app/marketing/guides/rideshare-vehicle-check/desktop.jpg "2880x1800")

You'll build **Hollin**, a ride-share operator's app: drivers add a car by VIN and see every rule it passes. [See what it does](https://cardog.app/use-cases/rideshare-vehicle-check).

- **Time:** About 45 minutes, or 5 to clone and run.
- **You need:** Node 20 or later and pnpm, A Cardog API key, Some Next.js
- **Cardog SDK:** `cardog.vin.image`, `cardog.v2.vin.getByVin`, `cardog.v2.specs.sheetByBuild`, `cardog.v2.recalls.entity`
- **Clone the finished app:** [cardog-ai/example-rideshare-vehicle-check](https://github.com/cardog-ai/example-rideshare-vehicle-check), or build it step by step below.

```bash
git clone https://github.com/cardog-ai/example-rideshare-vehicle-check.git
```

## 1. Get an API key

Every request to Cardog carries a key. Create one under [Account → API](https://cardog.app/account/api), then copy `.env.example` to `.env.local`. The app reads the key on the server; the browser never sees it.

The example values run as shipped. Without a key the app runs on responses stored for six sample cars, so you can follow along before you sign up.

`BETTER_AUTH_SECRET` ships as a development placeholder, so a fresh clone builds and runs. Replace it with your own (`openssl rand -base64 32`) before you deploy: a production server will not start without one, and warns on every start while the placeholder is set.

**`.env.example`**

```ini
# Copy to .env.local. With these values the app runs on stored fixtures
# and a development auth secret.

# Your Cardog API key (https://cardog.app/account/api). Server-side only; it
# never reaches the browser. Leave empty for fixture mode.
CARDOG_API_KEY=

# Signs sessions. This value is a DEVELOPMENT PLACEHOLDER so a fresh clone
# builds and runs; the app warns on every production start while it is set.
# Replace it before deploying: openssl rand -base64 32
BETTER_AUTH_SECRET=hollin-dev-only-secret-replace-before-deploying

# Where the app is served. Needed when it is not http://localhost:3024.
BETTER_AUTH_URL=http://localhost:3024

# SQLite file. Created on first run.
DATABASE_PATH=data/hollin.db

# Seeded review logins (created by `pnpm db:setup` on an empty database):
#   driver  driver@hollin.example / hollin-driver
#   ops     ops@hollin.example    / hollin-ops
```

## 2. Create the Next.js app

Hollin is one Next.js app: the driver's screens, the ops queue, the rules editor, and the server code that talks to Cardog. SQLite keeps it to one process. `better-sqlite3` is a native module, so the config loads it at runtime.

**`terminal`**

```bash
pnpm create next-app@latest hollin --ts --app --tailwind --src-dir
cd hollin
pnpm add @cardog/api better-auth better-sqlite3 drizzle-orm server-only
pnpm add -D drizzle-kit tsx @types/better-sqlite3
```

**`next.config.ts`**

```ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  // better-sqlite3 is a native module: load it from node_modules at runtime.
  serverExternalPackages: ["better-sqlite3"],
  // This app is its own root, even when it sits inside another repository.
  outputFileTracingRoot: process.cwd(),
};

export default nextConfig;
```

## 3. Add sign-in and roles

Drivers and ops sign in with email and password. `role` is a user field sign-up cannot set, so every sign-up is a driver and nobody makes themselves ops from the form.

`authSecret()` picks the secret that signs sessions: yours whenever `BETTER_AUTH_SECRET` is set, and the development placeholder only on your machine and during `next build`. A production server with no secret stops, and one still on the placeholder logs a warning.

**`src/lib/auth.ts`**

```ts
function authSecret(): string {
  const set = process.env.BETTER_AUTH_SECRET?.trim();
  const production = process.env.NODE_ENV === "production" && process.env.NEXT_PHASE !== "phase-production-build";
  if (set) {
    if (production && set === DEV_SECRET) {
      console.warn("[hollin] BETTER_AUTH_SECRET is the development placeholder. Replace it before deploying.");
    }
    return set;
  }
  if (!production) return DEV_SECRET;
  throw new Error("[hollin] BETTER_AUTH_SECRET is not set. Generate one with `openssl rand -base64 32`.");
}
```

**`src/lib/auth.ts`**

```ts
export const auth = betterAuth({
  appName: "Hollin",
  baseURL: process.env.BETTER_AUTH_URL || "http://localhost:3024",
  secret: authSecret(),
  database: drizzleAdapter(db, {
    provider: "sqlite",
    schema: { user: schema.user, session: schema.session, account: schema.account, verification: schema.verification },
  }),
  emailAndPassword: { enabled: true, minPasswordLength: 8, autoSignIn: true },
  user: {
    additionalFields: {
      role: { type: "string", required: false, defaultValue: "driver", input: false },
    },
  },
  plugins: [nextCookies()],
});
```

**`src/app/api/auth/[...all]/route.ts`**

```ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";

export const { GET, POST } = toNextJsHandler(auth);
```

## 4. Construct the Cardog client on the server

One file holds every Cardog call. The client is built once from the key, and the `server-only` import fails the build if a browser bundle reaches for it. Each call is timed and logged, which is what the "What the app called" pane shows.

SDK call: `new CardogClient({ apiKey, appId })`

**`src/lib/cardog.ts`**

```ts
const apiKey = process.env.CARDOG_API_KEY?.trim();

/** The one Cardog client. Server-side only. */
export const cardog: CardogClient | null = apiKey
  ? new CardogClient({ apiKey, appId: "hollin-vehicle-check", baseUrl: process.env.CARDOG_BASE_URL || undefined })
  : null;

export const dataMode: "live" | "fixture" = cardog ? "live" : "fixture";
export { FIXTURE_VINS };

// The response types, read off the client's own method signatures.
type V2 = CardogClient["v2"];
export type VinIdentity = Awaited<ReturnType<V2["vin"]["getByVin"]>>;
export type BuildSpecSheet = Awaited<ReturnType<V2["specs"]["sheetByBuild"]>>;
export type ModelYearRecalls = Awaited<ReturnType<V2["recalls"]["entity"]>>;
export type VinImageRead = Awaited<ReturnType<CardogClient["vin"]["image"]>>;

export interface Result<T> {
  data: T | null;
  call: CallRecord;
  error: string | null;
}
```

## 5. Check the VIN as it is typed

Count to seventeen and check the ninth character before spending a request. An I or O is almost always a 1 or 0, and a check digit that does not add up means a character is wrong.

**`src/lib/vin.ts`**

```ts
export function checkVin(vin: string): VinCheck {
  if (!vin) return { state: "empty" };
  const bad = vin.match(/[IOQ]/);
  if (bad) {
    const hint = bad[0] === "I" ? "1" : "0";
    return { state: "invalid", message: `VINs never use the letter ${bad[0]}. It's probably the number ${hint}.` };
  }
  if (/[^A-HJ-NPR-Z0-9]/.test(vin)) return { state: "invalid", message: "Only letters and numbers." };
  if (vin.length < 17) return { state: "typing", remaining: 17 - vin.length };
  const expected = expectedCheckDigit(vin);
  if (expected && expected !== vin[8]) {
    return {
      state: "check-mismatch",
      message: `The 9th character should be ${expected} for this VIN. Check for a mistyped character.`,
    };
  }
  return { state: "ok", message: "17 characters, check digit matches." };
}
```

![Hollin's add-a-car screen with a VIN entered](https://cardog.app/marketing/guides/rideshare-vehicle-check/vin.jpg "2880x1800")

## 6. Read the VIN from a photo

Typing seventeen characters on a phone is where drivers give up. Let them scan the door-jamb sticker instead; the route sends the image from the server and returns the VIN.

SDK call: `cardog.vin.image(base64)`

**What comes back.** `success` and `result`, the VIN read from the photo.

**`src/lib/cardog.ts`**

```ts
export function readVinFromImage(base64: string, step: string): Promise<Result<VinImageRead>> {
  return run<VinImageRead>({
    step,
    vin: FIXTURE_VINS[0].vin,
    key: "image",
    method: "cardog.vin.image",
    args: [`<${Math.round((base64.length * 3) / 4 / 1024)} KB image, base64>`],
    http: "POST /vin/image",
    returns: "The VIN read from the photo",
    live: (c) => c.vin.image(base64),
  });
}
```

**`src/app/api/vehicle/photo/route.ts`**

```ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readVinFromImage } from "@/lib/cardog";
import { normalizeVin } from "@/lib/vin";

const MAX_BYTES = 8 * 1024 * 1024;

/** POST multipart { photo } → the VIN read from it. Signed-in users only. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const form = await req.formData().catch(() => null);
  const file = form?.get("photo");
  if (!(file instanceof File)) return NextResponse.json({ ok: false, error: "No photo received." }, { status: 400 });
  if (file.size > MAX_BYTES) return NextResponse.json({ ok: false, error: "That photo is over 8 MB." }, { status: 413 });

  const base64 = Buffer.from(await file.arrayBuffer()).toString("base64");
  const r = await readVinFromImage(base64, "Scan your VIN");
  const vin = r.data?.success ? normalizeVin(r.data.result) : null;
  const found = Boolean(vin && vin.length === 17);
  return NextResponse.json({
    ok: found,
    vin: found ? vin : null,
    error: found ? null : (r.error ?? "We couldn't find a VIN in that photo. Get closer, or type it in."),
    calls: [r.call],
  });
}
```

## 7. Decode the VIN

Send the VIN from a route handler, never from the browser. The decode returns the facts most rules read: the model year, the doors, and refs for body style, vehicle type, weight class, electrification and fuel.

SDK call: `cardog.v2.vin.getByVin(vin)`

**What comes back.** Year, make, model, trim and doors, `refs` such as `bodyStyle`, `vehicleType`, `gvwr` and `electrificationLevel`, and `nano`, the build code.

**`src/lib/cardog.ts`**

```ts
export function decodeVin(vin: string, step: string) {
  return run<VinIdentity>({
    step,
    vin,
    key: "decode",
    method: "cardog.v2.vin.getByVin",
    args: [vin],
    http: `GET /v2/vin/${vin}`,
    returns: "Identity card: year, make, model, doors, body, vehicle type, GVWR class, electrification",
    live: (c) => c.v2.vin.getByVin(vin),
  });
}
```

## 8. Read seating from the spec sheet

A VIN does not encode seats; the spec catalogue does. Read the build's spec sheet with the `nano` the decode returned. It also backs up doors and weight class when the decode has none.

Then turn both responses into facts. Each fact keeps its value, a label and its source, so the check can say which one decided a rule.

SDK call: `cardog.v2.specs.sheetByBuild(nano)`

**What comes back.** Spec values by section, each with its source; the app reads `sections.identity.seatingCapacity`.

**`src/lib/cardog.ts`**

```ts
export function readBuildSpecs(nano: string, vin: string, step: string) {
  return run<BuildSpecSheet>({
    step,
    vin,
    key: "specs",
    method: "cardog.v2.specs.sheetByBuild",
    args: [nano],
    http: `GET /v2/specs/${nano}`,
    returns: "Spec sheet for the build: seating capacity, doors, body, GVWR",
    live: (c) => c.v2.specs.sheetByBuild(nano),
  });
}
```

**`src/lib/vehicle-read.ts`**

```ts
export async function readCar(userId: string, vin: string, step: string): Promise<ReadResult> {
  const decoded = await decodeVin(vin, step);
  if (!decoded.data) return { ok: false, error: decoded.error, facts: null, calls: [decoded.call] };

  const calls = [decoded.call];
  let specs: unknown = null;
  if (decoded.data.nano) {
    const s = await readBuildSpecs(decoded.data.nano, vin, step);
    calls.push(s.call);
    specs = s.data;
  }
  const facts = buildFacts(vin, {
    decode: decoded.data as unknown as Record<string, unknown>,
    specs: specs as Record<string, unknown> | null,
  });
  held.set(keyOf(userId, vin), { facts, calls, at: Date.now() });
  return { ok: true, error: null, facts, calls };
}
```

**`src/app/api/vehicle/check/route.ts`**

```ts
import { NextResponse } from "next/server";
import { getViewer } from "@/lib/session";
import { readCar } from "@/lib/vehicle-read";
import { normalizeVin } from "@/lib/vin";

/** POST { vin } → the car's facts (decode + build spec sheet) and the call log. */
export async function POST(req: Request) {
  const me = await getViewer();
  if (!me) return NextResponse.json({ ok: false, error: "Sign in first." }, { status: 401 });
  const { vin } = (await req.json().catch(() => ({}))) as { vin?: string };
  const v = normalizeVin(vin ?? "");
  if (v.length !== 17) return NextResponse.json({ ok: false, error: "A VIN has 17 characters." }, { status: 400 });
  return NextResponse.json(await readCar(me.id, v, "Check your car"));
}
```

**`src/lib/vehicle-facts.ts`**

```ts
export function buildFacts(vin: string, raw: { decode: Loose | null; specs: Loose | null }): VehicleFacts {
  const d = raw.decode ?? {};
  const refs: Loose = d.refs ?? {};
  const attrs: Loose = d.attributes ?? {};
  const identity: Loose = raw.specs?.sections?.identity ?? {};

  const bodyRef: string | null = refs.bodyStyle ?? identity.bodyStyle?.value ?? null;
  const typeRef: string | null = refs.vehicleType ?? identity.vehicleType?.value ?? null;
  const gvwrRef: string | null = refs.gvwr ?? raw.specs?.sections?.dimensions?.gvwr?.value ?? null;
  const elecRef: string | null = refs.electrificationLevel ?? null;
  const fuelRef: string | null = refs.fuelType ?? null;

  // Doors: the decode first, then the build's spec sheet.
  const decodedDoors = typeof d.doors === "number" ? d.doors : attrs.doors?.value;
  const specDoors = identity.doors?.value;
  const doors: Fact<number> | null =
    typeof decodedDoors === "number"
      ? { value: decodedDoors, label: `${decodedDoors} doors`, source: "VIN decode" }
      : typeof specDoors === "number"
        ? { value: specDoors, label: `${specDoors} doors`, source: "Spec catalogue" }
        : null;

  // Seats come from the catalogue: a VIN does not encode seating.
  const seatValue = identity.seatingCapacity?.value;
  const seats: Fact<number> | null =
    typeof seatValue === "number" ? { value: seatValue, label: `${seatValue} seats`, source: "Spec catalogue" } : null;

  return {
    vin,
    year: typeof d.year === "number" ? d.year : (raw.specs?.year ?? null),
    make: d.make ?? raw.specs?.make ?? null,
    model: d.model ?? raw.specs?.model ?? null,
    trim: d.trim ?? identity.trim?.value ?? null,
    body: bodyRef ? { value: bodyRef, ref: bodyRef, label: labelOf(BODY_STYLES, bodyRef), source: "VIN decode" } : null,
    vehicleType: typeRef
      ? { value: typeRef, ref: typeRef, label: VEHICLE_TYPES[typeRef] ?? fromRef(typeRef) ?? typeRef, source: "VIN decode" }
      : null,
    doors,
    seats,
    gvwr: gvwrRef
      ? {
          value: gvwrRef,
          ref: gvwrRef,
          label: attrs.gvwr?.toName ?? fromRef(gvwrRef) ?? gvwrRef,
          source: "VIN decode",
          ...gvwrRange(gvwrRef),
        }
      : null,
    electrification: elecRef
      ? { value: elecRef, ref: elecRef, label: labelOf(ELECTRIFICATION_LEVELS, elecRef), source: "VIN decode" }
      : null,
    fuel: fuelRef ? { value: fuelRef, ref: fuelRef, label: FUELS[fuelRef] ?? fromRef(fuelRef) ?? fuelRef, source: "VIN decode" } : null,
    refs: { modelYear: refs.modelYear ?? raw.specs?.modelYearRef ?? null, nano: d.nano ?? null },
    read: { decode: Boolean(raw.decode), specs: Boolean(raw.specs) },
  };
}
```

![Hollin asking the driver to confirm the decoded car and its plates](https://cardog.app/marketing/guides/rideshare-vehicle-check/confirm.jpg "2880x1800")

## 9. Write the rules

The rules are one config and one pure function. The same `evaluate()` runs on the driver's phone, in the ops queue and in the rules editor's preview.

A fact Cardog did not return never passes: the rule shows Check and the car needs a look from a person. Plates are the one thing the driver answers, because nothing in a VIN says how a car is plated.

**`src/lib/rules.ts`**

```ts
export interface RuleConfig {
  /** Oldest model year allowed, as an age in years: 2026 − 10 → 2016 or newer. */
  maxAgeYears: number;
  minDoors: number;
  minSeats: number;
  /** Body-style refs a car may have. */
  bodies: string[];
  /** Heaviest gross vehicle weight rating allowed, in pounds. */
  maxGvwrLb: number;
  /** Refuse vehicle types plated as commercial (truck, bus, incomplete). */
  blockCommercialTypes: boolean;
  /** Ask the driver about plates and refuse commercial ones. */
  requirePersonalPlates: boolean;
  /** Electrification levels that put a car in the Green ride tier. */
  greenLevels: string[];
}
```

**`src/lib/rules.ts`**

```ts
export function evaluate(f: VehicleFacts, a: Answers, rules: RuleConfig, now = new Date()): Evaluation {
  const results: RuleResult[] = [];
  const thisYear = now.getFullYear();
  const oldest = thisYear - rules.maxAgeYears;

  // Age, from the decoded model year.
  results.push(
    f.year == null
      ? { id: "age", label: `${oldest} or newer`, outcome: "look", fact: "Model year not decoded", source: "Not decoded" }
      : {
          id: "age",
          label: `${oldest} or newer`,
          outcome: f.year >= oldest ? "pass" : "fail",
          fact: `${f.year} model year, ${Math.max(0, thisYear - f.year)} ${thisYear - f.year === 1 ? "year" : "years"} old`,
          source: "VIN decode",
        }
  );

  // Doors.
  results.push(
    f.doors
      ? {
          id: "doors",
          label: `${rules.minDoors} doors or more`,
          outcome: f.doors.value >= rules.minDoors ? "pass" : "fail",
          fact: f.doors.label,
          source: f.doors.source,
        }
      : { id: "doors", label: `${rules.minDoors} doors or more`, outcome: "look", fact: "Door count not decoded", source: "Not decoded" }
  );

  // Seats, including the driver.
  results.push(
    f.seats
      ? {
          id: "seats",
          label: `${rules.minSeats} seats or more`,
          outcome: f.seats.value >= rules.minSeats ? "pass" : "fail",
          fact: `${f.seats.label}, driver included`,
          source: f.seats.source,
        }
      : { id: "seats", label: `${rules.minSeats} seats or more`, outcome: "look", fact: "Seating not in the catalogue", source: "Not decoded" }
  );

  // Body style.
  results.push(
    f.body
      ? {
          id: "body",
          label: "An allowed body style",
          outcome: rules.bodies.includes(f.body.value) ? "pass" : "fail",
          fact: f.body.label,
          source: f.body.source,
        }
      : { id: "body", label: "An allowed body style", outcome: "look", fact: "Body style not decoded", source: "Not decoded" }
  );

  // Weight: the GVWR class, or failing that a passenger-car vehicle type.
  const gvwrLabel = `GVWR ${fmt(rules.maxGvwrLb)} lb or less`;
  if (f.gvwr && (f.gvwr.maxLb != null || f.gvwr.minLb != null)) {
    const outcome: Outcome =
      f.gvwr.maxLb != null && f.gvwr.maxLb <= rules.maxGvwrLb
        ? "pass"
        : f.gvwr.minLb != null && f.gvwr.minLb > rules.maxGvwrLb
          ? "fail"
          : "look";
    results.push({ id: "gvwr", label: gvwrLabel, outcome, fact: f.gvwr.label, source: f.gvwr.source });
  } else if (f.vehicleType?.value === "vehicle-type:passenger-car") {
    results.push({
      id: "gvwr",
      label: gvwrLabel,
      outcome: "pass",
      fact: "No GVWR class decoded; a passenger car, so light duty",
      source: "VIN decode",
    });
  } else {
    results.push({ id: "gvwr", label: gvwrLabel, outcome: "look", fact: "GVWR not decoded", source: "Not decoded" });
  }

  // Commercial vehicle class.
  if (rules.blockCommercialTypes) {
    results.push(
      f.vehicleType
        ? {
            id: "commercial",
            label: "Not a commercial vehicle type",
            outcome: COMMERCIAL_TYPES.has(f.vehicleType.value) ? "fail" : "pass",
            fact: f.vehicleType.label,
            source: f.vehicleType.source,
          }
        : { id: "commercial", label: "Not a commercial vehicle type", outcome: "look", fact: "Vehicle type not decoded", source: "Not decoded" }
    );
  }

  // Plates: the driver says; a VIN cannot.
  if (rules.requirePersonalPlates) {
    results.push({
      id: "plates",
      label: "Personal plates",
      outcome: a.plates === "personal" ? "pass" : a.plates === "commercial" ? "fail" : "look",
      fact: PLATES[a.plates],
      source: "Driver",
    });
  }

  const counts = { pass: 0, fail: 0, look: 0 } as Record<Outcome, number>;
  for (const r of results) counts[r.outcome]++;
  const verdict: Verdict = counts.fail > 0 ? "not-eligible" : counts.look > 0 ? "look" : "eligible";

  // Green ride: electrified at a level ops counts.
  const green = f.electrification
    ? {
        tier: rules.greenLevels.includes(f.electrification.value),
        fact: f.electrification.label,
        source: f.electrification.source,
      }
    : f.fuel
      ? { tier: false, fact: `${f.fuel.label}, not electrified`, source: f.fuel.source }
      : { tier: false, fact: "Powertrain not decoded", source: "Not decoded" as const };

  return { verdict, results, green, counts };
}
```

![Hollin's check: every rule passed and the Green ride tier](https://cardog.app/marketing/guides/rideshare-vehicle-check/check.jpg "2880x1800")

## 10. Save the car for review

Sending reuses the reads the driver just saw, held for an hour, so a car is not paid for twice. The row keeps the facts, the driver's answer, the call log, and the verdict and rules version it was sent under.

**`src/app/actions.ts`**

```ts
export async function sendCar(input: { vin: string; plates: Plates }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "driver") return { ok: false, error: "Sign in as a driver first." };
  const vin = normalizeVin(input.vin);
  if (vin.length !== 17) return { ok: false, error: "A VIN has 17 characters." };
  if (!(input.plates in PLATES)) return { ok: false, error: "Tell us about the plates." };

  const existing = db
    .select({ id: schema.car.id })
    .from(schema.car)
    .where(and(eq(schema.car.userId, me.id), eq(schema.car.vin, vin)))
    .get();
  if (existing) return { ok: false, error: "This car is already on your account." };

  // Save what the driver saw; read again only if that has expired.
  let h = heldRead(me.id, vin);
  if (!h) {
    const r = await readCar(me.id, vin, "Send for review");
    if (!r.ok || !r.facts) return { ok: false, error: r.error ?? "We couldn't read that VIN.", calls: r.calls };
    h = { facts: r.facts, calls: r.calls, at: Date.now() };
  }
  const rules = currentRules();
  const answers = { plates: input.plates };
  const e = evaluate(h.facts, answers, rules.config);
  const id = crypto.randomUUID();
  db.insert(schema.car)
    .values({
      id,
      userId: me.id,
      vin,
      year: h.facts.year,
      make: h.facts.make,
      model: h.facts.model,
      trim: h.facts.trim,
      facts: h.facts,
      answers,
      calls: h.calls,
      verdictAtSubmit: e.verdict,
      rulesVersion: rules.version,
      green: e.green.tier,
      status: "waiting",
      readAt: new Date(h.at),
    })
    .run();
  dropRead(me.id, vin);
  revalidatePath("/drive");
  revalidatePath("/ops");
  return { ok: true, id };
}
```

## 11. Build the ops queue

Ops sees the waiting cars ranked by today's result. Opening one reads the recalls issued for its model and year; drivers never see them, and the screen says they are matched by model year.

Ops approves or declines with a note the driver reads.

SDK call: `cardog.v2.recalls.entity(modelYearRef)`

**What comes back.** Transport Canada and NHTSA campaigns issued for the model year, with `total`.

**`src/lib/cardog.ts`**

```ts
export function readModelYearRecalls(modelYearRef: string, vin: string, step: string) {
  return run<ModelYearRecalls>({
    step,
    vin,
    key: "recalls",
    method: "cardog.v2.recalls.entity",
    args: [modelYearRef, { limit: 25 }],
    http: `GET /v2/recalls/entity/${modelYearRef}?limit=25`,
    returns: "Recall campaigns issued for the model year (Transport Canada and NHTSA)",
    live: (c) => c.v2.recalls.entity(modelYearRef, { limit: 25 }),
  });
}
```

**`src/app/actions.ts`**

```ts
export async function decide(input: { carId: string; status: "approved" | "declined"; note: string }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "ops") return { ok: false, error: "Only ops can decide." };
  if (input.status !== "approved" && input.status !== "declined") return { ok: false, error: "Approve or decline." };
  const note = input.note.trim().slice(0, 500);
  if (input.status === "declined" && !note) return { ok: false, error: "Tell the driver why in a line." };
  db.update(schema.car)
    .set({
      status: input.status,
      decisionNote: note || null,
      decidedAt: new Date(),
      decidedBy: me.name,
      updatedAt: new Date(),
    })
    .where(eq(schema.car.id, input.carId))
    .run();
  revalidatePath("/ops");
  revalidatePath(`/ops/${input.carId}`);
  return { ok: true };
}
```

![Hollin's ops view of a car that needs a look, with the recalls issued for its model and year](https://cardog.app/marketing/guides/rideshare-vehicle-check/review.jpg "2880x1800")

## 12. Let ops edit the rules

The rules live in the database with versions. The editor re-runs `evaluate()` over the waiting cars as ops changes a stepper or a switch, so they see the effect before saving; saving writes a new version and the old ones stay.

**`src/lib/data.ts`**

```ts
export function currentRules(): Rules {
  const row = db.select().from(schema.ruleset).orderBy(desc(schema.ruleset.version)).limit(1).get();
  return row
    ? toRules(row)
    : { version: 0, config: DEFAULT_RULES, note: null, createdBy: "Hollin", createdAt: new Date(0) };
}
```

**`src/app/actions.ts`**

```ts
export async function saveRules(input: { config: Partial<RuleConfig>; note: string }): Promise<Done> {
  const me = await getViewer();
  if (!me || me.role !== "ops") return { ok: false, error: "Only ops can change the rules." };
  const config = normalizeRules(input.config);
  if (config.bodies.length === 0) return { ok: false, error: "Allow at least one body style." };
  const version = currentRules().version + 1;
  db.insert(schema.ruleset)
    .values({ version, config, note: input.note.trim().slice(0, 200) || null, createdBy: me.name })
    .run();
  revalidatePath("/", "layout");
  return { ok: true };
}
```

![Hollin's rules editor with steppers, body styles and the effect on waiting cars](https://cardog.app/marketing/guides/rideshare-vehicle-check/rules.jpg "2880x1800")

## 13. Deploy

Hollin ships as one container with SQLite on a volume. Build the image, mount the volume, and set the key, the auth secret and the public URL in the host's environment. On a serverless host, swap SQLite for a hosted database; the Cardog code does not change.

**`Dockerfile`**

```dockerfile
# Hollin vehicle check: one container, SQLite on a mounted volume.
#   docker build -t hollin .
#   docker run -p 3024:3024 -v hollin-data:/app/data \
#     -e CARDOG_API_KEY=... -e BETTER_AUTH_SECRET=... -e BETTER_AUTH_URL=https://your.host hollin
FROM node:22-bookworm-slim AS build
WORKDIR /app
RUN corepack enable
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build

FROM node:22-bookworm-slim
WORKDIR /app
ENV NODE_ENV=production
RUN corepack enable
COPY --from=build /app ./
VOLUME /app/data
EXPOSE 3024
CMD ["pnpm", "start"]
```

**`terminal`**

```bash
docker build -t hollin .
docker run -p 3024:3024 -v hollin-data:/app/data \
  -e CARDOG_API_KEY=your_cardog_api_key \
  -e BETTER_AUTH_SECRET=$(openssl rand -base64 32) \
  -e BETTER_AUTH_URL=https://drive.example.com \
  hollin
```

## Next steps

- [VIN decode](https://cardog.app/docs/vin): Every field the decode returns.
- [Recalls](https://cardog.app/docs/recalls): Campaigns by model year, from each authority.
- [Entity refs](https://cardog.app/docs/entity-resolution): How refs like body style and weight class are named.


---

Previous: [Check a VIN at the registration counter](https://cardog.app/docs/guides/registration-counter.md) (Build guides)
Next: [Build an EV charging map](https://cardog.app/docs/guides/ev-charging-map.md) (Build guides)
