Four read-only, service-token-protected MCP provider endpoints (operations summary, attention vehicles, vehicle details, knowledge search facade). Shared-secret auth reusing the M4 callback pattern. Service-request audit trail for every call. Extracted shared operations-summary logic out of the dashboard router to avoid duplicating retrieval logic. 66 backend tests passing, ruff clean. Verified all four endpoints and audit trail directly via curl against the live stack (no live MCP Hub instance available in this environment).
34 KiB
Project state
Current milestone
M6 — complete. Starting M7 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.
M5 — RAGcore knowledge integration
app/services/knowledge/__init__.py:KnowledgeProviderProtocol (sync, not async — the rest of the backend is sync SQLAlchemy/FastAPI, so an async provider interface would have meant bridging paradigms for no benefit) withhealth()/ask(), plusGroundedAnswer/SourceCard/KnowledgeHealthPydantic models matchingcontracts/openapi.yaml'sGroundedAnswerschema exactly.get_knowledge_provider()factory switches onsettings.knowledge_provider("demo" default, "ragcore" opt-in).app/services/knowledge/demo.py—DemoKnowledgeProvider: parses the 10knowledge/procedures/*.mdfiles' YAML frontmatter (hand-rolled flat parser, not PyYAML — avoided adding a dependency for a 6-key flat block) and##-delimited sections at startup, then does TF-IDF-weighted keyword retrieval (not naive keyword counting) with light suffix-stripping stemming (returns→return,damaged→damage). This is extractive, not generative: it returns real excerpts and a templated answer sentence, never invented text.- Real bug found and fixed by testing the actual S6 question, not by inspection: naive flat keyword-overlap scoring (first cut) let the word "vehicle" — present in nearly every document's title — crowd out the actually-relevant
damage-proceduredocument from the top-3 results for "What must I do when a vehicle returns with damage?", because generic words scored the same as distinctive ones. Fixed by computing corpus-wide IDF per token (log((N+1)/(df+1)) + 1) and weighting matches by it, so common terms contribute little and rare/distinctive terms (like "damage") dominate the ranking. Verified: the S6 question now returnsdamage-procedureandvehicle-return-procedurein the top 3, matching the documented expectation exactly.
- Real bug found and fixed by testing the actual S6 question, not by inspection: naive flat keyword-overlap scoring (first cut) let the word "vehicle" — present in nearly every document's title — crowd out the actually-relevant
app/services/knowledge/ragcore.py—RAGcoreKnowledgeProvider: realhttpxadapter guessing a plausible REST contract (GET /health,POST /api/v1/ask) percontracts/ragcore-contract-assumptions.md(RAGcore is built separately; no live instance was reachable this session to verify against). Any connection error, timeout, or malformed response degrades toevidence_state: "unavailable"rather than raising — this is the adapter that actually exercises the architecture's "RAGcore failure disables knowledge answers only" reliability boundary. Not wired as the active provider by default;KNOWLEDGE_PROVIDER=ragcorewould need a real, verified base URL to turn on.POST /api/v1/knowledge/questions+GET /api/v1/knowledge/status(app/api/routers/knowledge.py). Audit eventknowledge_question_askedlogsevidence_state,provider,source_ids, andquestion_lengthonly — not the question text itself, perdocs/12-security-and-audit.md("log question metadata and source IDs, not unnecessary full prompts").- Knowledge nav + page (
pages/Knowledge.tsx): chat-style question box, source cards (title/version/section/excerpt) prioritized over the answer text perdocs/06-ui-ux.md, explicitgrounded/insufficient/unavailablestates with distinct visual treatment — never a fabricated-looking answer for the latter two. - Dockerfile now also
COPY knowledge ./knowledge; addedKNOWLEDGE_DIRsetting (/app/knowledge/proceduresin-container, same pattern asSEED_DIR) rather than deriving the path from__file__— simpler and doesn't break if the module moves. - Commands run and verified from this checkout:
docker compose run --rm api pytest -q— 57 passed (newtests/test_knowledge.py: S6 grounded-with-expected-sources, unrelated question is honestly insufficient with no fabrication, demo provider health/document count, endpoint auth required, audit doesn't leak question text, RAGcore adapter degrades to unavailable on a simulated connection error).docker compose run --rm api ruff check .— All checks passed.npm run build— clean.- Full browser run of S6 end-to-end: asked "What must I do when a vehicle returns with damage?" on
/knowledge→ grounded answer citing "Vehicle return procedure" (2 sections) and "Damage handling procedure" with real excerpts. Also asked an unrelated question ("What is the weather forecast for tomorrow?") → correctly returned "Insufficient evidence" / "No matching procedure was found" with zero sources, confirming no fabrication.
M6 — ITWorx MCP Hub publication
app/api/routers/mcp_integrations.py: four read-only endpoints under/api/v1/integrations/mcp/—GET operations-summary,GET attention-vehicles(query paramsminimum_severity/date/limitmatchingcontracts/mcp-tools.json'sinputSchemaexactly),GET vehicles/{vehicle_ref},POST search-knowledge(the "narrow façade" the doc calls for — wraps M5'sget_knowledge_provider()rather than re-implementing retrieval; the contract's tool has no MobilityOpsendpointfield, onlyrouting.preferred: ragcore, so this façade path is MobilityOps's own addition for when the Hub needs a single provider boundary, not literally specified by the contract).- Auth: new
require_mcp_service_tokendependency inapp/api/deps.py, same shared-secret-header shape as the M4 n8n callback (X-Service-TokenagainstMCP_HUB_SERVICE_TOKEN) plus an optionalX-Client-Idheader (defaults to"unknown-mcp-client") used as the audit actor label — the Hub's actual client-identity header name is unknown (no live Hub to confirm against), so this is a reasonable guess documented here rather than assumed silently. McpVehicleDetailOutdeliberately omitsregistration_numberand all customer data — narrower than the browser-facingVehicleOut/VehicleDetailOut, matching "no customer or vehicle database access" and the read-only/summary intent of an AI-facing tool. Testtest_vehicle_details_known_refasserts the field's absence explicitly so a future change can't silently widen the exposed surface.- Extracted
app/services/operations.py(compute_metrics,list_attention_vehicles) out ofapp/api/routers/dashboard.pyso the MCP operations-summary/attention-vehicles endpoints and the human dashboard share one query implementation instead of two copies that could drift — the same "do not duplicate retrieval logic" principle the doc states for the knowledge tool, applied here to the operational-summary tools too. - Every provider call writes an
AuditEvent(actor_type="service",actor_label=X-Client-Id,action="mcp_tool_request",metadata={tool, status}) — MobilityOps's own record that its provider APIs were reached, independent of whatever central tool-call audit the Hub itself keeps (perdocs/10-mcp-hub-integration.md's audit section, the Hub owns the central log; this is the local corroborating one). - No write/mutation endpoints exist under the
/api/v1/integrations/mcp/namespace at all (verified bytest_no_write_endpoints_exist_under_mcp_namespace— POST/PUT/DELETE against the vehicle-details path all 404/405) — return registration, customer merge, and any booking/vehicle mutation are correctly absent, per the doc's explicit restriction list. - Commands run and verified from this checkout:
docker compose run --rm api pytest -q— 66 passed (newtests/test_mcp_integrations.py: token-required, wrong-token 401, all four tools' happy paths, severity/limit filtering, 404 for unknown vehicle,max_sourcesrespected, audit actor/action verified, write-method rejection).docker compose run --rm api ruff check .— All checks passed.- Live
curlverification against the running stack (no browser needed — these are service-to-service endpoints, not UI): missing header → 422; wrong token → 401; correct token → all four endpoints return correct data (operations-summarymetrics match the dashboard;attention-vehicles?minimum_severity=highreturnedMO-016×2 andMO-031, all severityhigh;vehicles/MO-016returned the narrow read-only shape;search-knowledgewithmax_sources=2returned exactly 2 grounded sources for the S6 question). Confirmed viaGET /api/v1/audit?action=mcp_tool_requestthat all four calls were recorded with correctactor_type=service, tool name, and status.
Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps from M4 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. RAGcore and the ITWorx MCP Hub itself were never reachable this session — RAGcoreKnowledgeProvider and the M6 MCP provider endpoints are implemented and directly tested/curl-verified against MobilityOps's own API, but neither a real RAGcore instance nor a real Hub round trip was available to confirm end-to-end; M5's demo provider and M6's direct endpoint tests are what actually satisfy those milestones' acceptance criteria in this environment.
Exact next action
Start M7 (portfolio polish and final acceptance): read docs/06-ui-ux.md, docs/14-testing-and-acceptance.md, docs/16-portfolio-case-study.md. Work through docs/14-testing-and-acceptance.md's clean-checkout acceptance list item by item from a genuinely fresh checkout (new clone, cp .env.example .env, documented bootstrap commands only) rather than this session's already-running/already-seeded stack — several manual one-time steps accumulated this session (n8n owner setup + workflow import/publish/restart, see M4 notes) that must either be scripted into the bootstrap or written up precisely in docs/17-runbook.md, since a true clean checkout won't have them done yet. Also needed: Playwright end-to-end test covering the full documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S4 overlap already-visible → S6 knowledge question → audit → responsive nav at 360px — the ≤360px layout was flagged as visually unverified back in M1 and should finally be checked here), screenshots of the seven main pages, an architecture diagram, artifacts/evidence/final-summary.md per the doc's exact required contents (commit/tag, commands, test counts — currently 66 backend tests across M0–M6 — screenshots, RAGcore success/unavailable evidence, n8n success/retry evidence, MCP sample calls, known limitations, truthful portfolio wording), and a final pass removing any rough edges/placeholders across the UI.