Outbox dispatcher (background thread, FOR UPDATE SKIP LOCKED claim, exponential backoff, no transaction held during HTTP I/O). n8n callback endpoint with shared-secret auth and idempotency by event ID. Automation nav + UI with manual retry. 49 backend tests passing, ruff clean. Fixed a crash-on-redelivery bug in seeded outbox payloads and made the dispatcher defensive against malformed payloads. Verified the full live round trip against a real n8n instance: return -> outbox -> dispatcher -> n8n workflow -> callback -> succeeded, including the S5 failed-retry demo scenario.
25 KiB
Project state
Current milestone
M4 — complete. Starting M5 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.
M3 — Data Quality Workbench
app/services/data_quality.py:run_scan()implements all five rules and is called automatically at the end ofseed_loader.reset_and_seed()(afterdb.commit()of the base seed), plus exposed asPOST /api/v1/data-quality/scan(Operations Manager only). Idempotency is simplified from the doc's literal(rule_type, entity_type, entity_id, evidence fingerprint)to just(rule_type, entity_type, entity_id)while an issue is open — see rationale below.- Router
app/api/routers/data_quality.py:GET /issues(filters status/rule_type/severity),GET /issues/{ref}(addsentity_snapshot/related_snapshotsfor the UI),POST /issues/{ref}/defer,/reject,/merge-customers(Operations Manager only — enforced viarequire_operations_manager),POST /scan. - Real bug found and fixed during this milestone, before any browser check: the first cut of DQ-03 (odometer regression) compared every historical returned booking's
end_odometer_kmagainst the vehicle's currentodometer_km. Since the seed generator assignsvehicle.odometer_kmindependently of booking history (seeseed/generate_seed.py), this is true for nearly every historical booking by construction (odometer is monotonically increasing over time, so all-but-the-latest reading is "below current") — it produced 51 false-positive issues out of 50 vehicles on first run. Fixed twice: first attempt (compare only the single most-recent booking against canonical) still produced the same problem because canonical itself is disconnected from booking history in this dataset; the working fix compares each vehicle's own returned-booking sequence against itself (each booking's end reading vs. the immediately preceding one, chronologically) — a self-consistency check that doesn't depend on the unrelatedvehicle.odometer_kmfield at all. Final deterministic seed+scan totals: 15 CSV-seeded + 11 scan-discovered = 26 open/resolveddata_quality_issues(breakdown: 14 vehicle_status_conflict, 5 missing_required_field, 3 possible_duplicate_customer, 3 odometer_regression, 1 booking_overlap).tests/test_seed.py's exact-count assertion was updated from 15 to 26 accordingly — if the scan logic changes again, update that count. - Idempotency simplification rationale: the doc's fingerprint-based key would make the scan blind to issues it structurally can't compute a matching fingerprint for against the CSV-seeded rows (which don't carry a fingerprint field), producing duplicate issues for the same real-world problem (e.g. a second
MO-016overlap issue next to the seededDQ-DEMO-OVERLAP). Using(rule_type, entity_type, entity_id)alone while open is a stricter, safe simplification: it can never falsely suppress an issue for a different entity, and per-entity there's realistically only one meaningful open issue of a given rule type at a time for this PoC's scope. - Merge UI intentionally does not use
window.confirm()— a native dialog blocks further automation/testing and isn't screen-reader-distinguishable from page content the same way a renderedrole="alertdialog"panel is. Built an inline two-step confirm instead (ReturnForm-style pattern reused). AttentionItemgained anissue_reffield (dashboard now links attention items straight to/data-quality/{issue_ref}instead of only to vehicles); dashboard attention list capped at 8 items (was unbounded, would have shown up to 26 with the richer scan).- Commands run and verified from this checkout:
docker compose run --rm api pytest -q— 35 passed (newtests/test_data_quality.py: all five rule types present, scan idempotent on rerun, scan requires Operations Manager, S2/S4 issue-detail snapshots correct, defer→reject-on-closed 409, merge requires Operations Manager, merge rejects an unrelated survivor ref, full S2 merge scenario asserting rewiring + audit + replay-is-409).docker compose run --rm api ruff check .— All checks passed.npm run build— clean (had to fix twopossibly 'null'TS errors from a closure-narrowing limitation — TS doesn't narrowconstcaptured-by-closure across nested function boundaries when the value comes from an index/property expression; fixed by re-binding to explicitly-typed local consts right after the guard).- Full browser run: Data Quality list (26 open issues, filterable) →
DQ-DEMO-DUPLICATEtwo-column compare (CUS-0012 vs CUS-0178, per-field diff highlighting only where they differ) → merge with inline confirm → issue flips toresolved→ confirmedcustomer_mergedaudit event with correct actor/entity/correlation →DQ-DEMO-OVERLAP(non-duplicate type) renders evidence JSON + defer/reject, no dead compare UI shown for a rule type it doesn't apply to.
M4 — n8n automation
app/services/dispatcher.py: background daemon thread (started/stopped via FastAPIlifespan, not anon_eventhook) polling everyN8N_DISPATCH_INTERVAL_SECONDS(default 3s). Claim step (_claim_due_events) is a short transaction usingSELECT ... FOR UPDATE SKIP LOCKEDthat only flipspending→deliveringand commits immediately; the HTTP call to n8n happens with no open transaction; the outcome is recorded in a separate short transaction. Exponential backoffmin(2**attempts, 60)seconds,N8N_MAX_ATTEMPTS=5before a permanentfailed.- Dispatcher reconstructs the wire event from
contracts/events.schema.json's exact fields (event_id,event_type,occurred_at,correlation_id,aggregate,data) rather than forwardingOutboxEvent.payload_jsonwholesale — that column also carries an internalaggregate_refconvenience key (used by dashboard/workflows list rendering) that the schema'sadditionalProperties: falsewould reject. POST /api/v1/integrations/n8n/return-callback(app/api/routers/integrations.py): shared-secret auth viaX-Service-Tokenheader (N8N_CALLBACK_TOKEN, propagated to bothapiandn8ncontainers asMOBILITYOPS_CALLBACK_TOKEN); idempotent byIdempotency-Key(the event UUID) — checked by querying for an existingAuditEventwith that event ID in its metadata, not byOutboxEvent.external_run_id, because the dispatcher only sets that field after it gets n8n's final response, which happens after n8n has already called this callback mid-workflow — usingexternal_run_idas the idempotency guard would have missed the exact redelivery case it's meant to catch.GET /api/v1/workflows+POST /api/v1/workflows/{event_id}/retry(app/api/routers/workflows.py), both Operations Manager only. Retry only allowed fromfailed; setspending+ clearsnext_attempt_atso the live dispatcher picks it up on its next cycle (does not resetattempts, so the counter reflects true delivery history).- Automation nav + page (
pages/Automation.tsx): table of all runs with status/attempts/last error, Retry button forfailedrows, visible only to Operations Manager (matches backend authorization rather than just hiding a link). - Two real bugs found and fixed, the second only by testing the actual live n8n round-trip, not by pytest:
- Seed-loaded
workflow_runs.csvrows only ever gotpayload_json = {"aggregate_ref": ...}(nocorrelation_id/aggregate/data) — fine for M1–M3 since nothing read those keys yet, but once the dispatcher tried to redeliver a seeded row (i.e. the S5 manual-retry demo scenario) it crashed withKeyError: 'correlation_id', leaving that event stuck indeliveringforever (the crash happened before the outcome-recording transaction). Fixed in two places:seed_loader.pynow builds the full schema-compliant envelope for everyworkflow_runs.csvrow (matching what the live M2 return flow produces), anddispatcher._deliver_onenow catches malformed-payloadKeyErrors defensively and resolves the row topending/failedinstead of leaving it orphaned — addedtest_deliver_one_handles_malformed_payload_without_getting_stuckas a regression test for the latter. - This n8n image (2.32.7) has dropped
N8N_BASIC_AUTH_ACTIVEas a UI/API gate — it requires an actual owner account via the/setupflow before anything (including webhook registration reliability) works correctly. Also:n8n import:workflowrequires the workflow JSON to have a top-level"id"field (added"id": "mobilityops-return-processing") and always deactivates the imported workflow regardless of its"active"field — activation requiresn8n publish:workflow --id=<id>followed by a full n8n restart (documented in n8n 2.x CLI, not obvious from the docs pack). Did this manually this session via the CLI + browser setup wizard; this is a one-time operational step that is not automated — a truly clean checkout still needs someone to rundocker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json,docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing,docker compose restart n8n, and complete the one-time owner setup athttp://localhost:5678/setup(any email/password, no verification required) before the automation demo will work.docs/17-runbook.mdshould get this exact sequence in M7.
- Seed-loaded
- Commands run and verified from this checkout:
docker compose run --rm api pytest -q— 49 passed (newtests/test_dispatcher.py— claim/deliver success/failure/backoff/exhaustion-to-failed/malformed-payload, all viamonkeypatch.setattr(dispatcher.httpx, "post", ...), no real network calls in tests;tests/test_integrations.py— callback auth, unknown-event 404, idempotent-by-event-ID with a real duplicate-call assertion;tests/test_workflows.py— role gating, retry-only-from-failed, S5 retry-and-audit).docker compose run --rm api ruff check .— All checks passed.npm run build— clean.- Full live round trip (not mocked): registered a real return on
BK-DEMO-RETURN→ outbox event queued → background dispatcher delivered it to the now-activated n8n workflow within its 3s poll interval → n8n called back into/api/v1/integrations/n8n/return-callback(200 OK, confirmed indocker compose logs api) → dispatcher's original POST received n8n's success response → event flipped tosucceededon attempt 1, visible on/automation. - S5 scenario end-to-end in the browser: seeded
BK-H-0020(failed, 3 attempts, "Synthetic connection timeout to n8n") → clicked Retry →pending→ within ~3s, live dispatcher delivered it through the real n8n instance →succeeded, 4 attempts. This is the full documented S5 scenario working for real, not simulated.
Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps above are a one-time manual setup requirement in this environment, not a blocker — but not yet scripted; M7 should either automate it (e.g. a bootstrap script CI/compose can run) or document it clearly enough for docs/14-testing-and-acceptance.md's clean-checkout criteria.
Exact next action
Start M5 (RAGcore knowledge integration): read docs/09-ragcore-integration.md, knowledge/manifest.json. Implement the KnowledgeProvider protocol (health, ask, sync_manifest), a DemoKnowledgeProvider doing deterministic keyword/BM25-style retrieval directly over knowledge/procedures/*.md (extractive, not generative — must return real excerpts + insufficient/unavailable states honestly, never fabricate), and a RAGcoreKnowledgeProvider adapter stub for the real service (RAGCORE_BASE_URL etc. are already in config/compose from M0, but no actual RAGcore instance is confirmed reachable this session — build the demo provider as the one that actually has to work for the acceptance criteria, and make the RAGcore adapter degrade to unavailable cleanly if unreachable, per docs/03-architecture.md's reliability boundary "RAGcore failure disables knowledge answers only"). Add POST /api/v1/knowledge/questions and GET /api/v1/knowledge/status, a Knowledge nav item + page (chat-style question box, source cards with title/version/section/excerpt, explicit unavailable/insufficient states — no fabricated answers ever). S6 demo scenario: "What must I do when a vehicle returns with damage?" must cite knowledge/procedures/03-damage-handling.md and 02-vehicle-return.md.