docs(wiki): decide vision service packaging — apps/vision/ in the monorepo

Settle WHERE the host-side ANPR service lives and how it joins the build: in this
monorepo at apps/vision/ (not a separate repo), still a separate OS process called
over localhost HTTP, wired into the Turbo graph via a thin package.json shim whose
scripts shell to Python tooling (uv/uvicorn/ruff/pytest). Co-located source honors the
vision-service runtime+license isolation decision (AGPL reach is a linking boundary,
not a folder); the fast-alpr MIT baseline removes most of the split-repo pressure
anyway. New page vision-service-packaging; updates vision-service, opencv-anpr-service,
the CLAUDE.md layout, index, log. Not built yet — packaging decision only.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 14:25:31 +02:00
parent e4827c9651
commit ee28b7302f
6 changed files with 109 additions and 4 deletions
@@ -0,0 +1,95 @@
---
type: decision
tags: [parking, decisions, vision, anpr, monorepo, packaging]
sources: []
updated: 2026-06-19
status: settled
---
# Decision: the vision service lives in this monorepo (apps/vision/), wired into Turbo via a shim
Taken 2026-06-19, when planning how to *implement* the host-side [[opencv-anpr-service|vision
service]] decided in [[vision-service]]. That decision settled WHAT (a separate localhost Python
process) and the recognizer baseline ([[opencv-anpr-service|fast-alpr]]); this one settles WHERE the
source lives and how it joins the build.
## Decision
1. **In THIS monorepo, at `apps/vision/`** — a Python/FastAPI service co-located with the Node
backend, **not** a separate repository. One git history, atomic cross-cutting commits (the
`/analyze` contract + the Node-side adapter change together), one wiki.
2. **Still a separate OS process** — co-location is source-level only. It runs as its own process
(`uvicorn`), called over **localhost HTTP** by the Node backend, with its own failure domain.
Nothing about putting it in `apps/vision/` weakens the runtime isolation [[vision-service]]
requires.
3. **Wired into the Turbo task graph via a thin `package.json` shim.** `pnpm-workspace.yaml` already
globs `apps/*`, so an `apps/vision/package.json` auto-joins the workspace. Its `scripts` shell out
to Python tooling, so the existing `turbo run` tasks cover it:
- `dev` → `uv run uvicorn app:app --reload` (matches `turbo.json` `dev`: persistent, uncached)
- `lint` → `ruff check` · `test` → `pytest` · `typecheck` → `ruff`/`mypy`
- `build` → **no-op or model-fetch** (Python has no `dist/**`; the `build` task's `outputs:
["dist/**"]` simply won't match — fine). If models are fetched/cached at build, point outputs at
the model dir.
Python **dependencies** stay managed by `uv` + `pyproject.toml` (NOT pnpm) — the shim only exposes
*tasks*, not deps.
4. **Node talks to it through an interface** (`VisionClient` behind a port, the
[[device-adapter-pattern]] style) so the recognizer/service is swappable without touching business
logic — as [[opencv-anpr-service]] already specifies.
## Why co-located beats a separate repo
- **Atomic changes.** The service contract (`POST /analyze` shape) and its Node consumer evolve
together; one repo = one PR, no two-repo version skew.
- **`uv` makes Python-in-monorepo painless** — fast, lockfile-based, offline-friendly (fits
[[offline-first]]); the appliance build pulls a pinned env.
- **Turbo still orchestrates it.** The shim makes `turbo run lint`/`test` include the Python service
as a first-class node — one command lints front, back, AND vision — even though Turbo can't *build*
Python. Turbo orchestrates **tasks**, and a task can be a Python command.
- **One knowledge base.** The wiki + CLAUDE.md already describe the whole system; a split repo
fragments that.
## Why this still honors the isolation decision
The "[[vision-service|separate process]]" decision is about **runtime isolation** (own process +
failure domain) and **license isolation** (AGPL obligations don't reach the Node/React code because
it is **not linked** — it's a separate program over HTTP). **Neither depends on a separate
repository.** AGPL's reach is a linking/distribution-boundary question between *programs*, not a
which-folder question. A Python service in `apps/vision/` that Node calls over localhost is exactly as
isolated, license-wise, as one in its own repo.
- With the **[[opencv-anpr-service|fast-alpr]] MIT-end-to-end baseline**, the AGPL pressure to split
the repo out **largely evaporates** (pending the weight-provenance caveat). Co-location is the
low-friction default.
- If a true-AGPL model (Ultralytics YOLO) is later adopted, its weights live under `apps/vision/` —
still fine (separate process), and that dir is the natural place to document the license boundary +
the `[[standing-decisions|scoped exception]]`.
## Rejected
- **Separate repo** — strongest separation, but loses atomic contract changes and adds coordination
overhead; justified only if a different team owns it or the AGPL concern becomes acute. Kept as the
fallback if either happens.
- **Embed Python in the Node process** (opencv4nodejs / a child-process module) — already rejected by
[[vision-service]] (native-build pain, no process isolation, shares the app's failure + license
surface). Unchanged.
- **A Python package under `packages/`** — `packages/` is for shared *JS* libraries imported by other
workspaces; the vision service is a deployable app, so `apps/vision/` is the right bucket.
## Consequences
- Add `apps/vision/` (`pyproject.toml` + `uv.lock`, FastAPI `app.py`, a thin `package.json` shim);
`apps/*` glob picks it up. Update the repo-layout block in the root `CLAUDE.md` + this wiki.
- A `Dockerfile`/process unit builds the Python service as its own image/process for the appliance;
CI runs `ruff`/`pytest` (via the shim or a dedicated job).
- The Node backend gains a `VisionClient` adapter (localhost HTTP) + per-camera opt-in wiring (the
open item in [[opencv-anpr-service]]).
- **Not built yet** — this is the packaging decision; scaffolding follows when the vision work starts
(the "scaffold as the work reaches them" rule in CLAUDE.md).
## Open
- `uv` vs. `pip-tools`/`poetry` for the Python env (leaning `uv` — speed + lockfile + offline).
- Whether `build` should fetch/cache model weights (and set Turbo `outputs` to the model dir) or keep
weights out of the build entirely (baked into the Docker image instead).
- Container/runtime supervision on the appliance (systemd unit vs. compose) — deployment detail,
defer to the install/hardening pass.