Transactional return command with idempotency, row-lock concurrency control, odometer-regression handling, vehicle status derivation, outbox event, audit trail. Result-summary UI on booking detail. 26 backend tests passing, ruff clean. Verified end-to-end via browser against S1 demo scenario; fixed two real defects found only through browser testing (UI state loss on status transition, unflushed UUID default).
14 KiB
Project state
Current milestone
M2 — complete. Starting M3 next.
Locked decisions
- Product name: MobilityOps.
- Fictitious tenant: Northstar Mobility Demo.
- PoC only; all operational and knowledge data are synthetic.
- Core stack and boundaries are defined in
CLAUDE.mdanddocs/03-architecture.md. - RAGcore and ITWorx MCP Hub are external central services.
- n8n receives post-commit events through an outbox dispatcher.
- SQLAlchemy 2 declarative models cover the full domain model (
backend/app/models/); enums are plainStringcolumns validated at the Pydantic/service layer, not native PG enums (simpler migrations). backend/requirements.lockis compiled inside apython:3.12-slimcontainer (matches the Dockerfile base image) viapip-compile --extra dev; regenerate the same way ifpyproject.tomlchanges.- Frontend dependencies pinned (no more
"latest");package-lock.jsoncommitted; Docker build usesnpm ci. - Demo auth is a lightweight HMAC-signed cookie (
app/core/security.py), not a real password/JWT flow — matches "Demo role buttons create an authenticated session; they do not bypass authorization middleware." Two fixed demo users (USR-OPSoperations_manager,USR-EMPrental_employee) are created by the seed loader, not from a CSV (nousers.csvinseed/). - Seed loader (
backend/app/seed_loader.py) only supportsseed --reset(always rebuilds); there is no incremental/idempotent-without-reset mode, since the acceptance criteria only require deterministic reset, not partial import. DataQualityIssue.entity_ref/related_reffrom the CSVs are resolved toentity_type/entity_id(UUID) at load time per the domain model; the original human-readable refs are kept inevidence_json(entity_ref,related_refs) since the API and UI need them and re-resolving UUID→public_ref on every read would be wasteful.backend/app/core/config.pyaddedapp_secret,session_cookie_name,session_ttl_seconds,seed_dir(/app/seedin-container),cors_allow_origins(comma-separated string, not a list — simpler with pydantic-settings env parsing),demo_today(drives the dashboard's "Today" section against the deterministic anchor date, default2026-08-01).compose.yamlapi build context changed from./backendto repo root withdockerfile: backend/Dockerfile, so the image canCOPY seed ./seed(seed CSVs are outsidebackend/).- Frontend: added
react-router-dom@7.18.2(bumped from 6.x to clear two real advisories — open redirect + arbitrary constructor injection in v6). One residualnpm auditfinding (RSC-mode CSRF, GHSA-qwww-vcr4-c8h2) does not apply — this SPA never uses React Router's RSC/SSR mode. - Nav/pages built so far: Dashboard, Vehicles (list+detail with tabs), Bookings (list+detail), Audit. Data Quality, Knowledge and Automation nav items are intentionally omitted until M3/M5/M4 build the pages behind them — CLAUDE.md forbids dead routes/placeholders.
- Return workflow (
app/services/returns.py): the spec's "validate submitted reading against booking start reading" step was dropped as a hard rejection. For the seeded S1 scenario, a booking'sstart_odometer_kmcan already equal the vehicle's canonical odometer, so any regression-testing value would also be below the booking start, making a hard floor there indistinguishable from — and in conflict with — the documented soft-regression path. Only one odometer check exists now: submitted vs. the vehicle's canonical odometer (vehicle.odometer_km), matching the domain-model invariant verbatim ("a return with a lower submitted reading is recorded as an inspection and issue, while canonical odometer remains unchanged"). - Idempotency: new
idempotency_recordstable (migratione7b08389f47f), unique onidempotency_key, keyed tobooking_id. Same key + same booking replays the stored response; same key + different booking → 409IDEMPOTENCY_KEY_REUSED; different key on an already-returned booking → 409INVALID_BOOKING_STATE. Concurrency is enforced bySELECT ... FOR UPDATEon the booking row (re-checked for the idempotency record immediately after acquiring the lock, as a safety net for two simultaneous identical-key requests racing the pre-lock check). seed_loader.clear_all()must deleteidempotency_recordsbeforebookings(FK) — easy to forget when adding new booking-referencing tables; the ordering list at the top ofseed_loader.pyis the single place to update.- Inspection
public_refis assigned asINSP-{count+1:04d}from a live count query (not gap-safe, fine for a PoC single-writer demo, would need a sequence for real concurrency-safe numbering). - Found and fixed during browser verification (not caught by pytest, since it's a UI-only defect):
ReturnFormoriginally held its ownresultstate and was conditionally rendered only whenbooking.status === "active"; once the return succeeded the booking flipped toreturnedand React unmounted the form before the user ever saw the result panel. Fixed by lifting the result intoBookingDetail(ReturnResultPanelis now a sibling, not nested inReturnForm). Also found:OutboxEvent.event_id's Python-sidedefault=uuid.uuid4on the mapped_column only applies at flush/commit time, so readingevent.event_idbeforedb.commit()returnedNone(rendered as the literal string "None" in the result panel); fixed by assigningevent_id=uuid.uuid4()explicitly at construction. Lesson: SQLAlchemy columndefault=callables are not available on the in-memory Python object until flush — never rely on the generated value for a same-transaction response body without an explicitdb.flush()or an explicit Python-side assignment. - Operational note for this environment:
docker compose run --rm api ...(used for tests/lint) only starts a throwaway one-off container — it does not update the long-runningapi/webservice containers. After any code change meant to be verified live (browser, curl),docker compose up -d --build <service>is required, not justdocker compose build.
Completed evidence
M0 — Reproducible foundation
- Added
backend/app/core/db.py(engine/session),backend/app/models/*(User, Customer, Vehicle, Booking, Inspection, MaintenanceRecord, DataQualityIssue, OutboxEvent, AuditEvent), Alembic config (backend/alembic.ini,backend/alembic/env.py) and initial migrationbackend/alembic/versions/c9498525abb5_initial_schema.py. - Commands run and verified from this checkout:
docker compose build api— OKdocker compose run --rm api alembic upgrade head— applied cleanly to empty DB, created 9 tables +alembic_version.docker compose run --rm api pytest -q— 1 passed.docker compose run --rm api ruff check .— All checks passed (addedextend-exclude = ["alembic/versions"]tobackend/pyproject.tomlfor autogenerated migration line length).docker compose up -d --build— all 4 services healthy:curl http://localhost:8128/health→{"status":"ok",...};curl -o /dev/null -w "%{http_code}" http://localhost:1228/→ 200;curl http://localhost:5678/healthz→ 200.
- Fixed a real scaffold bug:
frontend/src/App.tsxusedimport.meta.envwithout avite/clienttypes reference, which brokenpm run buildin Docker (works fine under plainvite devbecause Vite injects the global at dev-time buttsc -bstill type-checks it). Addedfrontend/src/vite-env.d.ts. makeis not installed in this Windows/git-bash shell — validated the underlyingdocker compose ...commands directly instead (Makefile targets are thin wrappers around them and are correct as written for a Linux/CI shell or WSL).- Known accepted gap:
npm auditreports 1 moderate/1 high transitiveesbuildadvisory (dev-server-only, fixed only by a Vite 8 major bump); left as-is for the PoC, noted here rather than silently upgrading a major version.
M1 — Operational core
- Backend additions:
app/core/security.py(HMAC-signed session cookies),app/api/deps.py(get_current_user,require_operations_manager),app/core/errors.py(AppError+ the documented{"error": {...}}shape wired as a FastAPI exception handler for bothAppErrorandHTTPException),app/seed_loader.py,app/cli.py(python -m app.cli seed --reset),app/services/audit.py,app/schemas.py, routers underapp/api/routers/(demo,dashboard,vehicles,bookings,audit). - Frontend additions: React Router-based app shell (
src/App.tsx,src/components/Layout.tsx,src/components/RequireAuth.tsx),AuthContext, typedapiclient (src/api/client.ts,src/api/types.ts), pagesLogin,Dashboard,Vehicles/VehicleDetail,Bookings/BookingDetail,Audit. Full responsive stylesheet (src/styles.css) covering nav collapse and table→card layout under 700px, visible focus states, no hover-only actions. - Commands run and verified from this checkout (container rebuilt each time to pick up code changes):
docker compose run --rm api pytest -q— 19 passed (new:test_seed.py,test_auth.py,test_dashboard.py,test_vehicles.py,test_bookings.py,test_audit.py; tests seed the real Postgres viareset_and_seedin a session fixture, then exercise the FastAPI app throughTestClient, not mocks).docker compose run --rm api ruff check .— All checks passed (addedignore = ["B008"]— FastAPI'sDepends()-as-default is idiomatic, not a real bug).npm run build(local, Node 24) — cleantsc -b && vite build.docker compose up -d --buildthendocker compose exec api python -m app.cli seed --reset— counts:users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:15 workflow_runs:20.curlend-to-end:POST /api/v1/demo/loginsets cookie and returns the user; unauthenticatedGET /api/v1/dashboard→ 401 with the documented error shape; authenticated dashboard/vehicle-detail return real seeded data (verified metricsavailable:21 rented:11 cleaning:6 maintenance:5 blocked:7, matching the 50 seeded vehicles).- Browser smoke test (Chrome via MCP) at desktop width: login page → Operations Manager login → Dashboard (metrics + attention items + today + recent automation all populated) → Vehicle detail
MO-016(tabs render, "Needs attention" badge correct — it'sDQ-DEMO-OVERLAP/DQ-DEMO-STATUS) → Booking detailBK-DEMO-RETURN(matches S1 scenario: vehicleMO-024, statusactive, start odometer53610). Responsive CSS (@media max-width:700px) was written and code-reviewed but the automated resize during this session didn't visibly reflect in the captured screenshot (likely a screenshot-timing quirk of the browser tool, not necessarily a real bug) — treat the ≤360px layout as visually unverified and re-check with a real device/DevTools emulation before final acceptance (M7).
- Known accepted gap carried over from M0:
npm auditresidualesbuild/Vite-8 dev-server-only advisory.
M2 — Vehicle return vertical slice
- Backend additions:
app/models/idempotency.py(IdempotencyRecord), migratione7b08389f47f_idempotency_records,app/services/returns.py(register_vehicle_return— full transaction: row locks, idempotency replay, inspection, canonical-odometer update or regression issue, vehicle status derivation, two audit events,vehicle.returned.v1outbox event matchingcontracts/events.schema.json, next-booking-risk lookup),POST /api/v1/bookings/{public_ref}/returnwired inapp/api/routers/bookings.pywith requiredIdempotency-Keyheader. - Frontend additions:
components/ReturnForm.tsx(form +ReturnResultPanel), wired intopages/BookingDetail.tsx(shown only whenbooking.status === "active"; result persists via lifted state after the booking flips toreturned). - Commands run and verified from this checkout:
docker compose run --rm api pytest -q— 26 passed, includingtests/test_return.py(success/canonical-update, S1 regression scenario by name, damage→blocked, idempotent replay, reject-already-returned, missing-header validation, and a real multi-threaded concurrent-submission test against Postgres asserting exactly 1×201 + 2×409).docker compose run --rm api ruff check .— All checks passed.npm run build— clean.docker compose up -d --build(all services) thendocker compose exec api python -m app.cli seed --reset, then a full browser run of the S1 demo scenario againstBK-DEMO-RETURN/MO-024: submitted 53000 km (below canonical 54820) → result panel showedINSP-0076,resulting_vehicle_status: maintenance(correctly derived, since canonical 54820 ≥next_service_km40000),DQ-RET-0076created, automation event queued with a real UUID, "no upcoming booking" risk; vehicle detail page confirmed odometer unchanged at 54,820 km and a "Needs attention" badge.- Both real defects listed above (form disappearing before showing its result;
event_idreading asNone) were found via the browser run, not by pytest — the test suite asserted on API response shape/values, not on what the UI actually rendered after a status transition. Worth remembering for M3+: UI state-after-mutation bugs need a browser check, not just API tests.
Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers.
Exact next action
Start M3 (Data Quality Workbench): read docs/07-data-quality.md. Implement the five rule scanners (possible_duplicate_customer with weighted scoring, missing_required_field, odometer_regression, booking_overlap, vehicle_status_conflict) as an explicit scan service (idempotent by (rule_type, entity_type, entity_id, evidence fingerprint) while open, per the doc's lifecycle section), issue defer/reject/merge-customers endpoints, a transactional customer-merge (rewires bookings, tombstones the loser, audits before/after), the Data Quality nav item + Workbench UI (two-column duplicate comparison, evidence/proposed-resolution for other types), and wire "open quality issues" into the dashboard attention section it already reads from. Seed data already has 15 data_quality_issues including the named DQ-DEMO-* scenarios (S2 duplicate CUS-0012/CUS-0178, S4 overlap MO-016) to scan/resolve against — remember M2's return workflow also creates ad-hoc odometer_regression issues (DQ-RET-*) directly, so the scan service's idempotent-fingerprint rule must not double-report those.