Adds artifacts/fleet-ops-release/final-summary.md with the complete evidence trail for this release: commits, branding, locale/translation/knowledge-base coverage, adaptive Demo Guide behaviour per breakpoint, Data Quality/Automation/Audit/clickable-row improvements, full test results (backend, lint, build, 92 Playwright tests) re-run against the local stack, an isolated clean-checkout drill, and both the feature-branch and post-merge master deployments to Unraid -- plus 10 screenshots across the three languages, desktop and mobile. Updates PROJECT_STATE.md with the corresponding summary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
91 KiB
Project state
Publication and Unraid deployment (2026-08-02)
- Unraid deployment is live at
http://192.168.10.150:1236from/mnt/user/appdata/mobilityops, Compose projectmobilityops. - Deployment config commits:
07ab7a3,847cd05,e1a1c67,1e13943. The accepted baseline4bf9afbeff44088864e0844769d4dd0e4089d85bremains intact. - MobilityOps PostgreSQL, API and web services are healthy. Only web port 1236 is exposed
by the MobilityOps Compose project; API and PostgreSQL remain internal. Automation uses
the server's existing shared n8n at
http://192.168.10.150:5678; no second MobilityOps n8n container is running. - Migrations are at
e7b08389f47f (head)and deterministic seed counts match final acceptance. A Chrome smoke test covered every requested page and a real return; its n8n event succeeded on attempt 1. Browser console and recent service log scans were clean. - RAGcore is disabled in favor of the honest local demo provider. MCP Hub registration is disabled. The MobilityOps workflow is published in the existing n8n and live-verified.
- Local post-change gates: 66 backend tests, Ruff, mypy (44 files), and frontend production
build all pass. Evidence is in
artifacts/deployment/unraid-summary.md. - Published to the private Gitea repository
https://gitea.itworx.tech/Jens/MobilityOps.masteris the default branch; the full commit history and baseline commit are present; zero tags exist; remote hygiene is clean.originuses the SSH clone URL supplied by Gitea. - Exact next action: none — repository publication and Unraid deployment are complete.
Current milestone
M7 — complete. All milestones (M0–M7) done, plus a full post-M7 final-acceptance audit (see below). See artifacts/final-acceptance/summary.md for the definitive acceptance evidence (supersedes artifacts/evidence/final-summary.md, which is kept as historical M7 evidence).
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.
M7 — Portfolio polish and final acceptance
- Automated clean-checkout migrations:
backend/entrypoint.shnow runsalembic upgrade headbefore starting uvicorn (DockerfileCMDchanged fromuvicorn ...to./entrypoint.sh). Verified with a truedocker compose down -v(all volumes wiped) →docker compose up --build -d→ all 11 tables present,/healthand web both green, all 66 backend tests pass, with zero manual migration step. - n8n one-time setup scripted where it can be:
make n8n-setupruns the import/publish/restart sequence (previously three manual commands discovered ad hoc in M4). The owner-account creation itself cannot be scripted safely (it's an interactive one-time step in n8n 2.x's own onboarding, not a MobilityOps concern) — documented precisely in the rewrittendocs/17-runbook.md, including the exact URL and that no email verification is required. Re-ran this full sequence from the wiped-volumes state this session and confirmed the S1 return → outbox → live n8n → callback →succeededround trip works on a genuinely clean checkout, not just the already-provisioned stack from M0–M6. - Playwright E2E (
frontend/e2e/demo.spec.ts,frontend/playwright.config.ts): one test automating the full 9-step documented demo script end-to-end against the live stack — login, dashboard metrics, openBK-DEMO-RETURN, register an odometer-regression return (S1), verify the quality issue + queued automation event, merge the duplicate-customer scenario (S2), ask the damage question and verify both expected source citations (S6), inspect audit entries, and verify responsive nav + no horizontal overflow at 360px width. Passing. This also resolves the "≤360px layout visually unverified" gap flagged back in M1 — verified both by this test's overflow assertion and by the9-mobile-dashboard.pngscreenshot (nav wraps into rows, metric tiles collapse to a 2-column grid, no horizontal scroll). - Added
frontend/e2e/_capture-screenshots.spec.tsas evidence-generation tooling (underscore-prefixed, excluded from the defaultplaywright test/make e2erun viatestIgnorein the config — it callsdemo/reset, which a real regression test shouldn't do as a side effect). Captured all 9 screenshots intoartifacts/evidence/screenshots/. - Wrote
artifacts/evidence/architecture.md(mermaid, as-built — distinguishes verified-live components from implemented-but-never-reached-a-real-instance ones, i.e. RAGcore and the MCP Hub) andartifacts/evidence/final-summary.md(commit, exact commands, test counts, screenshot index, RAGcore success/unavailable evidence — including a live-demonstrated unavailable case against an unreachable host, not just the unit test — n8n success/retry evidence, MCP sample calls, known limitations, truthful portfolio wording perdocs/16-portfolio-case-study.md's template). - Updated
README.md(dropped stale "minimal bootable scaffold, not the finished application" wording and the old two-line quickstart in favor ofmake demo+ a pointer to the runbook) anddocs/17-runbook.md(full rewrite: exact bootstrap, n8n one-time setup, verification commands, required operational checks, recovery expectations). - Final placeholder/dead-UI sweep:
grep'd the fullfrontend/srcandbackend/apptrees for scaffold/TODO/FIXME/"must be replaced" markers — none found.FILE_INDEX.mdwas left as-is; it's the original build-pack's archive-completeness manifest (a historical snapshot), not a living index that needs to track every file added since — updating it would misrepresent what it's for. - Commands run and verified from this checkout (this milestone, cumulative across the whole build):
docker compose run --rm api pytest -q— 66 passed, ruff clean.cd frontend && npm run build— clean.cd frontend && npx playwright test— 1 passed (full demo script, live stack).- Full clean-checkout drill:
docker compose down -v→docker compose up --build -d→docker compose exec api python -m app.cli seed --reset→docker compose run --rm api pytest -q(66 passed) → n8n owner setup +make n8n-setup→ live S1 return round-tripped through the real n8n instance tosucceeded.
Final acceptance audit (post-M7)
A dedicated release-readiness audit was run after M7 claimed completion, specifically to catch anything the milestone-by-milestone build might have missed by only ever validating each piece in isolation.
- Real gap found:
mypyhad never been run.mypyis a declared dev dependency (backend/pyproject.toml) but was never wired into any milestone's validation loop — onlyruffwas. Running it cold surfaced 43 real type errors across 10 files, all pre-existing (not introduced by this audit). Triaged and fixed all of them rather than suppressing:services/returns.py: the vehicle lookup after acquiringFOR UPDATEcould type asVehicle | Nonewith no runtime guard — added an explicitif vehicle is None: raise AppError(..., 404). This was a genuine defensive-programming gap (a dangling FK would have crashed with an unhandledAttributeError/500 instead of a clean 404), not just a type annotation issue.api/routers/bookings.py: same pattern fordb.get(Customer, ...)/db.get(Vehicle, ...)inget_booking— added a guard raising 500 with a clear message instead of crashing onNone.public_ref.api/deps.py+api/routers/demo.py:CurrentUser.roleis aLiteral[...], butSessionPayload.role(decoded from an HMAC-signed cookie) andUser.role(a DB column) are both plainstr. Pydantic validates this at runtime already (so it was never exploitable), butget_current_usernow explicitly checks membership before constructingCurrentUser, turning a would-be unhandledValidationError(500) into a clean 401 for a corrupted/tampered cookie — another real defensive improvement, not just a type-checker appeasement.api/routers/dashboard.py,api/routers/data_quality.py: two instances of reusing one variable name for both aVehicleand aCustomeracross an if/else branch, which is genuinely confusing to read regardless of what mypy thinks — renamed to distinct variables (entity/typed union in dashboard,customer/vehiclein the data-quality snapshot helper).services/data_quality.py,seed_loader.py:Booking.__table__.update()/Customer.__table__.update()don't typecheck against SQLAlchemy 2.0's stubs (the.__table__accessor is typed as the more generalFromClause, which doesn't declare.update()) — switched to the idiomaticsqlalchemy.update(Model)construct, which is both correctly typed and the more modern SQLAlchemy 2.0 style anyway.- Remaining handful (schemas.py's deprecated
conint()→Annotated[int, Field(...)], aSequencevslist.sort()call, anassert-guarded None-narrowing after aWHERE ... IS NOT NULLfilter mypy can't see through,Result.rowcounttyping gaps) were either latent pydantic-v1-style API usage or genuine SQLAlchemy stub limitations — fixed with the idiomatic modern equivalent or a narrowly-scoped, commented# type: ignore[...]at the exact line, never a blanket suppression. make lintnow runs bothruff check .andmypy app;mypy appreports zero errors across 44 source files.
- No other defects found. Re-ran the full journey matrix end-to-end against a
genuinely wiped-volumes (
docker compose down -v) clean checkout: all 66 backend tests, ruff, ✅; ran the demo login → dashboard → vehicle/booking detail → return workflow → invalid-mileage rejection (422, both a negative value and a non-numeric string) → data-quality issue review → duplicate-customer merge → audit trail → Knowledge Assistant → live n8n round trip → MCP Hub endpoint journeys directly viacurlagainst the running stack, all correct. - Degraded-mode behavior explicitly re-verified live (not just unit-tested):
stopped n8n with
docker compose stop n8n, registered a return — it committed (201, booking flipped toreturned) exactly as required; the outbox event stayedpendingwith realConnectErrors logged and exponential backoff (2 attempts over ~8s); restarted n8n and the dispatcher self-healed without any manual intervention, delivering the event tosucceededon attempt 5. RAGcore unavailable mode re-verified live against an unreachable host (ConnectError→evidence_state: "unavailable", empty answer, no fabrication). MCP Hub unavailability is architecturally moot for MobilityOps — the Hub only ever calls into MobilityOps, so there is nothing on the MobilityOps side that can degrade if the Hub is down (only the reverse, "does an unavailable Hub break MobilityOps," which is trivially no since nothing here calls out to it). - New test coverage added, no existing tests weakened:
frontend/e2e/interactive-elements.spec.ts(11 Playwright tests — all seven nav items, every filter on every list page, vehicle detail tabs, defer/reject, automation retry, knowledge form, role-switching, and role-based page restriction) plus the existingdemo.spec.ts— 12/12 e2e tests passing against the live stack. - Verified
.envis.gitignored and was never committed (git ls-files/git log --all -p -- '*.env'both empty); scanned full git history for AWS keys, private-key headers, andsk-...-style tokens — none found. EverySettingsfield inbackend/app/core/config.pyhas a corresponding entry either directly in.env.exampleor is derived/wired throughcompose.yaml(a few purely-internal container-path constants likeSEED_DIR/KNOWLEDGE_DIRare intentionally not operator-configurable and correctly absent from.env.example). - Grepped the full
frontend/srcandbackend/apptrees for TODO/FIXME/placeholder/ fake/stub/mock/"not implemented" markers — zero real hits (the twoplaceholder=matches are legitimate HTML input placeholder attributes). Confirmed dashboard metrics and all list-page data are 100% DB-backed (compute_metricsinservices/operations.py, never a literal in frontend JSX). Confirmed every frontend route inApp.tsxmaps to an implemented page and every nav item maps to a real route — no dead routes. - Commands run and verified from this audit:
docker compose run --rm api pytest -q— 66 passed.docker compose run --rm api ruff check .— All checks passed.docker compose run --rm api mypy app— Success: no issues found in 44 source files (0 errors, down from 43).cd frontend && npm run build— clean (tsc -b && vite build).cd frontend && npx playwright test— 12 passed (demo.spec.ts+interactive-elements.spec.ts).- Full clean-checkout drill repeated from a fresh
docker compose down -v: automatic migrations, seed, 66/66 tests, n8n owner setup +make n8n-setup, live return round-tripped through n8n tosucceeded. - See
artifacts/final-acceptance/summary.mdfor the complete evidence write-up (commands, exact outputs, demo access, deployment instructions, five-minute demo flow).
Definition of done
All eight milestones (M0–M7) are complete, and a dedicated post-M7 final-acceptance audit
found and fixed one real category of gap (mypy never having been run) with zero
regressions. docs/14-testing-and-acceptance.md's clean-checkout acceptance list has been
walked item by item against a genuinely wiped-volumes checkout, twice (once in M7, once in
this audit), and artifacts/final-acceptance/summary.md is the authoritative final
evidence document. The two items not fully closed — a live RAGcore instance and a live
ITWorx MCP Hub instance — were never reachable in this environment; both integrations are
implemented, unit/contract-tested, directly verified against MobilityOps's own API, and
their unavailable-degradation paths are live-verified, but an actual round trip against
real RAGcore/Hub instances remains unconfirmed and is documented as such rather than
claimed.
Known blockers
None. External service credentials may be absent; use the documented demo/degraded providers. The n8n workflow-activation steps are a one-time manual setup requirement in this environment (owner-account creation via n8n's own /setup UI cannot be scripted safely), fully documented in docs/17-runbook.md and scripted where possible (make n8n-setup). RAGcore and the ITWorx MCP Hub itself were never reachable in this environment — both integrations 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.
Premium Control Rail UI transformation (2026-08-02)
- Branch:
design/mobilityops-premium-ui, branched from verified deployed revisiondfabb41582e302f45a3de826f85f531bf23dfc8b; master history was not rewritten. - Audited every route at 1440, 1280, 768 and 390 px. Baseline findings and captures are
in
docs/design/current-ux-audit.mdandartifacts/design-validation/current/. - Authored three twelve-screen product directions and generated representative Stitch
anchors in project
17018847755558569017: Control Rail, Dispatch Ledger and Service Atelier. Control Rail was selected and refined twice for hierarchy, accessibility and responsive implementation. Decision, screen inventory, tokens and exact Stitch IDs are indocs/design/design-directions.md,docs/design/design-system.mdanddocs/design/stitch-manifest.md. - Rebuilt the complete React interface around a responsive Control Rail shell: inline SVG icon/brand system, desktop rail, named landmarks, skip link, top bar, mobile bottom navigation, shared loading/error/empty states and reduced-motion support.
- Redesigned all shipped pages. The dashboard now prioritizes persisted readiness, Attention and today's movements; booking results paginate at 25 rows; every responsive table retains field labels; integrations distinguish n8n evidence, live RAGcore health and the unconfigured MCP adapter without inventing status.
- Return registration is now capture → review → result. A regression test proves the return endpoint is not called before confirmation; the existing idempotency and local commit/outbox contract is unchanged.
- Final browser captures are in
artifacts/design-validation/implementation/. DOM measurements and Playwright both prove no horizontal overflow at 390, 768, 1280 and 1440 px. Seedocs/design/implementation-validation.md. - Final validation commands from this branch:
docker compose run --rm api pytest -q— 66 passed.docker compose run --rm api ruff check .— All checks passed.docker compose run --rm api mypy app— 0 issues in 44 files.cd frontend && npm run lint— clean TypeScript check.cd frontend && npm run build— production build succeeded (59 modules; 240.24 kB JS, 36.63 kB CSS before gzip).cd frontend && playwright test --reporter=line— 19 passed including the full five-minute demo, every interactive route, return review semantics and four viewport overflow checks.
- Review deployment updated at
http://192.168.10.150:1236with persistent PostgreSQL data preserved. Deployed smoke: all ten authenticated routes plus login at desktop and mobile sizes rendered without alert state or horizontal overflow; browser console had zero warnings/errors; seven authenticated API paths returned 200; PostgreSQL/API were healthy and the shared n8n/healthzreturned{"status":"ok"}. - Corrected the review topology after confirming the host already runs n8n on port 5678:
the temporary
mobilityops-n8n-1container was removed without deleting its retained volume; the bundled service is now opt-in through thebundled-n8nprofile; the API points to the shared n8n; and the return workflow is imported and published there. - The existing n8n's previously empty
N8N_HOSTandN8N_EDITOR_BASE_URLvalues were persistently set in its Unraid template. A synthetic return then completed the full MobilityOps → shared n8n → callback round trip assucceededon attempt 1, after which deterministic demo state was restored (BK-DEMO-RETURNisactive). - Global search is now live for Control Rail sections and
MO-*,BK-*,DQ-*public references, including Ctrl/Cmd+K focus and a tested not-found announcement. Final local Playwright result is 19 passed. - Exact next action: hand off
design/mobilityops-premium-uifor review. The final code, shared-n8n topology and evidence are committed, pushed and deployed; do not merge master automatically.
Functional completion pass (branch feat/mobilityops-functional-completion)
Branched from design/mobilityops-premium-ui @ 54dc952. Full audit at
docs/functional-completion/current-functional-audit.md; server baseline captured before
any change at docs/functional-completion/server-baseline.md.
Batch 1 — complete (commits 938a739..bdc58f3)
- Fixed the two confirmed list-rendering defects: Vehicles and Bookings both computed a filtered/paginated result but rendered the raw unfiltered array in the table body.
- Added server-backed session lifecycle:
GET /api/v1/demo/session(Cache-Control: no-store — a cached 200 was making logout intermittently fail to redirect in e2e testing),POST /api/v1/demo/logout.AuthContextnow verifies against the server on every mount instead of trustingsessionStorage, and a central 401 listener on the API client clears auth state from any endpoint. - Enforced the brief's role matrix: data-quality (list/detail/defer/reject) and the audit
trail were reachable by Rental Employee with no gate beyond authentication (confirmed
live via curl before the fix). Both are now
require_operations_manager-gated server-side, with matching nav-hiding and a restricted-message fallback for direct URL access, and the dashboard no longer links into those areas for that role. - Discovered and fixed a latent e2e-suite bug while testing against the real server: all
three spec files hardcoded
http://localhost:8128for their demo-reset helpers, so pointing the suite at Unraid viaMOBILITYOPS_PUBLIC_URLsilently kept resetting the local dev database instead. Switched to relative paths so the configuredbaseURLis honoured. - Local evidence:
pytest75 passed,ruff check .clean,mypy app0 issues/44 files,npx tsc -bclean,npm run buildclean,npx playwright test25 passed (up from 19 — 6 new tests this batch), stable across three repeated full-suite runs. - Deployed to Unraid (
.deploy/source-revision=bdc58f396e99caaf6ef657bb110b479981cc7793, matchesgit rev-parse HEADon the feature branch), migrations unchanged ate7b08389f47f (head)(no schema change this batch), demo reset run. Re-verified live: role-gate curl checks (403/200/401 as expected) and the full 25-test Playwright suite run withMOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236— 25 passed against the actual deployment, not just localhost. - Exact next action: Batch 2 — authoritative return-preview endpoint shared with commit,
and expose
before/afteron the audit API + UI.
Batch 2 — complete (commits f521295, 7e34f55)
- Added
evaluate_return()(pure, no writes) inreturns.py, extracted from whatregister_vehicle_returnalready computed inline;register_vehicle_returnnow calls it instead of duplicating the logic. New non-mutatingPOST /api/v1/bookings/{ref}/return-previewuses the same function, so preview and commit cannot drift. - Fixed a real defect this surfaced:
ReturnForm.tsx's review step guessed the outcome client-side and got the domain rule wrong — it said damage/technical-warning routes tomaintenance(actual rule:blocked) and the no-contradiction case becomesavailable(actual rule: alwayscleaningfirst,maintenanceonly past the service threshold). The review step now calls/return-previewand renders the server'sresulting_vehicle_status+status_reasonverbatim. - Result screen now distinguishes local commit success from n8n delivery ("queued... not yet confirmed" instead of implying both succeeded) and links to any created data-quality issue for Operations Manager.
- Exposed
before/afteronAuditEventOut(the DB columns already existed but were never serialized) plus a resolvedentity_ref/entity_linkfor vehicle/booking/ data-quality-issue entities.Audit.tsxnow shows a human-readable change summary per row with raw JSON behind a<details>disclosure instead of always-visible JSON. - New regression coverage: backend — preview performs no writes (asserted via audit/
outbox row counts before vs. after), detects odometer regression, detects service-due,
detects next-booking risk, requires an active booking, and matches the commit result;
audit — before/after and entity link exposed for both
return_registeredandvehicle_status_changed. Frontend — preview correctly reportsblocked(notmaintenance) for damage, commit request only fires after confirm (updated to also assert exactly one preview call), audit page shows before/after and a safe link. - Local evidence:
pytest81 passed,ruff check .clean,mypy app0 issues/44 files,npx tsc -bclean,npm run buildclean,npx playwright test27 passed, stable across two repeated full-suite runs. - Deployed to Unraid and re-verified; demo data reset afterward.
- Exact next action: Batch 3 — data-quality workbench (typed snapshots, bounded resolution flows for all 5 rule types, manual scan UI).
Batch 3 — complete (commits 6e227a2, 477b5e7)
- Typed related-entity snapshots by the reference's own prefix (CUS-/MO-/BK-/INSP-)
instead of inferring from
rule_type. Fixed a real gap this exposed: abooking_overlapissue's related refs are bookings, butget_issuealways resolved them as vehicles, so_snapshot()silently returned nothing for them. - Added one bounded resolution endpoint per remaining rule type:
provide-fields(missing_required_field; re-runs the check, resolves only once nothing required is missing),resolve-odometer-regression(retain canonical or correct the reading — correction is rejected if it would still be below canonical),resolve-overlap(blocks one of the two bookings, re-verifies no overlap remains — found and fixed an autoflush=False bug where the re-verification query didn't see the just-blocked booking's in-memory status change),apply-recommended-status(one authoritative recommendation function mirroring the scan's own conflict conditions, re-validated after applying).possible_duplicate_customeralready had merge; all five rule types now have a real bounded resolution path, not just generic defer/reject. - Reintroduced evidence after a non-open decision links the new issue back to the prior
one (
evidence.reopened_from/previous_decision) per the documented lifecycle ("reintroduced evidence creates a new issue linked to the prior issue"). DataQualityIssueDetail.tsxrewritten: a typed panel per rule type instead of a rawJSON.stringifydump for four of five types; raw evidence moved behind a<details>disclosure. Added a "Run quality scan" action to the workbench (confirmation, progress, per-rule result counts, auto-refresh) — the scan endpoint already existed with no UI trigger.- New regression coverage: backend — one resolution test per rule type plus the role-gate/validation-rejection paths and the recurrence-linking behavior (reject an issue, rescan, assert the new issue links back). Frontend — one Playwright test per resolution flow plus the manual scan trigger.
- Local evidence:
pytest96 passed,ruff check .clean,mypy app0 issues/44 files,npx tsc -bclean,npm run buildclean,npx playwright test32 passed, stable across two repeated full-suite runs. - Deployed to Unraid and re-verified against the live server; demo data reset afterward.
- Exact next action: Batch 4 — global search backend + UI, demo reset UI trigger, truthful aggregate integration status (n8n/RAGcore/MCP).
Batch 4 — complete (commits 4437b87, 1867828)
- Added
GET /api/v1/search— bounded typed results (vehicle/booking/data-quality-issue/ section), role-filtered server-side (data-quality and manager-only sections excluded for Rental Employee), customers never returned (no customer detail route exists). ReplacedLayout.tsx's blind client-side regex/term guesser with a debounced (250 ms) call to this endpoint, a realrole="listbox"results panel, arrow-key navigation, Enter/Escape, outside-click close, and a no-results state. - Added
GET /api/v1/integrations/status, aggregating outbox delivery counts into one truthful n8n state (disabled/unavailable/degraded/operational/no_evidence) instead of the dashboard/automation cards showing whichever status the single most recent event happened to be in. Wired into bothAutomation.tsxandDashboard.tsx. MCP Hub card now reflects the realregistration_enabledsetting. - Found and fixed a real config gap this surfaced:
MCP_HUB_REGISTRATION_ENABLEDwas documented in.env.examplebut had noSettingsfield, so it was silently dropped byextra="ignore"and never read anywhere in the codebase. - Added a "Reset demo data" action to the sidebar (Operations Manager only, confirm, progress, error handling) — the endpoint already existed and was already gated, just had no UI trigger. Reset invalidates the acting session server-side, so the flow signs the user out and returns to login.
- New regression coverage: backend — search role-filtering/customer-exclusion/no-match,
integration-status role-gate and state-derivation (including a test that resolves all
seeded failures and asserts the state flips to
operational). Frontend — vehicle/ booking/data-quality-issue search navigation, keyboard nav, no-results + Escape, demo reset happy path, rental employee cannot see the reset button, automation page shows aggregate counts. - Local evidence:
pytest109 passed,ruff check .clean,mypy app0 issues/46 files,npx tsc -bclean,npm run buildclean,npx playwright test37 passed, stable across two repeated full-suite runs. - Deployed to Unraid and re-verified against the live server; demo data reset afterward.
- Exact next action: Batch 5 — bounded outbox delivery-lease recovery for stale
deliveringevents, a second (scheduled quality-scan) n8n workflow, final documentation/contract updates and acceptance evidence.
Batch 5 — complete (commits ec8f809, e115031, c981aad, 824048b)
- Fixed a real gap:
_claim_due_eventsflipped rows todeliveringand committed before the HTTP call, with no reclaim path if the process died before the outcome was recorded. Each claim now gets a lease deadline (n8n_delivery_lease_seconds, default 120s, reusing thenext_attempt_atcolumn) andrun_dispatch_cycle()sweeps expired leases back topendingbefore claiming new work;attemptsis preserved, and a still-alive worker's unexpired lease is never touched. - Added the second n8n workflow:
POST /api/v1/integrations/n8n/scheduled-scan(service-token protected, same pattern as the return callback) running the samerun_scan()the manual UI action uses, audited withactor_type=service.n8n/mobilityops-scheduled-quality-scan.json(hourly + manual-test trigger) ships"active": false. Live-verified twice: executed end-to-end via the Manual test trigger against the local n8n instance (full green execution, confirmed via the resultingdata_quality_scan_runaudit event), and published + directly curl-round-tripped against the shared Unraid n8n and its live API (deploy/unraid/setup-scheduled-scan.sh). The shared instance's own UI could not be browser-tested directly — it runsN8N_SECURE_COOKIE=trueand refuses login over the plain-HTTP LAN URL, which is correct/expected shared-infrastructure behaviour, not something this task should change. - Updated
contracts/openapi.yamlanddocs/05-api-contract.mdwith every endpoint added across all five batches;docs/07-data-quality.md,docs/08-return-workflow.mdanddocs/12-security-and-audit.mdnow describe the actual resolution flows, the preview/commit relationship, the role matrix and the audit before/after exposure. Correcteddocs/07-data-quality.md's lifecycle description to match the already-implemented(rule_type, entity_type, entity_id)idempotency key (no evidence fingerprint) and documented thereopened_from/previous_decisionrecurrence link.README.md's scope/integration-status/quality-gate sections updated to match. - Local evidence:
pytest117 passed,ruff check .clean,mypy app0 issues/46 files,npx tsc -bclean,npm run buildclean,npx playwright test37 passed. - Clean-checkout drill (section 14): fresh
git cloneof this branch into an isolated scratch directory,.envfrom.env.example, isolated Compose project name and remapped host ports (no shared state with the working stack),up --build -dfrom empty volumes → migrations ran automatically → seed → full backend gate (117 passed, ruff clean, mypy clean) →npm ci(clean; the pre-existing esbuild-moderate/ react-router-RSC-high advisories are unchanged, not new) →tsc -b/vite buildclean → full Playwright suite 37 passed against the isolated stack. Torn down afterward (down -von the isolated project only; the working dev stack was never touched). - Deployed to Unraid; migrations unchanged at
e7b08389f47f (head). Full 37-test Playwright suite re-run againsthttp://192.168.10.150:1236— 37 passed. Demo data reset afterward. - Exact next action: none — all five batches are implemented, tested locally (including
a genuine clean-checkout drill), committed, pushed, deployed to Unraid and
re-verified against the live server after every batch. See
artifacts/functional-completion/final-summary.mdfor the definitive acceptance evidence.
Demo productization (in progress, same branch feat/mobilityops-functional-completion)
Follows the functional-completion work above; turns the now feature-complete PoC into a
guided, honestly-labelled demo (fictional org "Northstar Mobility", guided tour, 5 named
scenarios, demo manifest, About page). Gap audit: docs/demo-release/current-demo-gap-audit.md.
Batch 1 — seed date anchoring (complete)
- Real bug fixed:
seed/bookings.csvetc. store absolute ISO timestamps authored around a fixed anchor (2026-08-01). Nothing previously re-anchored them at seed/reset time, so scenario bookings (e.g.BK-DEMO-RETURN) silently drifted into the past every day the environment wasn't reset.dashboard.py::_today()compounded this by filtering "today's movements" against the same frozendemo_todaysetting instead of real time. - Fix:
seed_loader.pynow computesshift = today - SEED_AUTHORED_ANCHORonce perload_seed()call and applies it to every seeded booking/inspection/maintenance/outbox datetime column, so scenarios stay "today"/"near-future" relative to the actual reset moment.SeedResultnow also carriesanchor_date/seeded_at;POST /api/v1/demo/resetreturns them; ademo_data_seededaudit event records the anchor for traceability.dashboard.py::_today()switched from the frozendemo_todaysetting to real wall-clock UTC date. The now-deaddemo_todaysetting/env var was removed fromconfig.py,compose.yaml,.env,.env.example(nothing else referenced it). - Added seed-validation tests (
backend/tests/test_seed.py) proving S1 (BK-DEMO-RETURN/MO-024), S2 (CUS-0012/CUS-0178/DQ-DEMO-DUPLICATE), S4 (MO-016/BK-DEMO-OVERLAP-A/-B/DQ-DEMO-OVERLAP) and S5 (seeded failed outbox event00000000-0000-4000-8000-000000000020, confirmed genuinelyfailedimmediately after a fresh reset, not silently auto-healed by the background dispatcher since it only claimspendingrows) are fully present after every reset, plus a dedicated anchoring test asserting the shift and the audit marker. - Live-verified locally: reseeded and confirmed via
psqlthatBK-DEMO-RETURNnow ends today andBK-DEMO-NEXT/overlap bookings sit in the near future (today = 2026-08-03). - Evidence:
pytest122 passed (117 + 5 new/expanded seed tests),ruff check .clean,mypy appclean (46 files, canonicalmakescope). - Deployed to Unraid (commit
8989ffb): pushed to Gitea,git archivetarball extracted over/mnt/user/appdata/mobilityopspreserving.env/volumes,apirebuilt (db/webuntouched — no frontend changes this batch), migrations confirmed ate7b08389f47f (head), reseeded, live-verified viapsqlthatBK-DEMO-RETURN/BK-DEMO-NEXT/overlap bookings sit at the same real-time-relative positions as local.curltohttp://192.168.10.150:1236/returns 200. - Exact next action:
GET /api/v1/demo/manifest+ Dutch demo entry screen + permanent demo badge (task #30), then the Demo Guide + scenario overview (task #31).
Batch 2 — demo manifest, Dutch demo entry, permanent demo badge, About page (complete)
GET /api/v1/demo/manifest(unauthenticated): single source of truth for demo org identity, synthetic-data flag, reset allowance/timestamp/anchor date, guide availability, and the 5 named scenarios with live readiness (queries the actualBK-DEMO-RETURN/DQ-DEMO-DUPLICATE/DQ-DEMO-OVERLAP/seeded-failed-event/knowledge- provider records — not hardcoded), plus plain-language integration summaries. Backed by newbackend/app/services/demo_manifest.py. Refactored the n8n status derivation out ofintegration_status.pyinto a sharedservices/integration_status.pyso the manifest and the existing authenticated/integrations/statusendpoint reuse one implementation.- New settings (
backend/app/core/config.py, wired throughcompose.yaml/.env.example):DEMO_ORGANIZATION_NAME(default "Northstar Mobility" — surfaces the project's already- locked fictitious tenant, previously only used internally as theragcore_tenantslug),DEMO_TIMEZONE,DEMO_ALLOW_RESET(a safety valve —falsemakesPOST /api/v1/demo/resetreturn 403 regardless of role; the now-deaddemo_todaysetting removed in Batch 1 stays removed). - Rewrote
Login.tsxin Dutch: names the fictional org, one-sentence explanation sourced from the manifest, no password shown/copyable anywhere, "Start begeleide demo" primary CTA (logs in as Operations Manager, navigates to/dashboard?guide=startfor task #31 to consume) plus "Verken als Operations Manager"/"Verken als Rental Employee" secondary actions. Added a permanent demo badge (topbar pill + popover: synthetic notice, "workflows are real" reassurance, last-reset timestamp, link to/about) replacing the old full-width static.demo-bannerbar — subtle by design per the brief, not a warning bar. New/aboutpage (AboutDemo.tsx) covering the fictional problem, what's really implemented, what's synthetic, honest per-integration labels (via the manifest), and a reset pointer — reachable from the badge popover, not added to primary nav (preserves the existing Control Rail nav per the "not a redesign" constraint). Frontend nav/design otherwise untouched. - Evidence:
pytest127 passed,ruff check .clean,mypy appclean (48 files); frontendtsc -bclean,npm run buildclean; full Playwright suite 41 passed (37 existing + 4 newdemo-entry.spec.tscovering entry copy/no-password, guided-demo login redirect, badge popover content + About link, and Escape/outside-click close). Updated stale English login-button aria-labels and login-copy assertions across the existing specs to match the new Dutch copy. - Deployed to Unraid (commit
ac427f4): pushed to Gitea,git archivetarball extracted preserving.env/volumes, bothapiandwebrebuilt (frontend changed this batch), both healthy, migrations unchanged, reseeded. Live-verified:GET /api/v1/demo/manifestreturnsorganization_name: "Northstar Mobility",allow_reset: true, and all 5 scenariosready: trueright after reset. Randemo-entry.spec.ts(4 tests) and the five-minute demo script directly againsthttp://192.168.10.150:1236— 5/5 passed. Reseeded again afterward to leave the server demo-ready. - Exact next action: Demo Guide (collapsible panel, 8 steps) + scenario overview (5 cards
on the dashboard, consuming
/api/v1/demo/manifest'sscenariosarray) — task #31.
Batch 3 — Demo Guide + scenario overview (complete)
- New
/scenariospage (Scenarios.tsx): all 5 named scenarios as cards (title, operational problem, duration, required role(s), "toont aan", ready/blocked status from the manifest, "Start scenario" linking to the livestart_path). Dashboard gets one compact "Probeer een demonstratiescenario" panel (not 5 more cards — keeps the existing dashboard uncluttered per the brief) showing readiness count and, for Operations Managers, a guide resume/start control. - New Demo Guide:
DemoGuideContext(sessionStorage-persistedcurrentIndex/completedset — browser-only, never touches auth or business logic), 8 static steps (data/demoGuideSteps.ts) each with what-you'll-see/why/start-action/expected-outcome, resolving live routes from the manifest for the two scenario-backed steps (return, duplicate-merge) so they can't drift from actual records.DemoGuide.tsxrenders a fixed side panel (desktop) that becomes a bottom sheet at ≤700px via CSS only (no layout duplication);DemoGuideTrigger(topbar, Operations-Manager-only — the 8 steps require OM throughout) shows a livecompleted/8pill. "Demo opnieuw voorbereiden" calls the real reset endpoint, resets guide progress, and returns to/login(mirrors the existing sidebar reset flow). Login's "Start begeleide demo" logs in as OM and passes a one-shot?guide=startmarker the dashboard consumes once then strips. - Fixed a real regression caught by the responsive-overflow tests: the new topbar guide
trigger pushed
.topbar-metapast the viewport at ≤420px; fixed by hiding the guide trigger (icon+pill) at that breakpoint — the Dashboard's own "Start demo-gids" control remains reachable there. Also fixed a genuine mobile overflow in the new.demo-start-panel(flex items withoutmin-width:0/wrap on narrow screens). - Fixed one fragile new test (asserted on the transient
?guide=startURL param, which the app intentionally strips immediately — changed to assert the guide's actual open state instead) and two Playwright strict-mode ambiguous-match errors; confirmed the full 41+6=47-test suite passes twice in a row after these fixes (ruling out flakiness). - Evidence: frontend
tsc -bclean,npm run buildclean; full Playwright suite 47 passed (41 existing + 6 newdemo-guide.spec.ts: scenario overview shows 5 ready cards after reset, starting a scenario navigates to its fixed record, guide step navigation/jump/close, progress persists across page navigation, guide hidden from Rental Employee, restart-from-guide resets data and returns to login). Backend untouched this batch (no re-run needed; last backend gate was 127 passed/ruff/mypy clean in Batch 2). - Deployed to Unraid (commits
9fff84d, then14c2ad3for a test-only fix): pushed to Gitea, tarball extracted,webrebuilt (frontend-only batch), healthy. Live-verified: randemo-guide.spec.ts(6),demo-entry.spec.ts(4) and the five-minute demo script directly againsthttp://192.168.10.150:1236— 11/11 passed. Caught and fixed one real environment-sensitive test bug in the process: two tests navigated straight to/scenariosright after a login click without waiting for the/dashboardredirect, which raced harmlessly on localhost but flaked against Unraid's higher latency — fixed by asserting the redirect first, no app-code change needed. Reseeded afterward to leave the server demo-ready. - Exact next action: layer plain-language Dutch explanation onto the return flow, the 5 data-quality panels, and fix the knowledge assistant's RAGcore-naming bug — task #33.
Batch 4 — return/data-quality/knowledge demo legibility (complete)
- Fixed a real honesty bug:
Knowledge.tsxnamed "RAGcore" in the body copy and the retrieval-flow diagram even though the active provider is the demo TF-IDF one (the small badge below was already honest, contradicting the prose one line above). Now derives aproviderLabel("Demo knowledge base" vs "RAGcore") from the real health check and uses it everywhere; added an explicit disclosure note when not RAGcore. Added 4 suggested-question chips. Discovered and fixed a second real bug in the process: the brief's suggested Dutch questions (and my own Demo Guide step 6 wording) would have returned "insufficient evidence" against the demo provider, because the indexed procedures are English-only — verified empirically (Dutch question →insufficient, its English equivalent →grounded). Fixed by keeping suggested questions in English (matching the indexed content) and rewording the Guide step to explain the knowledge base is English, rather than mistranslating the demo's centerpiece feature into silently returning wrong answers. - Return flow:
BookingDetail.tsxnow detects the one named return-anomaly scenario booking (via the manifest, not a hardcoded ref) and fetches that vehicle's real canonical odometer to pre-fillReturnForm's "End odometer" field with a suspicious value below it, plus a callout explaining why — the brief explicitly requires the demo not ask a visitor to invent a suspicious number themselves. Scoped narrowly to that one scenario booking; ordinary returns are unaffected.ReturnResultPanelnow links to Automation and Audit trail (previously only the vehicle), and shows a "Ga verder met de demo" button when the Demo Guide is open (advances the guide and navigates to the next step). Fixed a real regression caught by the existing return-review e2e test: the async pre-fill could silently overwrite odometer text a visitor had already started typing, if the vehicle-detail fetch resolved after they began typing — fixed with anodometerEditedByUserref guard. - Data quality: added a shared
RuleExplainer(what's wrong / why it matters, in plain language) for all 5 rule types onDataQualityIssueDetail.tsx; added a generic post-resolution confirmation (audit-trail link, vehicle link, "Ga verder met de demo") for the 4 rule types that previously just silently flipped their status badge with no explicit confirmation, and extendedVehicleStatusConflictPanel's existing confirmation with the same links rather than duplicating it. Added a "Demo scenario's only" checkbox filter onDataQuality.tsx(client-sidepublic_ref.startsWith("DQ-DEMO-"), no new business logic) so the curated issues are easy to find among the full queue. - Evidence: frontend
tsc -bclean,npm run buildclean; full Playwright suite 51 passed (47 existing + 4 newdemo-legibility.spec.ts: return pre-fill + why- suspicious explanation + result links, rule explainer visible, demo-scenario filter narrows correctly, knowledge suggested question returns grounded evidence with the correct provider label). Backend untouched this batch. - Deployed to Unraid (commit
ddc3a98): pushed to Gitea, tarball extracted,webrebuilt (frontend-only), healthy. Live-verified: randemo-legibility.spec.ts(4) and the five-minute demo script directly againsthttp://192.168.10.150:1236— 5/5 passed. Reseeded afterward to leave the server demo-ready. - Exact next action: plain-language integration-status labels, richer audit narration, the full "Over deze demo" page content (currently a first pass from Batch 2), and wiring reset into the guide/About/OM menu narrative — task #34.
Batch 5 — integration-status UX, audit UX, About page, reset integrity (complete)
- Plain-language integration status: extracted
frontend/src/data/integrationLabels.ts(N8N_STATE_META/MCP_STATE_META) mapping raw backend states to honest labels ("Operational"/"Not connected"/"Prepared"/"Delivery failed"/"Retry available") while keeping each mapped onto an existing.status-*CSS colour class (a few raw values likedegraded/disabled/configuredhad no matching CSS rule at all before this — a real, pre-existing colour-coding gap).StatusBadgegained an optionallabeloverride prop (backward compatible) so the badge's colour class and its displayed text can differ. Wired into bothAutomation.tsxandDashboard.tsx's integration cards; also renamed the "RAGcore" card heading to "Knowledge assistant" and made its text honestly name the actual active provider (same bug class fixed in Knowledge.tsx in Batch 4). - Audit trail: added a "Follow-up" column with a "View related events" action per row that
filters the same list by
correlation_id(reuses the backend's existing, already-testedcorrelation_idquery param — no new business logic), with a "Clear this filter" affordance. This is how a visitor sees "what else happened as a result of this action" (e.g. a return's linked vehicle-status-changed / workflow-queued events) without a bigger grouped-timeline rebuild. - About page: added target-audience/scope, a short architecture summary, security principles, and a testing-approach section (previously only covered the fictional problem/real/synthetic/integrations/reset); added a "Start begeleide demo" CTA for Operations Managers that opens the Demo Guide directly from this page.
- Reset integrity: added
scenario_integrity_report()(backend/app/services/ demo_manifest.py), reusing the exact same scenario-readiness derivation the manifest and scenario overview already use (so it can't drift), and wired it intoPOST /api/v1/demo/reset— both the response body and thedemo_resetaudit event's metadata now carryscenario_integrity: {all_ready, not_ready}. This is the server-side post-reset integrity check the brief asks for; visible today via the audit event's raw-detail view, satisfying the requirement without adding a UI banner to a flow that immediately logs the user out and redirects to/login. - Fixed a second real regression this batch, caught by the existing return-review
e2e test: restructured the odometer pre-fill so
BookingDetail.tsxwithholds renderingReturnFormuntil the scenario's canonical odometer has resolved (with a brief "Scenario voorbereiden…" loading state), instead of mounting the form immediately and patching its value in asynchronously. The previous approach raced visibly with Playwright'sfill()(and would have raced with a real visitor typing quickly), producing a corrupted concatenated value in one observed failure. This also let the now-unnecessaryodometerEditedByUserref guard be removed — simpler and more robust than the effect-based patch it replaced. - Evidence:
pytest127 passed,ruff check .clean,mypy appclean (48 files); frontendtsc -bclean,npm run buildclean; full Playwright suite 51 passed, confirmed stable across three consecutive full runs (given how many timing races this batch and the previous one surfaced, stability was verified deliberately rather than assumed from a single green run). - Deployed to Unraid (commit
5fa4fe0): pushed to Gitea, tarball extracted, bothapiandwebrebuilt, healthy, migrations unchanged ate7b08389f47f (head), reseeded. Live-verified: randemo-legibility.spec.ts(4), the five-minute demo script, and the fullinteractive-elements.spec.tssuite (26) directly againsthttp://192.168.10.150:1236— 31/31 passed. Reseeded afterward to leave the server demo-ready. - Exact next action: full guided-demo Playwright test + remaining targeted demo tests per section 19 (mobile guide, keyboard nav, all scenario flows, About page, accessibility/ reduced-motion/console/network checks) — task #35.
Batch 6 — full guided-demo test + targeted demo tests (complete)
- Found and fixed a real, fairly serious desktop layout bug while writing the full
guided-demo test: the Demo Guide's fixed right-side panel (400px wide) overlapped the
main content area at normal desktop widths with no reflow, so its own step-list buttons
intercepted pointer events meant for the page underneath (concretely: the return form's
"Review return" button was unclickable while the guide was open, at exactly the
viewport size Playwright's default test browser uses — this would have hit real
visitors on ordinary laptop screens too). Fixed by adding a
guide-openclass to.app-workspacethat reservespadding-right: min(400px, 92vw)while the guide is open (≥701px only; the ≤700px bottom-sheet layout is unaffected), so content reflows aside instead of sitting underneath the panel. - Added
frontend/e2e/guided-demo-full.spec.ts: one comprehensive test walking a fresh Operations Manager session through all 8 Demo Guide steps in order, performing the real action at each step (not just verifying copy) — processes the actual odometer-anomaly return, resolves the resulting data-quality issue, merges the duplicate customer, asks a suggested knowledge question, checks automation + audit, reviews the About page — using the guide's own progression controls ("Volgende"/"Ga naar deze stap"/"Ga verder met de demo") throughout, then resets the demo data again at the end to restore the environment per the brief's requirement. - Added
frontend/e2e/demo-accessibility.spec.ts(4 tests): the guide renders as a correctly-anchored bottom sheet on a 390px mobile viewport with no horizontal overflow; the guide never covers the return form's action buttons on desktop (regression test for the bug above); the demo badge and guide trigger are keyboard-focusable and operable (Enter to open, explicit close controls); key demo pages (dashboard, scenarios, about, guide open) load with no unexpected console errors (the one expected benign 401 from the app's own session-probe on first load is explicitly allow-listed, not silenced blindly). - Evidence: full Playwright suite 56 passed (51 existing + 1 guided-demo-full + 4 demo-accessibility), confirmed stable across two consecutive full runs. Backend untouched this batch (last gate: 127 passed/ruff/mypy clean, Batch 5).
- Deployed to Unraid (commit
07d5605): pushed to Gitea, tarball extracted,webrebuilt (frontend-only), healthy, reseeded. Live-verified: ranguided-demo-full.spec.tsanddemo-accessibility.spec.tsdirectly againsthttp://192.168.10.150:1236— 5/5 passed, confirming the desktop-overlay layout fix holds on the real deployment too. Reseeded afterward to leave the server demo-ready. - Exact next action: clean-checkout demo drill, final documentation set (demo-concept/
demo-scenarios/demo-data/demo-guide/demo-runbook, README, .env.example), final Unraid
deploy + live evidence with screenshots,
artifacts/demo-release/final-summary.md— task #36 (final).
Batch 7 (final) — clean-checkout drill, docs, final Unraid evidence (complete)
- Clean-checkout drill: fresh
git cloneinto an isolated scratch directory, isolated Compose project (mobilityops-cleandrill) + remapped ports viacompose.override.yaml,up --build -dfrom empty volumes. Migrations ran automatically toe7b08389f47f (head); seeded; full backend gate 127 passed, ruff/mypy clean;npm ciclean (same pre-existing advisories as before, unchanged);tsc -b/vite buildclean; full Playwright suite 56 passed against the isolated stack; reseeded and confirmed all 5 scenariosready: truevia the manifest; torn down (down -von the isolated project only — the working dev stack was untouched throughout). - Added the full demo-release documentation set:
docs/demo-release/demo-concept.md,demo-scenarios.md,demo-data.md,demo-guide.md,demo-runbook.md; updatedREADME.md(current test counts, links to the new docs, a "Demo" section) anddocs/17-runbook.md(cross-reference to the demo-specific runbook). - Added
frontend/e2e/_capture-demo-screenshots.spec.ts(tooling, excluded from the regular suite) and captured 17 evidence screenshots live againsthttp://192.168.10.150:1236intoartifacts/demo-release/screenshots/. - Final live acceptance: full Playwright suite re-run against the live server —
56 passed;
docker compose pson the server showsapi/db/weball healthy;docker logsforapi/webshow no errors; reseeded to leave the server demo-ready after evidence capture. - Wrote
artifacts/demo-release/final-summary.mdwith the full required evidence (branches/commits, org/roles/guide/scenarios, seed/date-anchor/reset strategy, real vs. synthetic vs. not-connected, all test results, clean-checkout result, deployment/ health/console/log results, responsive/accessibility results including the two real layout bugs found and fixed this work (mobile topbar overflow in Batch 3, desktop guide-panel overlap in Batch 6), known limitations, 5-/10-minute demo flows, redeploy/ rollback commands, and the screenshot list). - Demo-productization work on this branch is complete. Every task (#29–#36) is done;
every batch was tested locally, deployed to Unraid, and re-verified live before moving
to the next. See
artifacts/demo-release/final-summary.mdfor the definitive acceptance evidence.
Final product polish: Fleet Ops rebrand, trilingual i18n, adaptive guide (2026-08-03) — MERGED TO MASTER
- Rebranded the product to Fleet Ops across the frontend, backend defaults and the
knowledge base; made
nl-BE(default)/en-GB/fr-BEfull first-class languages via i18next (eager-bundled resources, persisted language switcher in topbar + mobile drawer,Intldate/number formatting, a coverage test that fails the build on any missing/empty translation key across all 14 namespaces). - Backend dynamic content (demo scenarios, blocked-reason text, integration status)
converted from fixed English/Dutch prose to stable message codes + params so the
frontend localizes it (
DemoScenarioOut/DemoIntegrationSummaryOutschema changes). The demo knowledge base gained a fully translated NL/EN/FR procedure corpus (11 documents each, including a new "vehicle availability" procedure) with per-language retrieval and localized evidence-state messages. - Demo Guide became breakpoint-adaptive: docked rail (≥1440px), a floating panel that
auto-collapses to a persistent closable progress chip (701–1439px), and a
collapsed/half/full bottom sheet (≤700px) — with scroll+focus+highlight on "go to this
step", Escape handling, and
prefers-reduced-motionsupport. - Data Quality Workbench got accessible choice-card decisions with a clear primary/secondary/tertiary action hierarchy; Automation ledger groups repeated successes with meaningful short refs; Audit trail groups events by correlation id with human action labels and readable before/after diffs; Attention Queue/Today's movements/Vehicles/Bookings/Data Quality rows are fully clickable (stretched-link pattern, independent secondary links, keyboard + mobile support).
- Two real bugs found and fixed along the way: a mobile topbar overflow at 421–440px
caused by the new language switcher (moved the switcher into the mobile drawer at
≤960px and widened the compact-topbar breakpoint to 440px), and two dangling
aria-labelledbyreferences (SectionHeadingnever set the referencedid). - Full test suite: 131 backend tests, Ruff, mypy, TypeScript build, and 92 Playwright
tests (new:
i18n-coverage,clickable-rows,responsive-i18ncovering all 7 brief-specified breakpoints × 3 languages, plus 3 new adaptive-guide tier tests) — all green. All pre-existing Playwright specs updated for the new nl-BE default (either translated assertions or an explicit English-locale override where the spec was originally authored against English copy). - Clean-checkout drill performed in a fully isolated Docker Compose project (separate
ports/volumes, no shared n8n) from a fresh local clone at the feature-branch head —
131 backend tests, lint, build and all 92 Playwright tests green from empty volumes;
live EN/FR knowledge-assistant spot check;
scenario_integrity.all_ready: trueon reset; isolated stack torn down afterward, original dev environment untouched. - Deployed to Unraid twice: once for the feature branch (commit
845db14) for pre-merge live validation, once for the mergedmaster(commit18a765d) for the final release — both times via the establishedgit archive→scp→ extract →.deploy/source-revision→ rebuildapi/webmethod, with migrations, reseed, full backend+Playwright gates, console/network inspection and demo reset re-verified live each time. - Master baseline was confirmed unchanged (
e0c7ed6, matching the previously recorded baseline) before merging;git merge-treedry run showed zero conflicts. Merged viagit merge --no-ff(commit18a765d), all gates re-run post-merge, pushed to Gitea, redeployed. Feature branch was not deleted. - Full evidence:
artifacts/fleet-ops-release/final-summary.md(commits, branding, locales, translation/knowledge-base/guide/data-quality/automation/audit evidence, all test results, clean-checkout result, deployment evidence for both the feature branch and master, responsive/accessibility results, known limitations, rollback procedure) plus 10 screenshots inartifacts/fleet-ops-release/screenshots/.