--- name: mvp-research-frontier description: >- Open problems where MotoVaultPro can genuinely advance, plus the methodology for turning a hunch into an accepted result. Load when the issue tracker is empty and you need to seed the next work; when someone asks "what should we work on next", "what are the biggest open problems", "is this idea worth pursuing", or "how do I propose/validate a new improvement"; or when starting an experiment, evaluation harness, accuracy measurement, performance study, or multi-tenant/SaaS feasibility study. Trigger keywords - no open issues, roadmap, next steps, research, experiment, hypothesis, golden set, eval harness, accuracy of OCR/VIN extraction, multi-user readiness. --- # MotoVaultPro Research Frontier Everything in this file is labeled **candidate/open**. Nothing here is a commitment, a roadmap, or owner-approved scope. This skill exists because the issue tracker was fully drained (zero open issues as of 2026-07-07) and the next work must be seeded deliberately, not by whichever bug happens next. ## When to use / when NOT to use Use this skill when: - You need to propose new work and there is no open issue telling you what to do. - You are about to start an experiment, measurement, or feasibility study and need the discipline that makes its result acceptable (Part B). - Someone asks whether one of the five frontier problems below is real or already solved. Do NOT use this skill for: - Executing the issue/branch/PR workflow itself -- `mvp-change-control`. - Diagnosing a live failure -- `mvp-debugging-playbook`, then `mvp-failure-archaeology` to check whether the battle is already settled. - The CI/deploy-safety hole specifically -- `mvp-deploy-safety-campaign` is the executable campaign; Problem 3 below is only its extended end-state. - Root-cause evidence standards and benchmark discipline in depth -- `mvp-proof-and-analysis-toolkit` (this skill cross-references it, not replaces it). - What must be true before launch or public claims -- `mvp-launch-readiness`. - OCR/Gemini subsystem mechanics -- `mvp-ocr-gemini-pipeline`. - Running tests / what counts as validation evidence -- `mvp-validation-and-qa`. Project reality this skill assumes (owner-confirmed 2026-07-07): development is done by AI sessions in this repo, the human owner reviews PRs, and end-to-end verification happens on staging via the PR deploy pipeline because there is no fully working local dev loop. Two owner non-negotiables apply to every experiment below: (1) no destructive database operations without a fresh backup (this includes the backend integration tests, which `DROP TABLE ... CASCADE` on the shared dev database -- see `backend/src/features/vehicles/tests/integration/vehicles.integration.test.ts:38`); (2) never hand-edit files on staging/prod servers -- `rsync --delete` on every deploy reverts them (`.gitea/workflows/staging.yaml:117-118`, `production.yaml:108-109`). --- # PART A -- Frontier problems (all candidate, verified against the repo 2026-07-07) | # | Problem | One-line asset | Falsifiable milestone | |---|---------|----------------|-----------------------| | 1 | LLM-extraction evaluation harness | Real extraction pipeline + real receipts | A model-swap PR shows a scorecard diff, not vibes | | 2 | Type-safe DB boundary | Recurring-bug corpus (#239/#241/#244; #243 reported but not confirmed) + shipped detector script | Detector returns zero suspects AND a regression gate fails a planted bug | | 3 | CI as a product asset | Working per-PR staging deploy pipeline to extend | A red PR cannot merge; staging never clobbered by unmerged PRs | | 4 | Async OCR service | Known sync-in-async defect with a measurable symptom | /health p99 < 1s during saturated VIN decode, zero healthcheck restarts | | 5 | Multi-user/SaaS readiness study | Post-#206 UUID identity scoping already done | Written go/no-go with each blocker proven by a test | ## Problem 1: LLM-extraction evaluation harness (candidate) **Why the current state fails.** Receipt and VIN extraction accuracy has never been measured. All OCR tests mock Gemini and generate synthetic images inline with PIL (e.g. `ocr/tests/test_receipt_extraction.py:269-291` builds a blank 100x100 image); there is no fixtures directory of real labeled images anywhere under `ocr/tests/`. Consequences already observed: the deployed model was changed to a preview model (`GEMINI_MODEL: gemini-3-flash-preview`, `docker-compose.yml:210`, overriding the `gemini-2.5-flash` code default in `ocr/app/config.py`) with no before/after accuracy evidence. Every future model or prompt change is currently judged by eyeball. **This project's asset.** A complete, deployed extraction pipeline (`ocr/app/extractors/`, `ocr/app/routers/{extract,decode}.py`) with known output schemas to score against: fuel receipts produce `totalAmount`, `fuelQuantity`, `pricePerUnit`, `fuelGrade` (`ocr/app/extractors/fuel_receipt.py:111-147`); maintenance receipts produce `serviceName`, `serviceDate`, `totalCost`, `shopName`, `laborCost`, `partsCost`, `odometerReading` (`ocr/app/extractors/maintenance_receipt_extractor.py:40-46`); VIN decode produces year/make/model/trimLevel/engine/transmission (`ocr/app/routers/decode.py`, response model in `ocr/app/models/schemas.py`). Real usage exists: the owner's own uploaded documents persist in the `data/documents/` volume (treat as PII -- get owner sign-off and strip identifying content before committing any image to git). **First three steps in this repo:** 1. Create `ocr/tests/fixtures/golden/` with `receipts/`, `maintenance/`, `vins/` subdirectories and one `labels.json` per subdirectory mapping filename to expected field values (start with N=10 per category; owner-supplied, sanitized images). 2. Write `ocr/tests/test_golden_extraction.py`: for each labeled image, call the extractor and score field-level precision/recall (a field counts as correct only on exact match after normalization; report per-field and aggregate). Gate the whole module behind an env var (e.g. `GOLDEN_EVAL=1` + `pytest.mark.skipif`) so the default `cd ocr && python -m pytest` run stays hermetic -- live Gemini credentials (the WIF chain, see `mvp-ocr-gemini-pipeline`) exist only in containers. 3. Run the baseline on staging (the only environment with working credentials): shell into the OCR container and run the gated suite; commit the resulting scorecard JSON next to the fixtures with the model name and date. This number is the baseline every future prompt/model PR must diff against. **You have a result when** a PR that changes `GEMINI_MODEL`, a prompt, or an extractor includes a before/after scorecard produced by the harness on the same golden set -- and a reviewer can reject it because a number went down. Falsified if, after building it, scores are so noisy across identical runs (LLM nondeterminism) that diffs are meaningless; that outcome itself is a publishable finding (then measure variance first and report mean-of-3). ## Problem 2: Type-safe DB boundary -- eliminate the pg-numeric bug class (candidate) **Why the current state fails.** node-postgres returns NUMERIC/DECIMAL (PostgreSQL type OID 1700) as strings. `backend/src/core/config/database.ts:12` overrides only the DATE parser (OID 1082 -> plain string), so every repository mapper must hand-coerce numerics. This produced at least three shipped bugs (#239, #241, #244 -- #243 was reported for stations but the stations mappers already coerced via parseFloat, see `mvp-failure-archaeology` incident 12) with inconsistent coercion styles across repositories, and #244 showed a second failure shape: "enhanced" methods returning raw rows that bypass the mapper entirely. The convention is enforced by review only; nothing stops the next repository from reintroducing it. **This project's asset.** The bug class is fully characterized (see `mvp-failure-archaeology`), and a working detector already ships: `.claude/skills/mvp-diagnostics-and-logging/scripts/check-numeric-coercion.sh` (harvests numeric columns from migration SQL, flags uncoerced `row.` reads and raw-row returns in `*.repository.ts`; exit 0 = clean). There is also an unused codegen seed: `cd backend && npm run schema:generate` (`backend/src/_system/schema/generate.ts`). **First three steps in this repo:** 1. Run the detector and record the current suspect list as the baseline: `bash .claude/skills/mvp-diagnostics-and-logging/scripts/check-numeric-coercion.sh`. 2. Decide the mechanism with an explicit trade-off writeup (stress-test per `mvp-proof-and-analysis-toolkit`). Three options, in ascending ambition: (a) shared `mapNumeric()` helper in `backend/src/core/` that every mapper calls; (b) global OID 1700 parser in `backend/src/core/config/database.ts` -- the OID 1082 override at line 12 is the successful precedent, but weigh honestly: a global parser changes every consumer at once, and `parseFloat` on money columns trades string safety for float representation (audit currency columns before choosing); (c) schema-derived row types via `schema:generate` so uncoerced reads become compile errors. Record the decision and the rejected options in the issue. 3. Fix all current suspects in one PR, then add the regression gate: wire the detector into `backend/package.json` lint (or an eslint `no-restricted-syntax` rule in `backend/eslint.config.js` banning `return result.rows` in repository files -- candidate, feasibility unproven) so a new violation fails locally and, once Problem 3 lands, in CI. **You have a result when** (a) the detector script exits 0 on main, AND (b) a deliberately planted violation -- an uncoerced `row.price` read added on a scratch branch -- is caught by the automated gate without human review. Falsified if the global parser (option b) breaks an existing consumer that depended on string numerics: that is a finding, not a failure; document it in `mvp-failure-archaeology` and fall back to (a). ## Problem 3: CI as a product asset (candidate -- end-state extension of the campaign) Prerequisite: read and follow `mvp-deploy-safety-campaign`. This entry only records the frontier beyond that campaign's scope. Do not start here first. **Why the current state fails.** CI gates nothing beyond build + boot (canonical statement: `mvp-validation-and-qa` section 1). Verified sharp edges: `staging.yaml` triggers on `pull_request` (line 12) and every PR build tags and pushes `:latest` (lines 58-98), so an unmerged PR overwrites both the shared staging environment and the very tag that `production.yaml` deploys by default (`image_tag` default `'latest'`, `production.yaml:17`). The mobile+desktop hard requirement is validated nowhere: the single Cypress spec (`frontend/cypress/e2e/stations.cy.ts`) has no config and cannot run. **This project's asset.** A working self-hosted pipeline that already builds, deploys, and health-verifies a full stack per PR -- most projects have to build that from scratch; here only the gates and isolation are missing. **First three steps in this repo** (after the campaign's own steps): 1. Split image tagging in `.gitea/workflows/staging.yaml`: PR builds push only `:{shortsha}` (and optionally `:pr-{n}`); `:latest` moves only on push to `main`. This alone ends the unmerged-code-to-prod path. 2. Add a test job ahead of `build`: backend unit tests (`cd backend && npm test -- --forceExit`, excluding `tests/integration` unless the job provisions an ephemeral postgres+redis -- never point CI tests at a shared database; they DROP TABLE CASCADE), backend/frontend `npm run lint` and `npm run type-check`, and `cd ocr && python -m pytest`. Red job blocks merge. 3. Add a post-deploy viewport smoke job: Playwright (dep already in root `package.json`, currently dead -- no config exists) hitting `https://staging.motovaultpro.com` at 375x812 and 1280x800, asserting login page renders and no console errors. This is the first automated teeth behind the mobile+desktop rule. **You have a result when** (a) a PR containing a deliberately failing unit test cannot be merged (workflow red, branch protection enforcing it), AND (b) after a PR build completes, `docker manifest inspect` (or registry UI) shows `:latest` unchanged -- staging mainline is never clobbered by an unmerged PR. Per-PR ephemeral environments or a namespaced staging are the stretch goal, candidate only; the milestone above does not require them. ## Problem 4: Async OCR service -- Gemini off the event loop (candidate) **Why the current state fails.** `ocr/app/routers/decode.py` calls the synchronous, unbounded `_gemini_engine.decode_vin(vin)` directly inside an `async def` handler (same pattern for sync receipt paths in `routers/extract.py`); only the manual-PDF path uses `run_in_executor` (`extract.py:472`, `jobs.py:131`). A Search-grounded VIN decode has been observed taking 34s+ (issue #230 history), during which the entire uvicorn event loop -- including `GET /health` (`ocr/app/main.py:44`) -- is blocked. The Docker healthcheck is `interval: 5s, timeout: 5s, retries: 3` (`docker-compose.yml`), so a ~15s blockage can get the container killed mid-request. There is no timeout on the Gemini call itself (the only guards are the upstream HTTP timeouts: backend `OCR_TIMEOUT_MS = 120000`, `backend/src/features/ocr/external/ocr-client.ts:9`, and the frontend's 120s at `frontend/src/features/vehicles/api/vehicles.api.ts:90` -- equal values, no headroom), and no retry for transient failures. **This project's asset.** The defect has a crisp, cheap-to-measure symptom, an in-repo reference implementation of the fix pattern (`extract.py:472`), and duration instrumentation already flowing to Loki: backend logs `msg="Request processed"` with a `duration` field (`backend/src/core/plugins/logging.plugin.ts:20-25`), so `POST /api/vehicles/decode-vin` latency is queryable in Grafana (see `mvp-diagnostics-and-logging`). **First three steps in this repo:** 1. Baseline with predicted numbers first (Part B discipline). Prediction: with K=3 concurrent VIN decodes in flight, OCR `/health` response time exceeds 5s and the container restarts. Measure on staging: fire concurrent decodes through the API, while a loop times `/health` from inside the network (`docker exec mvp-ocr-staging curl -s -w '%{time_total}\n' -o /dev/null http://localhost:8000/health`) and `docker events`/restart count watches for healthcheck kills. 2. Fix the blocking call: in `ocr/app/routers/decode.py`, wrap the engine call -- `await asyncio.get_running_loop().run_in_executor(None, _gemini_engine.decode_vin, vin)` -- mirroring `extract.py:472`; repeat for the sync receipt handlers in `extract.py`. (The google-genai async client `client.aio` is an alternative -- UNVERIFIED against the installed SDK version; `google-genai>=1.0.0` is an unpinned floor in `ocr/requirements.txt:25` and an unsupported config field already broke VIN decode on staging once, per `mvp-failure-archaeology`. Test any SDK-surface change against the actual deployed image before trusting it.) 3. Add a service-side timeout shorter than the 120s client timeouts (e.g. 90s around the executor future) plus one retry with jitter for transient Gemini errors, then rerun the step-1 measurement unchanged. **You have a result when** during the same saturation experiment as the baseline, `/health` p99 stays under 1s, the container records zero healthcheck restarts, and VIN decode success latency is unchanged within 10%. Falsified if p99 stays >1s after the executor change -- that means the bottleneck is elsewhere (e.g. thread-pool exhaustion or PaddleOCR model loading), which redirects the work rather than ending it. ## Problem 5: Multi-user/SaaS readiness study (candidate -- study, not build) **Why the current state fails.** The product is single-tenant by declaration, heading toward paying users; nobody knows how far the gap to 2+ real users actually is, so pricing/positioning decisions (see `mvp-launch-readiness`) rest on an unexamined assumption in both directions. **This project's asset.** The hardest prerequisite is already done: issue #206 migrated all user identity to UUIDs referencing `user_profiles.id` across 17 feature tables (`backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql`). Note precisely: the columns are still NAMED `user_id` (the migration renames `user_profile_id` back to `user_id` at lines 324+ after backfill) but they now hold `user_profiles.id` UUIDs -- if any doc says the columns are named `user_profile_id`, the code wins. Auth already resolves each request to that UUID (`backend/src/core/plugins/auth.plugin.ts:165`) and repositories scope queries by it. Stripe scaffolding exists per-customer already (`subscriptions` table with `stripe_customer_id`, made nullable for admin overrides in `backend/src/features/subscriptions/migrations/002_nullable_stripe_customer_id.sql`), and an `admin_users` table exists (`backend/src/features/admin/migrations/001_create_admin_users.sql`). **First three steps in this repo:** 1. Enumerate candidate blockers by inspection, each phrased as a testable claim. Verified starting list (2026-07-07): no Row Level Security anywhere (zero RLS statements in any migration SQL -- isolation is 100% application-layer WHERE clauses); the tier guard fails open on unknown feature keys (`backend/src/core/config/feature-tiers.ts:57-63` returns true for unregistered keys); auth is per-route `preHandler`, not a global hook, so a forgotten preHandler is an open route; Redis cache keys and the fuel-logs cache invalidation are per-user but the known delete-invalidation miss (imperial-only) would leak stale data per user; vehicle limits free=2/pro=5/enterprise=unlimited (`feature-tiers.ts:92-95`) assume per-user enforcement paths all consult the same counter. 2. Run the two-user experiment on staging (never dev/prod without a fresh backup): create a second Auth0 user, then for each feature capsule attempt cross-user reads and writes through the API (user B requests user A's vehicle/fuel-log/document IDs). Record every endpoint that returns another user's data or mutates it. This is the ground truth the study stands on. 3. Write the go/no-go document as a table: blocker, evidence (the failing/passing test or request transcript from step 2), remediation class (WHERE-clause audit vs RLS vs per-tenant DB vs admin model vs Stripe webhook multiplexing), rough size. File one issue per confirmed blocker, labeled `type/chore` or `type/feature`, all `status/backlog`. **You have a result when** the go/no-go document exists with every enumerated blocker backed by a concrete test or transcript -- including the negative results ("tried cross-user read on all 21 capsules' endpoints; N leaked, M correctly 403/404"). A study that finds zero blockers is a result; a study with unproven blockers is not. --- # PART B -- Methodology: from hunch to accepted result ## The evidence bar A claim (root cause, improvement, feasibility verdict) is accepted here only when: 1. **One mechanism explains ALL observations, including the negatives.** If your explanation covers the failure but not why the same code path works elsewhere, it is incomplete. (Full protocol and worked examples: `mvp-proof-and-analysis-toolkit`.) 2. **It survives assigned adversarial refutation.** Before acting, spend one explicit pass (or a spawned subagent) trying to break the conclusion: what observation would disprove it, and did you look for that observation? A conclusion nobody tried to kill is a guess with confidence. 3. **The result is reproducible from the artifacts in the repo** -- fixture set, script, LogQL query, or test -- not from a session transcript. ## Hypothesis predicts numbers BEFORE running Write the predicted number down (in the issue or PR description) before running the measurement. "Wrapping the Gemini call in an executor will take /health p99 from >5s to <1s under 3 concurrent decodes" is a hypothesis; "made it async, seems snappier" is not. If you cannot predict a number, you do not yet understand the mechanism -- go back to step 1. After measuring, report predicted vs observed, especially when wrong: a wrong prediction that gets explained is worth more than a right one that does not. ## The idea lifecycle in this repo ``` hunch --> issue --> gated experiment --> adopted | retired ``` | Stage | Concrete form here | |-------|--------------------| | Hunch | A suspicion from a bug, a log pattern, or this file. Costs nothing; commits nothing. | | Issue | File it via Gitea MCP tools, labeled `type/feature` or `type/chore`, `status/backlog`. State the falsifiable milestone ("result when...") in the body. No sprints/milestones -- work flows directly from issues (owner directive 2026-05-12). | | Gated experiment | A branch (`issue-{n}-{slug}`) or a config flag, with the measurable gate declared up front and the predicted numbers written down. Staging is the lab; respect both non-negotiables. | | Adopted | Merged per `mvp-change-control`, documented per `mvp-docs-and-writing`, and -- if it settled a question -- an entry in `mvp-failure-archaeology` so it is not relitigated. | | Retired | Written down in `mvp-failure-archaeology` with the evidence that killed it. A retired idea with documented cause is a settled battle; an undocumented one will be re-fought by a future session that has no memory of yours. | ## Where good ideas have historically come from in this repo Use these patterns as prospecting ground, verified in git history: - **User-pain bug clusters -> systemic fixes.** One numeric-as-string bug report (#239, maintenance cost blank on the vehicle summary) triggered an audit that fixed the same defect class in maintenance/ownership-costs (#241) and fuel-logs (#244) within a day, and confirmed stations (#243) already coerced (see `mvp-failure-archaeology` incident 12) -- and ultimately the detector script and Problem 2 above. When you fix a bug, always ask: where else does this exact shape exist? The audit is usually cheaper than the next bug. - **Audits -> capsules.** The December 2025 security audit (`docs/AUDIT.md`, now partially stale -- treat its findings as historical, code wins) recommended audit logging; a full `backend/src/features/audit-log/` capsule now exists. A written audit with concrete findings reliably converts into shipped features. - **Incidents -> settled constraints.** The google-genai unsupported-config breakage (AFC parameter, reverted in commits `1add6c8`/`56df5d4`) produced the standing rule "test SDK-surface changes against the deployed image" that Problem 4 inherits. Incidents are expensive; extracting a constraint from each one is how the cost is amortized. ## Provenance and maintenance Authored 2026-07-07 from direct repo inspection at commit `e729d42` (main). Everything in Part A is a point-in-time snapshot; re-verify before acting. Volatile facts and their re-verification commands: | Fact (as of 2026-07-07) | Re-verify with | |--------------------------|----------------| | Issue tracker has zero open issues | Gitea MCP `list_repo_issues` with `state=open` | | CI runs zero tests/lint; PR builds push `:latest` | `grep -n "npm test\|pytest\|latest" .gitea/workflows/staging.yaml` | | Prod deploy defaults to `latest` | `grep -n "default" .gitea/workflows/production.yaml \| head -5` | | Only DATE (OID 1082) parser overridden, not NUMERIC (1700) | `grep -n setTypeParser backend/src/core/config/database.ts` | | Numeric-coercion detector exists and its current verdict | `bash .claude/skills/mvp-diagnostics-and-logging/scripts/check-numeric-coercion.sh` | | No golden fixtures dir in OCR tests | `ls ocr/tests/fixtures 2>/dev/null` | | VIN decode is sync-in-async; executor used only in manual path | `grep -rn run_in_executor ocr/app` and read `ocr/app/routers/decode.py` | | OCR healthcheck 5s/5s/3 | `grep -A4 healthcheck docker-compose.yml` (mvp-ocr block) | | `google-genai>=1.0.0` unpinned; deployed model is `gemini-3-flash-preview` | `grep genai ocr/requirements.txt; grep GEMINI_MODEL docker-compose.yml` | | Client timeouts both 120s (frontend + backend OCR client) | `grep -n timeout frontend/src/features/vehicles/api/vehicles.api.ts backend/src/features/ocr/external/ocr-client.ts` | | User-scoping columns named `user_id` but hold `user_profiles.id` UUIDs | `grep -n "RENAME COLUMN" backend/src/core/identity-migration/migrations/001_migrate_user_id_to_uuid.sql` | | No RLS anywhere | `grep -rin "row level security" backend/src --include='*.sql'` | | Tier guard fails open on unknown keys; limits free=2/pro=5 | `grep -n -A5 canAccessFeature backend/src/core/config/feature-tiers.ts` | | `stripe_customer_id` nullable (admin overrides) | `ls backend/src/features/subscriptions/migrations/` |