---
type: plan
title: coachpapa — prototype build plan (P0-P2)
slug: coachpapa-prototype-2026
status: active
owner: rian
project: coachpapa
created: 2026-07-30
updated: 2026-07-30
status_note: P0 and P1 complete and gated; P2 partial (exports done, Pattern B pending)
summary: Build plan to a demonstrable maintenance-ticket prototype. Capture-first
  PWA (photo + dictation + asset picker) producing permanently-numbered auditable
  tickets, a planner loop, and two CSV exports. FastAPI + React + Postgres per the
  house standards, in the v8 workspace/instance shape with a documented deviation
  ledger for what the current gateway cannot yet support.
---

# coachpapa — prototype build plan

Genesis, source material, and known gaps: [`../../brief.md`](../../brief.md).
Session operating rules: [`../../agents.md`](../../agents.md).

This plan takes the project from empty workspace to a prototype Mike can open on
his phone. It stops at the point where the capture loop is proven — not at a
sellable product.

---

## 1. What the prototype must prove

One thing: **that a plant worker can create a useful, complete maintenance ticket
in under ten seconds, one-handed, with gloves on.** Everything else in the build
exists to make that ticket worth having.

The lifecycle comes from Mike's own flow chart (`notes/Executive Summary/
COACHPAPA FLOW CHART.docx`), which is the spec of record:

1. Someone spots a problem → records it on a phone → ticket number created,
   timestamped, permanent, auditable.
2. Planner reviews → checks parts → orders what's missing → prioritizes.
3. Job scheduled, referencing the ticket number; may split by skill/priority.
4. Tech works it, records parts used, closes it — timestamped at each step.
5. Anything new spotted mid-job spawns a **new ticket** linked to the old one.
6. Everything exports to CSV for Excel/Sheets, or onward into a customer's
   existing CMMS/ERP.

---

## 2. Locked decisions

These are decided. Do not relitigate them without a `handoff.md` entry saying why.

| Decision | Rationale |
|---|---|
| **Tenant: `apps/`** | Client & general apps (v8 §3.1). This is Mike's product, not a Bowden Works one, so not `works/`. |
| **Stack: FastAPI + SQLAlchemy 2/Alembic + Postgres 17 + Vite React 19 SPA** | The only fit in the standards library (`react.md`); needs app-like UI, a real DB, and a product path. No new stack is invented. |
| **Delivered as a PWA, not a native app** | Mike asked for exactly this in his own exec summary ("APPLE IOS PWA"). Removes the App Store and the $99/yr fee he budgeted. A Vite SPA + manifest + service worker is the standard-stack way to get there. |
| **Voice input = keyboard dictation, PLUS in-app record-and-transcribe** | ~~Server-side transcription is a later phase~~ — **revised 2026-07-30 by rian.** Keyboard dictation still works and costs nothing, but the prototype now also records a voice memo and transcribes it into the description via the Gemini API, mirroring the garden2 implementation. Rationale: rian judged the prototype not demonstrable to Mike without it, since speaking is the whole premise. Proof of concept — the model is swappable behind a provider seam. |
| **Code in `main/`, runtime state at workspace root** | v8 §3.2 — instance folders are code-only. `data/` (uploaded media) and `backups/` sit at the workspace root and mount into the container. |
| **Identity: name-picker through P1, Pattern B from P2** | See §6. Contained behind one module so the swap is a one-file change. |
| **Postgres from day one, never SQLite-then-swap** | `react.md`. The swap always slips. |

---

## 3. v8 conformance and the deviation ledger

The project follows the v8 workspace/instance shape as closely as the current
gateway allows. Each deviation below is deliberate, cheap, and named so it can be
reversed when the blocking roadmap step lands.

| # | Deviation from v8 | Why | Cost now | Resolves at |
|---|---|---|---|---|
| 1 | `docker-compose.yml`, `.env`, `.project.json`, `nginx.conf` sit at the **workspace root**, not with the instance | `srv-gw deploy` hard-requires the compose file at the project root (`gateway.py:3642`) and refuses otherwise | None — compose builds from `./main` | T3.1 |
| 2 | Gateway registry is **project-granular**, not instance-granular; port 3140, domain, and Unix group bind to the project | No `add-instance` command; `APPS_DIR = Path("/srv/apps")` is hardcoded (`gateway.py:35`) | A second instance would need manual wiring and a hand-allocated port. Not needed here. | T3.1 |
| 3 | Standards are pointed at **by hand** from `agents.md` | Tenant-level auto-dispatch is T2.4, not started | One line. Path also moves `/srv/projects/standards` -> `/srv/system/standards` at T3.2 | T2.4 / T3.2 |
| 4 | `handoff.md` / `diary.md` are **written by hand** each session | `/checkpoint` + SessionEnd hook is T2.3, not started | Discipline, not tooling | T2.3 |
| 5 | No **built-but-not-deployed** detection for this app | T1.4's deploy-state check covers `/srv/system` services only | Mitigated by the universal visible-version rule (§7) | later T1.4 extension |
| 6 | No **backup substrate** | T4.8 not started | Nightly `pg_dump` to `backups/` — same disk, so a hedge not a backup. Recorded in `brief.md` known gaps. | T4.8 |

**Surfaced, not resolved** (per `coding.md`: name conflicts, never silently pick
a side): `react.md`'s header says *"no app is built on this stack yet,"* but v8
§3.14 and §4 both name hosting-web as its production proof, and `with` runs on it.
The status line is stale. Not corrected here — editing a standards file as a side
effect of a different task would violate the surgical-changes rule.

---

## 4. Target shape

```
/srv/apps/coachpapa/
├── brief.md  agents.md  CLAUDE.md -> agents.md
├── .logs/{planning/, handoff.md, diary.md}
├── .memory/  .archive/  notes/
├── docker-compose.yml          # app + postgres sidecar; build context ./main
├── .env                        # gateway-managed (PROJECT_NAME, PORT) — do not edit
├── .app.env                    # app secrets, mode 600, placeholders filled by rian
├── data/                       # uploaded photos/audio (bind-mounted; not web-served)
├── backups/                    # nightly pg_dump
└── main/                       # THE INSTANCE — code only
    ├── VERSION  CHANGELOG.md  Makefile  Dockerfile
    ├── alembic/
    ├── app/
    │   ├── routers/            # thin: HTTP in/out + validation
    │   ├── services/           # business logic (incl. identity.py, exports.py)
    │   └── models/             # SQLAlchemy models + Pydantic schemas
    ├── frontend/               # Vite React SPA, built in the Docker node stage
    └── scripts/                # seed importers, gate scripts
```

---

## 5. Data model

Derived from Mike's real files, not invented. Field shapes match his spreadsheets
so his data imports without transformation.

- **`assets`** — from `Britco Equipment List COGZ.xlsx`: `equipment_no`,
  `description`, `line`, `manufacturer`. 376 rows seed the prototype.
- **`parts`** — from `5 MX Parts Inventory Import File.xlsx`: `part_name`,
  `description`, `size`, `uom`, `location`, `area`, `barcode`, `unit_cost`,
  `quantity`, `minimum_quantity`, `critical`, `lead_time`.
- **`tickets`** — `ticket_number` (see below), `asset_id`, `reported_by`,
  `reported_at`, `description`, `status`, `priority`, `parent_ticket_id`
  (follow-ups spawned mid-job), `closed_at`.
- **`ticket_events`** — append-only audit trail. Every status change, assignment,
  and comment, each with actor and timestamp. This is what makes "auditable" true
  rather than claimed.
- **`ticket_media`** — photo/audio attachments; file on disk under `data/`, row
  holds path, mime, size, uploader, timestamp.
- **`part_usage`** — `ticket_id`, `part_id`, `qty`, `direction` (used | returned),
  `at`, `by`. This table *is* the item-movement report; Mike's `BP Parts Report
  .xlsx` is the output shape to match.
- **`technicians`** — seed table backing the name picker. At P2 it becomes a view
  over BW accounts rather than an identity store.

**Ticket numbering — the auditability guarantee.** A dedicated Postgres sequence,
never reset, never cycled, never reused. Display format `CP-000123`; the raw
sequence value is stored alongside. **Tickets are never deleted** — the API has no
delete route; unwanted tickets get status `void`. A gap in the number series must
always mean something happened, never that a row vanished.

---

## 6. Identity — the phasing, and why it is not hand-rolled auth

`coding.md` is absolute: apps never build their own login or password store.
Nothing here does.

- **P0-P1:** the site stays behind the existing id-auth gate (shared password).
  Inside that gate, the app shows a **name picker** seeded from `technicians`.
  That is *display attribution behind an already-authenticated boundary* — it
  guards nothing, grants nothing, and stores no credential. It is not auth and
  must never grow into auth.
- **P2:** swap to **Pattern B** (`srv-gw app-client-register --name coachpapa
  --secret-out /srv/apps/coachpapa/.bw-auth.env`, drop in `bw_auth.py`), with
  `role_of()` returning tech | planner | owner.

**Ordering matters and is a documented trap:** registering an app-client
**auto-ungates the host**. Do not register until the app draws its own BW login,
or the site is briefly public. If the app needs hiding mid-build, use
`srv-gw id-site-set --host coachpapa.demoing.info --lan-only true` — never a
password gate, which would hijack the app's own `/login`.

**Containment:** all identity resolution goes through `app/services/identity.py`
returning a single `CurrentActor`. Routers never read headers or session state
directly. The P2 swap is then one file, not a refactor.

---

## 7. Milestones

Gates are **scripts, not vibes** (v8 §3.5). Each gate is a runnable file; a
milestone is done when its gate exits 0. Version bumps and a newest-first
`CHANGELOG.md` line accompany every change; `/api/meta` and the UI footer surface
the running version — that is how a deploy is confirmed to have landed.

### P0 — capture to ticket (v0.1.0) — **DONE 2026-07-30, gate green (12/12)**

The skeleton and the one thing that matters.

- Instance skeleton in `main/`; compose at workspace root with Postgres sidecar
- Alembic migration 001: all tables from §5
- Seed importers reading the real `.xlsx` files out of `notes/`
- API: create ticket (multipart photo + text + asset + reporter), list tickets,
  list/search assets, `/api/meta`
- UI: capture screen (large camera button, dictation-friendly text field,
  searchable asset picker), ticket list, PWA manifest, add-to-home-screen
- Media stored under `data/`, served only through an authenticated app route

**Gate `main/scripts/gate-p0.sh`:**
1. `make check` green — ruff, `tsc`, pytest, vitest
2. Two tickets created via the API have strictly increasing numbers
3. Container restarted, third ticket's number still exceeds the second — the
   sequence is durable and non-resetting
4. No delete route exists for tickets (assert 404/405 on `DELETE /api/tickets/{n}`)
5. Uploaded media is on disk with restrictive permissions **and is not fetchable
   at a guessable URL** through Caddy
6. `/api/meta` version equals `main/VERSION`
7. Asset count is 376 after seeding (the source sheet has 377 rows including its header)

### P1 — the planner loop (v0.2.0) — **DONE 2026-07-30, gate green (16/16)**

- Status, priority, assignment; parts-used capture (qty + used/returned)
- Close-out; spawn a follow-up ticket linked to its parent
- Every transition writes a `ticket_events` row with actor and timestamp
- Planner queue UI (works on a phone; comfortable on desktop)

**Gate `main/scripts/gate-p1.sh`:** replays Mike's flow chart end-to-end as a
pytest — report → review → schedule → work → parts used → close → spawn
follow-up — and asserts (a) every transition is present, timestamped and
attributed, in `ticket_events`, and (b) the follow-up ticket references its parent.

### P2 — the two reports, and real identity (v0.3.0) — **PARTIAL (reports done)**

Exports shipped in 0.2.0 and are reachable from the UI as of 0.5.0, with role
enforcement, assignment, scheduling and stock-on-hand alongside. The Pattern B
swap has NOT started - identity is still the name picker, deliberately: rian
judged that login friction costs more in a demo than it buys. Remaining for P2
proper: item 3 below (the BW round-trip).

- CSV export: **ticket status master** (his "rich data report")
- CSV export: **item movement** (POS-style, tied to ticket number)
- Pattern B swap per §6; roles enforced at the middleware layer

**Gate `main/scripts/gate-p2.sh`:**
1. Item-movement CSV columns are checked programmatically against the real
   `BP Parts Report.xlsx` header row — the export must carry the equivalent fields
2. Both CSVs parse cleanly with correct header and row counts
3. A BW account signs in, role resolves, and a `tech` role receives 403 on a
   planner-only route

---

## 8. Security posture

Applied at design time rather than audited in afterwards.

- **Postgres sidecar on the internal compose network only** — no host port,
  unreachable from LAN, VPN, or the Docker bridge (`react.md`).
- **App binds `172.17.0.1:3140` only.** Gateway-managed; never change to `0.0.0.0`.
- **Uploaded media is never a static directory.** Photos of a customer's plant
  floor behind a gated site must not be fetchable by URL guess — they are served
  through an authenticated route that checks the actor, or not at all.
- **Secrets:** `.app.env` staged with `REPLACE_WITH_*` placeholders, mode 600,
  filled by rian in his own shell. Never echoed back, never in chat, never in
  logs. Presence verified by shape only.
- **Logging:** `error_code`s and entity IDs. Never tokens, cookies, or raw media.
- **Default-deny middleware** with an explicit public allowlist (health, static
  assets, auth callback). No broad `/api/*` exemption, ever.
- **`srv-gw security-audit` after the first deploy** and after the P2 auth change.

---

## 9. Out of scope

Binding until the prototype is validated. Everything here appears somewhere in
Mike's documents; none of it belongs in a prototype. If he asks for one of these,
the answer is "after the capture loop is proven," not a new branch.

- AI voice-to-part-number matching (his generative-AI section). NOTE: plain
  speech-to-text transcription moved IN SCOPE 2026-07-30 (see §2); matching a
  transcript against the item file to pull part numbers remains out.
- Barcode/QR scanning — P3, after capture is proven
- Offline capture queue — P3; genuinely needed in a plant, genuinely not needed
  to prove the idea
- Thermal label printing, SATO/NiceLabel, hot-folder integration
- Auto-emailing item-movement reports to vendors
- ERP/CMMS integration (SAP, Oracle, Dynamics, MaintainX sync)
- PLC / IoT / cycle-count triggers
- Preventive-maintenance scheduling and calendars
- Purchase orders, invoices, vendor management
- Multi-tenant / multi-plant separation
- Billing, subscriptions, licensing

---

## 10. What is needed from Mike

**Nothing to start.** His documents already contain the asset list, the parts
schema, the report shape, a real captured ticket, and the paper form being
replaced. The prototype is deliberately built without a requirements conversation.

When P2 lands, the single ask is: *"Here is a link. Try it on your phone. Tell me
the one thing that is wrong."* Constraining the response to one item is
intentional and is the project's main interaction-management strategy.

Genuinely unresolved, and unresolved on purpose: whether Britco is a live pilot or
a 2023 memory. It changes nothing about the build; the prototype is a better way
to ask than an email.
