# Lot Watch — Design Doc
**Status:** Draft / planning **Branch:** `parking-watch-design` **Author:**
drafted with Claude **Related pattern:**
`packages/patterns/factory-outputs/parking-coordinator/main.tsx`
---
## 1. Background & Problem
We have **4 parking spots** in the building's lot: **1, 5, 12, and 13**. They
are ours, but in practice other people park in them regularly:
- **Customers** of neighboring businesses (e.g. Local Butcher Shop) — annoying.
- **Employees** of those businesses parking in our spots — **egregious**,
because they should know better and they do it repeatedly.
We want a lightweight way for **our employees** to document offenders from their
phone, build a record over time, and surface **repeat offenders** and **how
often each spot is taken**. The key intelligence is matching a sighting's
license plate against:
- **Our own employees** (totally fine — they belong here),
- **Our guests** (totally fine — invited),
- **Known employees of neighboring businesses** like Local Butcher Shop (**not
fine**).
## 2. Goals
1. **Dead-simple mobile capture.** Open pattern on phone → tap to photograph the
car → pick the spot → done. The LLM does the transcription work.
2. **Automatic extraction.** An LLM reads the photo and returns a structured
`{ description, plateNumber, plateState }` (e.g. "black Subaru Outback",
`7ABC123`, `CA`).
3. **Persistent record.** Keep both the original image _and_ the extraction, so
a human can audit/correct the LLM.
4. **Classification against known plates.** Each sighting is auto-tagged `ours`
/ `guest` / `offender` / `unknown` by matching against registries.
5. **Dedup / relate.** Group sightings of the same plate (and fuzzily, same
description) so we see "this car has been here 6 times."
6. **Reporting.** A clean report of spot-occupancy frequency and a
repeat-offenders leaderboard.
7. **Reuse, don't duplicate.** Absorb employee vehicle/plate info from
`parking-coordinator` rather than re-entering it.
8. **Fully idiomatic.** Use `pattern`, scoped `Writable` cells
(`PerSpace`/`PerUser`/`PerSession`), the **CFC admin** module for who may
edit the watchlists, `generateObject` for extraction, and `cf-*` components
for a clean responsive UI — mirroring the conventions already established in
`parking-coordinator`.
### Non-goals
- No legal/enforcement workflow (towing, citations). We're documenting, not
adjudicating.
- No real-time alerts/notifications in v1 (could be a later phase).
- Not a general ALPR system — accuracy depends on the LLM + photo quality, and a
human can always correct it.
## 3. Relationship to `parking-coordinator`
`parking-coordinator` already owns the canonical model of **our spots** and
**our people**, and it already uses the **CFC admin** registry to gate edits. We
want Lot Watch to be a sibling pattern in the same space that _shares_ data via
`PerSpace` cell wiring, exactly the way `parking-coordinator` accepts
`spots`/`people`/`adminRegistry` as `PerSpace<...>` inputs.
```ts
// parking-coordinator/main.tsx (today)
export interface ParkingCoordinatorInput {
spots?: PerSpace;
people?: PerSpace;
requests?: PerSpace;
adminRegistry?: PerSpace;
}
```
### 3a. Sharing spots
Lot Watch should reuse the **same `spots` cell** so spot numbers (1/5/12/13)
stay in sync. We take `spots?: PerSpace` as an input and, when
present, use it to drive the spot picker. When absent, Lot Watch seeds its own
`["1","5","12","13"]` default.
### 3b. Sharing employee vehicles — **requires a small upstream change**
> **Dependency / decision needed.** `parking-coordinator`'s `Person` type today
> has `name, email, commuteMode, spotPreferences, defaultSpot, priorityRank` —
> **no vehicle or plate fields** (verified in `main.tsx:38-45`). So there is
> nothing to "absorb" yet.
Two options:
1. **(Recommended) Extend `Person` upstream** with an optional `vehicles` array,
and have Lot Watch read employee plates from the shared `people` cell:
```ts
// Canonical shape, owned by parking-coordinator (see vehicles.ts):
export interface Vehicle {
plateId: string; // REQUIRED — raw plate characters
plateState: string; // optional, default "CA"
color: string; // optional, from VEHICLE_COLORS
make: string; // optional, from VEHICLE_MAKES
model: string; // optional, from MODELS_BY_MAKE[make]
}
// Person gains:
// vehicles?: Vehicle[];
```
Lot Watch normalizes `plateId`+`plateState` for matching, and derives a human
description from `color`/`make`/`model` (e.g. "black Subaru Outback"). Lot
Watch then treats every plate in `people[].vehicles` as **`ours`**. This is
the cleanest "single source of truth" approach and lets the coordinator's
admin UI manage employee plates too. It's a small additive change (optional
field).
2. **Keep them separate** — Lot Watch maintains its own `knownVehicles` registry
and we manually mark some as employees. Less duplication risk if we _also_
pull from `people`, but two places to edit.
**Decided:** do (1) — add `vehicles?: Vehicle[]` to coordinator's `Person`
(single source of truth, managed in the coordinator's admin UI), and have Lot
Watch consume `people` **read-only** for the "ours" set, while keeping its own
registries for guests and known-offenders (data the coordinator has no concept
of).
## 4. Domain Model
```ts
// A single documented sighting of a car in one of our spots.
export interface Sighting {
id: string; // genId()
spotNumber: string; // "1" | "5" | "12" | "13" (validated against spots)
capturedAt: number; // safeDateNow() at capture
reportedBy: string; // employee name (current actor)
image: ImageData; // original photo (kept for audit) — has .data/.url/.name
// LLM extraction (editable by a human after the fact):
description: string; // "black Subaru Outback"
plateNumber: string; // normalized uppercase, e.g. "7ABC123"
plateState: string; // "CA" (may be "" if not visible)
extractionPending: boolean; // true while the LLM call is in flight
extractionError: string; // non-empty if extraction failed
humanCorrected: boolean; // true once a person edited the extracted fields
// Derived/cached classification (recomputed; stored for report stability):
classification: Classification;
notes: string; // freeform ("blocked the dumpster", etc.)
}
export type Classification = "ours" | "guest" | "offender" | "unknown";
// Plates we recognize. `ours` is sourced from parking-coordinator people;
// guests and offenders are Lot-Watch-owned registries.
export interface KnownVehicle {
plateNumber: string;
plateState: string;
description: string;
category: "guest" | "offender";
org: string; // e.g. "Local Butcher Shop" (for offenders)
label: string; // human note, e.g. "delivery van, Tue mornings"
}
```
### LLM extraction target
`generateObject` infers/accepts a schema. The extraction shape:
```ts
export interface PlateExtraction {
description: string; // make/model/color in plain words; "" if unclear
plateNumber: string; // characters only; "" if not legible
plateState: string; // 2-letter US state; "" if not visible
confidence: "high" | "medium" | "low";
}
```
## 5. Cell Scopes
Following `parking-coordinator`'s discipline (it uses all three scopes):
| Cell | Scope | Why |
| ----------------------------------------------------------------- | ---------------- | --------------------------------------------------- |
| `sightings: Sighting[]` | **`PerSpace`** | shared team record — everyone sees the same log |
| `knownVehicles: KnownVehicle[]` | **`PerSpace`** | shared guest + offender registries |
| `spots` (input) | **`PerSpace`** | shared with `parking-coordinator` |
| `people` (input) | **`PerSpace`** | read employee `vehicles` (the "ours" set) |
| `adminRegistry` | **`PerSpace`** | shared CFC admin registry (who can edit watchlists) |
| `adminManagerCredential` | **`PerUser`** | per-user manager credential (CFC pattern) |
| capture draft fields (`draftSpot`, in-flight image, `draftNotes`) | **`PerSession`** | transient UI state per device/tab |
| `selectedTab` / report filters / confirm-dialog targets | **`PerSession`** | ephemeral UI state |
| `reporterName` (current actor) | **`PerUser`** | who's doing the documenting |
These map directly onto the constructors already in use:
`Writable.perSpace.of(...)`, `new Writable.perUser(...)`,
`new Writable.perSession(...)`.
## 6. Authorization (CFC Admin)
We reuse `packages/patterns/cfc/admin/mod.ts` exactly as `parking-coordinator`
does. The integrity-tagged role model:
- `adminManagerCredentialIsActive(credential)` — gates who may _assign_ admins.
- `adminRegistryEntries(registry)` — read the admin list.
- `AddIntegrity<...>` / `RequiresIntegrity<...>` — brand the role/list types so
they can only be produced through the credentialed path.
```ts
export const LOT_WATCH_ADMIN_INTEGRITY = "lot-watch-admin" as const;
export const LOT_WATCH_ADMIN_MANAGER_INTEGRITY =
"lot-watch-admin-manager" as const;
```
**What admin gates:** editing the **guest** and **offender** registries,
deleting sightings, and bulk-merging duplicates. **Any employee can capture a
sighting** — documentation must be frictionless; only curation is privileged.
**Decided:** Lot Watch **reuses parking-coordinator's `adminRegistry`** (shared
`PerSpace` input) but tags its role with a distinct `lot-watch-admin` integrity,
so the one registry carries both role types — the admin module is already
generic over `Role`. One admin list to manage operationally.
## 7. Capture Flow (mobile-first)
The marquee interaction. Built on ``,
which opens the **rear camera** directly on mobile (confirmed in
`packages/ui/src/v2/components/cf-image-input/cf-image-input.ts:79,165`).
```tsx
;
```
Handler mirrors `image-analysis.tsx:40-47`:
```ts
const onPhotoCaptured = handler<
ImageUploadEvent,
{ draftImage: Writable }
>(
({ detail }, { draftImage }) => {
const img = (detail?.allImages ?? detail?.images ?? [])[0] ?? null;
draftImage.set(img);
},
);
```
### Extraction
Reuse the `store-mapper.tsx:326-370` idiom — a `generateObject` call whose
`prompt` is a content-parts array mixing image + text, with an explicit JSON
`schema`:
```ts
const extraction = generateObject({
system:
"You are reading a photo of a parked car. Extract the vehicle description " +
"(color + make + model in plain words), the license plate characters, and " +
"the 2-letter US state if visible. If a field is not legible, return an " +
"empty string. Do not guess.",
prompt: computed(() => {
const img = draftImage.get();
const image = img?.data || img?.url;
if (!image) return [];
return [
{ type: "image" as const, image },
{
type: "text" as const,
text:
"Extract description, plateNumber (characters only, no spaces/dashes), " +
"plateState (2-letter), and your confidence.",
},
];
}),
schema: {/* PlateExtraction JSON schema, like store-mapper */},
model: "anthropic:claude-sonnet-4-5",
});
// reactive: extraction.result / extraction.pending / extraction.error
```
### Confirm & save
The capture screen shows the photo, the **editable** extracted fields (so a
human can fix a misread plate before saving), the spot picker, and a Save
button:
1. Tap **📸 Photograph the car** → camera → preview.
2. LLM auto-fills description / plate / state (shows "Reading plate…" while
`pending`). Fields remain editable.
3. Pick the spot (segmented control of 1 / 5 / 12 / 13, sourced from `spots`).
4. Optional note.
5. **Save** → appends a `Sighting` to the `PerSpace` `sightings` cell with the
image, the (possibly human-corrected) extraction, and a computed
classification.
6. Form resets for the next car.
Normalization on save: `plateNumber → uppercase, strip non-alphanumerics`;
`plateState → uppercase 2-letter`. This makes matching reliable.
## 8. Classification
On save (and reactively in reports), each sighting is classified by matching its
normalized `(plateNumber, plateState)` against the registries, in priority
order:
1. **`ours`** — plate is in any `people[].vehicles` (from parking-coordinator).
2. **`guest`** — plate is in `knownVehicles` with `category === "guest"`.
3. **`offender`** — plate is in `knownVehicles` with `category === "offender"`.
4. **`unknown`** — no match (the interesting bucket — candidates to promote into
a registry).
`unknown` sightings get a one-tap **"Mark as guest"** / **"Mark as offender
(org…)"** action (admin-gated) that adds the plate to `knownVehicles` and
retro-classifies all sightings with that plate. This is the curation loop that
turns raw sightings into intelligence.
## 9. Dedup / Relating Sightings
Two cars are "the same" primarily by **normalized plate**
(`plateNumber+plateState`). Secondary, fuzzy signal when a plate is
missing/illegible: **description similarity** (lowercased token overlap — same
color + make).
A `computed` groups sightings:
```ts
const groups = computed(() => {
// key = plate if present, else `desc:${normalizedDescription}`
// → [{ key, plate, classification, count, firstSeen, lastSeen, spots:Set, sightings:[] }]
});
```
UI surfaces groups with count badges; tapping a group shows its timeline of
photos. A duplicate that the system _didn't_ auto-group (plate misread
differently across two photos) can be **manually merged** by an admin, which
rewrites the minority sightings' plate to the canonical one.
## 10. Reports
A dedicated tab (`PerSession` `selectedTab`), all driven by `computed` over the
`PerSpace` `sightings`:
1. **Spot occupancy frequency** — per spot (1/5/12/13): total sightings, # by
non-ours cars, and a sparkline/bar of last 30 days. Answers "how often is
each spot known to be taken?"
2. **Repeat offenders leaderboard** — groups with
`classification === "offender"` (and frequent `unknown`s), ranked by count,
showing org, plate, last seen, and which spots. Answers "who are the repeat
offenders?"
3. **Recent activity feed** — reverse-chronological sightings with thumbnail,
classification chip, spot, reporter.
4. **Filters** — by spot, by classification, by date range (`PerSession`).
Classification chips reuse the color language: `ours` green, `guest` blue,
`offender` red, `unknown` gray — consistent with `parking-coordinator`'s inline
style palette.
## 11. Mobile UX / Layout
Same shell as `parking-coordinator`: `` with a `slot="header"`, a
`` body, ``/`` for structure, ``
sections, ``, ``, ``, ``.
Top-level is a **3-tab** layout optimized so the primary action is one tap from
open:
- **📸 Capture** (default tab) — the capture flow (§7). Big camera button,
thumb-reachable.
- **🚗 Sightings** — grouped/deduped list (§9), filterable.
- **📊 Report** — occupancy + offenders (§10).
Capture-first ordering and large tap targets keep it usable one-handed in a
parking lot. Image previews use `previewSize="lg"`; the spot picker is a
segmented row of big buttons, not a tiny dropdown.
## 12. Pattern I/O Sketch
```ts
export interface LotWatchInput {
// Shared with parking-coordinator (all optional → standalone-capable):
spots?: PerSpace; // reuse spot numbers
people?: PerSpace; // read employee vehicles → "ours"
adminRegistry?: PerSpace;
// Lot-Watch-owned:
sightings?: PerSpace;
knownVehicles?: PerSpace;
}
export interface LotWatchOutput {
[NAME]: string;
[UI]: VNode;
sightings: Sighting[];
knownVehicles: KnownVehicle[];
// capture:
captureSighting: Stream<
{
spotNumber: string;
description: string;
plateNumber: string;
plateState: string;
notes: string;
}
>;
correctExtraction: Stream<
{ id: string; description: string; plateNumber: string; plateState: string }
>;
deleteSighting: Stream<{ id: string }>; // admin
// curation:
markVehicle: Stream<
{
plateNumber: string;
plateState: string;
category: "guest" | "offender";
org: string;
label: string;
}
>;
removeKnownVehicle: Stream<{ plateNumber: string; plateState: string }>;
mergeSightings: Stream<{ canonicalPlate: string; fromPlate: string }>; // admin
// admin (CFC), mirroring parking-coordinator:
enableAdminManager: Stream;
togglePersonAdmin: Stream<{ name: string }>;
// UI nav:
selectTab: Stream<{ tab: "capture" | "sightings" | "report" }>;
}
```
## 13. File Layout
```
packages/patterns/factory-outputs/lot-watch/
main.tsx # the pattern
main.test.tsx # tests (pattern test harness, like parking-coordinator)
DESIGN.md # this doc
```
Plus: add a one-line entry to `packages/patterns/index.md` (catalog index), and
the small additive `vehicles?: Vehicle[]` change to
`parking-coordinator/main.tsx`'s `Person` (§3b).
## 14. Implementation Phases
1. **Skeleton + capture (no LLM).** Pattern scaffold, scopes, `cf-image-input`
capture → store a `Sighting` with a manually typed plate/description. Spot
picker from `spots`. Verify save/list round-trips.
2. **LLM extraction.** Wire `generateObject` for image→`PlateExtraction`,
auto-fill editable fields, handle `pending`/`error`. Normalize on save.
3. **Registries + classification.** `knownVehicles` cell, "ours" from `people`,
classification computed + chips, curation actions (mark guest/offender),
retro-classification.
4. **Dedup/grouping + manual merge.**
5. **Reports tab** — occupancy frequency + repeat offenders.
6. **CFC admin gating** on curation/delete/merge; admin UI section.
7. **Upstream:** add `vehicles?: Vehicle[]` to coordinator `Person` + its admin
UI.
8. **Polish:** mobile layout pass, empty states, confirm dialogs (reuse
coordinator's `*ConfirmTarget` PerSession idiom).
## 15. Testing
Mirror `parking-coordinator/main.test.tsx` (pattern test harness): drive
`Stream` actions, assert on output cells / `computed` values.
- `captureSighting` appends with normalized plate.
- Classification: a plate in `people[].vehicles` → `ours`; in offender registry
→ `offender`; unmatched → `unknown`.
- `markVehicle` retro-classifies existing sightings.
- Dedup grouping counts repeats by plate; description fallback when plate empty.
- Report computeds: occupancy counts per spot; offender ranking order.
- Admin gating: curation actions are no-ops without an active manager
credential.
- LLM extraction is mocked at the boundary (don't hit a live model in tests);
assert the content-parts array shape and that `result` flows into editable
fields.
## 16. Resolved Decisions
- **Admin role** — reuse parking-coordinator's `adminRegistry` with a distinct
`lot-watch-admin` integrity tag. (§6)
- **Employee plate source** — extend coordinator's `Person` with
`vehicles?:
Vehicle[]`; Lot Watch reads it read-only as "ours". (§3b)
- **Retention** — v1 keeps everything (no auto-purge). Plates + photos are
sensitive, so this is a known, deliberate gap to revisit (a `#now`-driven
purge action can be added later without schema change).
## 17. Remaining Open Questions
1. **Multi-photo per sighting** — sometimes you want the plate _and_ a wide
shot. v1 = one photo; could later extend `Sighting.image` to
`images: ImageData[]`.
2. **Model choice / cost** — `claude-sonnet-4-5` for vision accuracy vs. a
cheaper model. Per-item caching (`generateObject` over a `.map`) keeps
re-renders cheap.