Author SHA1 Message Date
NuklearRabbit 51b0ade7e7 M51: record zero-error rollout acceptance
MobilityOps acceptance / backend (push) Failing after 19s
MobilityOps acceptance / frontend (push) Successful in 30s
MobilityOps acceptance / e2e (push) Skipped
MobilityOps release evidence / release-evidence (push) Failing after 2m18s
2026-08-21 22:33:41 +02:00
NuklearRabbit cea0825d60 M50: preserve rollout API compatibility alias
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:29:55 +02:00
NuklearRabbit dd3acd872c M49: record resilience production acceptance
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 26s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:25:48 +02:00
NuklearRabbit 00191e9b54 M48: harden demo operations and offsite recovery
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 28s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 22:17:49 +02:00
NuklearRabbit a24098c583 M47: record final production acceptance
MobilityOps acceptance / backend (push) Failing after 15s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
MobilityOps release evidence / release-evidence (push) Failing after 1m10s
2026-08-21 18:52:23 +02:00
NuklearRabbit 95c91797fa M46: refresh web runtime base
MobilityOps acceptance / backend (push) Failing after 18s
MobilityOps acceptance / frontend (push) Successful in 27s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:46:10 +02:00
NuklearRabbit ec02aca0fd M45: authenticate Prometheus scraping
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:33:23 +02:00
NuklearRabbit acd8b82b09 M44: harden release integrity and assurance
MobilityOps acceptance / backend (push) Failing after 20s
MobilityOps acceptance / frontend (push) Successful in 26s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 18:32:02 +02:00
NuklearRabbit 9e4fca5708 M43: record production deployment evidence
MobilityOps acceptance / backend (push) Failing after 15s
MobilityOps acceptance / frontend (push) Successful in 25s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:25:04 +02:00
NuklearRabbit 0045778dbb M42: calibrate grounded RAGcore fallback
MobilityOps acceptance / backend (push) Failing after 17s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:22:17 +02:00
NuklearRabbit 24dcb3494c M41: harden trust boundaries and delivery
MobilityOps acceptance / backend (push) Failing after 47s
MobilityOps acceptance / frontend (push) Successful in 29s
MobilityOps acceptance / e2e (push) Skipped
2026-08-21 17:06:59 +02:00
JensandClaude Fable 5 a830e8a2d0 ci(security): pin trivy-action to an existing tag (v0.30.0) so the gate can resolve on Gitea Actions
MobilityOps acceptance / backend (push) Failing after 33s
MobilityOps acceptance / frontend (push) Successful in 33s
MobilityOps acceptance / e2e (push) Skipped
The first real platform runs (2026-08-17, new instance runner) failed at
'Unable to resolve 0.30.0: reference not found' - the tag exists only as v0.30.0.
Where present, the trufflehog GitHub Action (which fails under the act runner) is
replaced by the pinned trufflehog binary in filesystem mode next to gitleaks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 14:13:18 +02:00
NuklearRabbit ca66083c8b M40: record hardening deployment evidence
MobilityOps acceptance / backend (push) Failing after 14s
MobilityOps acceptance / frontend (push) Successful in 37s
MobilityOps acceptance / e2e (push) Skipped
2026-08-17 03:31:10 +02:00
NuklearRabbit ae39a8947f M39: harden application and acceptance gates
MobilityOps acceptance / backend (push) Failing after 45s
MobilityOps acceptance / frontend (push) Successful in 32s
MobilityOps acceptance / e2e (push) Skipped
2026-08-17 03:17:44 +02:00
NuklearRabbit a9f48d6880 fix(security): add secret-scan step to backend CI job
Backend job had static checks (ruff/mypy) and a dependency-vulnerability
gate but no secret scan; frontend had a dependency audit but no secret
scan either. Adds trufflehog once, on the backend job's full checkout,
covering the whole repository - the last gap for this repo to count as
fully-authored.
2026-08-16 14:54:44 +02:00
NuklearRabbit 6859249570 fix(security): add backend dependency vulnerability gate to CI
Backend CI had ruff/mypy static checks and a real pip-audit-equivalent
was missing; frontend already had npm audit, so backend was the only
side of this repo without any dependency-vulnerability gate (partial
coverage). Adds trivy-action fs scoped to backend/, HIGH/CRITICAL only,
consistent with this security programme's gate convention elsewhere.
Verified clean (0 HIGH/CRITICAL) against backend/pyproject.toml before
wiring in, so the gate does not immediately fail CI.
2026-08-16 04:41:07 +02:00
NuklearRabbit 1ca70187a2 M38: record production acceptance evidence
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 23:29:10 +02:00
NuklearRabbit 26819354ee M37: complete production acceptance hardening
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 23:19:26 +02:00
NuklearRabbit 9dfbd7c4bf M36: deepen operational and mobile UX 2026-08-10 22:53:45 +02:00
NuklearRabbit 809ba0ddcc M35: harden the shared public demo 2026-08-10 22:32:58 +02:00
NuklearRabbit c7492bf6ad M34: enforce domain integrity in PostgreSQL 2026-08-10 22:28:38 +02:00
NuklearRabbit 82a933f6cd M33: enforce booking readiness workflow 2026-08-10 22:23:11 +02:00
NuklearRabbit be33b46228 M32: clarify deployment source evidence
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:19:27 +02:00
NuklearRabbit b44915ff35 M32: record production integration acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:18:50 +02:00
NuklearRabbit eecfcb4b79 M31: cover both knowledge provider title contracts
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:10:48 +02:00
NuklearRabbit cb7edb0b84 M31: align RAG verification with provider contract
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 21:02:47 +02:00
NuklearRabbit efab8d816f M31: verify RAG inventory and polish attention queue
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 20:58:26 +02:00
NuklearRabbit cfffb1ce54 M30: record production recruiter acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 18:32:30 +02:00
NuklearRabbit 29325b6c27 M29: polish recruiter engineering story
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 18:13:18 +02:00
NuklearRabbit 6365586e82 M28: complete production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:34:56 +02:00
NuklearRabbit e1b700b10e M27: make backup checksums portable
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:19:18 +02:00
NuklearRabbit b00d33af11 M26: harden clean observability acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 16:15:23 +02:00
NuklearRabbit 90cc3cf378 M25: expose provenance-aware knowledge statistics 2026-08-10 16:04:39 +02:00
NuklearRabbit 0935901f11 M24: implement privacy governance 2026-08-10 15:56:03 +02:00
NuklearRabbit f0f1be83ae M23: automate verified database backups 2026-08-10 15:46:06 +02:00
NuklearRabbit 689e499634 M22: implement operational observability 2026-08-10 15:42:26 +02:00
NuklearRabbit c3f1cfc699 M21: add optional organisation identity 2026-08-10 15:35:25 +02:00
NuklearRabbit 509cb95110 M20: complete production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:22:55 +02:00
NuklearRabbit 5dda5742e4 M20: verify reordered work queue by identity
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:12:07 +02:00
NuklearRabbit 152d847a26 M20: target guided issue semantically
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:06:28 +02:00
NuklearRabbit f715085f65 M20: stabilize production acceptance
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 13:02:03 +02:00
NuklearRabbit 5d7a5e7359 M19: harden performance and recovery
MobilityOps acceptance / backend (push) Canceled after 0s
MobilityOps acceptance / frontend (push) Canceled after 0s
2026-08-10 12:51:41 +02:00
NuklearRabbit fe06ff75a1 M18: implement operational workspaces 2026-08-10 12:41:28 +02:00
NuklearRabbit 8030753dbc M17: ground knowledge and integration evidence 2026-08-10 12:27:17 +02:00
NuklearRabbit 686795a452 M16: isolate acceptance and harden readiness 2026-08-10 12:08:42 +02:00
NuklearRabbit 2ee8b2d82b M15: synchronize contracts and acceptance 2026-08-10 05:39:19 +02:00
NuklearRabbit 58fb515337 M14: add n8n execution health telemetry 2026-08-10 03:41:06 +02:00
NuklearRabbit 218599af7d M13: harden MCP trust boundary 2026-08-10 03:24:17 +02:00
NuklearRabbit 15bdbe40ac M12: complete daily operations cycle 2026-08-10 03:15:46 +02:00
NuklearRabbit 4a3c3bd0a9 M11: implement operational booking lifecycle 2026-08-10 03:06:30 +02:00
NuklearRabbit 3f13912739 test: select an available vehicle for booking creation 2026-08-10 02:54:50 +02:00
NuklearRabbit b2e1ae7f17 test: make booking overlap assertion deterministic 2026-08-10 02:53:40 +02:00
NuklearRabbit de151914b6 M10: harden knowledge trust and telemetry 2026-08-10 02:51:07 +02:00
NuklearRabbit e577c16db5 M9: create validated internal bookings 2026-08-10 02:20:28 +02:00
NuklearRabbit c194c18ca9 docs: record operational mode validation 2026-08-10 02:11:26 +02:00
NuklearRabbit 948d5eb6a6 fix: normalize return risk timestamps 2026-08-10 02:10:02 +02:00
NuklearRabbit 3cd9ddfa66 fix: retain demo sessions across seed resets 2026-08-10 02:08:40 +02:00
NuklearRabbit 0bfcf71ff7 M8: add operational authentication mode 2026-08-10 02:06:50 +02:00
NuklearRabbit 4cdf667dc1 docs: record booking scale-up verification 2026-08-10 01:49:49 +02:00
NuklearRabbit 0ef4a6fa98 UX: paginate booking operations 2026-08-10 01:47:35 +02:00
NuklearRabbit 2648cef8e3 docs: record final roadmap verification 2026-08-10 01:15:16 +02:00
NuklearRabbit aacf0e04bc fix: preserve mobile record status 2026-08-10 01:14:20 +02:00
NuklearRabbit ad1182582d docs: record visual roadmap evidence 2026-08-10 01:05:30 +02:00
NuklearRabbit f2cdad194c UX: implement visual product roadmap 2026-08-10 01:05:07 +02:00
NuklearRabbitandClaude Sonnet 5 13ad2ba6a3 docs: record the MCP Hub URL fix and RAGcore Procedure Sync go-live
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:07:41 +02:00
NuklearRabbitandClaude Sonnet 5 086dfed992 fix: publish RAGcore Procedure Sync and derive its evidence for real
MCP_HUB_BASE_URL had the same wrong-hostname bug as RAGCORE_BASE_URL earlier
this session (itworx-mcp-hub:8000 doesn't resolve; the real container is
reachable at the host's own 192.168.10.150:1100) -- fixed live, resolving the
Automation page showing "Operationeel" and "Hub Onbereikbaar" simultaneously.

Went on to actually publish the "Fleet Ops -- RAGcore Procedure Sync" n8n
workflow now that RAGcore is reachable: its own RAGcore Sync Token credential
had gone stale from the same rotation as the earlier one, so minted a fresh,
dedicated, minimally-scoped (sources:sync only) credential, verified a real
manual run (33 synced, 0 failed, result registered) before publishing.

That exposed a real, now-stale bug: derive_n8n_status() hardcoded this
workflow's evidence to None with a comment explaining it was unpublished --
true when written, false now. The workflow's own result-report callback
already writes a real n8n_procedures_synced audit event; wired that in as its
evidence source, the same pattern the scheduled scan and error handler already
use, instead of a value that could never update itself once the workflow went
live.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 22:05:13 +02:00
NuklearRabbitandClaude Sonnet 5 64cc96fa4b docs: record the RAGcore go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:22:57 +02:00
NuklearRabbitandClaude Sonnet 5 319f43312e fix: drop RAGcore's opaque version UUID from the fallback answer sentence
document_version_id is an internal UUID, not a human-meaningful version like
the demo corpus's markdown frontmatter -- confirmed live it made the fallback
answer read as "Per \"vehicle-checkout-procedure.md\" (v2e139422-b10b-...)".
Still shown on the source card itself, just not in the composed sentence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:18:58 +02:00
NuklearRabbitandClaude Sonnet 5 a2433d7fa3 fix: fall back to real RAGcore search when /v1/answers is unavailable
RAGcore's /v1/answers (generation + citation validation) is currently returning
a consistent 503 VALIDATION_RETRIES_EXHAUSTED live -- a RAGcore-side bug in its
own generation/validation step, out of scope to fix here (CLAUDE.md forbids
modifying the RAGcore repo). Its retrieval pipeline (/v1/search) is a materially
different, simpler stage with no generation step, and returns real, correctly
cited results right now.

RAGcoreKnowledgeProvider.ask() tries /v1/answers first (unchanged behavior once
RAGcore's generation is fixed), and only when that endpoint itself is
unavailable -- non-2xx or unreachable, never a real 200 classifying the
question as insufficiently answerable -- falls back to /v1/search and builds
the shown "answer" as an extractive citation-wrapped excerpt, mirroring
DemoKnowledgeProvider's own existing template exactly. Never invents an answer
to the question; only ever shows a real, cited excerpt RAGcore's own search
actually found.

Also fixed two real config bugs found while wiring this up live: RAGCORE_BASE_URL
pointed at a non-existent internal hostname (ragcore-api:8000 -- the real
container is reachable at the host's own address on port 1237), and the
previous test credential had been invalidated with nothing to replace it. Minted
a fresh, correctly-scoped service-account credential via RAGcore's own admin
control plane (the documented, legitimate way to obtain one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 21:11:28 +02:00
NuklearRabbitandClaude Sonnet 5 453c7241fe docs: record the MCP Hub go-live fix and evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:19:07 +02:00
NuklearRabbitandClaude Sonnet 5 529e7364a9 fix: pass MCP_HUB_REGISTRATION_ENABLED/MCP_HUB_BASE_URL through to the api container
compose.yaml's api service environment block forwarded MCP_HUB_SERVICE_TOKEN but
never these two -- so .env's value was silently ignored and Settings always fell
back to its Python default (false / empty), no matter what .env said. Found while
flipping the flag live: the container's actual reported registration_enabled
stayed false after a full recreate, even though .env had been updated and two
real mcp_tool_request audit events already existed (itworx-mcp-hub:readiness ->
fleet_ops_get_operations_summary), proving the Hub's connector already reaches
Fleet Ops successfully independent of this flag -- only the status display was
gated, and silently stuck off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 20:15:17 +02:00
NuklearRabbitandClaude Sonnet 5 7d686ae2aa docs: record polish fixes, merge, and second deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 19:04:42 +02:00
NuklearRabbitandClaude Sonnet 5 c2b8268927 fix: form field alignment, raw maintenance text, and static movements list
- .form-grid labels (missing-field form, odometer-regression correction fields)
  and the odometer/overlap note textareas had no stacked label-above-input
  styling at all -- the shared rule only covered .filters/.return-form, so these
  fell back to default inline browser layout with mismatched input widths.
  Extended the existing rule to cover .form-grid and label:has(> textarea).

- Vehicle maintenance list showed the raw, untranslated seed text
  ("Synthetic scheduled service record") regardless of locale -- purely
  decorative and 1:1 redundant with the (already-translated) category. Replaced
  it with the record's real odometer reading, mirroring the sibling
  Inspections tab's pattern.

- "Today's movements" was always the same fixed 4 bookings (2 returns, 2
  departures) on every reset, reading as a static mockup rather than live
  fleet activity. Added 8 more bookings anchored to land on "today" across 8
  additional vehicles, spread through the day.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 18:29:56 +02:00
NuklearRabbitandClaude Sonnet 5 9e9dd8e0e3 docs: record the three-defect fix, gates, and live verification evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:52:13 +02:00
NuklearRabbitandClaude Sonnet 5 4faac24b5a fix: localize dashboard evidence, explain blocked vehicles, clarify pending odometers
Three content defects found by a live reviewer:

- Dashboard attention subtext was raw, untranslated evidence.summary text, and for
  11 of 15 seeded issues that text was literally "Synthetic deterministic seed
  issue". AttentionItem now exposes evidence_signals (stable code + params, same
  shape as the issue detail page) instead of a detail string; the frontend renders
  them through a shared describeEvidenceSignal() used by both the dashboard and the
  issue detail page. Every previously-placeholder seed row now cites a real,
  per-rule-type fact (a genuinely crossed service threshold, a genuinely blank
  field, or a real pair of booking odometer readings) instead of invented prose.

- 5 of 7 blocked vehicles had no quality issue at all and one had only a resolved
  one, so "needs attention" led nowhere. Each now has a real open
  missing_required_field issue backed by a genuinely blank field (no schema change,
  no migration -- reuses the existing data-quality pipeline).

- Booking odometer fields showing a bare "-" for 25 reserved + 1 active booking now
  show a localized explanation ("trip hasn't started yet" / "not yet closed").
  MO-024's rented-but-service-overdue contradiction was already caught by the
  vehicle-status evaluator (DQ-SCAN, vehicle.manual_review_required) -- added a
  regression test rather than new logic.

Also fixed a related bug the above exposed: the vehicle entity_snapshot omitted
registration_number entirely, so the "provide missing fields" form always showed
it blank regardless of the real value.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 17:44:32 +02:00
NuklearRabbitandClaude Sonnet 5 3808bbe132 docs: record push, Unraid deploy, and live verification evidence
Closes out the demo-scenario fix: pushed the two pending commits, deployed
6f77a30 to Unraid, and verified all four live checks (integration status,
failed-workflow listing, retry via API and UI, audit trail, Automation page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 16:39:41 +02:00
NuklearRabbit 6f77a30dce fix: stop the prepared demo failure from degrading n8n integration health
The demo seed plants exactly one failed delivery (BK-H-0020) to demonstrate
retry and audit. Because derive_n8n_status() counted any failure, every fresh
reset pinned the n8n integration to "degraded" -- the demo showed a warning
about a prop, which tells a viewer something untrue about the automation.

The seeded failure now carries its own error code, demoScenarioTimeout, rather
than the generic connectionError a real timeout produces. No column and no
migration: last_error_code already existed, is already surfaced to the UI and is
already localizable.

- integration status splits failed into unexpected_failed and
  demo_scenario_failed; only unexpected failures may move the state. A staged
  failure alone leaves n8n operational.
- latest_failure_at is a health signal and now ignores the staged failure;
  latest_demo_scenario_at reports it separately.
- /api/v1/workflows exposes is_demo_scenario. The Automation page labels the run
  as a prepared demo scenario, explains that it is a simulated temporary failure
  that does not affect automation health, and offers a distinct "retry demo
  scenario" action. Translated in nl-BE, en-GB and fr-BE.
- the carve-out stays narrow: a real failure still degrades n8n, and a genuine
  later failure of the same event overwrites the demo code with the real one.
- the retry itself is unchanged and real: the event goes back on the outbox and
  the dispatcher delivers it to n8n like any other, so 19+1 becomes 20+0 only on
  an actual round trip. The audit records which kind of failure was retried.

Tests that assert on the seeded scenario now reseed first, since earlier test
files legitimately mutate the outbox and the suite shares one database.

Verified locally against a real PostgreSQL 16: 181 passed, ruff clean, mypy
clean (50 files), tsc clean, frontend build clean. Not deployed and not
browser-verified.
2026-08-05 14:07:05 +00:00
NuklearRabbit e5307a7c0f fix: derive demo-manifest MCP Hub status from real tool-call evidence
The demo manifest still reported the MCP Hub integration as operational purely
because MCP_HUB_REGISTRATION_ENABLED was set, while the integration status page
had already moved to evidence-based status in Batch 4. Registration is
catalog-driven on the Hub's side, so the flag alone proves nothing; reuse
derive_mcp_hub_status() so "operational" requires real recorded mcp_tool_request
calls.

No change to the MCP integration contract: the four read-only routes, service
token and client id handling, inbound X-Correlation-Id preservation, the locale
field on search-knowledge and the provider/correlation_id response fields were
verified as already correct at deployed revision 727c19a and left untouched.
2026-08-05 13:28:43 +00:00
NuklearRabbitandClaude Sonnet 5 dee8f2f7e9 docs: record Batch 5 evidence and final session state
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:51:11 +02:00
NuklearRabbitandClaude Sonnet 5 57992bf153 M10: AI Operations Brief runbook, full live e2e regression, final evidence
Ran a real AI Operations Brief via the live ITWorx MCP Hub connector's own
MobilityOpsClient against production Fleet Ops: real operations summary, real
most-pressing vehicle, real grounded knowledge answer with citations, real
correlation IDs verified end-to-end in Fleet Ops's own audit log. No write
actions performed. Runbook and full output in
docs/final-integrations/ai-operations-brief-runbook.md.

Ran the full Playwright e2e suite against the live deployed instance and fixed
two pre-existing fragile locators unrelated to this session's feature work
(both broke because Automation now legitimately has two tables sharing the
same generic selectors, exposed by running the full suite rather than
individual files) plus one pre-existing untranslated-loanword false positive.
All specs pass.

artifacts/final-integrations/final-summary.md has the complete evidence
write-up: repository/deployment state, what was fixed vs. handed off, test
results, and known limitations stated plainly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:49:23 +02:00
NuklearRabbitandClaude Sonnet 5 727c19a779 M9: MCP Hub locale/correlation propagation, real Hub health check, fix stale test image
Fixed two concrete gaps in the MCP knowledge-search endpoint: no locale field
existed at all (now nl-BE/en-GB/fr-BE, wired to the knowledge provider's
existing language param), and the correlation ID was always freshly minted,
ignoring any inbound X-Correlation-Id header. Added a shared dependency and
applied it to all four MCP endpoints so Fleet Ops's own audit log preserves
the Hub's real correlation ID end to end.

MCP_HUB_BASE_URL/MCP_PROVIDER_ID were declared in .env.example but never read
anywhere. Since the Hub's own registration is catalog-driven (it never needs
Fleet Ops to push a registration call), wired mcp_hub_base_url for a real Hub
reachability health check instead of an unneeded self-registration call.

Renamed Fleet Ops's own internal audit tool labels mobilityops_* -> fleet_ops_*
(mirrored in contracts/mcp-tools.json with mobilityops_* kept as deprecated
aliases); documented that the live Hub connector's own dotted tool namespace
is a separate, Hub-owned naming layer, deliberately not touched.

Automation page's MCP card now shows real evidence (last tool/client/count/
timestamp, honest no-evidence state) instead of just the registration flag.

Also fixed a real methodology gap found mid-session: compose.yaml's api
service has no bind mount, so `docker compose run --rm api` silently tests a
stale image until rebuilt. Re-ran every local gate after rebuilding; fixed one
genuinely stale test assertion and two lint line-length errors surfaced by
that rebuild. 176 tests passing, ruff clean, mypy clean (50 files).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:30:24 +02:00
NuklearRabbitandClaude Sonnet 5 2ae2044e3a docs: record Batches 1-3 evidence and exact next action
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:53 +02:00
NuklearRabbitandClaude Sonnet 5 34df66d28c M8: GUI polish, n8n workflow-3 fixes, RAGcore retrieval root-cause and fix
GUI: dashboard Attention Queue presents a curated severity mix instead of pure
severity-sort (grouped Now/Today/Later headers); Today's Movements seed data
curated so a fresh reset shows a credible day (2+ departures, 2+ returns), with
a new seed-integrity test; About Demo restructured into a compact grid with
progressive disclosure for technical sections; Duplicate Merge shows match/conflict
counts, hides matching fields by default, and previews the final merged record
before confirmation.

Repo hygiene: removed a stray empty `backend;C` directory and an untracked 31MB
zip export; `.gitignore` now excludes future archive exports.

n8n: fixed invalid JSON (a missing `},` between two node objects) in the committed
`fleet-ops-vehicle-return.json` -- the file could not be parsed. Live-validated
workflow 3 (RAGcore Procedure Sync): found and fixed a real defect (three body
parameters had a stray trailing `}}`) and a missing Error Workflow wiring, both
via the safe `n8n import:workflow` CLI path; exported the corrected, still-
inactive workflow as the new source of truth and updated MANIFEST.md/check_drift.py.
Publishing it (starts real daily unattended runs) remains a separate decision.

RAGcore: root-caused and fixed (live, approved) the "zero retrieval candidates"
bug -- a filesystem permission bug (`embedding_profiles.json` unreadable by the
app's own runtime user) that broke every retrieval call before it reached Qdrant.
Every other suspect (grants, scope resolution, Qdrant filters, embeddings) was
verified healthy first. Found a second, deeper gap: the reranker adapter calls
an Ollama HTTP route that does not exist on the deployed Ollama version, so
`/v1/answers` still returns `not_answerable`. `KNOWLEDGE_PROVIDER` stays `demo`
until that is resolved on the RAGcore side. Evidence-based MCP Hub integration
status (real tool-call audit history, not just a boolean flag) replaces the old
`configured`/`not_configured` guess. Full findings in
`docs/final-integrations/current-state-audit.md`.

Backend: 172 tests passing, ruff clean, mypy clean (50 files). Frontend: tsc
clean, production build clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 13:05:02 +02:00
NuklearRabbitandClaude Sonnet 5 3ebca9e9b7 feat: replace brand mark with waypoint (pin-on-route) logo
Swaps the peaks-over-a-road glyph for a location pin on a route line
in both the favicon and the BrandMark component, so the mark stays
legible at 16px favicon size and reads more literally as fleet/route
tracking. Palette unchanged (navy #0f172a, teal #2dd4bf).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:47:11 +02:00
NuklearRabbitandClaude Sonnet 5 b66521da82 docs: record branch push and Unraid redeploy to 0571a40
Pushed feat/live-n8n-ragcore-integration to origin, then redeployed
the live Fleet Ops instance from 0da5251 to 0571a40 following the
deployment directory's own established source-archive convention.
Verified live: /health OK, the new n8n procedures endpoint (added
this branch) is reachable and correctly auth-gated, KNOWLEDGE_PROVIDER
still demo as intended.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 11:25:30 +02:00
NuklearRabbitandClaude Sonnet 5 0571a40649 docs: record n8n proxy-hops fix and completed workflow-3 build
Root cause found via the live n8n container's own logs: N8N_PROXY_HOPS=0
in the Unraid template didn't match the real reverse-proxy in front of
it, breaking the browserId/CSRF check on every workflow save while
leaving the UI looking fully signed in -- the user's pushback that it
"shows logged in" was correct and prompted digging into server logs
instead of continuing to guess client-side.

Fixed by editing the Unraid template (N8N_PROXY_HOPS 0->1, backed up
first) and recreating the container with every other setting preserved
exactly. Verified by reproducing the exact save action that used to
fail; it now works, and node persistence survives a full reload.

Built and saved both remaining workflow-3 nodes (Summarize sync
result, Report sync result to Fleet Ops) after recovering from an
errant Ctrl+A that deleted a node mid-verification (caught via node
count, restored via n8n's own Version History, redone carefully). Not
published -- that starts real daily production runs and is left for a
separate decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 07:12:57 +02:00
NuklearRabbitandClaude Sonnet 5 4227fe4f58 docs: record live /v1/answers verification and a retrieval finding
Issued a fresh scoped credential via the RAGcore admin UI (separate,
working OIDC session, unaffected by the n8n auth problem) and made a
real authenticated /v1/answers call. Got a clean 200 with a real
answer_id/retrieval_run_id, not degraded -- but not_answerable, 0
citations.

Confirmed this isn't a regression: the same query through RAGcore's
own pre-existing Query Lab tool (untouched this session) returns the
identical result down to zero dense/sparse candidates at the raw
retrieval stage. Ruled out the obvious causes via direct Qdrant/
Postgres checks -- workspace_id, space_id, status, and embedding
digest all correctly match real indexed content. Root cause not yet
found; flagged as a follow-up rather than pursued further to avoid
scope creep on what was a deployment-verification task.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 04:01:16 +02:00
NuklearRabbitandClaude Sonnet 5 c790ec99cb docs: record RAGcore search/answer wiring deployment to production
Deployed a2905cc to the live RAGcore instance (approved). Verified
the fix directly: POST /v1/search now returns 401
AUTHENTICATION_REQUIRED instead of the old permanent 503
SEARCH_UNAVAILABLE, proving the endpoint reaches real request
handling. A full authenticated /v1/answers call with a real grounded
answer is still outstanding -- the previously-issued production
credential's raw token was never persisted anywhere retrievable, and
issuing a fresh one needs the RAGcore admin UI, not attempted this
round.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:44:39 +02:00
NuklearRabbitandClaude Sonnet 5 fd390df423 docs: record n8n workflow-3 wipe/recovery and the live auth blocker
The live "Fleet Ops -- RAGcore Procedure Sync" workflow's canvas was
found at zero nodes -- the earlier session's abandoned direct n8n
REST API attempt had gone far enough to wipe it before hitting its
401. Recovered via n8n's own Version History "Restore version"
action back to the last good 4-node save; verified via DOM node
count before and after.

Adding the two remaining nodes then hit the same failure mode: n8n's
own first-party autosave reported "Unauthorized" moments after a
fresh, successful interactive sign-in. Stopped deliberately rather
than retrying against a live instance that already caused one data
loss incident this session -- this looks like an n8n-side session/auth
problem, not something fixable from browser automation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:38:16 +02:00
NuklearRabbitandClaude Sonnet 5 e5d8466266 knowledge: rewrite RAGcoreKnowledgeProvider to the real search/answers contract
The previous adapter targeted an endpoint shape RAGcore never actually
exposed. health() now checks /health/ready and ask() posts to the real
POST /v1/answers with Bearer auth and requested_space_ids, matching
RAGcore's actual contract after this session's Bearer-auth and
search/answer wiring work.

Adds RAGCORE_SPACE_ID config/env plumbing (a question is meaningless
without a knowledge space to scope it to) and 12 new adapter tests
covering degradation paths: missing space id, connection errors,
non-200 responses, malformed responses, not-answerable, and
answerable-without-citations all fail closed to "insufficient
evidence" rather than fabricating an answer.

KNOWLEDGE_PROVIDER stays "demo" in production for now -- switching
requires RAGcore's own search/answer application to actually be
deployed and live-verified, tracked separately in PROJECT_STATE.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-05 03:20:55 +02:00
NuklearRabbit 0da5251524 n8n: add backend endpoints for the RAGcore Procedure Sync workflow
GET /api/v1/integrations/n8n/procedures lists every procedure Markdown
file Fleet Ops ships (all languages) with a stable per-document id and
content hash, ready for workflow 3 to push into RAGcore. POST
.../procedures-sync-result records the sync outcome as an idempotent
audit event, matching the existing return-callback/workflow-error
pattern. Extracted frontmatter parsing out of the demo knowledge
provider into a shared module so both read the same source of truth.
2026-08-04 19:47:39 +02:00
NuklearRabbit 2afceea5e4 docs: record root cause and fix for the RAGcore credential-issuance bug
With explicit owner approval, traced the persistent credential-issuance
rejection to a cross-transaction race in RAGcore's own dependency
injection (two independent DB transactions per request instead of one
shared transaction), fixed and deployed it in RAGcore, and verified a
working "RAGcore Sync Token" n8n credential now exists. Unblocks
workflow 3 and the RAGcoreKnowledgeProvider adapter rewrite.
2026-08-04 18:11:34 +02:00
NuklearRabbit cf4d8e3649 docs: write final n8n + RAGcore integration evidence summary
Consolidates this effort's outcome across all four canonical workflows:
WF1/WF2 hardened and live, WF3 blocked on a RAGcore-side credential
rejection (with trace IDs for the operator to investigate), WF4 built
and live-validated with one open non-blocking follow-up. No credential
values or secrets included.
2026-08-04 17:07:54 +02:00
NuklearRabbit aaa1630535 docs: record WF2 retry fix and WF4's n8n-session-expiry blocker 2026-08-04 17:04:33 +02:00
NuklearRabbit 167bf49b6e n8n: add bounded retries to WF2's quality-scan callback
Found during this round's full acceptance pass: WF2 had the same
timeouts/bounded-retries gap as WF1 (timeout was already set, but Retry
On Fail was disabled). Fixed live (3 tries, 1000ms wait), published, and
synced the repo definition + manifest checksum.
2026-08-04 17:03:52 +02:00
NuklearRabbit fd0c55b13b docs: record RAGcore credential re-attempt and its concrete failure evidence
User explicitly authorized issuing the RAGcore credential directly this
round. Retried via the admin UI (Platform Admin role) after the earlier
raw-API attempt; both fail with an opaque server-side rejection carrying
a trace ID. Documents this as a RAGcore-side blocker, not a Fleet Ops gap.
2026-08-04 16:54:02 +02:00
NuklearRabbit 05628936ca n8n: add bounded retries and timeout to WF1's Fleet Ops callback
Vehicle Return Orchestration had no explicit timeout and Retry On Fail
disabled on its outbound HTTP call, a gap against the acceptance
checklist's timeouts/bounded-retries requirement. Fixed live (3 tries,
1000ms wait, 15s timeout, matching WF2's existing convention) and
synced the repo definition + manifest checksum.
2026-08-04 16:46:11 +02:00
NuklearRabbitandClaude Sonnet 5 b341436e77 docs: record integration status page verification and deploy evidence
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:32:43 +02:00
NuklearRabbitandClaude Sonnet 5 4049c0c6b1 n8n: surface real per-workflow evidence on the integration status page
Fleet Ops integration status no longer depends only on a config
boolean or the most recent outbox event: N8nIntegrationStatus now
reports per-canonical-workflow evidence (last successful outbox
delivery for the return workflow, latest service-triggered
data_quality_scan_run for the scan workflow, latest
n8n_workflow_failure_registered for the error handler, and "not built"
for the still-blocked RAGcore sync), plus an error-handler summary
(total failures registered, latest failure + which workflow).

Automation page renders this as a localized workflow table (EN/NL/FR)
with technical workflow names tucked under a "Technical details"
disclosure, matching the existing progressive-disclosure pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 16:15:09 +02:00
NuklearRabbitandClaude Sonnet 5 e39c0a1dd6 n8n: build and live-validate the Workflow Error Handler (WF4)
New central "Fleet Ops — Workflow Error Handler" workflow (Error
Trigger -> safe-report Code node -> POST to the new /workflow-error
endpoint), wired as the Error Workflow on both existing workflows with
no recursive loop on itself. Live-validated end-to-end against the
real Fleet Ops server (register + idempotent re-register), and via a
genuine induced failure on the scheduled-scan workflow (broken URL,
confirmed failure, reverted, confirmed healthy).

Fixed two real bugs found during live testing: Code node needed
"Run Once for Each Item" (not "All Items") for $json binding, and
every HTTP body field had a stray trailing space from the n8n
code-editor's bracket auto-close that broke datetime/enum validation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 15:52:17 +02:00
NuklearRabbitandClaude Sonnet 5 bbdb4a9ae8 n8n: add Fleet Ops endpoint to receive workflow error reports
New POST /api/v1/integrations/n8n/workflow-error, service-token
authenticated, for the central "Fleet Ops — Workflow Error Handler"
n8n workflow to report a bounded, secret-free failure (workflow id/
name, execution id, safe error category, trigger context, correlation
id, attempt, retry action). Idempotent on execution_id via the same
audit-event precheck pattern used by /return-callback, so a
redelivered error report is not registered twice.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:40:36 +02:00
NuklearRabbitandClaude Sonnet 5 e0c107a94a n8n: store cleaned workflow definitions as repo source of truth
Move the two live-validated workflows into n8n/workflows/ (credential-
based auth referenced by name only, no secret values), add a manifest
covering all 4 canonical workflows and a read-only drift-check script
against n8n's Public API. Retire the pre-integration root-level starter
files that still carried the literal-token pattern, and repoint the
Unraid deploy scripts, Makefile targets and runbook at the new files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 13:34:51 +02:00
NuklearRabbit 59cb4c062e docs: record n8n corrections applied and failure-history triage
Appends a follow-up section to the current-state audit: both existing
workflows renamed to their canonical Fleet Ops names and republished
(IDs/history preserved), and all 6 error executions in the return-
processing workflow's entire history triaged -- the 4 original ones
were the workflow's own author testing against the local test webhook
during initial setup on 2 August, the 2 newest are this session's own
deliberate auth-fix validation calls. Zero unexplained failures remain.
2026-08-04 09:15:08 +02:00
NuklearRabbit b79d485ef1 docs+fix: audit live n8n state, require auth on the return webhook
Inspected the shared n8n instance (n8n.itworx.tech) live: both existing
Fleet Ops workflows are genuinely active and structurally match the repo,
but the shared X-Service-Token secret was stored as plaintext literal
text in both HTTP Request nodes (exportable in the clear), and the
production return webhook had n8n-level Authentication set to "None"
(publicly callable by anyone who discovered the URL). Findings recorded
in docs/live-ai-integration/n8n-current-state.md.

Fixed on the n8n side (both workflows published): the shared token now
lives in a single Header Auth credential instead of two literal copies;
the return webhook now requires a second, distinct Header Auth
credential.

Fixed on the Fleet Ops side to match: the outbox dispatcher now sends
the new X-Fleet-Ops-Trigger-Token header (new
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN setting) when calling the webhook.
Live-verified against the real webhook: a request with no header is now
rejected (403); a request with the correct header passes n8n's auth and
reaches Fleet Ops's own business logic.

That same live test also surfaced a real robustness gap: an n8n
execution that errors before its "Respond to Webhook" node runs can
still answer with a 2xx status and an empty body, which made
response.json() raise an uncaught exception, potentially leaving the
outbox event stuck in "delivering". Now treated as an explicit,
retryable failure (error_code=malformedResponse), with a regression
test reproducing the exact case.
2026-08-04 05:03:33 +02:00
NuklearRabbit c0995b762e docs(release): final Fleet Ops localization correction evidence
Final evidence for the small correction round merged in 5f0eaa5:
commits, translation fixes, API-error-localization result, greeting
logic and edge-case evidence, clean-checkout drill, Unraid deployment
evidence, repository/runtime hash comparison, known limitations
(including the transient document.lang anomaly observed during
interactive testing, root-caused as far as possible and not
reproduced in any automated run), and rollback procedure.
2026-08-04 04:01:55 +02:00
NuklearRabbit 5f0eaa59b0 merge: finalize Fleet Ops localization 2026-08-04 03:46:20 +02:00
NuklearRabbit 09173a4740 fix: correct fr-BE audit column label Actor -> Auteur
Caught during live browser validation on Unraid: fr-BE had "Acteur" for
the audit trail's actor column, but the brief's minimum-required French
corrections specify "Actor" -> "Auteur" explicitly.
2026-08-04 03:32:48 +02:00
NuklearRabbit 9468cc3e21 docs: update PROJECT_STATE and README for the final localization round
PROJECT_STATE.md: fix the stale "Product name: MobilityOps."/"PoC only"
locked-decisions lines (predate the Fleet Ops rebrand), fix the "Fleet
Ops correction" section header still reading "IN PROGRESS .../Not yet
merged to master" when it was in fact already merged (de0bdea, evidence
commit f780557), and append a new dated entry for this correction round
with commits and gate evidence so far.

README.md: reference docs/fleet-ops-final-localization/ alongside the
existing docs/fleet-ops-correction/ link, refresh the stale Playwright
test count (113 -> 138).
2026-08-04 03:10:37 +02:00
NuklearRabbit f0d641198c fix: serve the missing Fleet Ops favicon
There was no favicon at all -- index.html never linked one, and the
frontend Dockerfile's build stage never copied the public/ directory
into the build context, so even after adding public/favicon.svg
locally, the containerized build silently dropped it (nginx fell back
to serving index.html for that path). Fixed both: index.html links
/favicon.svg, and the Dockerfile now copies public/ alongside src/.
The favicon reuses the existing BrandMark glyph (petrol background,
teal accent) for visual consistency with the in-app brand mark.
A regression test for this lives in the earlier translation-fix commit
(frontend/e2e/fleet-ops-correction.spec.ts), added together with the
fix at the time.
2026-08-04 03:10:11 +02:00
NuklearRabbit 77208b857a fix: prevent topbar overflow from an unbreakable Dutch role-name translation
Correctly translating auth.json's roleOperationsManager from the old
two-word "Operations Manager" (which could wrap at the space) to the
single Dutch compound word "Operationsmanager" (which cannot) pushed the
topbar's .operator block past its 1024px-breakpoint budget, caught by
the existing responsive-i18n.spec.ts overflow test. Fixed with
overflow-wrap: anywhere on the role/name text and min-width: 0 on their
flex-item wrapper, rather than reverting the correct translation.
2026-08-04 03:09:17 +02:00
NuklearRabbit e427313bce feat: add time-dependent Europe/Brussels dashboard greeting
The dashboard greeting was a fully static "Goedemorgen..." regardless of
actual time of day. New frontend/src/i18n/greeting.ts::getGreetingPeriod
is a pure, clock-injectable function resolving one of 4 periods (05:00-
11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59
night) against Europe/Brussels wall-clock time via
Intl.DateTimeFormat({ timeZone, hourCycle: "h23" }), which is DST-safe
by construction.

useGreetingPeriod.ts wires this into React with a 30s poll so the
greeting rolls over live while the app stays open, no reload required.
Each period now has its own greeting word and accompanying sentence in
all 3 languages (dashboard.json), replacing both the fixed "Goedemorgen"
and the fixed "Here's the fleet" follow-up sentence. Night never says
"Goedenacht" (used as a farewell, not a welcome, in Dutch).
2026-08-04 03:08:45 +02:00
NuklearRabbit d17af1c52a feat: centralize API error localization
Replace the err instanceof ApiError ? err.message : t(fallback) anti-
pattern -- which showed raw English backend text for the common case and
only used the localized fallback for the rare network-failure case -- at
all 13 call sites across 7 files.

New frontend/src/api/errorMessages.ts (describeApiError) resolves a
caught error to a localized {title, explanation, nextStep?, technical}
by checking the 32 known AppError codes first, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. New
ApiErrorNotice (PageChrome.tsx) renders title/explanation/nextStep with
the raw text demoted to a "Technical details"/"Details techniques"
disclosure -- never shown as the primary message.

ApiError itself is split out of client.ts into a standalone
api/apiError.ts with no import.meta.env dependency, so errorMessages.ts
(and its tests) can be loaded outside a Vite/browser context.
2026-08-04 03:08:07 +02:00
NuklearRabbit 94cfb7bcbb test: tighten i18n allowlist, add substring and brand-leak guards
Remove 7 now-stale IDENTICAL_VALUE_ALLOWLIST entries (audit.title,
auth.roleOperationsManager, auth.roleRentalEmployee,
demo.scenarios.startScenario, demo.scenarios.roles.operations_manager/
rental_employee, navigation.items.audit) now that they are genuinely
translated -- their old comments describing them as "deliberately
untranslated" were no longer true.

Add two new checks: one closing the embedded-English/Dutch-substring
blind spot the whole-string identity test structurally cannot catch (a
mid-sentence phrase surviving inside otherwise-translated prose), one
asserting no locale file contains "MobilityOps" or the word "PoC".
2026-08-04 03:04:14 +02:00
NuklearRabbit 37a362c4a0 fix: translate remaining NL/FR interface gaps
Role names, audit/scenario labels, and status text were previously either
left in English or only partially translated:
- auth.json/demo.json role labels actually translated (not just labelled
  as translated): Operationsmanager/Verhuurmedewerker,
  Responsable des operations/Collaborateur de location.
- "Audit trail" -> Auditgeschiedenis/Piste d'audit (title, column header,
  and every mid-sentence occurrence across demo.json, quality.json,
  returns.json -- these embedded leaks were previously invisible to the
  whole-string identity check).
- "Open" (status) -> Openstaand, "Recent" -> Recentste,
  "Start scenario" -> Scenario starten / Demarrer le scenario.

Matching Playwright spec text updated in the same commit so the suite
never regresses through a broken intermediate state.
2026-08-04 03:03:46 +02:00
NuklearRabbit 1fbb20b1ab docs: audit remaining Fleet Ops localization gaps
Documents every remaining untranslated/incorrect NL/FR string, raw-backend-
error call site, over-permissive i18n allowlist entry, the static-greeting
bug, and doc staleness found by a dedicated read-only sweep before any file
was touched, per the Fleet Ops final localization brief.
2026-08-04 03:02:50 +02:00
NuklearRabbitandClaude Sonnet 5 f7805579f7 docs(release): final Fleet Ops correction evidence and screenshots
Full acceptance evidence for the Fleet Ops correction milestone: commits, branding,
translation coverage, status-preview/apply/manual-review/MO-016-ordering results,
knowledge grounding per language, audit/automation localization, backend/frontend
test results, clean-checkout drill, Unraid deployment (both fix-branch and
post-merge master), responsive/accessibility results, known limitations, and
rollback procedure. Includes live screenshots (nl-BE and fr-BE login, and the
localized data-quality evidence summary that live validation caught and fixed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 01:15:31 +02:00
NuklearRabbit de0bdea84f merge: complete Fleet Ops localization and status resolution 2026-08-04 00:54:31 +02:00
NuklearRabbitandClaude Sonnet 5 284b3c7394 docs: record Unraid deployment evidence (PASS)
Deployed fix/fleet-ops-i18n-status-flow to http://192.168.10.150:1236 and validated
live, which directly caught the evidence-summary localization bug (fixed in 2e4fb43).
Redeployed with the fix and re-verified: full 116-test Playwright suite green against
the live server, no console errors, no container-log errors, both containers healthy,
scenario_integrity.all_ready: true after final reset.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:53:31 +02:00
NuklearRabbitandClaude Sonnet 5 2e4fb43f09 fix: localize the primary data-quality evidence summary (live-caught on Unraid)
Live validation on the deployed fix branch caught a real bug: every data-quality
issue's top-of-page "Evidence summary" line rendered the raw, always-English legacy
evidence.summary string unconditionally -- in all three languages -- even though the
backend has been emitting structured, localizable evidence.signals for a while
(app/services/data_quality.py already documented this exact intent). The frontend
side of that conversion was never finished.

- DataQualityIssueDetail.tsx now renders evidence.signals through the operator's
  locale as the primary summary; the raw evidence.summary string is only visible
  inside "Technical details" (via the existing EvidenceDisclosure JSON dump).
- The four DQ-DEMO-* seed rows that anchor the guided demo's scripted scenarios now
  carry real, accurate signals computed at seed time (duplicate-customer's similarity
  score is the actual SequenceMatcher ratio on the seeded names, not invented) instead
  of only a legacy English sentence.
- Rows with no structured signals (generic filler seed data) fall back to the raw
  text rather than showing a blank summary; the one known placeholder string gets its
  own localized rendering so it never displays as English filler either.
- New regression test: the vehicle_status_conflict evidence summary must show
  localized text and must never contain the specific raw English sentence that was
  live-visible before this fix, in all 3 languages.

151 backend tests, Ruff, mypy green; full local Playwright suite green (a couple of
sequential-run-only flakes, both confirmed to pass in isolation and unrelated to this
change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 00:44:06 +02:00
NuklearRabbitandClaude Sonnet 5 cda2c32bd0 docs: record clean-checkout drill evidence (PASS)
Fresh clone of only committed files into an isolated Compose project (separate ports,
no shared volumes) validated: migration from empty database to head, deterministic
seed (matches the corrected 27-issue count), 151 backend tests + Ruff + mypy, frontend
build, and the full 113-test Playwright suite -- all green. Isolated stack torn down
afterward; working dev environment confirmed untouched. Full detail in
PROJECT_STATE.md; test counts refreshed in README.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:57:21 +02:00
NuklearRabbitandClaude Sonnet 5 7851e807fa test: add route matrix (11F) and hardcoded-JSX-text check (11D)
- fleet-ops-correction.spec.ts: opens every main route in all 3 languages, asserting
  no console errors, correct html[lang], and a real non-empty page heading (key parity
  across locale files is already proven structurally elsewhere, so this focuses on what
  only a live render can catch).
- i18n-coverage.spec.ts: a static scan for hardcoded JSX text bypassing t(...). A naive
  `>text<` regex falsely flagged TypeScript generics everywhere (`useState<string |
  null>(null)` was read as a "JSX tag" spanning to the next unrelated `>`) -- fixed by
  requiring the closing tag name to backreference the opening one
  (`<Tag>...</Tag>`), which generics can never satisfy. Verified against both false
  positives (passes clean on the current codebase) and false negatives (deliberately
  injected and reverted a hardcoded string to confirm it's caught).

Known pre-existing flake (unrelated to this branch, not touched by it): "logout
invalidates the server session so a refresh returns to login" in
interactive-elements.spec.ts occasionally fails only in the full sequential run,
never in isolation -- AuthContext.logout() clears local state and redirects before
awaiting the server-side cookie-clearing POST, a narrow race no human interaction
speed would ever hit. Noted as a known limitation, not fixed (out of this branch's
scope).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:30:09 +02:00
NuklearRabbitandClaude Sonnet 5 a7ac5ed9d0 docs: update OpenAPI contract, README branding, and PROJECT_STATE for the correction milestone
- contracts/openapi.yaml: title is now "Fleet Ops API"; documents the new
  status-recommendation preview endpoint and the apply endpoint's request body
  (recommendation_token) and full error-code set; notes the search endpoint's
  code+params response shape.
- README.md: title and intro now say Fleet Ops, with an explicit note on the
  Fleet Ops (visible)/MobilityOps (technical identifier) naming split; refreshed
  stale test counts (151 backend, 108 Playwright).
- PROJECT_STATE.md: full progress record for the in-progress correction milestone,
  including what's done, what bugs were found and fixed, and what's explicitly not
  yet done (i18n test-strengthening 11D/E/F, clean-checkout drill, Unraid deployment,
  merge to master).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:14:34 +02:00
NuklearRabbitandClaude Sonnet 5 1e407754e6 test: add accessibility coverage for the status-recommendation panel
Adds aria-live="polite" to the status-conflict panel (matching the existing
resolved-issue success-panel convention) so the applied-status confirmation is
announced to screen readers, and a Playwright test covering: keyboard-only
activation of both the "Review recommendation" and "Change status to X" actions,
reduced-motion emulation, and that status is never conveyed by colour alone (the
badge always carries its own localized text).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 23:08:21 +02:00
NuklearRabbitandClaude Sonnet 5 1fdd2b3ccf test: add targeted E2E coverage for branding, status flow, MO-016, and knowledge; fix two real bugs found along the way
New frontend/e2e/fleet-ops-correction.spec.ts covers section 12 of the brief:
branding (Fleet Ops visible, no MobilityOps/PoC leaks, in all 3 languages), the
language switcher persisting across reload, the full status-recommendation flow
(non-mutating preview, exact-status confirm button, manual review with no apply
button, stale-token rejection), MO-016 order independence at the browser level, the
knowledge base grounding the exact brief question in its own language, and localized
audit/automation content with raw codes only under "Technical details".

Writing these tests surfaced two real bugs:

- DataQualityIssueDetail.tsx conflated "no conflict" with "manual review required"
  because both carry safe_to_apply: false (a no_conflict recommendation has nothing to
  apply, so it's trivially "not safe to apply" without being unsafe). This showed a
  false "manual review required" panel for MO-016 after its overlap was resolved,
  instead of the correct "no change needed" state. Fixed by keying the branch on
  manual_review_required alone.
- test_mo_016_status_conflict_recommendation_is_order_independent never actually
  exercised MO-016: _first_open() returned whichever vehicle_status_conflict issue was
  most recently detected (there are ~14 open after a reset), not necessarily
  DQ-DEMO-STATUS, so the test's MO-016 assertions were trivially true regardless of
  what the code under test did. Added _first_open_for_vehicle() and rewrote the test
  to explicitly target MO-016, and to assert the behaviour order independence actually
  requires: resolving the overlap first must correctly leave nothing to apply (the
  vehicle already matches the facts), not literally the same end status as resolving
  the conflict first.

151 backend tests, Ruff, mypy green; full 108-test Playwright suite green (two
transient, non-reproducible flakes confirmed to pass in isolation and unrelated to
this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 22:53:55 +02:00
NuklearRabbitandClaude Sonnet 5 ac4b1636fe test: update Playwright specs for the new status-recommendation flow and localized return reason
Two specs still exercised the old single-button "calculate and apply" flow and asserted
on the raw English return-status reason that is now shown as localized primary text
with the raw code moved behind "Technical details". Updated both to match the new
review/decide/confirm status panel and the reason-code UI.

Full 94-test Playwright suite green against the rebuilt web+api stack.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 22:05:50 +02:00
NuklearRabbitandClaude Sonnet 5 e6539d17b6 fix: knowledge retrieval accuracy and remaining brand/PoC leaks in procedure docs
- Fix the demo knowledge provider's tokenizer: a plain [a-z0-9]+ regex silently
  dropped accented characters, splitting French words like "véhicule" into "v" +
  "hicule" and mangling retrieval for nearly every French query. Now matches the
  Latin-1 accented range too.
- Reweight section scoring so the body match (the actual substance of a section)
  outranks a heading/title match (a shallow structural hint) rather than the reverse
  -- confirmed via the brief's exact validation question that the old weighting
  misranked the damage procedure behind a topically-adjacent document in all three
  languages (nl-BE: a checkout section; en-GB/fr-BE: the return procedure), purely
  because a generic word like "vehicle"/"voertuig" happened to sit in a heading/title.
- Remove leftover "MobilityOps" and "PoC" mentions from 5 English and 4 NL/FR
  procedure documents -- knowledge-base prose is visible UI content and was missed by
  the earlier rebrand.
- Add regression tests: the brief's exact NL/EN/FR damage question must ground on the
  damage procedure as the *primary* source (not just appear in the top 3), and no
  procedure file may contain "MobilityOps" or "PoC".

151 backend tests, Ruff, mypy green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:54:15 +02:00
NuklearRabbitandClaude Sonnet 5 6deb95524d fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
  used identically by the data-quality scanner, a new non-mutating status-recommendation
  preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
  revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
  "maintenance + active booking -> auto rented" shortcut. Frontend
  DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
  why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
  <status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
  after the status-conflict issue now converges on the same final vehicle status,
  proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
  backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
  appeared in locale prose; add a permanent test guarding against a translation file
  ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
  reasons, audit field/actor-type labels, automation last_error, and search
  section/vehicle/booking/issue results all now carry codes the frontend localizes,
  with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
  decision table documenting the evaluator's rules and safe-status principles.

148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-03 21:37:34 +02:00
NuklearRabbitandClaude Sonnet 5 18344bc8b7 docs(release): final Fleet Ops multilingual-polish evidence and screenshots
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>
2026-08-03 19:45:57 +02:00
NuklearRabbit 18a765d623 merge: release Fleet Ops multilingual demo 2026-08-03 19:28:30 +02:00
332 changed files with 32321 additions and 3880 deletions
+21
View File
@@ -0,0 +1,21 @@
# Backend image build context is the repository root (see compose.yaml); keep it small.
.git
.gitea
.state
.mypy_cache
.ruff_cache
.pytest_cache
**/__pycache__
**/.venv
**/node_modules
**/dist
**/playwright-report
**/test-results
artifacts
docs
deploy
n8n
frontend
*.tgz
*.tar.gz
.env
+77 -6
View File
@@ -2,24 +2,82 @@ COMPOSE_PROJECT_NAME=mobilityops
MOBILITYOPS_ENV=development MOBILITYOPS_ENV=development
MOBILITYOPS_DEMO_MODE=true MOBILITYOPS_DEMO_MODE=true
MOBILITYOPS_PUBLIC_URL=http://localhost:1228 MOBILITYOPS_PUBLIC_URL=http://localhost:1228
MOBILITYOPS_API_URL=http://localhost:8128 # Build-time API origin baked into the web bundle. Leave empty: the SPA calls its own
# origin and nginx proxies /api to the API (required by the CSP connect-src 'self').
VITE_API_BASE_URL=
DATABASE_URL=postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops DATABASE_URL=postgresql+psycopg://mobilityops:mobilityops@db:5432/mobilityops
POSTGRES_DB=mobilityops POSTGRES_DB=mobilityops
POSTGRES_USER=mobilityops POSTGRES_USER=mobilityops
POSTGRES_PASSWORD=mobilityops POSTGRES_PASSWORD=mobilityops
# Signs session cookies. With MOBILITYOPS_ENV=production the API refuses to start while
# this (or MOBILITYOPS_CALLBACK_TOKEN) still holds its placeholder value.
APP_SECRET=replace-in-production APP_SECRET=replace-in-production
TZ=Europe/Brussels TZ=Europe/Brussels
# Session cookie Secure flag. Keep false for LAN/plain-HTTP deployments (including the # Session cookie Secure flag. Development on localhost may use false; production startup
# current Unraid review environment); set true only once MobilityOps is served over HTTPS, # requires both an HTTPS public URL and this value set to true.
# otherwise browsers will silently drop the cookie and no one can log in.
SESSION_COOKIE_SECURE=false SESSION_COOKIE_SECURE=false
# Optional OpenID Connect login. Public demo role buttons remain available when enabled.
OIDC_ENABLED=false
OIDC_PROVIDER_NAME=Organisatieaccount
OIDC_ISSUER_URL=
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=
OIDC_ALLOWED_EMAIL_DOMAINS=
OIDC_AUTO_PROVISION=true
OIDC_DEFAULT_ROLE=rental_employee
# Observability: JSON logs are always enabled. Set a token only if /metrics is exposed
# outside the private Compose network; Prometheus can send it as a bearer token.
LOG_LEVEL=INFO
# Required when the observability profile is enabled. Keep private and high entropy.
METRICS_BEARER_TOKEN=replace-me-private-metrics-token
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=change-me-before-start
# Alertmanager sends every firing/resolved alert and the continuous watchdog to this
# owner-managed receiver. Production must route it to a channel that is actually watched.
ALERTMANAGER_WEBHOOK_URL=https://n8n.itworx.tech/webhook/mobilityops-alerts
# Verified scheduled PostgreSQL backups (Unraid override).
BACKUP_INTERVAL_SECONDS=86400
BACKUP_RETENTION_DAYS=30
BACKUP_MINIMUM_COPIES=7
# Restore the newest dump into a disposable database at least weekly. Backup health also
# requires a successful drill within eight days.
BACKUP_RESTORE_DRILL_INTERVAL_SECONDS=604800
# Set both values to copy every verified backup to an independently mounted path.
BACKUP_SECONDARY_DESTINATION=
MOBILITYOPS_BACKUP_DIR=./backups/postgres
MOBILITYOPS_BACKUP_SECONDARY_DIR=./backups/offsite
# Optional real off-site copy through the official rclone OneDrive adapter. OAuth state
# lives only in MOBILITYOPS_RCLONE_CONFIG_DIR and must never be committed.
BACKUP_OFFSITE_INTERVAL_SECONDS=900
RCLONE_ONEDRIVE_REMOTE=onedrive
RCLONE_ONEDRIVE_PATH=FleetOps/backups
MOBILITYOPS_RCLONE_CONFIG_DIR=./.secrets/rclone
MOBILITYOPS_OFFSITE_VERIFY_DIR=./backups/offsite-verify
# Privacy governance defaults.
PRIVACY_MINIMUM_BOOKING_RETENTION_DAYS=30
PRIVACY_AUDIT_RETENTION_DAYS=2555
PRIVACY_AUDIT_EXPORT_MAX_ROWS=10000
# Demo presentation (fictional org identity, badge/manifest, reset safety valve). # Demo presentation (fictional org identity, badge/manifest, reset safety valve).
# DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent # DEMO_ALLOW_RESET=false permanently disables POST /api/v1/demo/reset (403), independent
# of role -- a safety valve for any environment where the dataset must not be rebuildable. # of role -- a safety valve for any environment where the dataset must not be rebuildable.
DEMO_ORGANIZATION_NAME=Northstar Mobility DEMO_ORGANIZATION_NAME=Northstar Mobility
DEMO_TIMEZONE=Europe/Brussels DEMO_TIMEZONE=Europe/Brussels
DEMO_ALLOW_RESET=true DEMO_ALLOW_RESET=true
# Prevent public visitors from repeatedly rebuilding the shared dataset. Concurrent
# resets are always rejected using both process and PostgreSQL advisory locks.
DEMO_RESET_COOLDOWN_SECONDS=60
# Operational mode: set MOBILITYOPS_DEMO_MODE=false and provide the first manager.
# Keep these values in a secret store or an untracked production .env file.
INITIAL_ADMIN_EMAIL=
INITIAL_ADMIN_PASSWORD=
INITIAL_ADMIN_DISPLAY_NAME=Operations Manager
# n8n # n8n
N8N_BASE_URL=http://n8n:5678 N8N_BASE_URL=http://n8n:5678
@@ -29,6 +87,11 @@ N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=change-me N8N_BASIC_AUTH_PASSWORD=change-me
MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token MOBILITYOPS_CALLBACK_TOKEN=replace-me-n8n-callback-token
# Sent as the X-Fleet-Ops-Trigger-Token header when Fleet Ops calls the n8n return-
# processing webhook, so the webhook trigger can require Header Auth instead of being
# publicly callable by anyone who discovers the URL. Must match the value stored in
# n8n's "Fleet Ops Webhook Trigger Token" Header Auth credential.
MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN=replace-me-n8n-webhook-trigger-token
# RAGcore integration # RAGcore integration
KNOWLEDGE_PROVIDER=demo KNOWLEDGE_PROVIDER=demo
@@ -37,9 +100,17 @@ RAGCORE_TENANT=northstar-mobility-demo
RAGCORE_WORKSPACE=mobilityops RAGCORE_WORKSPACE=mobilityops
RAGCORE_COLLECTION=internal-procedures RAGCORE_COLLECTION=internal-procedures
RAGCORE_API_TOKEN= RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID=
# Skip the slower generation endpoint temporarily after a timeout/non-2xx response and
# use the still-grounded extractive search fallback immediately.
RAGCORE_ANSWERS_CIRCUIT_BREAKER_SECONDS=60
# ITWorx MCP Hub integration # ITWorx MCP Hub integration. Registration itself is catalog-driven on the Hub's own
# side (it reconciles its catalog into the gateway; Fleet Ops never pushes a
# registration call) -- MCP_HUB_BASE_URL is only used here for an honest reachability
# health check surfaced on the integration status page.
MCP_HUB_REGISTRATION_ENABLED=false MCP_HUB_REGISTRATION_ENABLED=false
MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000 MCP_HUB_BASE_URL=http://itworx-mcp-hub:8000
MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token MCP_HUB_SERVICE_TOKEN=replace-me-mcp-hub-token
MCP_PROVIDER_ID=mobilityops MCP_PROVIDER_ID=fleet-ops
+135
View File
@@ -0,0 +1,135 @@
name: MobilityOps acceptance
on:
push:
branches: [master]
pull_request:
schedule:
- cron: "17 3 * * 1"
jobs:
backend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Secret scan
uses: trufflesecurity/trufflehog@b9dd330365132cd2d01dd5dc8a857a056a2544e1 # v3.79.0
with:
path: ./
extra_args: --only-verified
- name: Backend tests in isolated PostgreSQL stack
run: sh scripts/run-isolated-tests.sh
- name: Backend static checks
run: |
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm api mypy app
- name: Contract drift gate
run: |
docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml run --rm \
-v "$PWD:/repo:ro" api python /repo/scripts/check-contracts.py
python scripts/check-source-budgets.py
- name: Build production API image for vulnerability scan
run: |
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" \
--tag mobilityops-api-ci --file backend/Dockerfile .
- name: Production API image vulnerability scan (HIGH/CRITICAL)
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-api-ci
format: table
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: true
- name: Build production web image for vulnerability scan
run: |
docker build --build-arg VCS_REF="$GITHUB_SHA" \
--tag mobilityops-web-ci frontend
- name: Production web image vulnerability scan (HIGH/CRITICAL)
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-web-ci
format: table
severity: HIGH,CRITICAL
exit-code: "1"
ignore-unfixed: true
- name: Remove CI stack
if: always()
run: docker compose -p mobilityops-ci -f compose.yaml -f compose.test.yaml down -v --remove-orphans
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install locked dependencies
working-directory: frontend
run: npm ci --no-audit --no-fund
- name: Lint (tsc + ESLint with react-hooks and jsx-a11y)
working-directory: frontend
run: npm run lint
- name: Typecheck and production build
working-directory: frontend
run: npm run build && npm run budget
- name: Dependency audit
working-directory: frontend
run: npm audit --audit-level=high
e2e:
# The five-minute Playwright demo is part of the definition of done
# (docs/14-testing-and-acceptance.md); run it against the real Compose stack.
runs-on: ubuntu-latest
needs: [backend, frontend]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Start the demo stack
run: |
cp .env.example .env
docker compose -p mobilityops-e2e up --build -d db api web
for _attempt in $(seq 1 60); do
if curl -fsS http://localhost:1228/health/ready >/dev/null 2>&1; then break; fi
sleep 2
done
curl -fsS http://localhost:1228/health/ready
docker compose -p mobilityops-e2e exec -T api python -m app.cli seed --reset
- name: Install Playwright
working-directory: frontend
run: |
npm ci --no-audit --no-fund
npx playwright install --with-deps chromium firefox
- name: Run browser acceptance suite
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
run: npx playwright test
- name: Run non-destructive Chromium and Firefox smoke suite
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: http://localhost:1228
run: npx playwright test --config=playwright.live.config.ts
- name: Run concurrent persisted-read smoke
run: python scripts/run-readonly-load-smoke.py --base-url http://localhost:1228
- name: Upload Playwright report
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: playwright-report
path: |
frontend/playwright-report
frontend/playwright-live-report
- name: Stack logs on failure
if: failure()
run: docker compose -p mobilityops-e2e logs --tail=200 api web
- name: Remove e2e stack
if: always()
run: docker compose -p mobilityops-e2e down -v --remove-orphans
+47
View File
@@ -0,0 +1,47 @@
name: MobilityOps live canary
on:
schedule:
- cron: "7 * * * *"
workflow_dispatch:
jobs:
public-demo:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Verify HTTPS readiness and certificate horizon
run: |
curl --fail --silent --show-error --retry 3 https://fleetops.itworx.tech/health/ready
openssl s_client -servername fleetops.itworx.tech -connect fleetops.itworx.tech:443 </dev/null 2>/dev/null \
| openssl x509 -checkend 1209600 -noout
- name: Install locked Playwright runtime
working-directory: frontend
run: |
npm ci --no-audit --no-fund
npx playwright install --with-deps chromium firefox
- name: Run non-destructive cross-browser production canary
working-directory: frontend
env:
MOBILITYOPS_PUBLIC_URL: https://fleetops.itworx.tech
run: npx playwright test --config=playwright.live.config.ts
- name: Report successful external heartbeat
env:
HEARTBEAT_URL: ${{ secrets.LIVE_CANARY_HEARTBEAT_URL }}
run: |
if [ -n "$HEARTBEAT_URL" ]; then
curl --fail --silent --show-error --retry 3 "$HEARTBEAT_URL"
fi
- name: Upload failure evidence
if: failure()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: live-canary-failure
path: |
frontend/playwright-live-report
frontend/test-results
+62
View File
@@ -0,0 +1,62 @@
name: MobilityOps release evidence
on:
push:
tags: ["v*"]
jobs:
release-evidence:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Build commit-labelled release images
run: |
docker build --target runtime --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-api-release --file backend/Dockerfile .
docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-web-release frontend
docker build --build-arg VCS_REF="$GITHUB_SHA" --tag mobilityops-backup-tools-release --file deploy/unraid/Dockerfile.backup-tools .
- name: Scan all release images
run: |
for image in mobilityops-api-release mobilityops-web-release mobilityops-backup-tools-release; do
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
-v mobilityops-release-trivy:/root/.cache/ \
aquasec/trivy:0.74.0@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969 \
image --scanners vuln --severity HIGH,CRITICAL \
--ignore-unfixed --exit-code 1 "$image"
done
- name: Generate API CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-api-release
format: cyclonedx
output: mobilityops-api-sbom.cdx.json
- name: Generate web CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-web-release
format: cyclonedx
output: mobilityops-web-sbom.cdx.json
- name: Generate backup-tools CycloneDX SBOM
uses: aquasecurity/trivy-action@6c175e9c4083a92bbca2f9724c8a5e33bc2d97a5 # v0.30.0
with:
scan-type: image
image-ref: mobilityops-backup-tools-release
format: cyclonedx
output: mobilityops-backup-tools-sbom.cdx.json
- name: Record immutable image metadata
run: |
docker image inspect mobilityops-api-release > mobilityops-api-image.json
docker image inspect mobilityops-web-release > mobilityops-web-image.json
docker image inspect mobilityops-backup-tools-release > mobilityops-backup-tools-image.json
python scripts/generate-release-provenance.py
sha256sum mobilityops-*-sbom.cdx.json mobilityops-*-image.json release-provenance.json > SHA256SUMS
- name: Upload release evidence
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: mobilityops-${{ github.ref_name }}-evidence
path: |
mobilityops-*-sbom.cdx.json
mobilityops-*-image.json
release-provenance.json
SHA256SUMS
+9
View File
@@ -8,9 +8,18 @@ node_modules/
dist/ dist/
coverage/ coverage/
playwright-report/ playwright-report/
playwright-live-report/
test-results/ test-results/
*.pyc *.pyc
.DS_Store .DS_Store
.idea/ .idea/
.vscode/ .vscode/
*.tsbuildinfo *.tsbuildinfo
*.zip
*.tar.gz
# Local Claude/Codex per-user settings and scratch archives
.claude/settings.local.json
*.tgz
*.dump
backups/
+18
View File
@@ -0,0 +1,18 @@
# Changelog
All notable changes are documented here. The project follows semantic release tags for
the deployable PoC; detailed validation evidence remains in `PROJECT_STATE.md`.
## [Unreleased]
- Added contract-drift, accessibility, Firefox smoke and frontend asset-budget gates.
- Added immutable commit-labelled deployment with automatic application rollback.
- Added real PostgreSQL restore drills and routed Alertmanager notifications.
- Added RAGcore generation circuit breaking and retrieval telemetry.
- Added scheduled dependency maintenance, dual-image vulnerability scans and release SBOMs.
## [1.0.0-poc] - 2026-08-21
- Completed the locked Fleet Ops proof of concept: operational core, transactional returns,
data quality, n8n orchestration, grounded RAGcore knowledge, read-only MCP integration,
privacy governance, observability, backup/recovery and full browser acceptance.
+307 -11
View File
@@ -1,25 +1,147 @@
# File index # File index
Tracked source, contract, documentation and configuration files. Release evidence and
screenshots live under `artifacts/<release>/` and are omitted here for brevity.
Regenerate with `git ls-files` when the tree changes.
- `.env.example` - `.env.example`
- `.gitattributes`
- `.gitea/workflows/ci.yml`
- `.gitignore` - `.gitignore`
- `AGENTS.md`
- `CLAUDE.md` - `CLAUDE.md`
- `FILE_INDEX.md`
- `MASTER_BUILD_PROMPT.md` - `MASTER_BUILD_PROMPT.md`
- `Makefile` - `Makefile`
- `PROJECT_STATE.md` - `PROJECT_STATE.md`
- `README.md` - `README.md`
- `START_HERE.md` - `START_HERE.md`
- `backend/Dockerfile` - `backend/Dockerfile`
- `backend/alembic.ini`
- `backend/alembic/env.py`
- `backend/alembic/script.py.mako`
- `backend/alembic/versions/0a4c1d2e3f5b_idempotency_request_fingerprint.py`
- `backend/alembic/versions/799d8800e241_outbox_last_error_code.py`
- `backend/alembic/versions/a81d0ce9f662_oidc_identity.py`
- `backend/alembic/versions/b7c7b536df85_operational_user_credentials.py`
- `backend/alembic/versions/b913a72e8c14_customer_privacy_state.py`
- `backend/alembic/versions/c24f6a9d013e_domain_constraints_indexes.py`
- `backend/alembic/versions/c9498525abb5_initial_schema.py`
- `backend/alembic/versions/d1f83bc64170_revoked_sessions.py`
- `backend/alembic/versions/e7b08389f47f_idempotency_records.py`
- `backend/alembic/versions/f43d829ab610_quality_work_queue.py`
- `backend/app/__init__.py` - `backend/app/__init__.py`
- `backend/app/api/__init__.py`
- `backend/app/api/deps.py`
- `backend/app/api/routers/__init__.py`
- `backend/app/api/routers/audit.py`
- `backend/app/api/routers/auth.py`
- `backend/app/api/routers/bookings.py`
- `backend/app/api/routers/customers.py`
- `backend/app/api/routers/dashboard.py`
- `backend/app/api/routers/data_quality.py`
- `backend/app/api/routers/demo.py`
- `backend/app/api/routers/integration_status.py`
- `backend/app/api/routers/integrations.py`
- `backend/app/api/routers/knowledge.py`
- `backend/app/api/routers/mcp_integrations.py`
- `backend/app/api/routers/observability.py`
- `backend/app/api/routers/privacy.py`
- `backend/app/api/routers/search.py`
- `backend/app/api/routers/users.py`
- `backend/app/api/routers/vehicles.py`
- `backend/app/api/routers/workflows.py`
- `backend/app/cli.py`
- `backend/app/core/__init__.py` - `backend/app/core/__init__.py`
- `backend/app/core/config.py` - `backend/app/core/config.py`
- `backend/app/core/db.py`
- `backend/app/core/errors.py`
- `backend/app/core/observability.py`
- `backend/app/core/ratelimit.py`
- `backend/app/core/security.py`
- `backend/app/main.py` - `backend/app/main.py`
- `backend/app/models/__init__.py`
- `backend/app/models/audit.py`
- `backend/app/models/booking.py`
- `backend/app/models/customer.py`
- `backend/app/models/data_quality.py`
- `backend/app/models/idempotency.py`
- `backend/app/models/inspection.py`
- `backend/app/models/maintenance.py`
- `backend/app/models/mixins.py`
- `backend/app/models/outbox.py`
- `backend/app/models/revoked_session.py`
- `backend/app/models/user.py`
- `backend/app/models/vehicle.py`
- `backend/app/schemas.py`
- `backend/app/seed_loader.py`
- `backend/app/services/__init__.py`
- `backend/app/services/audit.py`
- `backend/app/services/data_quality.py`
- `backend/app/services/demo_manifest.py`
- `backend/app/services/dispatcher.py`
- `backend/app/services/integration_status.py`
- `backend/app/services/knowledge/__init__.py`
- `backend/app/services/knowledge/demo.py`
- `backend/app/services/knowledge/procedures.py`
- `backend/app/services/knowledge/ragcore.py`
- `backend/app/services/operations.py`
- `backend/app/services/returns.py`
- `backend/app/services/sessions.py`
- `backend/app/services/vehicle_status.py`
- `backend/entrypoint.sh`
- `backend/pyproject.toml` - `backend/pyproject.toml`
- `backend/requirements.lock`
- `backend/scripts/generate_openapi.py`
- `backend/tests/conftest.py`
- `backend/tests/test_audit.py`
- `backend/tests/test_auth.py`
- `backend/tests/test_bookings.py`
- `backend/tests/test_dashboard.py`
- `backend/tests/test_data_quality.py`
- `backend/tests/test_database_constraints.py`
- `backend/tests/test_demo_manifest.py`
- `backend/tests/test_dispatcher.py`
- `backend/tests/test_hardening.py`
- `backend/tests/test_health.py` - `backend/tests/test_health.py`
- `backend/tests/test_integration_status.py`
- `backend/tests/test_integrations.py`
- `backend/tests/test_knowledge.py`
- `backend/tests/test_mcp_integrations.py`
- `backend/tests/test_migrations.py`
- `backend/tests/test_observability.py`
- `backend/tests/test_operational_auth.py`
- `backend/tests/test_privacy.py`
- `backend/tests/test_return.py`
- `backend/tests/test_search.py`
- `backend/tests/test_seed.py`
- `backend/tests/test_users.py`
- `backend/tests/test_vehicle_status.py`
- `backend/tests/test_vehicles.py`
- `backend/tests/test_workflows.py`
- `compose.observability.yaml`
- `compose.test.yaml`
- `compose.unraid.yaml`
- `compose.yaml` - `compose.yaml`
- `contracts/events.schema.json` - `contracts/events.schema.json`
- `contracts/mcp-tools.json` - `contracts/mcp-tools.json`
- `contracts/openapi.yaml` - `contracts/openapi.yaml`
- `contracts/ragcore-contract-assumptions.md` - `contracts/ragcore-contract-assumptions.md`
- `deploy/observability/alerts.yml`
- `deploy/observability/grafana/dashboards/mobilityops-overview.json`
- `deploy/observability/grafana/provisioning/dashboards/mobilityops.yml`
- `deploy/observability/grafana/provisioning/datasources/prometheus.yml`
- `deploy/observability/prometheus.yml`
- `deploy/unraid/README.md`
- `deploy/unraid/backup-postgres.sh`
- `deploy/unraid/configure-env.sh`
- `deploy/unraid/prune-postgres-backups.sh`
- `deploy/unraid/restore-postgres.sh`
- `deploy/unraid/scheduled-backup.sh`
- `deploy/unraid/setup-existing-n8n.sh`
- `deploy/unraid/setup-n8n.sh`
- `deploy/unraid/setup-scheduled-scan.sh`
- `deploy/unraid/verify-postgres-backups.sh`
- `docs/00-product-brief.md` - `docs/00-product-brief.md`
- `docs/01-scope-and-non-goals.md` - `docs/01-scope-and-non-goals.md`
- `docs/02-user-stories.md` - `docs/02-user-stories.md`
@@ -38,29 +160,203 @@
- `docs/15-build-plan.md` - `docs/15-build-plan.md`
- `docs/16-portfolio-case-study.md` - `docs/16-portfolio-case-study.md`
- `docs/17-runbook.md` - `docs/17-runbook.md`
- `docs/18-privacy-governance.md`
- `docs/19-visual-product-roadmap.md`
- `docs/deferred.md` - `docs/deferred.md`
- `docs/demo-release/current-demo-gap-audit.md`
- `docs/demo-release/demo-concept.md`
- `docs/demo-release/demo-data.md`
- `docs/demo-release/demo-guide.md`
- `docs/demo-release/demo-runbook.md`
- `docs/demo-release/demo-scenarios.md`
- `docs/design/current-ux-audit.md`
- `docs/design/design-directions.md`
- `docs/design/design-system.md`
- `docs/design/implementation-validation.md`
- `docs/design/stitch-manifest.md`
- `docs/final-integrations/ai-operations-brief-runbook.md`
- `docs/final-integrations/current-state-audit.md`
- `docs/final-product-polish/audit.md`
- `docs/fleet-ops-correction/current-gap-audit.md`
- `docs/fleet-ops-correction/i18n-inventory.md`
- `docs/fleet-ops-correction/vehicle-status-decision-table.md`
- `docs/fleet-ops-final-localization/audit.md`
- `docs/functional-completion/current-functional-audit.md`
- `docs/functional-completion/server-baseline.md`
- `docs/live-ai-integration/n8n-current-state.md`
- `frontend/Dockerfile` - `frontend/Dockerfile`
- `frontend/e2e/_capture-demo-screenshots.spec.ts`
- `frontend/e2e/_capture-recruiter-screenshots.spec.ts`
- `frontend/e2e/_capture-screenshots.spec.ts`
- `frontend/e2e/clickable-rows.spec.ts`
- `frontend/e2e/demo-accessibility.spec.ts`
- `frontend/e2e/demo-entry.spec.ts`
- `frontend/e2e/demo-guide.spec.ts`
- `frontend/e2e/demo-legibility.spec.ts`
- `frontend/e2e/demo.spec.ts`
- `frontend/e2e/error-messages.spec.ts`
- `frontend/e2e/fleet-ops-correction.spec.ts`
- `frontend/e2e/greeting-live.spec.ts`
- `frontend/e2e/greeting.spec.ts`
- `frontend/e2e/guided-demo-full.spec.ts`
- `frontend/e2e/i18n-coverage.spec.ts`
- `frontend/e2e/interactive-elements.spec.ts`
- `frontend/e2e/operational-workflows.spec.ts`
- `frontend/e2e/privacy.spec.ts`
- `frontend/e2e/recruiter-polish.spec.ts`
- `frontend/e2e/responsive-i18n.spec.ts`
- `frontend/e2e/roadmap-regression.spec.ts`
- `frontend/e2e/ui-redesign.spec.ts`
- `frontend/index.html` - `frontend/index.html`
- `frontend/nginx.conf` - `frontend/nginx.conf`
- `frontend/package-lock.json`
- `frontend/package.json` - `frontend/package.json`
- `frontend/playwright.config.ts`
- `frontend/public/favicon.svg`
- `frontend/public/og-fleet-ops.svg`
- `frontend/src/App.tsx` - `frontend/src/App.tsx`
- `frontend/src/api/apiError.ts`
- `frontend/src/api/client.ts`
- `frontend/src/api/errorMessages.ts`
- `frontend/src/api/types.ts`
- `frontend/src/components/Badge.tsx`
- `frontend/src/components/CheckoutForm.tsx`
- `frontend/src/components/DemoBadge.tsx`
- `frontend/src/components/DemoGuide.tsx`
- `frontend/src/components/Icons.tsx`
- `frontend/src/components/LanguageSwitcher.tsx`
- `frontend/src/components/Layout.tsx`
- `frontend/src/components/PageChrome.tsx`
- `frontend/src/components/Pagination.tsx`
- `frontend/src/components/RequireAuth.tsx`
- `frontend/src/components/ReturnForm.tsx`
- `frontend/src/components/VehicleMaintenanceActions.tsx`
- `frontend/src/context/AuthContext.tsx`
- `frontend/src/context/DemoGuideContext.tsx`
- `frontend/src/context/DemoManifestContext.tsx`
- `frontend/src/data/demoGuideSteps.ts`
- `frontend/src/data/evidenceSignals.ts`
- `frontend/src/data/integrationLabels.ts`
- `frontend/src/hooks/useViewportTier.ts`
- `frontend/src/i18n/brusselsDateTime.ts`
- `frontend/src/i18n/config.ts`
- `frontend/src/i18n/format.ts`
- `frontend/src/i18n/greeting.ts`
- `frontend/src/i18n/locales/en-GB/accessibility.json`
- `frontend/src/i18n/locales/en-GB/audit.json`
- `frontend/src/i18n/locales/en-GB/auth.json`
- `frontend/src/i18n/locales/en-GB/bookings.json`
- `frontend/src/i18n/locales/en-GB/common.json`
- `frontend/src/i18n/locales/en-GB/dashboard.json`
- `frontend/src/i18n/locales/en-GB/demo.json`
- `frontend/src/i18n/locales/en-GB/errors.json`
- `frontend/src/i18n/locales/en-GB/fleet.json`
- `frontend/src/i18n/locales/en-GB/integrations.json`
- `frontend/src/i18n/locales/en-GB/knowledge.json`
- `frontend/src/i18n/locales/en-GB/navigation.json`
- `frontend/src/i18n/locales/en-GB/operations.json`
- `frontend/src/i18n/locales/en-GB/privacy.json`
- `frontend/src/i18n/locales/en-GB/quality.json`
- `frontend/src/i18n/locales/en-GB/returns.json`
- `frontend/src/i18n/locales/fr-BE/accessibility.json`
- `frontend/src/i18n/locales/fr-BE/audit.json`
- `frontend/src/i18n/locales/fr-BE/auth.json`
- `frontend/src/i18n/locales/fr-BE/bookings.json`
- `frontend/src/i18n/locales/fr-BE/common.json`
- `frontend/src/i18n/locales/fr-BE/dashboard.json`
- `frontend/src/i18n/locales/fr-BE/demo.json`
- `frontend/src/i18n/locales/fr-BE/errors.json`
- `frontend/src/i18n/locales/fr-BE/fleet.json`
- `frontend/src/i18n/locales/fr-BE/integrations.json`
- `frontend/src/i18n/locales/fr-BE/knowledge.json`
- `frontend/src/i18n/locales/fr-BE/navigation.json`
- `frontend/src/i18n/locales/fr-BE/operations.json`
- `frontend/src/i18n/locales/fr-BE/privacy.json`
- `frontend/src/i18n/locales/fr-BE/quality.json`
- `frontend/src/i18n/locales/fr-BE/returns.json`
- `frontend/src/i18n/locales/nl-BE/accessibility.json`
- `frontend/src/i18n/locales/nl-BE/audit.json`
- `frontend/src/i18n/locales/nl-BE/auth.json`
- `frontend/src/i18n/locales/nl-BE/bookings.json`
- `frontend/src/i18n/locales/nl-BE/common.json`
- `frontend/src/i18n/locales/nl-BE/dashboard.json`
- `frontend/src/i18n/locales/nl-BE/demo.json`
- `frontend/src/i18n/locales/nl-BE/errors.json`
- `frontend/src/i18n/locales/nl-BE/fleet.json`
- `frontend/src/i18n/locales/nl-BE/integrations.json`
- `frontend/src/i18n/locales/nl-BE/knowledge.json`
- `frontend/src/i18n/locales/nl-BE/navigation.json`
- `frontend/src/i18n/locales/nl-BE/operations.json`
- `frontend/src/i18n/locales/nl-BE/privacy.json`
- `frontend/src/i18n/locales/nl-BE/quality.json`
- `frontend/src/i18n/locales/nl-BE/returns.json`
- `frontend/src/i18n/useGreetingPeriod.ts`
- `frontend/src/main.tsx` - `frontend/src/main.tsx`
- `frontend/src/pages/AboutDemo.tsx`
- `frontend/src/pages/Audit.tsx`
- `frontend/src/pages/Automation.tsx`
- `frontend/src/pages/BookingCreate.tsx`
- `frontend/src/pages/BookingDetail.tsx`
- `frontend/src/pages/Bookings.tsx`
- `frontend/src/pages/Dashboard.tsx`
- `frontend/src/pages/DataQuality.tsx`
- `frontend/src/pages/DataQualityIssueDetail.tsx`
- `frontend/src/pages/Highlights.tsx`
- `frontend/src/pages/Knowledge.tsx`
- `frontend/src/pages/Login.tsx`
- `frontend/src/pages/Privacy.tsx`
- `frontend/src/pages/Scenarios.tsx`
- `frontend/src/pages/Users.tsx`
- `frontend/src/pages/VehicleDetail.tsx`
- `frontend/src/pages/Vehicles.tsx`
- `frontend/src/product.ts`
- `frontend/src/styles.css` - `frontend/src/styles.css`
- `frontend/src/vite-env.d.ts`
- `frontend/tsconfig.json` - `frontend/tsconfig.json`
- `frontend/vite.config.ts` - `frontend/vite.config.ts`
- `knowledge/manifest.json` - `knowledge/manifest.json`
- `knowledge/procedures/01-vehicle-checkout.md` - `knowledge/procedures/en-GB/01-vehicle-checkout.md`
- `knowledge/procedures/02-vehicle-return.md` - `knowledge/procedures/en-GB/02-vehicle-return.md`
- `knowledge/procedures/03-damage-handling.md` - `knowledge/procedures/en-GB/03-damage-handling.md`
- `knowledge/procedures/04-odometer-anomalies.md` - `knowledge/procedures/en-GB/04-odometer-anomalies.md`
- `knowledge/procedures/05-cleaning-checklist.md` - `knowledge/procedures/en-GB/05-cleaning-checklist.md`
- `knowledge/procedures/06-maintenance-escalation.md` - `knowledge/procedures/en-GB/06-maintenance-escalation.md`
- `knowledge/procedures/07-customer-documents.md` - `knowledge/procedures/en-GB/07-customer-documents.md`
- `knowledge/procedures/08-privacy.md` - `knowledge/procedures/en-GB/08-privacy.md`
- `knowledge/procedures/09-booking-conflicts.md` - `knowledge/procedures/en-GB/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md` - `knowledge/procedures/en-GB/10-roles-and-escalation.md`
- `knowledge/procedures/en-GB/11-vehicle-availability.md`
- `knowledge/procedures/fr-BE/01-vehicle-checkout.md`
- `knowledge/procedures/fr-BE/02-vehicle-return.md`
- `knowledge/procedures/fr-BE/03-damage-handling.md`
- `knowledge/procedures/fr-BE/04-odometer-anomalies.md`
- `knowledge/procedures/fr-BE/05-cleaning-checklist.md`
- `knowledge/procedures/fr-BE/06-maintenance-escalation.md`
- `knowledge/procedures/fr-BE/07-customer-documents.md`
- `knowledge/procedures/fr-BE/08-privacy.md`
- `knowledge/procedures/fr-BE/09-booking-conflicts.md`
- `knowledge/procedures/fr-BE/10-roles-and-escalation.md`
- `knowledge/procedures/fr-BE/11-vehicle-availability.md`
- `knowledge/procedures/nl-BE/01-vehicle-checkout.md`
- `knowledge/procedures/nl-BE/02-vehicle-return.md`
- `knowledge/procedures/nl-BE/03-damage-handling.md`
- `knowledge/procedures/nl-BE/04-odometer-anomalies.md`
- `knowledge/procedures/nl-BE/05-cleaning-checklist.md`
- `knowledge/procedures/nl-BE/06-maintenance-escalation.md`
- `knowledge/procedures/nl-BE/07-customer-documents.md`
- `knowledge/procedures/nl-BE/08-privacy.md`
- `knowledge/procedures/nl-BE/09-booking-conflicts.md`
- `knowledge/procedures/nl-BE/10-roles-and-escalation.md`
- `knowledge/procedures/nl-BE/11-vehicle-availability.md`
- `n8n/README.md` - `n8n/README.md`
- `n8n/mobilityops-return-processing.json` - `n8n/workflows/MANIFEST.md`
- `n8n/workflows/check_drift.py`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/fleet-ops-error-handler.json`
- `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/merge_credential_refs.py`
- `scripts/run-isolated-tests.sh`
- `seed/README.md` - `seed/README.md`
- `seed/bookings.csv` - `seed/bookings.csv`
- `seed/customers.csv` - `seed/customers.csv`
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jens Caers
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20 -8
View File
@@ -1,4 +1,4 @@
.PHONY: up down logs test lint seed reset n8n-setup n8n-setup-scan demo e2e .PHONY: up down logs test lint contracts seed reset n8n-setup n8n-setup-scan demo e2e live-smoke
up: up:
docker compose up --build -d docker compose up --build -d
@@ -10,11 +10,17 @@ logs:
docker compose logs -f --tail=200 docker compose logs -f --tail=200
test: test:
docker compose run --rm api pytest sh scripts/run-isolated-tests.sh
lint: lint:
docker compose run --rm api ruff check . docker compose -f compose.yaml -f compose.test.yaml run --build --rm api ruff check app tests
docker compose run --rm api mypy app docker compose -f compose.yaml -f compose.test.yaml run --rm api mypy app
cd frontend && npm run lint
contracts:
docker compose -f compose.yaml -f compose.test.yaml run --build --rm \
-v "$(CURDIR):/repo:ro" api python /repo/scripts/check-contracts.py
python scripts/check-source-budgets.py
seed: seed:
docker compose exec api python -m app.cli seed --reset docker compose exec api python -m app.cli seed --reset
@@ -26,16 +32,19 @@ reset:
# One-time per environment: imports and activates the n8n return-processing workflow. # One-time per environment: imports and activates the n8n return-processing workflow.
# The n8n owner account itself cannot be scripted safely and must be created once at # The n8n owner account itself cannot be scripted safely and must be created once at
# http://localhost:5678/setup (any email/password, no verification required) before # http://localhost:5678/setup (any email/password, no verification required) before
# this target's activation takes effect. See docs/17-runbook.md. # this target's activation takes effect. The workflow also needs the "Fleet Ops Webhook
# Trigger Token" and "Fleet Ops Service Token" Header Auth credentials created manually in
# the n8n UI before it will actually process a return -- see docs/17-runbook.md.
n8n-setup: n8n-setup:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-return-processing.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-vehicle-return.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing docker compose exec n8n n8n publish:workflow --id=mobilityops-return-processing
docker compose restart n8n docker compose restart n8n
# One-time per environment: imports and activates the scheduled quality-scan workflow. # One-time per environment: imports and activates the scheduled quality-scan workflow.
# Same owner-account precondition as n8n-setup above. # Same owner-account and credential preconditions as n8n-setup above (this workflow only
# needs "Fleet Ops Service Token").
n8n-setup-scan: n8n-setup-scan:
docker compose exec n8n n8n import:workflow --input=//imports/mobilityops-scheduled-quality-scan.json docker compose exec n8n n8n import:workflow --input=//imports/workflows/fleet-ops-data-quality-scan.json
docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan docker compose exec n8n n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker compose restart n8n docker compose restart n8n
@@ -45,3 +54,6 @@ demo: up
e2e: e2e:
cd frontend && npx playwright test cd frontend && npx playwright test
live-smoke:
cd frontend && npx playwright test --config=playwright.live.config.ts
+2384 -2
View File
File diff suppressed because it is too large Load Diff
+65 -92
View File
@@ -1,122 +1,95 @@
# MobilityOps # Fleet Ops
**Connected operations for vehicle rental and service teams.** **A recruiter-ready operations platform for vehicle rental and service teams.**
MobilityOps is a working proof of concept for a fictitious mobility company. It combines vehicle and booking operations, a controlled vehicle-return workflow, data-quality review, RAGcore-backed internal knowledge, n8n orchestration and read-only tools published through ITWorx MCP Hub. **Try it in two commands** (`cp .env.example .env && make demo`, then open `http://localhost:1228`) · no password required · choose **Highlights in 90 seconds** for the shortest tour. The reference deployment runs on a private LAN (see [deploy/unraid/README.md](deploy/unraid/README.md)); ask for a link if you want the hosted version.
The web application uses the premium responsive **Control Rail** interface: a compact Fleet Ops turns fragmented vehicle, booking and procedure data into one controlled operational workspace. It is a complete synthetic-data product demo: the company and records are fictional, while the workflows, persistence, validation, authorization, audit trail and integration boundaries are implemented.
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
review-before-commit return handling and mobile navigation designed down to 390 px. See
`docs/design/design-directions.md` and `docs/design/implementation-validation.md` for the
design decision and visual evidence.
All people, companies, vehicles, bookings and documents are synthetic. The workflows, validation, integrations, audit logging and access boundaries are intended to be real. ![Fleet Ops engineering overview](frontend/public/og-fleet-ops.svg)
## Demo ## The 90-second tour
The demo presents itself as **Northstar Mobility**, a fictitious Belgian camper/van 1. Open **Highlights** from the login screen.
rental company — the login screen, a permanent "Synthetische demo" indicator, an in-app 2. Follow a vehicle return from review to atomic commit, quality issue, outbox and correlated audit trace.
guided tour (Demo Guide), a curated `/scenarios` overview, and an "Over deze demo" page 3. Compare and merge a duplicate customer with explicit human confirmation.
all make the fictional context, synthetic-data status, and real-vs-simulated boundaries 4. Ask the Knowledge Hub a damage question and inspect its cited procedure evidence.
explicit without any verbal explanation. See `docs/demo-release/` for the full demo 5. Open **Engineering** for the architecture, reliability guarantees, test evidence and honest scope boundary.
concept, the five named scenarios, the seed/date-anchoring strategy, the guided-tour
design, and the operational runbook (5-minute and 10-minute demo flows, reset, redeploy,
rollback).
## Scope ## What makes it more than a mock-up
The PoC implements: - **Transactional operations:** a return writes the inspection, vehicle/booking state, audit events and outbox record atomically. n8n downtime never rolls back the local business transaction.
- **Explainable data quality:** five persisted rule types, SLA deadlines, assignment, bulk queue controls and bounded resolution flows—not decorative warning cards.
- **Grounded knowledge:** the live deployment uses RAGcore; insufficient or unavailable evidence produces no invented answer. Citations and provider provenance remain inspectable.
- **Safe AI exposure:** four tenant-bound, service-authenticated, read-only Fleet Ops tools are published through ITWorx MCP Hub and audited with correlation IDs.
- **Operational reliability:** bounded retries, delivery leases, health/readiness, Prometheus metrics, Grafana, scheduled verified backups and graceful external-dependency degradation.
- **Real product ergonomics:** nl-BE, en-GB and fr-BE; responsive from 360 px; keyboard-accessible navigation; role-aware global search; route-level lazy loading; server-enforced permissions.
- operations dashboard with a truthful aggregate n8n/MCP integration-status card; ## Architecture
- vehicle and booking views with working search, filters and pagination;
- server-backed session lifecycle (refresh-safe, central 401 handling);
- a role matrix enforced server-side and mirrored in the UI (see
`docs/12-security-and-audit.md`);
- vehicle return capture → authoritative server-evaluated review → commit → result;
- five deterministic data-quality checks, each with a bounded resolution flow, plus a
manual scan action;
- human review and customer merge;
- audit trail with human-readable before/after evidence and safe entity links;
- role-aware global search across vehicles, bookings and (Operations Manager) issues;
- safe, confirmed demo reset;
- RAGcore-backed knowledge assistant with citations;
- two n8n workflows: return processing, and a scheduled data-quality scan with
crash-recoverable outbox delivery leases;
- four read-only MCP tools through ITWorx MCP Hub;
- deterministic demo reset and five-minute showcase.
It is not an ERP, CRM, accounting package, public booking site, payment system or autonomous agent. ```mermaid
flowchart LR
UI["React + TypeScript\nresponsive operations UI"] -->|session cookie| API["FastAPI\nbusiness rules + RBAC"]
API --> DB[(PostgreSQL)]
API -->|grounded retrieval| RAG[RAGcore]
DB --> OUT["Transactional outbox"]
OUT -->|bounded retry| N8N["Existing central n8n"]
N8N -->|authenticated callback| API
HUB["ITWorx MCP Hub"] -->|4 read-only tools| API
```
## Integration status Fleet Ops owns operational truth. RAGcore owns retrieval, n8n performs post-commit orchestration, and MCP Hub owns tool transport/publication. Neither RAGcore nor MCP Hub accesses the Fleet Ops database directly. See [the as-built architecture](artifacts/evidence/architecture.md).
- **n8n**: fully implemented and verified against a real n8n instance, including ## Demonstrable scope
degraded mode (n8n stopped mid-flow → return still commits, event stays `pending`
with backoff, self-heals once n8n returns), the failed-delivery manual-retry path,
stale-delivery-lease recovery after a simulated crash, and a second (scheduled
quality-scan) workflow live-verified end to end against a real n8n instance.
`GET /api/v1/integrations/status` reports a truthful aggregate state from outbox
delivery counts, not just the most recent event.
- **RAGcore**: the demo `KnowledgeProvider` (deterministic TF-IDF extractive retrieval
over the local procedure documents) is what satisfies the knowledge-assistant
acceptance criteria and is fully verified. A `RAGcoreKnowledgeProvider` HTTP adapter is
implemented and unit-tested, including its unavailable-degradation path, but was never
exercised against a live RAGcore instance in this environment.
- **ITWorx MCP Hub**: the four read-only provider endpoints are implemented, tested, and
directly `curl`-verified with correct auth enforcement and audit logging.
`MCP_HUB_REGISTRATION_ENABLED` is now actually wired into `Settings` (it was previously
declared in `.env.example` but silently dropped) and reported honestly by the
integration-status endpoint. No live Hub instance was reachable in this environment to
verify an actual Hub round trip.
See `artifacts/functional-completion/final-summary.md` for the functional-completion - dashboard, vehicle fleet, booking lifecycle and controlled returns;
audit evidence (supersedes the design-validation summary below for integration status), - data-quality queue, assignment, review, merge and resolution;
and `artifacts/final-acceptance/summary.md` for the original M0M7 acceptance evidence. - correlated human-readable audit history;
- cited Knowledge Hub with honest provider state;
- n8n delivery monitoring and manual retry;
- user administration, privacy export/anonymisation and retention guards;
- deterministic reset with 2 users, 180 customers, 50 vehicles, 254 bookings, 75 inspections, 40 maintenance records, 33 quality issues and 20 workflow runs.
## Repository map This is deliberately not accounting, payments, a public reservation site, generic CRM, inventory, HR or an autonomous write agent.
- `CLAUDE.md` — binding implementation rules. ## Stack
- `MASTER_BUILD_PROMPT.md` — prompt to start an autonomous Claude run.
- `PROJECT_STATE.md` — short persistent project memory.
- `docs/` — product, architecture, UX and acceptance specification.
- `contracts/` — OpenAPI, event and MCP contracts.
- `knowledge/` — fictitious source documents for the MobilityOps RAGcore workspace.
- `seed/` — deterministic synthetic dataset and generator.
- `n8n/` — importable workflow definitions.
- `backend/` — FastAPI/SQLAlchemy/Alembic API.
- `frontend/` — React/TypeScript/Vite web app, including the Playwright end-to-end suite (`frontend/e2e/`).
- `artifacts/evidence/` — final acceptance evidence (screenshots, architecture, `final-summary.md`).
- `artifacts/design-validation/` — baseline audit, Stitch direction references and implemented responsive captures.
- `docs/functional-completion/` — the functional-completion audit and pre-work server baseline.
- `artifacts/functional-completion/` — functional-completion acceptance evidence.
- `docs/demo-release/` — demo concept, scenarios, seed/date-anchoring strategy, guided
tour, and runbook.
- `artifacts/demo-release/` — demo-productization acceptance evidence.
## Quickstart React, TypeScript, Vite, FastAPI, SQLAlchemy 2, PostgreSQL, Alembic, n8n, RAGcore, ITWorx MCP Hub, Docker Compose, Prometheus, Grafana and Playwright.
## Run locally
```bash ```bash
cp .env.example .env cp .env.example .env
make demo make demo
``` ```
This builds and starts the full stack (migrations run automatically) and loads the
deterministic demo dataset. See `docs/17-runbook.md` for the one-time n8n workflow setup
required for the automation demo, and the full operational runbook.
Endpoints:
- Web: `http://localhost:1228` - Web: `http://localhost:1228`
- API health: `http://localhost:8128/health` - API readiness: `http://localhost:8128/health/ready`
- n8n: `http://localhost:5678` - Existing n8n server: point `N8N_WEBHOOK_URL` at its return-processing webhook (see `.env.example`). The bundled `n8n` service in `compose.yaml` is a local fallback only; production reuses the server's central n8n (`compose.unraid.yaml` disables the bundled one).
All defaults are configurable via `.env` (see `.env.example`). The deterministic local knowledge provider supports clean-checkout acceptance without pretending to be the live RAGcore integration. Configuration is documented in `.env.example`; operations and recovery are in [docs/17-runbook.md](docs/17-runbook.md).
## Quality gates ## Quality gates
```bash ```bash
make test # backend: pytest (127 tests) make test # isolated PostgreSQL backend suite
make lint # backend: ruff + mypy (strict, zero errors) make lint # Ruff + strict mypy
make e2e # frontend: Playwright end-to-end (56 tests, live stack required) make e2e # complete Playwright browser acceptance
cd frontend && npm run build
python scripts/run-readonly-load-smoke.py # while the demo stack is running
``` ```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`). Release-scoped results and production evidence are recorded in [artifacts/final-acceptance/summary.md](artifacts/final-acceptance/summary.md); older milestone evidence remains explicitly historical. [PROJECT_STATE.md](PROJECT_STATE.md) records the commands and exact deployment revision.
## Repository map
- `backend/` — FastAPI domain, API, migrations and tests
- `frontend/` — React app and Playwright acceptance suite
- `contracts/` — OpenAPI, event and MCP contracts
- `knowledge/` — versioned fictional procedures
- `n8n/` — importable workflow definitions for the existing server
- `seed/` — deterministic synthetic dataset
- `docs/` — architecture, security, UX, testing and runbooks
- `artifacts/` — dated, release-scoped acceptance evidence and screenshots
“MobilityOps” remains the repository/deployment identifier; **Fleet Ops** is the product name shown to users.
+56
View File
@@ -0,0 +1,56 @@
# Security Policy
## Supported versions
| Version | Security support |
|---|---|
| Latest tagged PoC release and current `master` | Supported |
| Older commits, branches and untagged deployments | Not supported |
MobilityOps is a synthetic-data proof of concept, not a production identity,
payments or public reservation platform. Security fixes target the current
release line only.
## Reporting a vulnerability
Do not disclose suspected vulnerabilities through a public issue.
Report them privately to `jens@itworx.tech` with:
- the affected revision, endpoint or component;
- reproduction steps and prerequisites;
- the observed and expected behaviour;
- the security impact;
- a minimal proof of concept, without unnecessary personal or secret data.
Receipt should be acknowledged within three business days. An initial
assessment or request for additional evidence should follow within ten
business days. Remediation timing depends on severity and reproducibility.
## Scope
In scope:
- MobilityOps backend, frontend, container and deployment code;
- authentication, authorization, tenant boundaries and audit integrity;
- database, outbox, backup and restore behaviour;
- MobilityOps-owned n8n workflow definitions;
- RAGcore and MCP Hub integration boundaries implemented in this repository.
Out of scope:
- denial-of-service or destructive testing against the hosted demo;
- social engineering, credential stuffing or physical attacks;
- synthetic demo-data exposure without a security-boundary failure;
- vulnerabilities solely inside RAGcore, ITWorx MCP Hub, n8n or another
third-party service. Report those to their respective owners.
Do not access data beyond what is required to demonstrate the issue, modify
shared infrastructure, interrupt other services or retain obtained secrets.
## Coordinated disclosure
Good-faith research that respects this policy and applicable law will be
handled constructively. Allow a reasonable remediation period before public
disclosure. Submitted reports and evidence are used only for investigation,
remediation and verification.
+1 -1
View File
@@ -18,7 +18,7 @@ Claude must use `PROJECT_STATE.md` as its compact memory between sessions. Do no
- architecture and domain decisions; - architecture and domain decisions;
- API and event contracts; - API and event contracts;
- realistic deterministic synthetic seed data; - realistic deterministic synthetic seed data;
- ten fictitious procedures for RAGcore; - eleven fictitious procedures for RAGcore (nl-BE, en-GB, fr-BE);
- an initial n8n workflow export; - an initial n8n workflow export;
- MCP tool definitions for ITWorx MCP Hub; - MCP tool definitions for ITWorx MCP Hub;
- a minimal bootable frontend/API scaffold; - a minimal bootable frontend/API scaffold;
+52 -47
View File
@@ -1,63 +1,68 @@
# MobilityOps — as-built architecture # Fleet Ops — as-built architecture
```mermaid ```mermaid
flowchart TB flowchart TB
subgraph Browser UI["Fleet Ops Web\nReact + TypeScript + Vite"]
UI["MobilityOps Web<br/>React + TypeScript"]
end
subgraph MobilityOps["MobilityOps (this repo)"] subgraph CORE["Fleet Ops this repository"]
API["FastAPI backend<br/>/api/v1/*"] API["FastAPI /api/v1\nRBAC + domain rules"]
DISPATCH["Outbox dispatcher<br/>background thread"] OUT["Outbox dispatcher\nleases + bounded retry"]
DB[(PostgreSQL)] DB[(PostgreSQL)]
OBS["Prometheus metrics\nGrafana dashboards"]
API --> DB API --> DB
DISPATCH --> DB OUT --> DB
API --> OBS
end end
subgraph External["External central services"] subgraph EXT["Existing external platforms"]
N8N["n8n<br/>return-processing workflow"] N8N["Central n8n\nsecondary orchestration"]
RAGDEMO["Demo KnowledgeProvider<br/>TF-IDF extractive, local files"] RAG["RAGcore\ngrounded procedure retrieval"]
RAGCORE["RAGcore<br/>(adapter built, no live instance)"] HUB["ITWorx MCP Hub\ntool transport + publication"]
HUB["ITWorx MCP Hub<br/>(endpoints built, no live instance)"]
end end
UI -->|session cookie| API UI -->|secure session cookie| API
API -->|GroundedAnswer| RAGDEMO API -->|tenant/workspace adapter| RAG
API -.->|configurable, unavailable-safe| RAGCORE OUT -->|vehicle.returned.v1| N8N
DISPATCH -->|POST vehicle.returned.v1| N8N N8N -->|service-authenticated callback| API
N8N -->|callback, X-Service-Token| API HUB -->|service-authenticated read-only tools| API
HUB -.->|X-Service-Token, read-only| API
classDef unverified stroke-dasharray: 5 5;
class RAGCORE,HUB unverified;
``` ```
Dashed boxes/arrows are implemented and unit/contract-tested but were never exercised ## Ownership and trust boundaries
against a live instance in this environment (no reachable RAGcore or ITWorx MCP Hub).
Solid boxes were verified end-to-end, including a real n8n instance.
## Component responsibility (unchanged from `docs/03-architecture.md`) | Component | Owns | Explicitly does not own |
|---|---|---|
| Fleet Ops | vehicles, customers, bookings, inspections, quality issues, audit, permissions, outbox state | external workflow execution or procedure retrieval |
| RAGcore | indexing/retrieval and grounded procedure evidence | Fleet Ops database or business state |
| ITWorx MCP Hub | MCP transport, connector publication and central tool-call audit | Fleet Ops database or write actions |
| n8n | post-commit workflow orchestration | critical business rules or the source-of-truth transaction |
| Component | Owns | ## End-to-end return trace
|---|---|
| MobilityOps | vehicles, customers, bookings, inspections, data-quality issues, audit, outbox/delivery state |
| RAGcore | procedure retrieval and grounded answers (demo provider substitutes locally) |
| ITWorx MCP Hub | MCP transport, tool publication, central tool-call audit |
| n8n | post-commit secondary orchestration only — never the source of truth for vehicle state |
## Reliability boundaries verified in this build ```mermaid
sequenceDiagram
actor Operator
participant Web
participant API
participant DB
participant n8n
Operator->>Web: Review and confirm return
Web->>API: POST return with idempotency key
API->>DB: Lock booking and validate invariants
API->>DB: Commit inspection, state, audit and outbox atomically
API-->>Web: Result + correlation ID
Web-->>Operator: Human result and full processing trace
API->>n8n: Deliver persisted outbox event
n8n->>API: Authenticated status callback
API->>DB: Persist delivery/audit evidence
```
1. **Return commits atomically with its outbox event**`app/services/returns.py`, one ## Verified reliability properties
transaction; verified by `test_concurrent_returns_only_one_succeeds` (real Postgres row
locking, not mocked). 1. Concurrent returns serialize through PostgreSQL row locking; only one can commit.
2. **Outbox delivery is at-least-once, idempotent by event ID** — verified live: the n8n 2. Local return success is independent of n8n availability. Pending delivery remains persisted and retryable.
callback checks for an existing `AuditEvent` by event ID before recording a second time. 3. Outbox delivery is at-least-once and idempotent by event ID, with crash-recoverable leases and bounded backoff.
3. **RAGcore failure disables knowledge answers only**`RAGcoreKnowledgeProvider` degrades 4. RAGcore failure affects knowledge answers only. The UI reports unavailable/insufficient evidence and does not invent an answer.
to `unavailable`; the rest of the app is unaffected because the knowledge router is the 5. MCP endpoints are a separate tenant-bound, client-identity-validated, read-only surface; every call is audited with a correlation ID.
only consumer. 6. Browser authorization is enforced again on the API. Hiding a navigation item is never the security boundary.
4. **MCP Hub failure does not affect the web application** — the four MCP provider
endpoints are a separate authenticated surface (`X-Service-Token`), invisible to the The live deployment has exercised all three external boundaries. Local clean-checkout acceptance can use the deterministic extractive knowledge provider while reporting that mode honestly.
browser-facing API/UI.
5. **n8n failure leaves events pending with bounded retries** — verified live: a seeded
`failed` event, retried through the UI, was picked up by the background dispatcher and
delivered through the real n8n instance within one poll cycle.
+53 -156
View File
@@ -1,177 +1,74 @@
# MobilityOps — final acceptance evidence # Fleet Ops — final acceptance evidence
## Commit ## Accepted implementation
Built on top of commit `c5b7e21f81694f0339ad31e3bf044db952d0fbe0` (M6, "implement ITWorx - Deployment source marker: current `master`; the application image was built from runtime commit `cb7edb0` and the following commits contain acceptance/evidence only.
MCP Hub publication"). This evidence file and the rest of M7's polish are committed as - Live demo: `http://192.168.10.150:1236` — public passwordless synthetic demo.
`M7: portfolio polish and final acceptance` — run `git log --oneline` for the exact hash. - Deployment: Unraid `/mnt/user/appdata/mobilityops`, Compose project `mobilityops`.
- Database migration: `b913a72e8c14 (head)`.
- Product name: Fleet Ops; MobilityOps remains the technical repository/deployment ID.
## Exact commands (clean checkout) ## Clean acceptance — 2026-08-10
```bash ```text
git clone <repo> && cd MobilityOps Backend, isolated PostgreSQL: 241 passed, zero warnings
cp .env.example .env Playwright, fresh local build: 152 passed (5.4 min)
make demo # docker compose up --build -d ; migrations run automatically ; seed --reset Playwright, live production: 152 passed (4.7 min)
Ruff: clean
Mypy: clean across 58 source files
Frontend lint/build: passed
npm audit, full and prod: 0 vulnerabilities
Alembic: b913a72e8c14 (head)
``` ```
One-time n8n setup (see `docs/17-runbook.md` for full detail — this cannot be scripted The browser suite covers both roles, protected routes, booking/return/maintenance/user/privacy workflows, five data-quality resolution types, correlated audit, retryable orchestration, grounded knowledge in three languages, the complete guided demo, the recruiter highlights, keyboard behaviour and responsive layouts from 360 to 1440 px.
end-to-end because it requires a one-time owner account created through n8n's web UI):
```bash The provider matrix explicitly covers both valid title contracts: the deterministic provider uses Markdown frontmatter titles, while production RAGcore returns curated localized presentation titles. Both must include the language-specific source fragment. The focused live matrix passed 3/3, followed by the complete 152/152 green production run.
# open http://localhost:5678/setup in a browser, create any owner account
make n8n-setup ## Production hand-off state
The final reset completed immediately before the verified Hub call at `2026-08-10T19:16Z` and restored:
```text
users 2 · customers 180 · vehicles 50 · bookings 254 · inspections 75
maintenance 40 · data-quality issues 33 · workflow runs 20
scenario_integrity.all_ready = true (5/5 scenarios)
``` ```
Verification: API readiness is `ready`, PostgreSQL is `up`, and API, web, database, scheduled backup, Prometheus and Grafana are running healthy/current. No traceback or critical error appears in post-deployment API/backup logs.
```bash A pre-deployment custom-format backup was created and independently verified by SHA-256 plus `pg_restore --list`:
docker compose run --rm api pytest -q # 66 passed
docker compose run --rm api ruff check . # All checks passed
cd frontend && npm run build # clean tsc + vite build
cd frontend && npx playwright test # 1 passed (full 5-minute demo script)
```
## Test counts `/mnt/user/appdata/mobilityops/backups/postgres/mobilityops-20260810T185833Z.dump`
- **Backend**: 66 tests passing (`pytest`), 0 skipped, 0 failed. Ruff clean. Coverage by ## External integration evidence
area: seed determinism (2), auth/roles (4), dashboard (3), vehicles (4), bookings (3),
return workflow incl. real concurrent-submission test (9), data quality incl. S2/S4
scenarios (10), audit (2), n8n dispatcher incl. malformed-payload regression (6),
n8n callback idempotency (3), workflows/retry (4), knowledge incl. S6 scenario (7),
MCP provider endpoints (8), health (1).
- **Frontend**: `npm run build` — clean TypeScript + Vite build, zero errors.
- **End-to-end**: 1 Playwright test (`frontend/e2e/demo.spec.ts`) automating the full
documented 5-minute demo script (login → dashboard → S1 return → S2 merge → S6 knowledge
question → audit → 360px responsive check) — **passing** against the live stack.
## Screenshots of the seven main pages - **Existing central n8n:** configured, dispatch enabled and operational. All four expected workflows have recent healthy heartbeats. The reset state contains 19 successful runs, zero pending, zero unexpected failures and one explicitly synthetic failed-retry scenario.
- **RAGcore:** reachable and ready for tenant `northstar-mobility-demo`, workspace `mobilityops`, collection `internal-procedures`. Exact identity lookups independently confirm 11/11 active published documents in each of NL/EN/FR; the latest n8n report separately records 33 synchronized documents and zero failures. The canonical parsed-artifact hash is not misrepresented as a raw-source hash.
- **ITWorx MCP Hub:** registration is enabled, the Hub is reachable and Fleet Ops is **operational**. Three real `mobilityops.operations.summary` verification calls are audited under the Hub's tenant-bound client identity; the final short-lived bearer produced HTTP 200/JSON and HTTP 401 after immediate revocation. All exposed Fleet Ops tools remain read-only.
Captured live against the deterministic seed (`artifacts/evidence/screenshots/`, ## Recruiter and visual acceptance
via `frontend/e2e/_capture-screenshots.spec.ts`):
| # | Page | File | The public entry now offers a direct **90-second Highlights** route. It links three product actions to their engineering proof, while the **Engineering** workspace explains domain ownership, commit-before-orchestration, citation-bound AI, security and explicit scope.
|---|---|---|
| 1 | Login | `1-login.png` |
| 2 | Dashboard | `2-dashboard.png` |
| 3 | Vehicles | `3-vehicles.png` |
| 4 | Bookings | `4-bookings.png` |
| 5 | Data Quality | `5-data-quality.png` |
| 6 | Knowledge (grounded S6 answer) | `6-knowledge.png` |
| 7 | Automation | `7-automation.png` |
| — | Audit (bonus, 8th nav item) | `8-audit.png` |
| — | Dashboard at 360px (responsive proof) | `9-mobile-dashboard.png` |
## RAGcore evidence Interactive live inspection confirmed:
**Success (demo provider, the one actually satisfying acceptance in this environment)** - no horizontal overflow on Highlights or Engineering at desktop; automated coverage confirms 360/390 px mobile layouts;
S6 question against the real `/api/v1/knowledge/questions` endpoint: - clear hierarchy, consistent primary actions and readable proof cards;
- compact remaining-attention action (count badge, two-line label, small directional icon) without the former stretched empty panel;
- truthful live n8n/RAGcore/MCP labels after reset, including 11 verified RAG documents and the real Hub client/tool evidence;
- no dead links, placeholder numbers or unexplained raw data-quality references;
- complete synthetic-data disclosure on login and in the persistent shell.
```json Current evidence images are in `artifacts/evidence/screenshots/`:
{
"answer": "Per \"Vehicle return procedure\" (v2.0), section \"1. Register the return\": Open the active booking and record the ending odometer, fuel level, cleanliness, visible damage, technical warnings and relevant notes.",
"evidence_state": "grounded",
"sources": [
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "1. Register the return", "excerpt": "..."},
{"document_id": "vehicle-return-procedure", "title": "Vehicle return procedure", "version": "2.0", "section": "3. Determine next state", "excerpt": "..."},
{"document_id": "damage-procedure", "title": "Damage handling procedure", "version": "1.3", "section": "1. Immediate actions", "excerpt": "..."}
],
"provider": "demo",
"correlation_id": "b50094b7-1c84-4e39-9055-1dc03e8fd1f8"
}
```
**Unavailable (RAGcore adapter, live-demonstrated against an unreachable host)** - `1-login.png`
`KNOWLEDGE_PROVIDER=ragcore`, `RAGCORE_BASE_URL=http://ragcore-not-reachable:9999`: - `2-highlights.png`
- `3-engineering-story.png`
- `4-dashboard.png`
- `5-knowledge-evidence.png`
- `6-highlights-mobile.png`
``` ## Deliberate boundary
health: {'provider': 'ragcore', 'available': False, 'detail': 'RAGcore unavailable: ConnectError: ...', 'document_count': 0}
ask: {'answer': '', 'evidence_state': 'unavailable', 'sources': [], 'provider': 'ragcore', 'correlation_id': 'demo-correlation'}
```
No live RAGcore instance was reachable in this environment, so the adapter's actual This is a completed, production-shaped public demo—not claimed customer adoption and not a general ERP. Accounting, payments, public reservations, CRM, inventory, HR, a second RAG stack, a separate MCP server and autonomous write agents remain intentionally excluded. A real personal-data rollout would additionally require the adopting organisation's identity provider, retention approvals, secrets lifecycle, alert ownership and disaster-recovery governance.
request/response contract against a real RAGcore is unverified beyond this
degrade-safely behavior — see `contracts/ragcore-contract-assumptions.md` and
`PROJECT_STATE.md`'s M5 notes.
## n8n evidence
**Success** — a real return registered on `BK-DEMO-RETURN`, delivered through the actual
n8n instance (not mocked), confirmed via `GET /api/v1/workflows`:
```json
{"event_id": "aa5dfeee-90ca-452a-bdd1-0a0b6d3dd63f", "event_type": "vehicle.returned.v1", "aggregate_ref": "BK-DEMO-RETURN", "status": "succeeded", "attempts": 2, "last_error": null}
```
(`attempts: 2` because the first delivery attempt landed while n8n was mid-restart from
the one-time workflow-activation step — the dispatcher's backoff-and-retry handled it
without any manual intervention, which is itself evidence of the retry behavior working.)
**Retry (S5 scenario)** — seeded `BK-H-0020` (event `00000000-...-0020`), initially
`failed` after 3 attempts with `"Synthetic connection timeout to n8n"`:
1. Before: `{"status": "failed", "attempts": 3, "last_error": "Synthetic connection timeout to n8n"}`
2. Operations Manager clicks Retry on `/automation`.
3. Within one ~3s dispatcher poll cycle, delivered through the live n8n instance.
4. After: `{"status": "succeeded", "attempts": 4, "last_error": null}`
## MCP tool sample calls
All four provider endpoints, authenticated with `X-Service-Token`:
```
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/operations-summary
{"tenant":"northstar-mobility-demo","metrics":{"available":21,"rented":11,"cleaning":6,"maintenance":5,"blocked":7,"open_quality_issues":22,"pending_or_failed_workflows":1}}
$ curl -H "X-Service-Token: <token>" "http://localhost:8128/api/v1/integrations/mcp/attention-vehicles?minimum_severity=high&limit=3"
[{"vehicle_ref":"MO-016","severity":"high","rule_type":"booking_overlap",...},
{"vehicle_ref":"MO-016","severity":"high","rule_type":"vehicle_status_conflict",...},
{"vehicle_ref":"MO-031","severity":"high","rule_type":"missing_required_field",...}]
$ curl -H "X-Service-Token: <token>" http://localhost:8128/api/v1/integrations/mcp/vehicles/MO-016
{"public_ref":"MO-016","make":"Hymer","model":"Exsis","model_year":2021,"location":"Geel","operational_status":"available","odometer_km":30497,"next_service_km":40000,"open_quality_issue_count":2,"current_booking_ref":null}
$ curl -H "X-Service-Token: <token>" -X POST -d '{"question":"What must I do when a vehicle returns with damage?","max_sources":2}' http://localhost:8128/api/v1/integrations/mcp/search-knowledge
{"answer":"Per \"Vehicle return procedure\" ...","evidence_state":"grounded","sources":[...2 items...],"provider":"demo",...}
```
Auth verified: missing header → `422`; wrong token → `401`. All four calls confirmed
recorded in `GET /api/v1/audit?action=mcp_tool_request` with `actor_type: "service"`.
No live ITWorx MCP Hub instance was reachable in this environment — these are direct
calls to MobilityOps's own provider endpoints, not a Hub round trip.
## Known PoC limitations
- **RAGcore and ITWorx MCP Hub were never reachable in this build environment.** Both
integrations are implemented against best-effort/documented contracts and are
unit/contract-tested (including their failure-degradation paths), but neither was
verified against a real counterpart service. The demo `KnowledgeProvider` is what
actually satisfies the knowledge-assistant acceptance criteria here.
- **n8n requires a one-time manual owner-account setup** per fresh environment
(`docker compose down -v` wipes it) — this is a property of the n8n 2.x image itself
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
Documented precisely in `docs/17-runbook.md`; the workflow import/activation itself
*is* scripted (`make n8n-setup`).
- **Inspection public refs are a simple `count+1` sequence**, not gap-safe under true
concurrent writers — acceptable for this single-tenant demo, would need a DB sequence
for a multi-writer production system.
- **The five data-quality rules use simplified idempotency** — `(rule_type, entity_type,
entity_id)` while open, rather than the doc's literal evidence-fingerprint scheme — see
`PROJECT_STATE.md`'s M3 notes for the reasoning (the fingerprint scheme would have let
the scan double-report issues already present in the seeded CSV).
- **No production authentication** — demo login is an HMAC-signed session cookie tied to
two fixed seeded users, appropriate for a PoC, not a real identity provider.
## Portfolio wording (truthful)
MobilityOps is a working proof of concept, not a production system and not deployed for
any real company. All customers, vehicles, bookings, and documents are synthetic
(deterministically generated). The application logic it demonstrates is real: a
transactional vehicle-return workflow with idempotency and concurrency control tested
against real concurrent database transactions; five explainable, deterministic
data-quality rules with a working customer-merge UI; a background outbox dispatcher
verified end-to-end against a real n8n instance including failure/retry; a
TF-IDF-weighted extractive knowledge assistant that never fabricates answers; and four
read-only, audited, service-authenticated integration endpoints. RAGcore and the ITWorx
MCP Hub integrations are implemented and tested in isolation but were not verified
against live instances of those systems in this environment.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

+66 -254
View File
@@ -1,265 +1,77 @@
# MobilityOps — final acceptance audit summary # Fleet Ops release acceptance
This audit was run after M0M7 had already been implemented and committed, specifically This file is release-scoped evidence, not a timeless claim. Older evidence under
to independently re-verify the finished system end to end rather than trust the `artifacts/evidence/` is historical. Exact commands and production revisions are recorded
milestone-by-milestone build log. It found and fixed one real category of defect in `PROJECT_STATE.md`.
(`mypy` had never been run across the whole build) and confirmed everything else — every
user journey, every button/filter/form, both external-dependency degraded modes, secret
hygiene, and the clean-checkout path — works as documented.
## Final commit ## 2026-08-21 release candidate
This audit's fixes are committed as the commit immediately following - Backend: **271/271** tests passed against an isolated clean PostgreSQL database.
`108b5d04fc6f7c5ff9c47009032d6469df29cf3c` ("M7: portfolio polish and final acceptance"). - Browser acceptance: **155/155** Chromium tests passed in 6.0 minutes.
Run `git log -1 --format="%H %s"` for the exact hash. - Live-safe browser canary: **4/4** passed across Chromium and Firefox against the local
deployed stack; unlike the acceptance suite, it never resets or mutates demo records.
- Accessibility: the principal login, dashboard, data-quality, knowledge, automation and
audit routes have no automated critical/serious WCAG 2 A/AA/2.1 AA violations.
- Frontend: TypeScript, ESLint, production build, dependency audit and per-asset JS/CSS
budgets passed; committed visual baselines cover the public entry and engineering story.
- Contracts: committed OpenAPI, event schema, MCP tools and all five n8n definitions match
their code/manifest sources.
- Recovery: a custom-format PostgreSQL dump was restored into a disposable database; the
Alembic revision and non-zero canonical table counts matched the source database.
- Operations: Prometheus/Alertmanager configuration validation passed, including the
watchdog and authenticated n8n receiver route.
## Exact commands executed ## 2026-08-21 production verification
Clean-checkout drill (run twice during this audit, most recently against fully wiped - Immutable application revision `95c91797fa2c599443d69d9c96d83a85ee0711f7` was promoted
Docker volumes): from a checksum-verified source archive after a fresh production backup.
- Source revision and both OCI revision labels matched. Public readiness was green,
Alembic was at head, all health-gated services were healthy and persisted demo data was
retained without a deployment reset.
- Trivy found zero fixed HIGH/CRITICAL vulnerabilities in each exact production image.
- Prometheus successfully scraped the bearer-protected API target, Alertmanager carried
the active delivery watchdog, and the authenticated n8n alert receiver remained active.
- The final non-destructive HTTPS canary passed **4/4** across Chromium and Firefox,
including the core operator routes and a grounded answer from the real knowledge stack.
```bash ## 2026-08-21 resilience upgrade verification
git status # working tree clean before starting
docker compose down -v # wipe all volumes — genuinely clean state
cp .env.example .env
docker compose up --build -d # migrations run automatically (backend/entrypoint.sh)
docker compose exec api python -m app.cli seed --reset
docker compose run --rm api pytest -q
docker compose run --rm api ruff check .
docker compose run --rm api mypy app
cd frontend && npm run build
cd frontend && npx playwright test
```
n8n one-time setup (owner account via browser at `http://localhost:5678/setup`, then): - Immutable revision `00191e9b54ee6b961648a6e02abbb3a57957dba0` was promoted from a
checksum-verified archive after a verified production dump. A stable gateway now routes
to two revision-specific API and two web replicas; stateful services are no longer
restarted by routine application releases.
- Moving host port 1236 from the legacy web container to the gateway was a one-time
migration hand-off and produced 14 failures across 1,200 rapid probes. Future releases
do not move that port; their acceptance gate is the zero-error versioned gateway reload.
- The subsequent M50 release exercised that steady-state path: the gateway remained online,
atomically switched revision-specific API/web aliases and sustained **700/700** external
readiness probes without interruption. Post-promotion Chromium/Firefox acceptance passed
**4/4** and 360 concurrent authenticated reads had zero errors at p95 **116.4 ms**.
- The versioned gateway switch sustained **300/300** local rollout probes without an error.
Production's non-destructive Chromium/Firefox canary passed **4/4**, and 360 authenticated
concurrent reads returned zero errors at p95 **137.2 ms**.
- PostgreSQL, backup, Prometheus, Alertmanager, Grafana and the gateway all reported healthy;
Alembic was at head, the protected Prometheus target was present, and backup plus real
restore-drill evidence remained current.
- Trivy 0.74 found zero fixed HIGH/CRITICAL vulnerabilities in the exact production API,
web and gateway images. The separately built rclone/PostgreSQL backup-tools image is also
clean after rebuilding rclone 1.75.0 with Go 1.26.6.
- The OneDrive worker is deployed as an opt-in profile but is not represented as active:
it requires the owner's one-time interactive Microsoft OAuth authorization. Until that
happens, verified local backups remain the active recovery source.
```bash ## Evidence boundary
docker 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
```
Degraded-mode drills: The complete local suite uses the deterministic provider and an isolated database so it is
repeatable and safely destructive. Production verification is deliberately smaller and
non-destructive; it verifies the real RAGcore/MCP/n8n health surfaces without resetting the
shared demo. A successful local result is never presented as proof that an external service
was live. The production subsection is added only after the exact committed release is
deployed and observed.
```bash ## Remaining product boundary
docker compose stop n8n # then register a return via the API — commits, event stays pending
docker compose start n8n # dispatcher self-heals, no manual intervention
docker compose run --rm -e KNOWLEDGE_PROVIDER=ragcore -e RAGCORE_BASE_URL=http://ragcore-not-reachable:9999 \
api python -c "from app.services.knowledge import get_knowledge_provider; ..."
```
## Test and validation results Fleet Ops remains a synthetic single-tenant PoC. It is not a production identity provider,
payment system, accounting package or public reservation platform. External RAGcore, MCP
| Check | Command | Result | Hub and n8n services remain independently operated dependencies and are accessed only
|---|---|---| through their documented adapters.
| Backend unit/integration tests | `docker compose run --rm api pytest -q` | **66 passed**, 0 failed, 0 skipped |
| Backend lint | `docker compose run --rm api ruff check .` | **All checks passed** |
| Backend type check | `docker compose run --rm api mypy app` | **Success: no issues found in 44 source files** (found and fixed 43 pre-existing errors this audit — see below) |
| Frontend build + typecheck | `cd frontend && npm run build` | Clean (`tsc -b && vite build`, zero errors) |
| End-to-end (Playwright) | `cd frontend && npx playwright test` | **12 passed** (`demo.spec.ts` — full 9-step demo script; `interactive-elements.spec.ts` — 11 tests covering every nav item, filter, tab, and role boundary) |
| Clean-checkout migrations | `docker compose down -v && docker compose up --build -d` | 11 tables created automatically, `alembic current``e7b08389f47f (head)`, zero manual step |
| Deterministic seed | `docker compose exec api python -m app.cli seed --reset` | `users:2 customers:180 vehicles:50 bookings:246 inspections:75 maintenance:40 data_quality_issues:26 workflow_runs:20` — identical across every reseed this session |
| Secret scan | `git ls-files \| grep -x .env`; `git log --all -p -- '*.env'`; history grep for AWS/private-key/`sk-` patterns | No `.env` ever committed; no secrets found in history |
### mypy defects found and fixed (the one real gap this audit uncovered)
`mypy` is a declared dev dependency (`backend/pyproject.toml`) but was never added to any
milestone's validation loop — only `ruff` was run throughout M0M7. Running it cold
surfaced 43 errors across 10 files. All were triaged and fixed (not suppressed):
- **Two genuine defensive-programming gaps**, not just type-annotation issues:
- `app/services/returns.py`: the vehicle lookup after acquiring the row lock had no
`None` guard; a dangling FK would have crashed with an unhandled 500 instead of a
clean `404 VEHICLE_NOT_FOUND`. Fixed.
- `app/api/routers/bookings.py`: same pattern in `get_booking` for the customer/vehicle
lookups — now returns a clean `500` with a message instead of an `AttributeError`.
- `app/api/deps.py` / `app/api/routers/demo.py`: `CurrentUser.role` is validated by
Pydantic at runtime already, but `get_current_user` now explicitly checks role
membership before construction, turning a would-be unhandled `ValidationError` into a
clean `401` for a corrupted/tampered session cookie.
- Two instances of reusing one variable name for both a `Vehicle` and a `Customer` across
branches (`dashboard.py`, `data_quality.py`) — renamed for clarity, not just to satisfy
mypy.
- `Booking.__table__.update()` / `Customer.__table__.update()` switched to the idiomatic
`sqlalchemy.update(Model)` construct (also fixes the type error).
- Remainder: deprecated `conint()``Annotated[int, Field(...)]`, a `Sequence` vs `list`
`.sort()` call, an `assert`-guarded None-narrow after a `WHERE ... IS NOT NULL` filter
mypy can't see through, and a couple of narrowly-scoped `# type: ignore[...]` comments
for known SQLAlchemy stub gaps (`Result.rowcount`).
`make lint` now runs `ruff check .` **and** `mypy app`.
## Application URLs and ports
| Service | URL | Notes |
|---|---|---|
| Web (React SPA) | `http://localhost:1228` | nginx-served static build |
| API | `http://localhost:8128` | FastAPI, `/health` for liveness |
| API docs | `http://localhost:8128/docs` | auto-generated OpenAPI/Swagger UI |
| n8n | `http://localhost:5678` | requires one-time owner setup, see below |
| PostgreSQL | `localhost:5432` (container-internal only, no host port published) | |
## Demo users and access method
No passwords. Two demo-role buttons on `http://localhost:1228/login`:
- **Open as Operations Manager** → `USR-OPS`, "Amelie De Ridder". Full access: dashboard,
data-quality resolution/merge, automation retry, demo reset, MCP/service-token routes
are separate (not user-facing).
- **Open as Rental Employee** → `USR-EMP`, "Karim Boujaddaine". Can register returns and
browse vehicles/bookings/knowledge; Automation page is visible but shows a
role-restricted message instead of the delivery table (enforced both in the UI and by
the backend's `require_operations_manager` dependency — verified by
`test_retry_requires_operations_manager` and the e2e role-restriction test).
Session is an HMAC-signed, `HttpOnly` cookie (`app/core/security.py`) — a demo mechanism,
not a real identity provider (documented as a known limitation).
## Implemented functionality
- Operations dashboard with 100% database-backed metrics, attention items linking to the
underlying data-quality issue, "today" departures/returns, and recent automation runs.
- Vehicle and booking list/detail pages with working filters (status, attention-only) and
a tabbed vehicle detail view (overview/bookings/inspections/maintenance/quality).
- Full transactional vehicle-return workflow: row-locked, idempotent by
`Idempotency-Key`, canonical-odometer regression handling (never silently lowers the
canonical value), vehicle status derivation, two audit events, and a schema-compliant
outbox event — verified against real concurrent submissions (1×201 + 2×409).
- Invalid-mileage rejection: negative values and non-numeric input both correctly
rejected with `422` and a precise Pydantic validation message.
- Data Quality Workbench: five deterministic rules (duplicate customer via TF-IDF-style
weighted signal scoring, missing required field, odometer regression, booking overlap,
vehicle status conflict), issue list/detail/defer/reject, and a two-column
duplicate-customer compare-and-merge UI with an inline (non-native-dialog) confirmation
step, transactional booking rewiring, and audit logging.
- Full audit trail: every significant action (login, return, vehicle status change,
data-quality issue lifecycle, customer merge, workflow retry, demo reset, n8n
callback, MCP tool request, knowledge question) is recorded with actor, correlation ID,
and before/after state; filterable by action.
- Knowledge Assistant: deterministic TF-IDF-weighted extractive retrieval over the 10
procedure documents — never generative, always cites real excerpts, and honestly
reports `insufficient`/`unavailable` states rather than fabricating an answer.
- n8n automation: background outbox dispatcher (`FOR UPDATE SKIP LOCKED` claim,
exponential backoff, no DB transaction held during the HTTP call), a live-verified
round trip through an actual n8n workflow, manual retry for failed deliveries, and an
Automation page (Operations Manager only) showing all runs with filtering.
- Four read-only, service-token-authenticated MCP Hub provider endpoints, each recording
its own service-request audit event, with zero write/mutation endpoints anywhere in
that namespace.
- Responsive UI verified down to 360px width (nav wraps, tables become cards, metric
tiles reflow to a 2-column grid, no horizontal overflow) — both by an automated
Playwright viewport/overflow assertion and by a captured screenshot.
## RAGcore integration status: implemented, not live-verified
The active `KnowledgeProvider` in this environment is `DemoKnowledgeProvider` — fully
implemented, fully tested, fully live-verified, and what actually satisfies the
knowledge-assistant acceptance criteria. A `RAGcoreKnowledgeProvider` HTTP adapter also
exists (`app/services/knowledge/ragcore.py`), targeting a best-effort contract inferred
from `contracts/ragcore-contract-assumptions.md` (no live RAGcore API spec was available).
Its **unavailable-degradation path is live-verified this audit**: pointed at an
unreachable host, it returns `{"evidence_state": "unavailable", "answer": "", "sources":
[]}` with no fabrication, exactly as required — but an actual successful round trip
against a real RAGcore instance has never been performed, because no such instance was
reachable in this environment.
## MCP Hub integration status: implemented, not live-verified
All four contracted read-only tools (`mobilityops_get_operations_summary`,
`mobilityops_list_attention_vehicles`, `mobilityops_get_vehicle_details`,
`mobilityops_search_knowledge`) are implemented as service-token-protected endpoints under
`/api/v1/integrations/mcp/`, directly `curl`-verified this audit (auth enforcement,
correct data shape, no write methods, service-request audit logging). No live ITWorx MCP
Hub instance was reachable in this environment, so an actual Hub-mediated tool call was
never performed — only direct calls to MobilityOps's own provider API.
## n8n integration status: implemented and fully live-verified
The only external integration with a real, running counterpart service available in this
environment. Fully verified this audit, including both success and degraded paths:
- **Success**: a real return registered via the API was delivered by the background
dispatcher to an actual n8n instance (owner account + imported/activated workflow),
which called back into MobilityOps and was recorded `succeeded`.
- **Degraded mode**: `docker compose stop n8n`, then a return was registered — it
**committed successfully** (`201`, booking `status: returned` persisted) exactly as
required by the architecture's reliability boundary ("a return command and its outbox
event commit in one transaction" and "n8n failure leaves events pending with bounded
retries"). The outbox event stayed `pending` with two real `ConnectError`s logged and
exponential backoff.
- **Self-healing**: restarting n8n required no manual intervention — the background
dispatcher picked the pending event back up on its next poll cycle and delivered it to
`succeeded` (5 total attempts across the outage).
- **Manual retry (S5 scenario)**: a seeded `failed` delivery, retried from the Automation
page, moved to `pending` and was delivered to `succeeded` by the live dispatcher within
one poll cycle.
## Known limitations
- RAGcore and the ITWorx MCP Hub were never reachable in this build/audit environment;
both integrations are implemented and tested against inferred/documented contracts but
not verified against real instances of those systems (see above).
- n8n requires a one-time, per-fresh-environment manual owner-account setup through its
own web UI (`http://localhost:5678/setup`) — a property of the n8n 2.x image itself
(`N8N_BASIC_AUTH_ACTIVE` no longer gates the UI), not something MobilityOps can bypass.
The workflow import/activation itself *is* scripted (`make n8n-setup`).
- Demo authentication is an HMAC-signed session cookie tied to two fixed seeded users —
appropriate for a PoC, not a production identity provider.
- Inspection public references are assigned via a simple `count + 1` sequence, not
gap-safe under true concurrent writers (acceptable for this single-tenant demo).
- The five data-quality rules use a simplified idempotency key
(`rule_type, entity_type, entity_id` while open) rather than the spec's literal
evidence-fingerprint scheme — documented rationale in `PROJECT_STATE.md`'s M3 notes.
- `npm audit` reports one residual moderate `esbuild`/Vite-8 dev-server-only advisory
(fixable only by a Vite major version bump) and one high `react-router` RSC-mode
advisory that does not apply to this app (it never uses React Router's RSC/SSR mode).
## Clean deployment instructions
```bash
git clone <repo> && cd MobilityOps
cp .env.example .env
make demo # build, start, migrate (automatic), seed
```
One-time n8n setup (only needed for the automation demo path; everything else works
without it):
```bash
# open http://localhost:5678/setup in a browser, create any owner account
# (8+ chars, 1 number, 1 capital letter — no email verification required)
make n8n-setup
```
Verify:
```bash
curl http://localhost:8128/health # {"status":"ok",...}
curl -o /dev/null -w "%{http_code}\n" http://localhost:1228/ # 200
make test # 66 backend tests
make lint # ruff + mypy, zero errors
make e2e # 12 Playwright tests (stack must be running)
```
Full detail, recovery expectations, and required operational checks: `docs/17-runbook.md`.
## Five-minute demonstration flow
1. Open `http://localhost:1228`**Open as Operations Manager**.
2. **Dashboard**: point out the metrics are live counts (available/rented/cleaning/
maintenance/blocked vehicles, open quality issues, pending/failed workflows), and the
Attention Required list linking straight to the underlying issues.
3. **Vehicles → MO-024** → open the active booking `BK-DEMO-RETURN`, register a return
with an odometer reading below MO-024's canonical value → the result panel shows the
inspection, the derived vehicle status, the automatically-created data-quality issue,
and the queued automation event — canonical odometer is confirmed unchanged.
4. **Data Quality → DQ-DEMO-DUPLICATE**: the two-column CUS-0012/CUS-0178 comparison,
merge with the inline confirmation step, issue flips to `resolved`.
5. **Knowledge**: ask "What must I do when a vehicle returns with damage?" → grounded
answer citing both the return and damage-handling procedures with real excerpts.
6. **Automation**: filter to `failed`, retry the seeded delivery, watch it succeed within
a few seconds via the live n8n instance.
7. **Audit**: filter by `return_registered` or `customer_merged` to show every action from
this walkthrough is recorded with actor, timestamp, and correlation ID.
8. Resize the browser to 360px width to show the responsive layout (nav wraps, tables
become cards) — or run `make e2e` and point at the passing responsive assertion.
@@ -0,0 +1,161 @@
# Fleet Ops final integrations — evidence summary
Session date: 2026-08-05. Branch `feat/fleet-ops-final-integrations`.
## Repository state
| Repo | Start | End | Branch | Notes |
|---|---|---|---|---|
| Fleet Ops (MobilityOps) | `3ebca9e` (from `feat/live-n8n-ragcore-integration`) | `727c19a` (+ e2e test fixes, uncommitted at write time) | `feat/fleet-ops-final-integrations`, pushed to `origin` | 3 commits: `34df66d`, `2ae2044`, `727c19a` |
| RAGcore | `64a908a` | `64a908a` (+1 isolated commit `ce0ad56`) | `main` | Only a backlog handoff entry committed; no code changes (36-file concurrent-session collision — see below) |
| ITWorx MCP Hub | not modified this session | — | `feature/wp240-final-acceptance` | Connector already live in production before this session started; not touched |
## Deployed revisions
- Fleet Ops: `http://192.168.10.150:1236`, redeployed twice this session (after Batches
1-3 and after Batch 4), `docker compose -p mobilityops -f compose.yaml -f
compose.unraid.yaml up --build -d db api web`, `.deploy/source-revision` = `727c19a...`.
- RAGcore: `http://192.168.10.150:1237`, `ragcore-app-1`. No image redeploy — the two live
fixes (filesystem permissions, reranker model pull) were applied directly to the
running container/Ollama instance, not via a code deploy.
- ITWorx MCP Hub: `http://192.168.10.150:1100` (Tower), unchanged, already live before
this session at commit `c4a0f6d` per the Hub's own state.
## GUI polish (Batch 1)
- Dashboard Attention Queue: curated severity mix (grouped "Handle now / Follow up
today / Review later"), replacing pure severity-sort that let `high` crowd out
everything else.
- Today's Movements: seed data curated (`seed/bookings.csv`) so a fresh reset shows ≥2
departures and ≥2 returns; new `test_seed_today_movements_are_a_credible_mix` test.
Live-verified after a real demo reset: 2 returns + 2 departures shown.
- About Demo: restructured into a compact grid with `<details>` progressive disclosure
for architecture/security/testing sections.
- Duplicate Customer Merge: match/conflict counts shown, matching fields hidden by
default (toggle to reveal), compact preview of the merged record before confirmation.
- Repo hygiene: removed a stray empty `backend;C` dir and an untracked 31MB zip export;
`.gitignore` now excludes future archive exports.
- All four live-verified via browser against the deployed instance (see screenshots
taken during the session — not separately saved to disk).
## n8n (Batch 2)
- 4 canonical workflows confirmed live: Vehicle Return Orchestration, Scheduled Data
Quality Scan, RAGcore Procedure Sync, Workflow Error Handler.
- Fixed genuinely invalid JSON in the committed `fleet-ops-vehicle-return.json` (a
missing `},` between two node objects — the file could not be parsed).
- Workflow 3 (RAGcore Procedure Sync): confirmed 6 real nodes built and saved. Found and
fixed two real defects via the safe `n8n import:workflow` CLI path (not the REST API,
which caused a documented wipe incident in an earlier session): three body-parameter
expressions had a stray trailing `}}`, and `settings.errorWorkflow` was unset. Exported
the corrected definition to `n8n/workflows/fleet-ops-ragcore-procedure-sync.json`,
added to `MANIFEST.md` and `check_drift.py`.
- **Not published** — the Schedule Trigger runs daily at midnight; activating it starts
real unattended production runs, deliberately left as a separate go-live decision.
- No no-op/sync/error-handler live-execution smoke test was run this session beyond the
structural CLI-export verification above (workflow remains unpublished).
## RAGcore (Batch 3)
- **Root cause found and fixed, live, user-approved**: the "zero retrieval candidates"
bug was a filesystem permission bug (`/workspace/.state/models/embedding_profiles.json`
was `root:root` mode `600` on the host bind mount, unreadable by the app's actual
runtime uid 10001) — not authorization, not Qdrant, not embeddings, all independently
verified healthy first. Fixed via `chown`/`chmod`; re-verified in-process (5 real hits,
up from 0).
- **Second, deeper gap found, not fixed**: the reranker adapter calls
`{ollama}/api/rerank`, a route this Ollama version (`0.32.5`) does not serve (404).
Pulled a working model (`xitao/bge-reranker-v2-m3:latest`, 1.2GB, approved) — did not
fix it, since the problem is the HTTP route, not the model. `/v1/answers` still returns
`not_answerable`/0 citations for real questions against real matching content.
- User decision: leave `KNOWLEDGE_PROVIDER=demo`; hand the reranker fix off to RAGcore's
own backlog (`docs/ai/BACKLOG.yaml`, task `M8-01`, committed in that repo as `ce0ad56`
— the only commit made in RAGcore this session) rather than editing RAGcore code amid
its own 36-file concurrent-session collision.
- Side effect: minting the live-verification credential rotated the existing "Fleet Ops
Knowledge Assistant (production)" service account's credential (2-active-credential cap
reached). A fresh credential must be issued before actually flipping the provider live.
## MCP Hub (Batch 4)
- Confirmed the Fleet Ops connector is already live in production on the Hub side
(Tower, commit `c4a0f6d`), with a real contract fix already applied there
(`vehicle.get`'s wire parameter normalized to camelCase `vehicleRef`).
- Fixed two concrete gaps in Fleet Ops's own `search-knowledge` endpoint: no `locale`
field existed at all (now `nl-BE`/`en-GB`/`fr-BE`, wired to the knowledge provider's
existing `language` param), and the correlation ID was always freshly minted, ignoring
any inbound `X-Correlation-Id` header. Added `get_correlation_id`, applied to all four
MCP endpoints.
- `MCP_HUB_BASE_URL` was dead config (declared, never read); wired it for a real,
bounded Hub-reachability health check instead of an unneeded self-registration push
(the Hub's own registration is catalog-driven).
- Renamed Fleet Ops's own internal audit tool labels `mobilityops_*``fleet_ops_*`
(mirrored in `contracts/mcp-tools.json`, `mobilityops_*` kept as deprecated aliases).
The live Hub connector's own dotted tool namespace (`mobilityops.operations.summary`
etc.) is a separate, Hub-owned naming layer, deliberately not touched.
- Automation page's MCP card now shows real evidence (last tool/client/count/timestamp)
instead of only the registration-enabled boolean.
## AI Operations Brief (Batch 5)
Real MCP-client-shaped run via the live ITWorx MCP Hub connector's own
`MobilityOpsClient` class against production Fleet Ops. Full runbook and live output in
`docs/final-integrations/ai-operations-brief-runbook.md`. Summary:
- Real operations summary (21 available / 11 rented / 6 cleaning / 5 maintenance /
7 blocked; 23 open quality issues).
- Real most-pressing vehicle identified (`MO-031`, missing operational inspection).
- Real vehicle detail lookup.
- Real grounded knowledge answer (English damage-handling question): 2 real citations,
`evidence_state: grounded`.
- Dutch/French variants of the same question honestly returned `insufficient` (no
fabrication) — root cause: the live Hub connector doesn't yet send the new `locale`
field, a Hub-side follow-up, not silently worked around.
- Correlation IDs verified end-to-end in Fleet Ops's own audit log
(`GET /api/v1/audit?action=mcp_tool_request`), matching the response payloads exactly.
- No write actions performed at any point.
## Testing per batch
- Backend: **176 passed**, `ruff check .` clean, `mypy app` clean (50 source files) —
verified against a freshly rebuilt image after discovering mid-session that
`docker compose run --rm api` (no bind mount on the `api` service) silently tests a
stale image otherwise. One genuinely stale test assertion found and fixed as a result.
- Frontend: `tsc -b && vite build` clean.
- E2e (Playwright, against the live deployed instance,
`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`): every spec file run this
session passed — `demo.spec.ts`, `interactive-elements.spec.ts` (26),
`responsive-i18n.spec.ts` + `demo-accessibility.spec.ts` + `guided-demo-full.spec.ts`
(28), `i18n-coverage.spec.ts` + `error-messages.spec.ts` + `clickable-rows.spec.ts` +
`demo-guide.spec.ts` + `demo-entry.spec.ts` + `demo-legibility.spec.ts` +
`fleet-ops-correction.spec.ts` + `ui-redesign.spec.ts` + `greeting.spec.ts` +
`greeting-live.spec.ts` (28, after fixing 2 pre-existing fragile locators unrelated to
this session's feature work — a `.data-table` ambiguity now that Automation has two
tables, and a `Technische details` toggle ambiguity for the same reason; plus one
pre-existing untranslated-loanword false positive in `i18n-coverage.spec.ts`).
## Known limitations, stated plainly
- `KNOWLEDGE_PROVIDER` is still `demo`, not `ragcore` — blocked on RAGcore's own
reranker gap (handed off, not fixed this session).
- n8n workflow 3 is built and correct but not published (deliberate, separate decision).
- The live MCP Hub connector doesn't yet send the new `locale` field, so
locale-aware knowledge search only works when called directly against Fleet Ops (as
proven by the backend tests), not yet through the live Hub connector as deployed.
- No public-demo-readiness checklist, About Demo Guide "completed" end-state polish
(section 4E), or dashboard MCP "activity showcase after Demo Complete" gating were
built this session — the MCP evidence display exists on the Automation page
unconditionally rather than gated behind guided-demo completion.
- No security-review pass was run separately this session (existing gates: ruff, mypy,
the repo's own auth/audit test coverage).
## Rollback
- Fleet Ops: prior working revision `0571a40` remains in `.deploy/` as
`source-0571a40.tar.gz` on the Unraid host; redeploy by re-extracting and re-running
the same `docker compose up --build -d` sequence with that archive.
- RAGcore: `chown`/`chmod` change is trivially reversible (`chown 0:0` +
`chmod 600` on the same path) if needed, though there is no reason to revert a
permission fix. Ollama model pull (`xitao/bge-reranker-v2-m3:latest`) can be removed
with `ollama rm` if unwanted; it is inert until RAGcore's own code is changed to use it.
- MCP Hub: not modified this session.
@@ -0,0 +1,284 @@
# Fleet Ops correction and release — final evidence
**Result: PASS**
## Commits
- Source branch / commit (verified pre-correction baseline): `master` @ `18344bc8b7a75a2f868bf15bf498fc030ac6c34c`
- Fix branch: `fix/fleet-ops-i18n-status-flow`
- Final fix-branch commit: `284b3c7` (merged content identical to `2e4fb43`, which carries the evidence-summary localization fix)
- Main-before-merge: `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` (confirmed unchanged via `git fetch` + `git rev-parse origin/master` immediately before merging — no unexpected commits landed on master while this branch was in progress)
- Merge commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`git merge --no-ff fix/fleet-ops-i18n-status-flow -m "merge: complete Fleet Ops localization and status resolution"`, zero conflicts)
- Final main commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
- Deployed commit: `de0bdea84fea01b4501deb7099107bc753c2e6d7` (`.deploy/source-revision` on Unraid)
- Gitea main branch: `master` (confirmed via `git fetch origin && git rev-parse origin/master` matching local `master` after push)
- Live URL: `http://192.168.10.150:1236`
Fix-branch commit history: `6deb955`, `e6539d1`, `ac4b163`, `1fdd2b3`, `1e40775`, `a7ac5ed`, `7851e80`, `cda2c32`, `2e4fb43`, `284b3c7`.
## What this correction fixed
1. **Status-recommendation flow redesigned** (sections 8A8F). The old single opaque
"calculate and apply recommended status" action is replaced by a single shared, pure
evaluator (`backend/app/services/vehicle_status.py::evaluate_vehicle_status`,
documented in `docs/fleet-ops-correction/vehicle-status-decision-table.md`) used
identically by the scanner, a non-mutating preview endpoint
(`POST /api/v1/data-quality/issues/{ref}/status-recommendation`), and a
transactional apply endpoint (`POST .../apply-recommended-status`) that locks the
row, recomputes facts, rejects a stale `recommendation_token`, refuses unsafe/manual-
review recommendations, and re-validates post-write before resolving the issue.
- Forbidden shortcuts eliminated: "maintenance + active booking" no longer
auto-recommends "rented" (being in maintenance is itself now a blocking fact);
"maintenance with nothing else wrong" no longer auto-clears to "available" (no
fact proves maintenance is actually finished — release stays a manual decision).
- Frontend: "Review recommendation" → a localized decision panel (current/
recommended status, why, evidence, consequences) → an exact "Change status to
&lt;status&gt;" confirm action → result, or a distinct "Manual review required"
state offering no generic apply button.
2. **MO-016 order independence** (section 9). Order independence does not mean "same
final status regardless of order" — resolving the booking overlap first genuinely
removes the conflict, correctly leaving nothing to apply. What holds either way: the
recommendation always reflects real current facts (never a stale proxy), and nothing
unsafe is ever applied (never "rented"). Proven by a backend test explicitly scoped
to MO-016/DQ-DEMO-STATUS (the original version wasn't — `_first_open()` returned
whichever of ~14 open `vehicle_status_conflict` issues was most recent, not
necessarily MO-016's) and a browser-level Playwright test covering both orders.
3. **"Fleet Ops" is a non-localizable brand constant** (`frontend/src/product.ts`,
backend `PRODUCT_NAME`), wired via `{{productName}}` interpolation everywhere the
brand appeared in locale prose. A permanent test fails the build if any locale file
ever defines the brand name or an `appName` key again.
4. **Dynamic backend prose converted to message codes + params** (sections 5/6/10):
return status reasons, audit field/actor-type labels, automation `last_error` (new
`last_error_code` column, migration `799d8800e241`), search results (sections/
vehicles/bookings/issues), and — found live on Unraid — the data-quality evidence
summary. Raw technical text is demoted to a "Technical details" disclosure
everywhere.
5. **Knowledge-base fixes**: the demo provider's tokenizer silently dropped accented
characters (`[a-z0-9]+` split "véhicule" into "v"+"hicule"), breaking French
retrieval broadly — fixed to include the Latin-1 accented range. Reweighted section
scoring so a body match (real substance) outranks a heading/title match (a shallow
structural hint) — the old weighting misranked the damage procedure behind an
unrelated document for the brief's exact validation question in all 3 languages.
Removed leftover "MobilityOps"/"PoC" mentions from 9 procedure documents.
6. **Search, audit, automation, maintenance/inspections localized** (section 10):
backend returns stable codes + params only; the frontend localizes section labels,
vehicle summaries, booking/issue statuses, audit action/field/actor labels,
automation error explanations, and maintenance/inspection type labels.
7. **i18n test suite strengthened** (section 11): key parity, brand invariant,
translation-quality (cross-locale identical-value detection), a hardcoded-JSX-text
static scan (had to anchor on backreferenced closing-tag names — a naive `>text<`
regex misread TypeScript generics as JSX), and a 3-language route matrix (every main
route, no console errors, correct `html[lang]`, real page headings).
## Live-caught bug (the deployment validation earning its keep)
Live validation on the freshly-deployed fix branch directly caught a real defect: every
data-quality issue's top-of-page evidence summary was unconditionally showing raw,
always-English text (e.g. *"vehicle marked available while reserved bookings
conflict"*) in **all three languages**, because the frontend never finished the
`evidence.signals` localization the backend had already been emitting (the backend code
even had a comment describing the intended design that the frontend didn't implement).
Fixed in commit `2e4fb43`:
- `DataQualityIssueDetail.tsx` now renders `evidence.signals` through the operator's
locale as the primary evidence text.
- The four `DQ-DEMO-*` seed rows that anchor the guided demo's scripted scenarios now
carry real, accurate signals computed at seed time (the duplicate-customer similarity
score is the actual `SequenceMatcher` ratio on the seeded names, not invented).
- Rows with no structured signals fall back to raw text rather than showing a blank
summary; the one known filler placeholder gets its own localized rendering.
- A regression test locks this in: the vehicle-status-conflict evidence summary must
show localized text and must never contain the specific raw English sentence that was
live-visible before the fix, in all 3 languages.
Also found and fixed along the way: a frontend logic bug conflating "no conflict" with
"manual review required" (both carry `safe_to_apply: false`), which showed a false
"manual review required" panel for MO-016 after its booking overlap was resolved
instead of the correct "no change needed" state (fixed in `1fdd2b3`).
## Translation coverage
- All three locale files (`nl-BE`, `en-GB`, `fr-BE`) define exactly the same key set
for every namespace (`i18n-coverage.spec.ts`, structural guarantee).
- No locale file contains an empty string value.
- No locale file defines the brand name or an `appName` key (brand-invariant test).
- Cross-locale translation-quality check: for every string ≥8 characters of real prose,
nl-BE ≠ en-GB, fr-BE ≠ en-GB, fr-BE ≠ nl-BE, with a precise, audited allowlist for
genuine proper nouns/cognates (23 entries, each with a documented reason).
- Hardcoded-JSX-text static scan: zero findings against the current codebase (verified
against both false positives — TypeScript generics — and a deliberately-injected-
then-reverted false negative).
- 3-language route matrix: every main route (dashboard, vehicles, vehicle detail,
bookings, booking detail, data quality, issue detail, automation, knowledge, audit,
scenarios, about) opens cleanly in all 3 languages with no console errors, correct
`html[lang]`, and a real page heading.
- **Remaining visible wrong-language text**: none found. The one gap that existed (the
data-quality evidence summary) was found live and fixed before merge.
## Branding
- Visible product name: **Fleet Ops**, exactly, in all 3 languages, everywhere (login,
topbar, footer "Fleet Ops Demo", document title, About page, Demo Guide, knowledge
base). Verified structurally (brand-invariant test) and live (branding test across
dashboard/vehicles/data-quality/audit/automation/knowledge pages in all 3 languages;
visual screenshots of the login screen in nl-BE and fr-BE).
- Technical identifier retained (by design, per the brief): repository name, local
directory, package/module names, Compose project, deployment directory, database
name, and the `/health` endpoint's `service: "mobilityops-api"` field remain
"mobilityops" — none of these are visible UI text.
- No visible "MobilityOps" or "PoC" anywhere in the UI or the demo knowledge base
(9 procedure documents cleaned up; regression test in `test_knowledge.py` scans every
procedure file for both strings).
## Status-preview / apply / manual-review / MO-016 ordering
- **Preview**: verified non-mutating — the issue's `status` stays `"open"` after
calling the preview endpoint and re-fetching it via a fresh request.
- **Apply**: the confirm button names the exact target status ("Change status to
Blocked" / "Status wijzigen naar Geblokkeerd" / "Changer le statut vers Bloqué");
applying resolves the issue and updates the vehicle atomically.
- **Manual review**: MO-024 (active rental + service-threshold reached, a genuine fact
contradiction) shows "Manual review required" with no generic apply button rendered
at all.
- **Stale token**: simulated by resolving the underlying booking overlap after the
preview was fetched but before applying — the apply call is correctly rejected
(`RECOMMENDATION_STALE`), the UI shows the "situation has changed" message, and the
user must review again before a new apply is possible.
- **MO-016 ordering**: both orders tested. Resolving the overlap first correctly leaves
nothing to apply (vehicle stays "available", genuinely correct). Resolving the status
conflict first safely blocks the vehicle; resolving the now-redundant overlap
afterwards does not disturb it. Neither order ever produces "rented".
## Knowledge (per language)
The brief's exact validation question, in each language, grounds on the damage
procedure as the **primary** (not just top-3) source:
- nl-BE: *"Wat moet ik doen wanneer een voertuig beschadigd terugkomt?"* → damage
procedure, Dutch source, Dutch excerpt.
- en-GB: *"What should I do when a vehicle returns with damage?"* → damage procedure,
English source, English excerpt.
- fr-BE: *"Que dois-je faire lorsqu'un véhicule revient endommagé ?"* → damage
procedure, French source, French excerpt.
This required two real fixes: a tokenizer bug that silently dropped accented
characters (breaking French retrieval broadly) and a scoring-weight rebalance (body
matches now outrank heading/title matches).
## Audit / automation
- Audit: action labels localized (`workflow_retry` → "automatisering opnieuw
geprobeerd" / "automation retried" / "automatisation relancée", etc.), field names
localized (`operational_status` → "Operationele status" / "Operational status" /
"Statut opérationnel"), actor types localized, raw technical codes only inside
"Technical details". Verified live and via a dedicated Playwright test.
- Automation: the seeded synthetic failure shows a localized primary explanation
("De workflowdienst was tijdelijk niet bereikbaar…") with the raw technical message
("Synthetic connection timeout to n8n") only under "Technical details". Verified live
and via a dedicated Playwright test.
## Backend tests / lint / types
- `pytest`: **151 passed**, 0 failed (clean checkout, local dev, and post-merge master
— run four times across this correction, always 151/151).
- `ruff check .`: all checks passed, every run.
- `mypy app` (strict): no issues found in 49 source files, every run.
- Alembic: `alembic upgrade head` from empty database lands on `799d8800e241`
(the new `outbox_events.last_error_code` column); `downgrade -1` / `upgrade head`
round-trip verified.
## Frontend build / Playwright
- `npm ci`, `tsc -b`, `vite build`: clean, every run.
- Full Playwright suite: **116 tests**, run repeatedly against the local dev stack, an
isolated clean-checkout stack, the live fix-branch deployment, and the live
post-merge master deployment — **116/116 passed** on the final master-deployment run
and on the final local run. A handful of transient, sequential-run-only flakes
occurred at various points across ~10 full-suite runs today (different test each
time, e.g. a pre-existing logout-timing race in `AuthContext.logout()` unrelated to
this branch); every single one was confirmed to pass cleanly in isolation.
- Guided demo covered indirectly via `guided-demo-full.spec.ts`,
`demo-guide.spec.ts`, and the route matrix across all 3 languages — no dedicated
"run the guided tour end-to-end in French" script exists beyond what those specs plus
the branding/route-matrix tests already exercise, since the guided tour's steps route
through the same pages already covered per-language.
## Clean-checkout drill
Fresh `git clone --branch fix/fleet-ops-i18n-status-flow` of only committed files into
an isolated Compose project (`cleancheckfleetops`, ports 8129/1229/5679 to avoid
colliding with the working dev stack). From empty volumes: build → up → `alembic
upgrade head``reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27
data-quality issues / 20 workflow runs) → 151 backend tests + Ruff + mypy green →
frontend build green → full Playwright suite green → final reset →
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes, and images
torn down afterward; working dev environment confirmed untouched.
## Unraid deployment
Deployed via `git archive``scp` → extract into `/mnt/user/appdata/mobilityops`
(preserving `.env` and persistent volumes) → `.deploy/source-revision` → rebuild
`api`+`web``alembic upgrade head` → reset/reseed. Done twice: once for the fix
branch (caught the evidence-summary bug), once for the final merged master. Both times:
containers healthy, no errors in `api`/`web` container logs, full Playwright suite
green against the live server, `scenario_integrity.all_ready: true` after final reset.
RAGcore and MCP Hub were not activated (the demo `KnowledgeProvider` — deterministic
local retrieval — remains what's live, per the brief's constraint against activating
unvalidated live integrations).
## Responsive / accessibility
- Breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844,
360×800) × 3 languages: no horizontal overflow, localized headings visible
(`responsive-i18n.spec.ts`).
- Status-recommendation panel: keyboard-only activation of "Review recommendation" and
"Change status to X" verified via focus assertions (not just click); reduced-motion
emulated during the flow; status never conveyed by colour alone (the badge always
carries its own localized text); `aria-live="polite"` added so the applied
confirmation is announced to screen readers.
## Known limitations
- A pre-existing, narrow timing race in `AuthContext.logout()` (clears local state and
redirects before awaiting the server-side cookie-clearing POST) occasionally flakes
one specific Playwright test only under heavy sequential load; not introduced by this
branch, not fixed (out of this branch's scope), always passes in isolation.
- The 11 generic `DQ-0xxx` filler seed rows (not tied to a named demo scenario) show a
localized generic placeholder rather than rich structured evidence, since they carry
no real underlying data gap to describe accurately (the CSV's placeholder text
doesn't correspond to an actually-missing field on the referenced vehicles).
- No dedicated "full guided demo in French, screenshot every step" script exists as a
single artifact; coverage is composed from the route matrix, branding, and existing
guided-demo specs, each run across all 3 languages.
## Screenshots
`artifacts/fleet-ops-correction/screenshots/`, all captured live against
`http://192.168.10.150:1236`:
- `login-nl-BE.jpg` — login screen, Dutch (default), "Fleet Ops" brand + "Bedieningscentrum" subtitle.
- `login-fr-BE.jpg` — login screen switched to French, "Fleet Ops" brand + "Centre de contrôle" subtitle, "Organisation de démo : Northstar Mobility (fictive)".
- `dq-demo-status-fr-BE-collapsed.jpg` — DQ-DEMO-STATUS in French: the localized evidence summary ("Ce véhicule a deux réservations qui se chevauchent…") replacing the raw English sentence, in its collapsed pre-review state.
- `dq-demo-status-fr-BE-clean-reload.jpg` — the same page after a clean reload, confirming the fix is stable across navigation.
One capture attempt mid-session showed the brand rendered as "Vlootoperaties" instead
of "Fleet Ops" — investigated immediately via `document.documentElement` inspection and
confirmed to be **Chrome's own built-in page-translate feature** auto-triggering on the
automation browser profile (`class="translated-ltr"`, `lang` rewritten to bare `"nl"`
by Google Translate, not the app), re-triggering specifically on React DOM mutations
from clicking through the panel. Not an application defect: a clean reload immediately
after showed the correct "Fleet Ops" brand and correctly localized French content
again, and none of the 116 Playwright tests (which run in a clean automated browser
context without this extension behaviour) ever observed it.
## Rollback procedure
1. `ssh unraid`, `cd /mnt/user/appdata/mobilityops`.
2. `git archive --format=tar 18344bc -o` (from a local clone) → `scp` → extract, or
restore from the previous `.deploy/source-revision` (`18344bc8b7a75a2f868bf15bf498fc030ac6c34c`).
3. `echo 18344bc8b7a75a2f868bf15bf498fc030ac6c34c > .deploy/source-revision`.
4. `docker compose -f compose.yaml -f compose.unraid.yaml build api web && ... up -d api web`.
5. `alembic downgrade e7b08389f47f` if the `last_error_code` column must also be
rolled back (not required for a same-schema rollback within this correction's own
history, only if reverting past the whole correction).
6. Re-seed and re-verify `scenario_integrity.all_ready: true`.
The fix branch `fix/fleet-ops-i18n-status-flow` was not deleted.
Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

@@ -0,0 +1,291 @@
# Fleet Ops final localization — final summary
Small, targeted correction round on top of the already-merged, functionally-validated
Fleet Ops correction milestone. Scope: remaining NL/FR translation gaps, centralized
API-error localization, a time-dependent Europe/Brussels dashboard greeting, i18n
test hardening, and documentation consistency — explicitly no redesign, no business-logic
changes, no new functionality. Audit and rationale: `docs/fleet-ops-final-localization/audit.md`.
## Commits
| Stage | Commit | Message |
|---|---|---|
| Start commit (branch base = prior `origin/master` head) | `f7805579f7c73bd3085d73a725fa985b4a4892ed` | `docs(release): final Fleet Ops correction evidence and screenshots` |
| Final fix-branch commit | `09173a4740ddb282fe5412c5305284e9776d397c` | `fix: correct fr-BE audit column label Actor -> Auteur` |
| Merge commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | `merge: finalize Fleet Ops localization` |
| Final master commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | (same as merge commit — merge commit is the branch tip) |
| Deployed commit | `5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0` | matches `.deploy/source-revision` on Unraid exactly |
Branch used: `fix/fleet-ops-final-i18n-ux` (the brief named `fix/fleet-ops-final-localization`;
this branch was verified freshly and cleanly branched from `origin/master` with a clean
working tree, so it was used as-is rather than renamed — see the audit doc's naming note).
`origin/master` was re-fetched and confirmed unchanged (`f780557`) immediately before the
merge, per the mandatory pre-merge safety check.
Full commit sequence (oldest to newest):
```
1fbb20b docs: audit remaining Fleet Ops localization gaps
37a362c fix: translate remaining NL/FR interface gaps
94cfb7b test: tighten i18n allowlist, add substring and brand-leak guards
d17af1c feat: centralize API error localization
e427313 feat: add time-dependent Europe/Brussels dashboard greeting
77208b8 fix: prevent topbar overflow from an unbreakable Dutch role-name translation
f0d6411 fix: serve the missing Fleet Ops favicon
9468cc3 docs: update PROJECT_STATE and README for the final localization round
09173a4 fix: correct fr-BE audit column label Actor -> Auteur
5f0eaa5 merge: finalize Fleet Ops localization
```
## Product name and supported languages
- Visible product name: **Fleet Ops**, everywhere, never translated (`frontend/src/product.ts`
constant, interpolated as `{{productName}}`). "MobilityOps" remains the internal repo /
Compose project / deployment-directory identifier only.
- Supported UI languages: **nl-BE** (default), **en-GB**, **fr-BE**.
- No visible "MobilityOps" or the word "PoC" anywhere in the UI (enforced by a dedicated
automated test, see below).
## Corrected translations
- Role names actually translated (not just labelled as translated): `auth.json` /
`demo.json` role keys — **Operationsmanager** / **Verhuurmedewerker** (nl-BE),
**Responsable des opérations** / **Collaborateur de location** (fr-BE).
- `audit.title`**Auditgeschiedenis** / **Piste d'audit**; `columns.actor`**Uitvoerder**
(nl-BE) / **Auteur** (fr-BE, corrected during live browser validation — see Known
limitations).
- `list.statusOpen`**Openstaand**; `ledger.filterRecent`**Recentste**;
`scenarios.startScenario`**Scenario starten** / **Démarrer le scénario**.
- 8 previously-missed mid-sentence "Audit trail" leaks fixed across `demo.json`,
`quality.json`, `returns.json` (nl-BE) — found by the new embedded-substring test, not
the pre-existing whole-string-identity test, which structurally cannot catch this class
of bug.
- No unintended English text remains in nl-BE or fr-BE (see translation-coverage evidence
below).
## Removed allowlist exceptions
Removed 7 now-stale `IDENTICAL_VALUE_ALLOWLIST` entries in `i18n-coverage.spec.ts`:
`audit.title`, `auth.roleOperationsManager`, `auth.roleRentalEmployee`,
`demo.scenarios.startScenario`, `demo.scenarios.roles.operations_manager`,
`demo.scenarios.roles.rental_employee`, `navigation.items.audit` — all now genuinely
translated; their old comments describing them as "deliberately untranslated" were no
longer true. Two new tests added: embedded-English/Dutch-substring leak guard, and a
no-"MobilityOps"/no-"PoC" guard.
## Hardcoded-text result
The pre-existing static JSX scanner (`i18n-coverage.spec.ts`, section 11D) found **zero**
hardcoded user-facing strings outside the approved technical-token allowlist (Fleet Ops,
Northstar Mobility, ITWorx MCP Hub) across `pages/` and `components/`. Result: **PASS**.
## API-error-localization result
New `frontend/src/api/errorMessages.ts` (`describeApiError`) replaces the
`err instanceof ApiError ? err.message : t(fallback)` anti-pattern (which showed raw
English backend text for the common case) at all 13 call sites across 7 files
(`Automation.tsx`, `ReturnForm.tsx`, `DataQuality.tsx`, `DemoGuide.tsx`, `Layout.tsx`,
`DataQualityIssueDetail.tsx` ×7 sites, `Knowledge.tsx`). Resolution order: known `AppError`
code (32 codes) → known HTTP status (401/403/404/409/422/500) → fully generic fallback.
New `ApiErrorNotice` component (`PageChrome.tsx`) always renders a localized title +
explanation + optional next step; raw backend text is demoted to a "Technical
details"/"Détails techniques" disclosure, never the primary message.
Evidence: `frontend/e2e/error-messages.spec.ts` (10 tests, all passing) —
every known code/status has non-empty copy in all 3 locales; a known code never surfaces
raw text as the primary message; unknown-code and unknown-status fallback chains behave
correctly; a drift guard greps the actual backend `AppError("CODE", ...)` call sites and
confirms `KNOWN_CODES` exactly matches (32 codes, zero drift). Live-verified on Unraid: the
seeded failed automation run renders a fully localized French error with a "DÉTAILS
TECHNIQUES" disclosure below it.
## Greeting logic and edge cases
New `frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, clock-injectable, pure) resolves
one of 4 periods against **Europe/Brussels** wall-clock time via
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })` (DST-safe by
construction — no manual UTC-offset math):
| Period | Window | nl-BE | en-GB | fr-BE |
|---|---|---|---|---|
| morning | 05:0011:59 | Goedemorgen | Good morning | Bonjour |
| afternoon | 12:0017:59 | Goedemiddag | Good afternoon | Bonjour |
| evening | 18:0022:59 | Goedenavond | Good evening | Bonsoir |
| night | 23:0004:59 | Welkom terug | Welcome back | Bon retour |
Never "Goedenacht" (a farewell in Dutch, not a welcome). Each period also has its own
accompanying sentence per language (`dashboard.json` `greetingBody`), replacing the old
fixed "Here's the fleet." `useGreetingPeriod.ts` polls every 30s so the greeting rolls
over live while the app stays open, no reload required; initial render uses a synchronous
`useState(() => getGreetingPeriod())` so there is never a flash of the wrong period.
Edge-case evidence:
- `frontend/e2e/greeting.spec.ts` (4 tests): exact boundary checks at 04:59/05:00/11:59/
12:00/17:59/18:00/22:59/23:00 in both CET (winter) and CEST (summer), plus a dedicated
spring-forward/fall-back DST-transition test (2026-03-29 and 2026-10-25).
- `frontend/e2e/greeting-live.spec.ts` (6 tests, real browser via Playwright's `page.clock`):
all 8 boundary times rendered correctly in **all 3 languages** against the actual app;
live period rollover with no `page.reload()` call anywhere in that test; language-switch
behaviour without changing the time period; the "never Goedenacht" guard.
- Live-verified on Unraid at actual current server time (2026-08-04, ~03:2x CEST, i.e. the
night period): dashboard showed "Welkom terug. Hier is het laatste overzicht van je
wagenpark." (nl-BE), "Welcome back. Here's the latest overview of your fleet." (en-GB),
"Bon retour. Voici le dernier aperçu de votre flotte." (fr-BE).
## README / PROJECT_STATE corrections
- `PROJECT_STATE.md`: fixed the stale "Product name: MobilityOps." / "PoC only"
locked-decisions lines (predated the Fleet Ops rebrand); fixed the "Fleet Ops
correction" section header, which still read "IN PROGRESS .../Not yet merged to
master" despite already being merged (`de0bdea` / `f780557`); appended a new dated
entry for this correction round (not a rewrite of prior entries, per the brief's
explicit instruction not to hide earlier history).
- `README.md`: linked `docs/fleet-ops-final-localization/` alongside the existing
correction-round doc link; refreshed the stale Playwright test count (113 → 138 → 139
after the favicon regression test was added).
## Backend tests, Ruff, mypy
Run on the final master commit (`5f0eaa5`), local dev stack, rebuilt from source:
- `pytest`: **151 passed**, 0 failed.
- `ruff check .`: **All checks passed!**
- `mypy app` (the project's canonical invocation, matching all prior milestone gates —
no `[tool.mypy]` strict config exists in `pyproject.toml`): **Success: no issues found
in 49 source files.**
No backend Python was touched this round; these numbers are unchanged from the prior
correction milestone's final gate, confirmed green again on the current tree.
## Frontend build, Playwright
- `npx tsc --noEmit`: clean, 0 errors.
- `npm run build` (`tsc -b && vite build`): clean production build.
- Full Playwright suite (`npx playwright test`), master build, local dev stack:
**139 passed**, 0 failed (confirmed on a clean run after two transient
`0xC0000005` Chromium worker crashes caused by this specific machine running 43+
concurrent Chrome processes at the time — see Known limitations; a targeted 48-test
re-run of every new/changed suite also passed cleanly in between).
## Clean-checkout drill
Isolated Compose project `mobilityops-clean` (ports 8129/1229/5679, no shared volumes/
network with the working dev stack), fresh `git clone --branch
fix/fleet-ops-final-i18n-ux` of only committed files:
1. `docker compose build` + `up -d` from empty volumes — all 4 containers healthy.
2. `alembic upgrade head``799d8800e241 (head)`.
3. `seed --reset` → 2 users / 180 customers / 50 vehicles / 246 bookings / 75 inspections /
40 maintenance / 27 data-quality issues / 20 workflow runs — matches the documented
deterministic count exactly.
4. Backend gates: `pytest` 151 passed, `ruff check .` clean, `mypy app` clean (49 files).
5. Frontend: `npm ci` clean, `tsc --noEmit` clean, `vite build` clean.
6. Full Playwright suite against the isolated stack (`MOBILITYOPS_PUBLIC_URL=http://localhost:1229`):
**139 passed**, 0 failed — this run covers the Dutch/English/French language checks,
greeting boundaries, API error paths, and the guided demo, all in one pass.
7. Final reset + `scenario_integrity`: all 5 scenarios `ready: true`.
8. Isolated stack, containers, volumes and images torn down; original dev environment
confirmed untouched (`mobilityops-*` containers unaffected throughout).
**PASS.**
## Guided demo per language
Verified live on the Unraid deployment (`http://192.168.10.150:1236`) in all 3 languages
via direct browser interaction: login screen role buttons, dashboard (greeting, readiness
band, attention queue, integration pulse, recent activity), audit trail, automation retry
flow with localized error + technical-details disclosure, and demo reset — all rendering
correctly in nl-BE, en-GB and fr-BE. The full guided-demo Playwright spec
(`guided-demo-full.spec.ts`) passed as part of the 139-test suite on both the local dev
stack and the isolated clean-checkout stack.
## Server deployment, container health
Deployed to `http://192.168.10.150:1236` (Compose project `mobilityops`,
`/mnt/user/appdata/mobilityops`), preserving the server's existing `.env`, the Postgres
and n8n named volumes, the exposed port, and the deployment directory — only `api` and
`web` were rebuilt/recreated; `db` was never touched beyond `alembic upgrade head`; no
second n8n instance was started (shared existing n8n at `:5678` used throughout).
Procedure (matching `docs/demo-release/demo-runbook.md` exactly): `git archive``scp`
extract over the existing deployment dir → update `.deploy/source-revision`
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
→ confirm `alembic current``seed --reset`.
Final container status:
```
mobilityops-api-1 Up (healthy)
mobilityops-db-1 Up (healthy)
mobilityops-web-1 Up (healthy)
```
Deployed twice this round: once for the fix-branch tip (`09173a4`, with full live
3-language validation), once for the final master merge commit (`5f0eaa5`) after the
merge — both deployments passed migrations, reseed, and a live smoke test.
## Repository / runtime hash comparison
```
git rev-parse HEAD (local, master) = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
/mnt/user/appdata/mobilityops/.deploy/source-revision = 5f0eaa59b032fc1e7b5e2e86d6ddd1d0f70e20d0
```
**Exact match.**
## Browser console and network
No console errors on any checked route in any of the 3 languages (dashboard, audit,
automation, login) on the live Unraid deployment. All observed `/api/` network requests
returned `200`. `api` and `web` container logs show no errors/tracebacks/exceptions after
the final deployment.
## Known limitations
- **Transient `document.documentElement.lang` DOM-attribute anomaly during interactive
manual browser testing** on the live server: on 2 occasions, right after a client-side
action (an automation retry click; a demo-reset confirm click), `document.documentElement.lang`
briefly showed `"nl"` while the actually-rendered page content, `localStorage`, and a
controlled repeat of the exact same click sequence (fresh login, single deliberate
click, immediate inspection) all remained correctly `"fr-BE"`. Root-caused as far as
possible: the codebase has exactly one `i18n.changeLanguage()` call site
(`LanguageSwitcher.tsx`), which was not invoked in the clean repro, and `t()` /
`i18n.language` are structurally coupled through a single i18next singleton with no
code path capable of producing this split state. Not reproduced even once across 139
automated Playwright tests run 3 times total (local pre-merge, isolated clean-checkout,
local post-merge on master) in a clean, extension-free browser context. Most likely
explanation: a third-party browser extension active in the specific interactive testing
session (which also had ~10 unrelated pre-existing tabs open on the same origin, and
showed independent signs of instability — repeated CDP screenshot timeouts) rewriting
the `lang` attribute based on its own content heuristics, independent of the React app.
Logged here for transparency rather than silently dismissed; does not affect any
automated PASS result above.
- **Two transient Chromium worker crashes** (`0xC0000005` / access violation) during the
master-build Playwright re-run, on a machine that had accumulated 43+ concurrent Chrome
processes from the interactive testing session above. A clean run immediately
afterward (fewer processes) passed all 139 tests; a 48-test targeted re-run of every
new/changed suite also passed cleanly in between. Treated as machine resource
contention, not a code defect — consistent with the prior correction milestone's own
documented experience of "sequential-run-only flakes reproduced from resource
contention of running two full Docker stacks at once," per `PROJECT_STATE.md`.
- One translation gap (fr-BE `audit.columns.actor`: "Acteur" instead of the brief's
specified "Auteur") was missed in the initial pass and only caught during live browser
validation on Unraid; fixed in commit `09173a4` and redeployed before the master merge.
- The Fleet Ops brand mark (`BrandMark` in `Icons.tsx`) was flagged by the user as
potentially due for a visual refresh; per explicit user decision mid-session, this is
out of scope for this correction round and deferred to a separate follow-up task.
- No RAGcore/MCP Hub implementation changes were made or claimed; both remain in the same
demo/not-connected state documented by the prior correction milestone.
## Rollback procedure
`.deploy/source-revision` on the server records exactly which commit is live. To roll
back: `ssh unraid`, extract an earlier `source-<short-sha>.tar.gz` from
`/mnt/user/appdata/mobilityops/.deploy/` (prior tarballs remain in place, including
`source-9468cc3e.tar.gz`, `source-09173a4.tar.gz` from this round and earlier ones from
the prior correction milestone), update `.deploy/source-revision` to match, and re-run
`docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`
followed by `alembic upgrade head` (migrations are additive only — no destructive
migration exists on this branch, so no database rollback is needed). No secrets were
printed or read at any point in this process (`.env` was preserved byte-for-byte
throughout, verified via unchanged file timestamp after each extraction).
@@ -0,0 +1,155 @@
# Fleet Ops release — final-product-polish evidence
## Result: PASS
## Commits
- Original feature-branch baseline before this task: `257a4cf` (`docs(polish): audit finale demo-afwerking`)
- Feature-branch commits added this task, on `feat/mobilityops-functional-completion`:
- `337f871` — polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
- `845db14` — fix: mobile topbar overflow at 421-440px and add trilingual responsive coverage
- Feature branch final commit: `845db14e172539b1d10e40f6a3249a72122deb41`
- `master` before merge (verified against the previously recorded baseline): `e0c7ed60112510687627d20a957af91c8b9db7f8` — unchanged, no unexpected commits, no conflicts (confirmed via `git merge-tree` dry run before merging)
- Merge commit on `master`: `18a765d62345ea9a6660d04fb868f218cf4d0b6e` (`merge: release Fleet Ops multilingual demo`, `--no-ff`)
- Final `master` commit (pushed and deployed): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
- Deployed commit on Unraid (`.deploy/source-revision`): `18a765d62345ea9a6660d04fb868f218cf4d0b6e`
- Feature branch was **not** deleted, per instruction.
## URL
- Live review deployment: `http://192.168.10.150:1236`
## Visible branding
- Product name "Fleet Ops" (with a space) visible in: sidebar brand lockup, browser tab title, login screen, footer product line, About page heading ("What Fleet Ops is and isn't" / "Wat Fleet Ops wel en niet is" / "Ce que Fleet Ops est et n'est pas"), demo badge popover, dashboard copy, all 3 languages.
- No visible "MobilityOps" or "PoC"/"proof of concept" wording remains in user-facing copy (verified by full-page inspection of all main routes in all 3 languages plus a targeted source grep for stray hardcoded strings). The repository, Docker image names, and internal git history retain "MobilityOps" (out of scope; not user-visible).
- Retained technical identifiers (unchanged, as instructed): API paths (`/api/v1/...`), Docker Compose project name (`mobilityops`), internal vehicle/customer reference prefixes (`MO-`, `CUS-`), Gitea repository name.
## Supported locales
- `nl-BE` (default for a fresh session, unauthenticated visitor)
- `en-GB`
- `fr-BE`
- Persisted via `localStorage` key `fleetops.language`; survives refresh, logout/login, and demo reset. No flags used — accessible `<select>` language picker (visible name/code) in the topbar (desktop/tablet) and inside the mobile navigation drawer (≤960px, to avoid topbar overflow). `document.documentElement.lang` kept in sync. All dates/numbers rendered via `Intl.DateTimeFormat`/`Intl.NumberFormat` (`Europe/Brussels` timezone).
## Translation coverage
- `frontend/e2e/i18n-coverage.spec.ts`: recursively compares every key path across all 3 locale files for all 14 namespaces (`common, auth, navigation, dashboard, fleet, bookings, returns, quality, knowledge, integrations, audit, demo, errors, accessibility`) and fails the build on any missing key or empty string value. **2/2 passed** in every gate run this task (local, clean-checkout, and live-deployment runs).
- Command: `npx playwright test e2e/i18n-coverage.spec.ts --project=chromium`
## Knowledge-base locales
- `knowledge/procedures/{nl-BE,en-GB,fr-BE}/` — 11 procedure documents per language (same `document_id`s across languages so citations stay stable): vehicle checkout, vehicle return, damage handling, odometer anomalies, cleaning checklist, maintenance escalation, customer documents, privacy, booking conflicts, roles/escalation, and a new **vehicle availability** procedure (added this task to cover the "vehicle-available-again" guided-demo step explicitly).
- `DemoKnowledgeProvider` now retrieves per-language (only searches the UI-selected language's corpus), with localized "no match"/"low confidence" boilerplate text per language; the frontend passes the active UI language on every `/api/v1/knowledge/questions` and `/api/v1/knowledge/status` call.
- Verified live in all 3 languages this task (see Browser evidence below): NL/EN/FR suggested questions each return grounded, correctly-cited, same-language answers.
- Backend unit tests: `test_demo_provider_grounds_damage_question_in_dutch`, `test_demo_provider_grounds_damage_question_in_french`, `test_demo_provider_health_reports_document_count_per_language`, `test_demo_provider_insufficient_evidence_message_is_localized` — all passing.
## Demo Guide — adaptive per breakpoint
- **Extra-wide desktop (≥1440px)**: docked rail (`.demo-guide-panel.is-wide`), fixed 420px minimum width, no drop shadow (reads as part of the layout), never auto-collapses. Verified: `demo-guide.spec.ts` → "wide desktop viewport docks the guide as a rail that never collapses to a chip".
- **Standard desktop/tablet (7011439px)**: floating non-modal panel that auto-collapses to a persistent, closable progress chip ("Demo-gids · stap X van Y") the instant "Ga naar deze stap" is used; chip has its own expand action and a separate close (×) control; reopens on one click; content reflow padding shrinks to 0 while collapsed so nothing is permanently blocked. Verified: 3 dedicated tests in `demo-guide.spec.ts`.
- **Mobile (≤700px)**: bottom sheet with collapsed / half / full states, a drag-handle button that cycles states, no horizontal overflow, primary actions (Volgende/Ga naar deze stap) reachable in the half state. Verified: `demo-guide.spec.ts` → "mobile viewport shows a bottom sheet with collapsed/half/full states and no horizontal overflow", plus `demo-accessibility.spec.ts` → "demo guide is usable as a mobile bottom sheet".
- **Cross-cutting (4D)**: "Ga naar deze stap" scrolls the on-page target into view, moves programmatic focus to it (`tabindex=-1` + `.focus()`), and applies a 2.2s outline pulse (`.demo-guide-highlight`, disabled under `prefers-reduced-motion`); Escape collapses the standard-tier panel first, then closes it on a second press; progress (`currentIndex`/`completed`) persists in `sessionStorage` across navigation and reload. Verified: `demo-guide.spec.ts` → "Escape collapses the standard-tier panel, then closes it" and "going to a step scrolls, focuses and highlights the on-page target".
- Fixed along the way: two dangling `aria-labelledby` references (`SectionHeading` never actually set the referenced `id`) on Dashboard and Data Quality Issue Detail panels.
## Data Quality Workbench improvements
- Replaced plain radio rows with accessible `.choice-card` selectable tiles (title, consequence detail, `:has(input:checked)`/`.is-selected` state, visible focus ring, hover state) across the duplicate-customer survivor choice, odometer-regression decision, and booking-overlap block choice.
- Clear action hierarchy: primary resolve/apply/merge action uses `.button-primary`; defer uses a de-emphasized `.button-tertiary`; reject uses `.button-tertiary-destructive` (muted, turns critical-red only on hover) — no longer visually competing with the recommended resolution.
- Technical evidence (`evidence_json`) collapsed by default behind a localized "Technical details" `<details>` disclosure.
- Contrast/opacity audited: no unintended overlays, disabled-looking text, or weak borders found beyond the (fixed) dangling-aria-labelledby issue.
## Terminology mapping
- Achieved via the i18next namespace architecture itself rather than a separate module: technical codes (rule types, statuses, action codes, integration states) resolve through dedicated JSON keys (`quality:ruleTypes.*`, `quality:list.status*`, `audit:actions.*`, `integrations:statusLabels.*`, `fleet:statuses.*`, `bookings:statuses.*`) with a human label in all 3 languages; raw technical values (correlation IDs, full UUIDs, raw evidence JSON) are confined to "Technical details" disclosures. Example mappings implemented: `possible_duplicate_customer` → "Possible duplicate customer"/"Mogelijke dubbele klant"/"Client peut-être en double"; `demo_login` → "Logged in"/"Ingelogd"/"Connecté"; n8n `degraded` → "Retry available"/"Opnieuw proberen mogelijk"/"Nouvelle tentative possible"; `not_configured`/`disabled` → "Not connected"/"Niet gekoppeld"/"Non connecté".
## Automation / audit improvements
- Automation ledger: succeeded events group and collapse when >3 in view ("Show N succeeded jobs"/"Hide individual jobs"), filter chips (needs-attention/recent/succeeded/all), meaningful short refs (`AUT-RET-####` derived from the aggregate ref, full UUID behind a `<details>`), localized event types and statuses.
- Audit trail: events grouped by `correlation_id` into one card with a human action-label heading (`audit:actions.*`), related-event count and an expandable technical list; readable before/after diff (`ChangeDiff` component: humanized field names, `set to`/`was`/`X → Y` phrasing) instead of raw JSON by default; short reference (`AUD-XXXXXXXX`) with full UUID and correlation ID behind "Technical details".
## Attention Queue / clickable rows
- Full "stretched link" pattern applied to: Attention Queue, Today's movements, Vehicles table, Bookings table, Data Quality table. Entire row is one activation target (pointer cursor, hover state, keyboard-focusable, Enter/Space activates), secondary in-row links (e.g. the vehicle reference inside a booking row) remain independently clickable via `.cell-link { z-index: 2 }` layered above the row overlay.
- Dedicated tests in `frontend/e2e/clickable-rows.spec.ts` (8 tests): click on empty row space, keyboard focus + Enter, mobile-viewport click, secondary-link independence, correct routing for each of the 5 surfaces, pointer-cursor/focus-ring check.
## Test results (all commands re-run against this exact final state)
### Backend (local dev stack, clean-checkout instance, and live Unraid deployment — all three, all green)
```
docker compose exec api pytest -q → 131 passed
docker compose exec api ruff check . → All checks passed!
docker compose exec api mypy app → Success: no issues found in 48 source files
```
### Frontend
```
cd frontend && npm run build → tsc -b && vite build: success
```
### Playwright (92 tests; run against local dev stack, the isolated clean-checkout stack, and the live Unraid deployment — 92/92 passed in all three runs)
```
npx playwright test --project=chromium
```
Suites: `demo-accessibility`, `demo-entry`, `demo-guide` (including the 3 new adaptive-breakpoint tests, chip close-control test, Escape test, scroll/focus/highlight test), `demo-legibility`, `demo`, `guided-demo-full`, `i18n-coverage`, `interactive-elements`, `responsive-i18n` (7 breakpoints × 3 languages = 21 tests), `ui-redesign`, `clickable-rows` (new, 8 tests).
## Clean-checkout drill (evidence)
Performed in an isolated environment (separate Compose project `mobilityops-clean`, separate host ports 8129/1229, no shared volumes or n8n) so the user's existing long-running dev/n8n environment was never touched:
1. `git clone` of the local repository at commit `845db14` (feature branch, pre-merge) into a scratch directory.
2. `cp .env.example .env` (project name and ports overridden for isolation only).
3. `docker compose up --build -d db api web` — migrations ran automatically on API startup.
4. `docker compose exec api python -m app.cli seed --reset` — deterministic seed loaded (users:2, customers:180, vehicles:50, bookings:246, inspections:75, maintenance:40, data_quality_issues:26, workflow_runs:20).
5. `docker compose exec api pytest -q` → 131 passed. `ruff check .` → clean. `mypy app` → clean.
6. `npm ci && npm run build` → clean build.
7. `npx playwright test --project=chromium` (pointed at the isolated stack via `MOBILITYOPS_PUBLIC_URL`) → 92 passed.
8. Live browser verification in English and French (Dutch already covered as the automated-suite default): guided-demo dashboard, knowledge-assistant grounded answers in both languages with correct same-language citations.
9. `POST /api/v1/demo/reset``scenario_integrity: {"all_ready": true, "not_ready": []}`.
10. Isolated stack torn down (`docker compose down -v`) — original dev environment (containers, n8n owner account/workflows) confirmed untouched and healthy throughout.
No PASS was claimed from pre-existing containers at any point — every gate above ran against a stack built from empty volumes.
## Server deployment evidence
- Deployed via the established safe method: `git archive` from the exact commit → `scp` to `.deploy/source-<sha>.tar.gz` on Unraid → extract → update `.deploy/source-revision``docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web` (db never rebuilt; server `.env` and named volumes — Postgres, n8n — preserved throughout).
- Deployed twice this task: once for the feature branch (`845db14`) for pre-merge live validation, once for the merged `master` (`18a765d`) for the final release.
- Post-deploy, both times: migrations confirmed at head (`e7b08389f47f`), reseed run, `pytest`/`ruff`/`mypy` re-run in the container (all green), full 92-test Playwright suite re-run against the live URL (all green), console/network inspected via live browser (no errors, all `/api/*` calls 200), demo reset performed, `scenario_integrity.all_ready: true` confirmed both times.
- Real shared n8n instance (`http://192.168.10.150:5678`) integration confirmed live: the seeded failed-demo automation event correctly shows "Retry available"/"Opnieuw proberen mogelijk" (not the raw `degraded` string) on the Integration pulse card.
## Responsive / accessibility
- No-horizontal-overflow verified across the full 7-breakpoint matrix (1440×1000, 1280×800, 1024×768, 768×1024, 430×932, 390×844, 360×800) in all 3 languages (`responsive-i18n.spec.ts`, 21 tests) plus the original 4-breakpoint English suite (`ui-redesign.spec.ts`).
- Real bug found and fixed during this pass: the new topbar language switcher pushed the 421440px range into horizontal overflow (the existing "compact topbar" breakpoint stopped at 420px). Fixed by widening that breakpoint to 440px; re-verified clean at exactly 430px in all 3 languages.
- Focus-visible outlines, `prefers-reduced-motion` handling (demo-guide highlight pulse, bottom-sheet height transitions), and keyboard reachability verified via `demo-accessibility.spec.ts` and the new adaptive-guide/clickable-row tests.
## Screenshots
`artifacts/fleet-ops-release/screenshots/`:
- `01-login-nl.jpg` — login screen, Dutch default, language selector visible
- `02-dashboard-nl-desktop.jpg` — dashboard, Dutch, Attention Queue + Integration status
- `03-data-quality-choice-cards.jpg` — Data Quality Workbench choice-card redesign (duplicate-customer merge)
- `04-integrations-nl.jpg` — Integrations page, grouped/filterable automation ledger
- `05-audit-trail-nl.jpg` — Audit trail, correlation-grouped human action labels
- `06-dashboard-en-desktop.jpg` — dashboard, English
- `07-dashboard-fr-desktop.jpg` — dashboard, French
- `08-about-fr.jpg` — About page, French, confirming full rebrand + translated content
- `09-mobile-guide-bottom-sheet.png` — mobile bottom sheet, half state (390×844)
- `10-mobile-guide-full.png` — mobile bottom sheet, full state (390×844)
## Known limitations
- Data-quality evidence "summary" strings (the free-text detail line under each Attention Queue/Data Quality row, e.g. "exact email; exact phone; similar name") remain English-only — these are generated deep in the deterministic rule engine as diagnostic strings, not yet converted to message codes. The rule-type label, status, and all surrounding UI are fully localized; only this one diagnostic fragment is not. Documented as a follow-up, not blocking.
- RAGcore and ITWorx MCP Hub remain honestly labelled as not live-connected (unchanged from prior milestones) — the demo knowledge base is the multilingual, fully-verified stand-in.
- Vehicle/customer internal reference prefixes (`MO-`, `CUS-`) were left unchanged; they are generic internal codes, not user-visible "MobilityOps" branding, and changing them was out of scope for this task.
- Automated live-browser evidence for the guided demo was captured in Dutch (via the automated Playwright suite, which defaults to the app's own nl-BE default) and manually spot-checked live in English and French (knowledge assistant, dashboard, About page); a full manual click-through of all 8 guided-demo steps was not repeated live in all 3 languages beyond the automated `guided-demo-full.spec.ts` (Dutch) and the targeted EN/FR checks documented above, given the exhaustive automated coverage already exercising the same code paths per language via `responsive-i18n.spec.ts` and `i18n-coverage.spec.ts`.
## Rollback procedure
- `.deploy/source-revision` on Unraid records the exact deployed commit (`18a765d62345ea9a6660d04fb868f218cf4d0b6e`).
- Prior tarballs remain in `.deploy/` on the server, including `.deploy/source-845db14.tar.gz` (feature branch, pre-merge) and `.deploy/source-4a268c7.tar.gz` (previous release, pre-polish).
- To roll back: extract the desired `source-<short-sha>.tar.gz`, update `.deploy/source-revision` to match, and re-run `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d api web`. Database migrations on this branch are additive only; no destructive migration was introduced.
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

@@ -0,0 +1,207 @@
# Live n8n + RAGcore integration — final evidence
No credential values, tokens, or secrets appear anywhere in this document. Where a
credential or trace ID is referenced, only its name or an opaque reference identifier is
given, never its value.
## Commit
Built on branch `feat/live-n8n-ragcore-integration`, HEAD at commit
`aaa16305354d34f9c1f4d57253d33d9062c38faa` ("docs: record WF2 retry fix and WF4's
n8n-session-expiry blocker"). Run `git log --oneline feat/live-n8n-ragcore-integration`
for the full history of this effort.
## Scope
The brief required treating n8n (`https://n8n.itworx.tech`) as a full third integration
layer alongside RAGcore and MCP Hub, with Fleet Ops keeping exclusive ownership of
business rules, authorization, transactions, audit, and idempotency. Four canonical n8n
workflows were required. The repository (`n8n/workflows/*.json` + `MANIFEST.md` +
`n8n/workflows/check_drift.py`) is the source of truth for cleaned workflow definitions;
the Fleet Ops integration status page (`/automation`) shows real per-workflow operational
evidence, not a config boolean.
## Result summary
| # | Workflow | Status | Live evidence this round |
|---|---|---|---|
| 1 | Fleet Ops — Vehicle Return Orchestration | **Live, hardened** | Timeout+bounded-retry gap found and fixed |
| 2 | Fleet Ops — Scheduled Data Quality Scan | **Live, hardened** | Same gap found and fixed |
| 3 | Fleet Ops — RAGcore Procedure Sync | **Blocked** | Not built — RAGcore rejects credential issuance (see below) |
| 4 | Fleet Ops — Workflow Error Handler | **Live, validated** | Mock + genuine induced-failure test; own hardening incomplete (see below) |
Full per-workflow detail (purpose, trigger, event contract, required credentials, live
workflow ID, checksum) is in `n8n/workflows/MANIFEST.md`, which is the authoritative,
continuously-updated source — this document is a point-in-time summary of that state
plus the reasoning behind what's not done.
## Workflow 1 — Vehicle Return Orchestration
Live workflow ID `mobilityops-return-processing`. Validated in an earlier round of this
effort: webhook trigger requires Header Auth (`Fleet Ops Webhook Trigger Token`),
validates `event_type == vehicle.returned.v1`, derives a follow-up category, calls Fleet
Ops's `/return-callback` endpoint with an `Idempotency-Key` header via a named
`Fleet Ops Service Token` credential (not a literal secret), and responds with a
controlled JSON result. Idempotent on both sides (`event_id` flows through as the
dedup key; the backend independently checks for a prior audit event before recording
again).
**This round's finding**: the `Record follow-up` HTTP node had no explicit timeout and
"Retry On Fail" disabled — a real gap against the requirement that external dependencies
have timeouts and bounded retries. Fixed live: Retry On Fail (3 tries, 1000ms wait) + a
15000ms timeout, published. Safe to retry because the callback is idempotent. Repo
definition and manifest checksum synced (commit `0562893`).
Attached to workflow 4 as its Error Workflow.
## Workflow 2 — Scheduled Data Quality Scan
Live workflow ID `mobilityops-scheduled-quality-scan`. Validated earlier: hourly
Schedule Trigger + a Manual Trigger for on-demand testing, both feeding a single HTTP
call to Fleet Ops's `/scheduled-scan` endpoint (Header Auth via the same `Fleet Ops
Service Token` credential, 15000ms timeout already configured), which runs the
domain-level `run_scan()` function — documented and tested as idempotent by
construction (only ever creates an issue for a condition that doesn't already have one
open), so overlapping or retried triggers do no duplicate domain work.
**This round's finding**: the same Retry On Fail gap as workflow 1 (timeout was already
set, retries were not). Fixed live the same way (3 tries, 1000ms wait), published. Repo
definition and manifest checksum synced (commit `167bf49`).
Attached to workflow 4 as its Error Workflow.
## Workflow 3 — RAGcore Procedure Sync — blocked
**Not built.** This workflow needs an application credential (scope `sources:sync`) for
the `fleet-ops` application in RAGcore. Two independent issuance attempts, in two
separate rounds of this effort, both failed with an opaque server-side rejection:
1. **Raw API**: `POST /v1/applications/{id}/credentials``400`, "authoritative
service-account state rejected issuance".
2. **RAGcore admin UI**, this round, after the project owner explicitly authorized
Claude to self-issue the credential: the "Issue credential" form for the `fleet-ops`
application, submitted as the Platform Admin role (the highest role visible in the
RAGcore admin), with name `n8n-ragcore-procedure-sync` and scope `sources:sync` only.
Result: "Something went wrong. The credential could not be issued with those
values.", trace reference `1955c6a8968c4941a22a1faef39e17a7`.
The `fleet-ops` application itself shows as ordinary/`Active` in the RAGcore admin, with
no visible lock flag, and RAGcore's own OpenAPI spec documents no validation rule that
would explain either rejection (no `422`, no field-level errors). Two independent paths
— a raw API call and the admin UI as the top admin role — hitting the same failure
signature is conclusive evidence this is a RAGcore-side policy or bug, not a Fleet Ops
request-shape or permission problem. It is not fixable from the Fleet Ops side or
through further UI automation. Resolving it requires whoever operates the RAGcore
instance to look up the trace ID above (and the earlier raw-API rejection) in RAGcore's
own logs.
The real RAGcore contract this workflow will be built against — once a working
credential exists — was independently inspected via RAGcore's live OpenAPI spec and is
recorded in `docs/live-ai-integration/n8n-current-state.md` and
`contracts/ragcore-contract-assumptions.md`: control-plane endpoints require an
`Idempotency-Key` header; ingestion is `POST /v1/uploads`; retrieval is `POST
/v1/search` / `/v1/context` / `/v1/answers` (the latter requiring `requested_space_ids`,
an array of knowledge-space UUIDs); health is `/health/live` and `/health/ready` (not
`/health`); the scope enum is `search, context, answer, documents:read, citations:read,
feedback:write, sources:sync`.
**`RAGcoreKnowledgeProvider` adapter** (`backend/app/services/knowledge/ragcore.py`)
still targets the earlier speculative contract (`/health`, `POST /api/v1/ask`, Bearer
token) rather than the real one above. This was deliberately **not** rewritten this
round: rewriting it blind, without a credential to validate against, risks introducing
a silent behavioral bug in exactly the code path responsible for the project's "AI must
never invent an answer when RAGcore is unavailable or returns insufficient evidence"
guarantee — for example a wrong `evidence_state` mapping that looks fine in code review
but misclassifies "unavailable" as "insufficient" (or vice versa) against the real
response shape. The adapter's current behavior is honest and safe (it degrades cleanly
to `unavailable` on any request or parsing failure, and `ragcore_api_token` is unset by
default so the app correctly runs on the local demo knowledge provider today). The
rewrite stays queued behind the same credential blocker as workflow 3.
## Workflow 4 — Workflow Error Handler
Live workflow ID `Xppn2rAEqUuyiCJF`. Built and live-validated in an earlier round:
Error Trigger → a Code node that derives a bounded, secret-free failure report (error
category classified from the message text, truncated summary, no stack trace, no
headers or tokens) → an HTTP call to Fleet Ops's `/workflow-error` endpoint (Header Auth
via the same `Fleet Ops Service Token` credential), which registers the failure as an
audit event idempotently keyed on `execution_id`.
Set as the Error Workflow on both workflow 1 and workflow 2. Confirmed workflow 4 has no
Error Workflow of its own (prevents a recursive loop).
**Live validation performed**: a pinned mock Error Trigger payload produced a real `200
{"status":"registered", ...}` from the live Fleet Ops server; re-running the identical
payload produced `"status":"already_registered"`, confirming idempotency. A genuine
induced failure (temporarily pointing workflow 2's HTTP node at a nonexistent path, then
reverting) confirmed workflow 2 itself fails correctly against a broken endpoint and
recovers cleanly once reverted.
**Known limitation**: n8n's Error Workflow trigger does not fire for manual editor
"Execute workflow" test runs — checked via workflow 4's own Executions list after the
induced workflow-2 failure, and confirmed no new execution appeared. n8n only invokes a
workflow's assigned Error Workflow for unattended/production trigger executions, not
manual test runs from the editor. The mock-data path exercises the same nodes, logic,
and real Fleet Ops endpoint, but a fully automatic (schedule- or webhook-triggered)
failure cascading into workflow 4 was not observed live in either round.
**Open follow-up (minor, non-blocking)**: continuing this round's acceptance pass to
workflow 4 found the same timeout/retry gap as workflows 1 and 2 on its own outbound
HTTP call. A fix was started (15000ms timeout added, Retry On Fail toggled on) but n8n's
autosave began failing with "Unauthorized" mid-edit; a fresh browser tab confirmed the
n8n session had expired (redirected to `/signin`). Nothing was saved — workflow 4's live
definition is unchanged from before this round, so there is no partial or broken state.
This is lower-stakes than workflows 1/2 (workflow 4 is the error notifier itself, not a
primary business flow, and a failed error-report is already visible in n8n's own
execution history via `On Error: Stop Workflow`) but should be finished once the n8n
browser session is re-authenticated.
## Repository source of truth
`n8n/workflows/` holds cleaned, credential-value-free JSON definitions for all built
workflows, `n8n/workflows/MANIFEST.md` documents purpose/trigger/contract/credentials/
live-ID/checksum for all four canonical workflows (including workflow 3's blocked
status), and `n8n/workflows/check_drift.py` is a read-only script that compares the
repo definitions against the live instance via n8n's Public API and reports drift —
safe to run in CI as a non-blocking check. No literal export/download mechanism was
found working in this n8n version, so each definition was reconstructed from direct,
verified UI inspection rather than a native export; this limitation is noted in the
manifest itself.
## Integration status page
`/automation` (Operations Manager only) surfaces real per-workflow evidence derived
purely from Fleet Ops's own audit/outbox tables — no new dependency on n8n's API was
added to the backend. Each of the four canonical workflows shows a status (not built /
no evidence yet / operational) and a last-evidence timestamp; the scheduled-scan
evidence specifically filters to `actor_type == "service"` so a manually-triggered scan
in the UI doesn't count as n8n evidence. An error-handler summary line reports total
registered automation failures and the most recent one.
Verified live in the browser (Dutch locale) both locally and on the deployed
production server (`http://192.168.10.150:1236/automation`): correctly showed "3 van 4
canonieke n8n-workflows hebben actuele evidentie van werking" with real timestamps for
the return/scan/error-handler workflows, "Nog Niet Gebouwd" for the RAGcore sync, and
the real error-handler registration from this effort's live testing.
## Deployments performed (all explicitly user-approved)
1. Backend `/workflow-error` endpoint (commit `bbdb4a9`) — deployed and verified
(`/health` OK, new endpoint returns `422` not `404` on an empty POST body).
2. Integration status page, backend + frontend (commit `4049c0c`) — deployed and
verified (`/health` OK, page renders real evidence in the browser).
The three n8n-side node edits this round (WF1 timeout/retry, WF2 timeout/retry, WF4's
incomplete attempt) are live edits to the n8n instance itself and do not require a
Fleet Ops redeploy.
## What's left
1. **RAGcore credential issuance** — blocked on RAGcore's own server-side rejection
(trace `1955c6a8968c4941a22a1faef39e17a7` and the earlier raw-API `400`). Needs
RAGcore's operator to investigate. Unblocks workflow 3 and the
`RAGcoreKnowledgeProvider` real-contract rewrite.
2. **Workflow 4's own timeout/bounded-retry hardening** — needs the n8n browser session
re-authenticated to finish; a small, well-understood, non-blocking edit.
3. **Fleet Ops logo/favicon** — explicitly deferred by the project owner as a separate,
unrelated follow-up task, not part of this integration effort.
+22 -5
View File
@@ -1,16 +1,33 @@
FROM python:3.12-slim FROM python:3.12-slim-bookworm@sha256:a116514e19457bcb7af7efe9c3dd0b9b71e85b317694e7882a1c52aa15a78134 AS runtime-base
ARG VCS_REF=development
ARG BUILD_DATE=unknown
LABEL org.opencontainers.image.title="Fleet Ops API" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE" \
org.opencontainers.image.source="https://fleetops.itworx.tech"
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
WORKDIR /app WORKDIR /app
COPY backend/requirements.lock ./ COPY backend/requirements-prod.lock ./
RUN pip install --no-cache-dir -r requirements.lock RUN pip install --no-cache-dir -r requirements-prod.lock
COPY backend/pyproject.toml ./ COPY backend/pyproject.toml ./
COPY backend/app ./app COPY backend/app ./app
COPY backend/alembic ./alembic COPY backend/alembic ./alembic
COPY backend/alembic.ini ./ COPY backend/alembic.ini ./
COPY backend/tests ./tests
COPY seed ./seed COPY seed ./seed
COPY knowledge ./knowledge COPY knowledge ./knowledge
COPY backend/entrypoint.sh ./entrypoint.sh COPY backend/entrypoint.sh ./entrypoint.sh
RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh RUN pip install --no-cache-dir --no-deps -e . && chmod +x ./entrypoint.sh \
&& addgroup --system app && adduser --system --ingroup app --home /app app \
&& chown -R app:app /app
FROM runtime-base AS test
COPY backend/requirements.lock ./requirements.lock
RUN pip install --no-cache-dir -r requirements.lock
COPY backend/tests ./tests
USER app
FROM runtime-base AS runtime
# Run migrations and the API as an unprivileged user; nothing here needs root.
USER app
EXPOSE 8000 EXPOSE 8000
CMD ["./entrypoint.sh"] CMD ["./entrypoint.sh"]
+1 -1
View File
@@ -1,7 +1,7 @@
[alembic] [alembic]
script_location = alembic script_location = alembic
prepend_sys_path = . prepend_sys_path = .
version_path_separator = os path_separator = os
[loggers] [loggers]
keys = root,sqlalchemy,alembic keys = root,sqlalchemy,alembic
@@ -0,0 +1,28 @@
"""idempotency request fingerprint
Revision ID: 0a4c1d2e3f5b
Revises: c24f6a9d013e
Create Date: 2026-08-16 22:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "0a4c1d2e3f5b"
down_revision: Union[str, None] = "c24f6a9d013e"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"idempotency_records",
sa.Column("request_fingerprint", sa.String(length=64), nullable=True),
)
def downgrade() -> None:
op.drop_column("idempotency_records", "request_fingerprint")
@@ -0,0 +1,27 @@
"""enforce one open issue per detected condition
Revision ID: 4f2b9c8d7e61
Revises: 0a4c1d2e3f5b
"""
from alembic import op
import sqlalchemy as sa
revision = "4f2b9c8d7e61"
down_revision = "0a4c1d2e3f5b"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_index(
"uq_data_quality_one_open_condition",
"data_quality_issues",
["rule_type", "entity_type", "entity_id"],
unique=True,
postgresql_where=sa.text("status = 'open'"),
)
def downgrade() -> None:
op.drop_index("uq_data_quality_one_open_condition", table_name="data_quality_issues")
@@ -0,0 +1,25 @@
"""outbox last_error_code
Revision ID: 799d8800e241
Revises: e7b08389f47f
Create Date: 2026-08-03 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '799d8800e241'
down_revision: Union[str, None] = 'e7b08389f47f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True))
def downgrade() -> None:
op.drop_column('outbox_events', 'last_error_code')
@@ -0,0 +1,28 @@
"""add optional external OIDC identity
Revision ID: a81d0ce9f662
Revises: f43d829ab610
"""
import sqlalchemy as sa
from alembic import op
revision = "a81d0ce9f662"
down_revision = "f43d829ab610"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("identity_provider", sa.String(80), nullable=True))
op.add_column("users", sa.Column("external_subject", sa.String(255), nullable=True))
op.create_unique_constraint(
"uq_user_external_identity", "users", ["identity_provider", "external_subject"]
)
def downgrade() -> None:
op.drop_constraint("uq_user_external_identity", "users", type_="unique")
op.drop_column("users", "external_subject")
op.drop_column("users", "identity_provider")
@@ -0,0 +1,25 @@
"""operational user credentials
Revision ID: b7c7b536df85
Revises: 799d8800e241
"""
from alembic import op
import sqlalchemy as sa
revision = "b7c7b536df85"
down_revision = "799d8800e241"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("email", sa.String(length=320), nullable=True))
op.add_column("users", sa.Column("password_hash", sa.String(length=512), nullable=True))
op.create_unique_constraint("uq_users_email", "users", ["email"])
def downgrade() -> None:
op.drop_constraint("uq_users_email", "users", type_="unique")
op.drop_column("users", "password_hash")
op.drop_column("users", "email")
@@ -0,0 +1,24 @@
"""add explicit customer anonymisation state
Revision ID: b913a72e8c14
Revises: a81d0ce9f662
"""
import sqlalchemy as sa
from alembic import op
revision = "b913a72e8c14"
down_revision = "a81d0ce9f662"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("customers", sa.Column("anonymized_at", sa.DateTime(timezone=True)))
op.create_index("ix_customers_anonymized_at", "customers", ["anonymized_at"])
def downgrade() -> None:
op.drop_index("ix_customers_anonymized_at", table_name="customers")
op.drop_column("customers", "anonymized_at")
@@ -0,0 +1,68 @@
"""add domain constraints and operational indexes
Revision ID: c24f6a9d013e
Revises: b913a72e8c14
"""
import sqlalchemy as sa
from alembic import op
revision = "c24f6a9d013e"
down_revision = "b913a72e8c14"
branch_labels = None
depends_on = None
def upgrade() -> None:
checks = (
("bookings", "ck_bookings_status", "status IN ('reserved','active','returned','cancelled','blocked')"),
("bookings", "ck_bookings_time_window", "ends_at > starts_at"),
("bookings", "ck_bookings_start_odometer", "start_odometer_km IS NULL OR start_odometer_km >= 0"),
("bookings", "ck_bookings_end_odometer", "end_odometer_km IS NULL OR end_odometer_km >= 0"),
("vehicles", "ck_vehicles_operational_status", "operational_status IN ('available','rented','cleaning','maintenance','blocked')"),
("vehicles", "ck_vehicles_model_year", "model_year BETWEEN 1900 AND 2100"),
("vehicles", "ck_vehicles_odometer", "odometer_km >= 0"),
("vehicles", "ck_vehicles_next_service", "next_service_km >= 0"),
("vehicles", "ck_vehicles_version", "version >= 1"),
("data_quality_issues", "ck_data_quality_rule_type", "rule_type IN ('possible_duplicate_customer','missing_required_field','odometer_regression','booking_overlap','vehicle_status_conflict')"),
("data_quality_issues", "ck_data_quality_severity", "severity IN ('low','medium','high')"),
("data_quality_issues", "ck_data_quality_status", "status IN ('open','deferred','resolved','rejected')"),
("outbox_events", "ck_outbox_delivery_status", "delivery_status IN ('pending','delivering','succeeded','failed')"),
("outbox_events", "ck_outbox_attempts", "attempts >= 0"),
("audit_events", "ck_audit_actor_type", "actor_type IN ('user','service','system')"),
)
for table, name, condition in checks:
op.create_check_constraint(name, table, condition)
op.create_index("ix_bookings_vehicle_status_window", "bookings", ["vehicle_id", "status", "starts_at", "ends_at"])
op.create_index("ix_data_quality_work_queue", "data_quality_issues", ["status", "due_at", "severity"])
op.create_index("ix_outbox_delivery_next_attempt", "outbox_events", ["delivery_status", "next_attempt_at"])
op.create_index("ix_audit_action_occurred", "audit_events", ["action", "occurred_at"])
op.create_index("ix_audit_entity", "audit_events", ["entity_type", "entity_id"])
def downgrade() -> None:
op.drop_index("ix_audit_entity", table_name="audit_events")
op.drop_index("ix_audit_action_occurred", table_name="audit_events")
op.drop_index("ix_outbox_delivery_next_attempt", table_name="outbox_events")
op.drop_index("ix_data_quality_work_queue", table_name="data_quality_issues")
op.drop_index("ix_bookings_vehicle_status_window", table_name="bookings")
for table, name in (
("audit_events", "ck_audit_actor_type"),
("outbox_events", "ck_outbox_attempts"),
("outbox_events", "ck_outbox_delivery_status"),
("data_quality_issues", "ck_data_quality_status"),
("data_quality_issues", "ck_data_quality_severity"),
("data_quality_issues", "ck_data_quality_rule_type"),
("vehicles", "ck_vehicles_version"),
("vehicles", "ck_vehicles_next_service"),
("vehicles", "ck_vehicles_odometer"),
("vehicles", "ck_vehicles_model_year"),
("vehicles", "ck_vehicles_operational_status"),
("bookings", "ck_bookings_end_odometer"),
("bookings", "ck_bookings_start_odometer"),
("bookings", "ck_bookings_time_window"),
("bookings", "ck_bookings_status"),
):
op.drop_constraint(name, table, type_="check")
@@ -0,0 +1,49 @@
"""persist revoked sessions
Revision ID: d1f83bc64170
Revises: b7c7b536df85
"""
import sqlalchemy as sa
from alembic import op
revision = "d1f83bc64170"
down_revision = "b7c7b536df85"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"revoked_sessions",
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_revoked_sessions_expires_at", "revoked_sessions", ["expires_at"])
op.create_index(
"ix_revoked_sessions_token_hash",
"revoked_sessions",
["token_hash"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_revoked_sessions_token_hash", table_name="revoked_sessions")
op.drop_index("ix_revoked_sessions_expires_at", table_name="revoked_sessions")
op.drop_table("revoked_sessions")
@@ -0,0 +1,65 @@
"""add ownership and SLA deadlines to data quality issues
Revision ID: f43d829ab610
Revises: d1f83bc64170
"""
import sqlalchemy as sa
from alembic import op
revision = "f43d829ab610"
down_revision = "d1f83bc64170"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"data_quality_issues",
sa.Column("due_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"data_quality_issues",
sa.Column("assigned_to_user_id", sa.Uuid(), nullable=True),
)
op.create_foreign_key(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
"users",
["assigned_to_user_id"],
["id"],
ondelete="SET NULL",
)
op.create_index("ix_data_quality_issues_due_at", "data_quality_issues", ["due_at"])
op.create_index(
"ix_data_quality_issues_assigned_to_user_id",
"data_quality_issues",
["assigned_to_user_id"],
)
op.execute(
"""
UPDATE data_quality_issues
SET due_at = detected_at + CASE severity
WHEN 'high' THEN interval '4 hours'
WHEN 'low' THEN interval '3 days'
ELSE interval '1 day'
END
WHERE status = 'open' AND due_at IS NULL
"""
)
def downgrade() -> None:
op.drop_index(
"ix_data_quality_issues_assigned_to_user_id",
table_name="data_quality_issues",
)
op.drop_index("ix_data_quality_issues_due_at", table_name="data_quality_issues")
op.drop_constraint(
"fk_data_quality_issues_assigned_user",
"data_quality_issues",
type_="foreignkey",
)
op.drop_column("data_quality_issues", "assigned_to_user_id")
op.drop_column("data_quality_issues", "due_at")
+50 -8
View File
@@ -1,6 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import re
from collections.abc import Generator from collections.abc import Generator
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status from fastapi import Depends, Header, HTTPException, Request, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -8,7 +11,9 @@ from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.core.db import SessionLocal from app.core.db import SessionLocal
from app.core.security import SessionPayload, read_session_token from app.core.security import SessionPayload, read_session_token
from app.models.user import User
from app.schemas import CurrentUser, Role from app.schemas import CurrentUser, Role
from app.services.sessions import is_session_revoked
settings = get_settings() settings = get_settings()
_VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined] _VALID_ROLES = frozenset(Role.__args__) # type: ignore[attr-defined]
@@ -22,13 +27,32 @@ def get_db() -> Generator[Session, None, None]:
db.close() db.close()
def get_current_user(request: Request) -> CurrentUser: def get_current_user(request: Request, db: Session = Depends(get_db)) -> CurrentUser:
token = request.cookies.get(settings.session_cookie_name) token = request.cookies.get(settings.session_cookie_name)
payload: SessionPayload | None = read_session_token(token) if token else None payload: SessionPayload | None = read_session_token(token) if token else None
if payload is None or payload.role not in _VALID_ROLES: revoked = token is not None and is_session_revoked(db, token)
if payload is None or payload.role not in _VALID_ROLES or revoked:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
role: Role = payload.role # type: ignore[assignment] # Demo reset deliberately rebuilds the deterministic users table. Retaining the
return CurrentUser(public_ref=payload.public_ref, display_name=payload.display_name, role=role) # signed demo session until the reset endpoint clears its cookie keeps existing demo
# workflows stable; operational sessions are always checked against the live record.
if settings.mobilityops_demo_mode:
demo_role: Role = payload.role # type: ignore[assignment]
return CurrentUser(
public_ref=payload.public_ref,
display_name=payload.display_name,
role=demo_role,
)
user = db.get(User, payload.user_id)
if (
user is None
or not user.active
or user.public_ref != payload.public_ref
or user.role not in _VALID_ROLES
):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
role: Role = user.role # type: ignore[assignment]
return CurrentUser(public_ref=user.public_ref, display_name=user.display_name, role=role)
def require_operations_manager( def require_operations_manager(
@@ -41,12 +65,30 @@ def require_operations_manager(
return user return user
@dataclass(frozen=True)
class McpClientContext:
reported_client_id: str
tenant: str
_MCP_CLIENT_ID = re.compile(
r"^itworx-mcp-hub:(?:readiness|mobilityops:[A-Za-z0-9][A-Za-z0-9._:-]{0,127})$"
)
def require_mcp_service_token( def require_mcp_service_token(
x_service_token: str = Header(..., alias="X-Service-Token"), x_service_token: str = Header(..., alias="X-Service-Token"),
x_client_id: str = Header(default="unknown-mcp-client", alias="X-Client-Id"), x_client_id: str = Header(..., alias="X-Client-Id", min_length=1, max_length=180),
) -> str: x_tenant_id: str | None = Header(default=None, alias="X-Tenant-Id", max_length=120),
if x_service_token != settings.mcp_hub_service_token: ) -> McpClientContext:
if not hmac.compare_digest(x_service_token, settings.mcp_hub_service_token):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token" status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid service token"
) )
return x_client_id if not _MCP_CLIENT_ID.fullmatch(x_client_id):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Untrusted MCP client identity"
)
if x_tenant_id is not None and x_tenant_id != settings.ragcore_tenant:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tenant mismatch")
return McpClientContext(reported_client_id=x_client_id, tenant=settings.ragcore_tenant)
+126 -9
View File
@@ -1,22 +1,30 @@
from __future__ import annotations from __future__ import annotations
import csv
import io
import json
import uuid import uuid
from collections.abc import Sequence from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import AuditEventOut, CurrentUser from app.schemas import AuditEventOut, AuditEventPageOut, CurrentUser
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/audit", tags=["audit"]) router = APIRouter(prefix="/api/v1/audit", tags=["audit"])
settings = get_settings()
# Only entity types with a stable public reference and (optionally) a real frontend route # Only entity types with a stable public reference and (optionally) a real frontend route
# are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable # are resolved here. Types like "system", "knowledge" or "mcp_tool" carry no linkable
@@ -36,6 +44,81 @@ _ROUTE_TEMPLATES: dict[str, str] = {
} }
def _as_utc(value: datetime | None) -> datetime | None:
"""Treat naive query datetimes as UTC so they compare safely with aware values."""
if value is None:
return None
return value if value.tzinfo is not None else value.replace(tzinfo=UTC)
@router.get("/export.csv")
def export_audit_csv(
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> Response:
end = _as_utc(occurred_to) or datetime.now(UTC)
start = _as_utc(occurred_from) or end - timedelta(days=30)
if end <= start or end - start > timedelta(days=90):
raise HTTPException(status_code=422, detail="Audit export range must be 1 to 90 days")
events = db.scalars(
select(AuditEvent)
.where(AuditEvent.occurred_at >= start, AuditEvent.occurred_at <= end)
.order_by(AuditEvent.occurred_at)
.limit(settings.privacy_audit_export_max_rows + 1)
).all()
if len(events) > settings.privacy_audit_export_max_rows:
raise HTTPException(status_code=413, detail="Audit export exceeds configured row limit")
output = io.StringIO(newline="")
writer = csv.writer(output)
writer.writerow(
(
"id",
"occurred_at",
"actor_type",
"actor_label",
"action",
"entity_type",
"entity_id",
"correlation_id",
"before",
"after",
"metadata",
)
)
for event in events:
writer.writerow(
(
event.id,
event.occurred_at.isoformat(),
event.actor_type,
event.actor_label,
event.action,
event.entity_type,
event.entity_id or "",
event.correlation_id,
json.dumps(event.before_json, separators=(",", ":"), default=str),
json.dumps(event.after_json, separators=(",", ":"), default=str),
json.dumps(event.metadata_json, separators=(",", ":"), default=str),
)
)
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="audit_exported",
entity_type="audit",
metadata={"from": start.isoformat(), "to": end.isoformat(), "rows": len(events)},
)
db.commit()
return Response(
output.getvalue(),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="mobilityops-audit.csv"'},
)
def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]: def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid.UUID, str]:
ids_by_type: dict[str, set[uuid.UUID]] = {} ids_by_type: dict[str, set[uuid.UUID]] = {}
for event in events: for event in events:
@@ -51,26 +134,51 @@ def _resolve_entity_refs(db: Session, events: Sequence[AuditEvent]) -> dict[uuid
return refs return refs
@router.get("", response_model=list[AuditEventOut]) @router.get("", response_model=list[AuditEventOut] | AuditEventPageOut)
def list_audit_events( def list_audit_events(
actor_label: str | None = Query(default=None), actor_label: str | None = Query(default=None),
action: str | None = Query(default=None), action: str | None = Query(default=None),
entity_type: str | None = Query(default=None), entity_type: str | None = Query(default=None),
correlation_id: str | None = Query(default=None), entity_ref: str | None = Query(default=None, min_length=1, max_length=100),
limit: int = Query(default=100, le=500), correlation_id: uuid.UUID | None = Query(default=None),
occurred_from: datetime | None = Query(default=None),
occurred_to: datetime | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[AuditEventOut]: ) -> list[AuditEventOut] | AuditEventPageOut:
stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc()).limit(limit) stmt = select(AuditEvent).order_by(AuditEvent.occurred_at.desc())
if actor_label: if actor_label:
stmt = stmt.where(AuditEvent.actor_label == actor_label) stmt = stmt.where(AuditEvent.actor_label == actor_label)
if action: if action:
stmt = stmt.where(AuditEvent.action == action) stmt = stmt.where(AuditEvent.action == action)
if entity_type: if entity_type:
stmt = stmt.where(AuditEvent.entity_type == entity_type) stmt = stmt.where(AuditEvent.entity_type == entity_type)
if entity_ref:
matched_ids: set[uuid.UUID] = set()
for model in _ENTITY_MODELS.values():
matched_ids.update(
db.scalars(
select(model.id).where(model.public_ref.ilike(f"%{entity_ref.strip()}%"))
).all()
)
if not matched_ids:
if page is None:
return []
return AuditEventPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
stmt = stmt.where(AuditEvent.entity_id.in_(matched_ids))
if correlation_id: if correlation_id:
stmt = stmt.where(AuditEvent.correlation_id == correlation_id) stmt = stmt.where(AuditEvent.correlation_id == correlation_id)
events = db.scalars(stmt).all() if occurred_from:
stmt = stmt.where(AuditEvent.occurred_at >= occurred_from)
if occurred_to:
stmt = stmt.where(AuditEvent.occurred_at <= occurred_to)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
events = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
entity_refs = _resolve_entity_refs(db, events) entity_refs = _resolve_entity_refs(db, events)
out = [] out = []
@@ -94,4 +202,13 @@ def list_audit_events(
metadata=e.metadata_json, metadata=e.metadata_json,
) )
) )
if page is None:
return out return out
total_pages = max(1, (total + page_size - 1) // page_size)
return AuditEventPageOut(
items=out,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
+312
View File
@@ -0,0 +1,312 @@
from __future__ import annotations
import time
import uuid
from authlib.integrations.starlette_client import OAuth, OAuthError # type: ignore[import-untyped]
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from fastapi.responses import RedirectResponse
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import FailedAttemptLimiter
from app.core.security import (
SessionPayload,
create_session_token,
hash_password,
read_session_token,
verify_password,
)
from app.models.user import User
from app.schemas import CurrentUser, OidcStatusOut, PasswordLoginRequest
from app.services.audit import record_audit_event
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
settings = get_settings()
_login_limiter = (
FailedAttemptLimiter(
max_failures=settings.login_max_failures,
window_seconds=settings.login_failure_window_seconds,
)
if settings.login_max_failures > 0
else None
)
def _client_key(request: Request) -> str:
# The API sits behind the web container's reverse proxy in every documented
# deployment. The proxy appends/overwrites the socket peer as the final hop, so an
# attacker-controlled leading value must never select a fresh limiter bucket.
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
return forwarded.split(",")[-1].strip()
return request.client.host if request.client else "unknown"
oauth = OAuth()
if settings.oidc_enabled and settings.oidc_issuer_url:
oauth.register(
name="oidc",
client_id=settings.oidc_client_id,
client_secret=settings.oidc_client_secret,
server_metadata_url=f"{settings.oidc_issuer_url.rstrip('/')}/.well-known/openid-configuration",
client_kwargs={"scope": "openid email profile"},
)
def _current_user_out(user: User) -> CurrentUser:
return CurrentUser(
public_ref=user.public_ref,
display_name=user.display_name,
role=user.role, # type: ignore[arg-type]
)
def _set_session(response: Response, user: User) -> None:
token = create_session_token(
SessionPayload(
user_id=str(user.id),
public_ref=user.public_ref,
role=user.role,
display_name=user.display_name,
issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
)
)
response.set_cookie(
settings.session_cookie_name,
token,
httponly=True,
samesite="lax",
secure=settings.session_cookie_secure,
max_age=settings.session_ttl_seconds,
)
def bootstrap_initial_admin(db: Session) -> None:
"""Create or rotate the explicitly configured first manager in operational mode."""
if (
settings.mobilityops_demo_mode
or not settings.initial_admin_email
or not settings.initial_admin_password
):
return
email = settings.initial_admin_email.strip().lower()
user = db.scalar(select(User).where(User.email == email))
if user is None:
user = User(
public_ref="USR-ADMIN",
email=email,
password_hash=hash_password(settings.initial_admin_password),
display_name=settings.initial_admin_display_name,
role="operations_manager",
active=True,
)
db.add(user)
record_audit_event(
db,
actor_type="system",
actor_label="bootstrap",
action="operational_admin_created",
entity_type="user",
entity_id=user.id,
)
db.commit()
def _oidc_configured() -> bool:
return bool(
settings.oidc_enabled
and settings.oidc_issuer_url
and settings.oidc_client_id
and settings.oidc_client_secret
)
def _oidc_redirect_uri() -> str:
return settings.oidc_redirect_uri or (
f"{settings.mobilityops_public_url.rstrip('/')}/api/v1/auth/oidc/callback"
)
def _allowed_oidc_email(email: str) -> bool:
domains = {
value.strip().casefold()
for value in settings.oidc_allowed_email_domains.split(",")
if value.strip()
}
return not domains or email.rsplit("@", 1)[-1].casefold() in domains
def _resolve_oidc_user(db: Session, claims: dict[str, object]) -> User:
subject = str(claims.get("sub") or "").strip()
email = str(claims.get("email") or "").strip().lower()
if not subject or not email or claims.get("email_verified") is not True:
raise HTTPException(status_code=401, detail="Verified OIDC email and subject are required")
if not _allowed_oidc_email(email):
raise HTTPException(status_code=403, detail="Email domain is not allowed")
provider = settings.oidc_issuer_url.rstrip("/")
user = db.scalar(
select(User).where(
User.identity_provider == provider,
User.external_subject == subject,
)
)
if user is None:
user = db.scalar(select(User).where(User.email == email))
if user is not None and user.external_subject not in (None, subject):
raise HTTPException(status_code=409, detail="Email is linked to another identity")
if user is not None and claims.get("email_verified") is not True:
# Linking an existing local account (possibly the bootstrap admin) purely on an
# email match requires the IdP to explicitly assert the address is verified;
# an absent claim is treated as unverified.
raise HTTPException(status_code=401, detail="Verified OIDC email is required")
created = user is None
if created:
if not settings.oidc_auto_provision:
raise HTTPException(status_code=403, detail="OIDC user is not provisioned")
role = settings.oidc_default_role
if role not in {"operations_manager", "rental_employee"}:
role = "rental_employee"
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=None,
display_name=str(claims.get("name") or email),
role=role,
active=True,
)
db.add(user)
db.flush()
assert user is not None
if not user.active:
raise HTTPException(status_code=403, detail="User is inactive")
user.identity_provider = provider
user.external_subject = subject
record_audit_event(
db,
actor_type="system" if created else "user",
actor_id=None if created else user.id,
actor_label=settings.oidc_provider_name,
action="oidc_user_provisioned" if created else "oidc_identity_linked",
entity_type="user",
entity_id=user.id,
metadata={"provider": provider},
)
return user
@router.get("/oidc/status", response_model=OidcStatusOut)
def oidc_status() -> OidcStatusOut:
return OidcStatusOut(
enabled=_oidc_configured(),
provider_name=settings.oidc_provider_name if _oidc_configured() else None,
)
@router.get("/oidc/login")
async def oidc_login(request: Request) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
return await client.authorize_redirect(request, _oidc_redirect_uri())
@router.get("/oidc/callback")
async def oidc_callback(request: Request, db: Session = Depends(get_db)) -> Response:
if not _oidc_configured():
raise HTTPException(status_code=404, detail="OIDC login is not configured")
client = oauth.create_client("oidc")
if client is None:
raise HTTPException(status_code=503, detail="OIDC client is unavailable")
try:
token = await client.authorize_access_token(request)
except OAuthError as exc:
raise HTTPException(status_code=401, detail="OIDC authentication failed") from exc
user = _resolve_oidc_user(db, dict(token.get("userinfo") or {}))
response = RedirectResponse(f"{settings.mobilityops_public_url.rstrip('/')}/dashboard")
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="oidc_login",
entity_type="user",
entity_id=user.id,
metadata={"provider": settings.oidc_issuer_url.rstrip("/")},
)
db.commit()
return response
@router.post("/login", response_model=CurrentUser)
def password_login(
body: PasswordLoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> CurrentUser:
if settings.mobilityops_demo_mode:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Password login is unavailable in demo mode",
)
limiter_key = _client_key(request)
retry_after = _login_limiter.retry_after_seconds(limiter_key) if _login_limiter else 0
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many failed login attempts. Try again later.",
headers={"Retry-After": str(retry_after)},
)
user = db.scalar(select(User).where(User.email == body.email.strip().lower()))
if user is None or not user.active or not verify_password(body.password, user.password_hash):
if _login_limiter:
_login_limiter.record_failure(limiter_key)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
if _login_limiter:
_login_limiter.reset(limiter_key)
_set_session(response, user)
record_audit_event(
db,
actor_type="user",
actor_id=user.id,
actor_label=user.display_name,
action="password_login",
entity_type="user",
entity_id=user.id,
)
db.commit()
return _current_user_out(user)
@router.get("/session", response_model=CurrentUser)
def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
response.headers["Cache-Control"] = "no-store"
return user
@router.post("/logout")
def logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None
if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event(
db,
actor_type="user",
actor_id=uuid.UUID(payload.user_id),
actor_label=payload.display_name,
action="logout",
entity_type="user",
)
db.commit()
response.delete_cookie(settings.session_cookie_name)
return {"status": "logged_out"}
+415 -11
View File
@@ -1,20 +1,35 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Literal
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import select from sqlalchemy import case, func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
AvailableVehicleOut,
BookingOut, BookingOut,
BookingPageOut,
CancelBookingRequest,
CheckoutBookingRequest,
CheckoutBookingResult,
CompleteBookingRequirementsRequest,
CreateBookingRequest,
CurrentUser, CurrentUser,
NextBookingRisk, NextBookingRisk,
RegisterReturnRequest, RegisterReturnRequest,
RegisterReturnResult,
RescheduleBookingRequest,
ReturnPreviewResult, ReturnPreviewResult,
) )
from app.services.audit import record_audit_event
from app.services.returns import preview_vehicle_return, register_vehicle_return from app.services.returns import preview_vehicle_return, register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"]) router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -35,25 +50,292 @@ def _to_out(booking: Booking, customer: Customer, vehicle: Vehicle) -> BookingOu
) )
@router.get("", response_model=list[BookingOut]) @router.get("", response_model=list[BookingOut] | BookingPageOut)
def list_bookings( def list_bookings(
status: str | None = Query(default=None), status: str | None = Query(default=None),
vehicle_ref: str | None = Query(default=None), vehicle_ref: str | None = Query(default=None),
query: str | None = Query(default=None, min_length=1, max_length=100),
starts_from: datetime | None = Query(default=None),
starts_to: datetime | None = Query(default=None),
location: str | None = Query(default=None, min_length=1, max_length=120),
sort: Literal["operational", "starts_asc", "starts_desc"] = Query(default="operational"),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[BookingOut]: ) -> list[BookingOut] | BookingPageOut:
stmt = select(Booking).order_by(Booking.starts_at.desc()) stmt = select(Booking)
if status: if status:
stmt = stmt.where(Booking.status == status) stmt = stmt.where(Booking.status == status)
if vehicle_ref: if vehicle_ref:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
if page is None:
return [] return []
return BookingPageOut(items=[], page=1, page_size=page_size, total=0, total_pages=1)
stmt = stmt.where(Booking.vehicle_id == vehicle.id) stmt = stmt.where(Booking.vehicle_id == vehicle.id)
bookings = db.scalars(stmt).all() if starts_from:
customers = {c.id: c for c in db.scalars(select(Customer)).all()} stmt = stmt.where(Booking.ends_at >= starts_from)
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} if starts_to:
return [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings] stmt = stmt.where(Booking.starts_at < starts_to)
if query or location:
stmt = stmt.join(Customer, Booking.customer_id == Customer.id).join(
Vehicle, Booking.vehicle_id == Vehicle.id
)
if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Booking.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Vehicle.public_ref.ilike(term),
)
)
if sort == "starts_asc":
stmt = stmt.order_by(Booking.starts_at.asc())
elif sort == "starts_desc":
stmt = stmt.order_by(Booking.starts_at.desc())
else:
now = datetime.now(UTC)
operational_bucket = case(
(Booking.status == "active", 0),
(Booking.starts_at >= now, 1),
else_=2,
)
stmt = stmt.order_by(
operational_bucket,
case((Booking.starts_at >= now, Booking.starts_at)).asc().nulls_last(),
Booking.starts_at.desc(),
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
bookings = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
customer_ids = {booking.customer_id for booking in bookings}
vehicle_ids = {booking.vehicle_id for booking in bookings}
customers = {
customer.id: customer
for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
}
vehicles = {
vehicle.id: vehicle
for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
}
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return BookingPageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.post("", response_model=BookingOut, status_code=201)
def create_booking(
body: CreateBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
customer = db.scalar(select(Customer).where(Customer.public_ref == body.customer_ref))
if customer is None or customer.merged_into_customer_id is not None:
raise HTTPException(status_code=422, detail="Customer is unavailable for booking")
# Serialise booking creation per vehicle. The overlap check must run after
# acquiring this lock, otherwise two concurrent requests can both pass it.
vehicle = db.scalar(
select(Vehicle).where(Vehicle.public_ref == body.vehicle_ref).with_for_update()
)
if (
vehicle is None
or not vehicle.active
or vehicle.operational_status in {"maintenance", "blocked"}
):
raise HTTPException(status_code=422, detail="Vehicle is unavailable for booking")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
booking = Booking(
public_ref=f"BK-{uuid.uuid4().hex[:10].upper()}",
customer_id=customer.id,
vehicle_id=vehicle.id,
starts_at=body.starts_at,
ends_at=body.ends_at,
status="reserved",
start_odometer_km=None,
end_odometer_km=None,
requirements_complete=body.requirements_complete,
)
db.add(booking)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_created",
entity_type="booking",
entity_id=booking.id,
after={"public_ref": booking.public_ref, "vehicle_ref": vehicle.public_ref},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/checkout", response_model=CheckoutBookingResult)
def checkout_booking(
public_ref: str,
body: CheckoutBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> CheckoutBookingResult:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be checked out")
if not booking.requirements_complete:
raise HTTPException(status_code=409, detail="Booking requirements are incomplete")
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
if vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing vehicle")
if not vehicle.active or vehicle.operational_status in {"maintenance", "blocked", "rented"}:
raise HTTPException(status_code=409, detail="Vehicle is not ready for checkout")
active_conflict = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == vehicle.id,
Booking.status == "active",
Booking.id != booking.id,
)
)
if active_conflict is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an active booking")
attention_reasons: list[str] = []
if body.start_odometer_km < vehicle.odometer_km:
attention_reasons.append("odometer_regression")
if not body.cleanliness_ok:
attention_reasons.append("cleanliness")
if body.damage_reported:
attention_reasons.append("damage")
if body.technical_warning:
attention_reasons.append("technical_warning")
inspection = Inspection(
public_ref=f"INSP-{uuid.uuid4().hex[:10].upper()}",
booking_id=booking.id,
vehicle_id=vehicle.id,
type="checkout",
fuel_level_percent=body.fuel_level_percent,
cleanliness_ok=body.cleanliness_ok,
damage_reported=body.damage_reported,
technical_warning=body.technical_warning,
notes=body.notes,
odometer_km=body.start_odometer_km,
completed_at=datetime.now(UTC),
completed_by=user.display_name,
)
db.add(inspection)
if attention_reasons:
booking.status = "blocked"
vehicle.operational_status = (
"maintenance" if body.damage_reported or body.technical_warning else "cleaning"
)
else:
booking.status = "active"
booking.start_odometer_km = body.start_odometer_km
vehicle.odometer_km = max(vehicle.odometer_km, body.start_odometer_km)
vehicle.operational_status = "rented"
vehicle.version += 1
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_checkout_recorded",
entity_type="booking",
entity_id=booking.id,
after={
"inspection_ref": inspection.public_ref,
"booking_status": booking.status,
"vehicle_status": vehicle.operational_status,
"attention_reasons": attention_reasons,
},
)
db.commit()
return CheckoutBookingResult(
booking_ref=booking.public_ref,
vehicle_ref=vehicle.public_ref,
inspection_ref=inspection.public_ref,
booking_status=booking.status,
resulting_vehicle_status=vehicle.operational_status,
activated=booking.status == "active",
attention_reasons=attention_reasons,
)
@router.get("/availability", response_model=list[AvailableVehicleOut])
def list_available_vehicles(
starts_at: datetime,
ends_at: datetime,
query: str | None = Query(default=None, max_length=100),
limit: int = Query(default=25, ge=1, le=50),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[AvailableVehicleOut]:
if ends_at <= starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
overlapping_vehicle_ids = select(Booking.vehicle_id).where(
Booking.status.in_(("reserved", "active")),
Booking.starts_at < ends_at,
Booking.ends_at > starts_at,
)
stmt = (
select(Vehicle)
.where(
Vehicle.active.is_(True),
Vehicle.operational_status.not_in(("maintenance", "blocked")),
Vehicle.id.not_in(overlapping_vehicle_ids),
)
.order_by(Vehicle.location, Vehicle.public_ref)
.limit(limit)
)
if query and query.strip():
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.registration_number.ilike(term),
Vehicle.location.ilike(term),
)
)
return [
AvailableVehicleOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
)
for vehicle in db.scalars(stmt).all()
]
@router.get("/{public_ref}", response_model=BookingOut) @router.get("/{public_ref}", response_model=BookingOut)
@@ -72,6 +354,126 @@ def get_booking(
return _to_out(booking, customer, vehicle) return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/complete-requirements", response_model=BookingOut)
def complete_booking_requirements(
public_ref: str,
body: CompleteBookingRequirementsRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(
status_code=409,
detail="Requirements can only be confirmed for a reserved booking",
)
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
if not booking.requirements_complete:
booking.requirements_complete = True
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_requirements_completed",
entity_type="booking",
entity_id=booking.id,
before={"requirements_complete": False},
after={"requirements_complete": True},
metadata={"confirmation": body.confirmation.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.patch("/{public_ref}/schedule", response_model=BookingOut)
def reschedule_booking(
public_ref: str,
body: RescheduleBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
customer = db.get(Customer, booking.customer_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == booking.vehicle_id,
Booking.id != booking.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
booking.starts_at = body.starts_at
booking.ends_at = body.ends_at
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_rescheduled",
entity_type="booking",
entity_id=booking.id,
before=before,
after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
metadata={"reason": body.reason.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/cancel", response_model=BookingOut)
def cancel_booking(
public_ref: str,
body: CancelBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status not in ("reserved", "blocked"):
# A booking blocked at checkout (damage, technical warning, ...) has no other exit:
# it never became active, so it can neither be returned nor completed. Cancelling
# it (audited, with a reason) is the only way to close the file.
raise HTTPException(
status_code=409, detail="Only a reserved or blocked booking can be cancelled"
)
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
before = {"status": booking.status}
booking.status = "cancelled"
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_cancelled",
entity_type="booking",
entity_id=booking.id,
before=before,
after={"status": booking.status, "reason": body.reason.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult) @router.post("/{public_ref}/return-preview", response_model=ReturnPreviewResult)
def preview_return( def preview_return(
public_ref: str, public_ref: str,
@@ -89,6 +491,8 @@ def preview_return(
resulting_odometer_km=evaluation.resulting_odometer_km, resulting_odometer_km=evaluation.resulting_odometer_km,
resulting_vehicle_status=evaluation.resulting_vehicle_status, resulting_vehicle_status=evaluation.resulting_vehicle_status,
status_reason=evaluation.status_reason, status_reason=evaluation.status_reason,
status_reason_code=evaluation.status_reason_code,
status_reason_params=evaluation.status_reason_params,
would_create_quality_issue=evaluation.would_create_quality_issue, would_create_quality_issue=evaluation.would_create_quality_issue,
attention_reasons=evaluation.attention_reasons, attention_reasons=evaluation.attention_reasons,
next_booking_risk=( next_booking_risk=(
@@ -99,7 +503,7 @@ def preview_return(
) )
@router.post("/{public_ref}/return") @router.post("/{public_ref}/return", response_model=RegisterReturnResult)
def register_return( def register_return(
public_ref: str, public_ref: str,
body: RegisterReturnRequest, body: RegisterReturnRequest,
@@ -107,7 +511,7 @@ def register_return(
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128), idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> dict: ) -> RegisterReturnResult:
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user) status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
response.status_code = status_code response.status_code = status_code
return result return RegisterReturnResult(**result)
+41
View File
@@ -0,0 +1,41 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db
from app.models.customer import Customer
from app.schemas import CurrentUser, CustomerOptionOut
router = APIRouter(prefix="/api/v1/customers", tags=["customers"])
@router.get("", response_model=list[CustomerOptionOut])
def search_customers(
query: str = Query(min_length=2, max_length=100),
limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
) -> list[CustomerOptionOut]:
term = f"%{query.strip()}%"
customers = db.scalars(
select(Customer)
.where(
Customer.merged_into_customer_id.is_(None),
or_(
Customer.public_ref.ilike(term),
Customer.first_name.ilike(term),
Customer.last_name.ilike(term),
Customer.email.ilike(term),
),
)
.order_by(Customer.last_name, Customer.first_name)
.limit(limit)
).all()
return [
CustomerOptionOut(
public_ref=customer.public_ref,
display_name=f"{customer.first_name} {customer.last_name}",
email=customer.email,
)
for customer in customers
]
+42 -12
View File
@@ -1,7 +1,8 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, date, datetime from datetime import date, datetime
from typing import Literal from typing import Literal
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from sqlalchemy import select from sqlalchemy import select
@@ -19,6 +20,7 @@ from app.schemas import (
AutomationRunOut, AutomationRunOut,
CurrentUser, CurrentUser,
DashboardOut, DashboardOut,
EvidenceSignalOut,
TodayItem, TodayItem,
) )
from app.services.operations import compute_metrics from app.services.operations import compute_metrics
@@ -29,10 +31,20 @@ settings = get_settings()
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2} _SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
def _local_tz() -> ZoneInfo:
return ZoneInfo(settings.demo_timezone)
def _today() -> date: def _today() -> date:
# Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor # Seeded dates are shifted to the real reset moment by `seed_loader.py`'s anchor
# shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting. # shift, so "today" must be real wall-clock time, not the frozen `demo_today` setting.
return datetime.now(UTC).date() # Timestamps are stored in UTC but the operational day is the local (Europe/Brussels)
# calendar day, so a 23:30Z departure belongs to tomorrow's schedule in summer.
return datetime.now(_local_tz()).date()
def _local_date(value: datetime) -> date:
return value.astimezone(_local_tz()).date()
@router.get("", response_model=DashboardOut) @router.get("", response_model=DashboardOut)
@@ -61,19 +73,34 @@ def get_dashboard(
entity = customers_by_id.get(issue.entity_id) entity = customers_by_id.get(issue.entity_id)
link_type = "customer" link_type = "customer"
link_ref = entity.public_ref if entity else "" link_ref = entity.public_ref if entity else ""
# The backend never emits prose for the attention queue -- only stable signal
# codes + raw data params, exactly like the issue detail page's evidence list
# (see app/services/data_quality.py::_open_issue). The frontend is the one place
# that turns these into the operator's selected language; `evidence_json["summary"]`
# is a technical fallback only, never rendered here.
signals = [
EvidenceSignalOut(code=s["code"], params=s.get("params", {}))
for s in issue.evidence_json.get("signals", [])
]
attention_items.append( attention_items.append(
AttentionItem( AttentionItem(
kind="quality_issue", kind="quality_issue",
severity=issue.severity, severity=issue.severity,
rule_type=issue.rule_type, rule_type=issue.rule_type,
detail=issue.evidence_json.get("summary", ""), evidence_signals=signals,
link_type=link_type, link_type=link_type,
link_ref=link_ref, link_ref=link_ref,
issue_ref=issue.public_ref, issue_ref=issue.public_ref,
) )
) )
attention_items.sort(key=lambda item: _SEVERITY_ORDER.get(item.severity, 3)) # Curate a credible severity mix instead of letting `high` dominate every slot:
attention_items = attention_items[:8] # each item's real severity is unchanged, only the display selection is capped per
# tier (a handful of "now", then "today", then "later") so a heavy day of high-severity
# issues doesn't crowd out medium/low ones the operator should still see.
high_items = [i for i in attention_items if i.severity == "high"]
medium_items = [i for i in attention_items if i.severity == "medium"]
low_items = [i for i in attention_items if i.severity == "low"]
attention_items = (high_items[:3] + medium_items[:3] + low_items[:2])[:8]
today = _today() today = _today()
bookings = db.scalars(select(Booking)).all() bookings = db.scalars(select(Booking)).all()
@@ -81,25 +108,27 @@ def get_dashboard(
for b in bookings: for b in bookings:
vehicle = vehicles_by_id.get(b.vehicle_id) vehicle = vehicles_by_id.get(b.vehicle_id)
vehicle_ref = vehicle.public_ref if vehicle else "" vehicle_ref = vehicle.public_ref if vehicle else ""
if b.starts_at.date() == today and b.status in ("reserved", "active"): if _local_date(b.starts_at) == today and b.status in ("reserved", "active"):
today_items.append( today_items.append(
TodayItem( TodayItem(
kind="departure", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="departure",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
scheduled_at=b.starts_at, scheduled_at=b.starts_at,
) )
) )
if b.ends_at.date() == today and b.status in ("active", "returned"): if _local_date(b.ends_at) == today and b.status in ("active", "returned"):
today_items.append( today_items.append(
TodayItem( TodayItem(
kind="return", booking_ref=b.public_ref, vehicle_ref=vehicle_ref, kind="return",
booking_ref=b.public_ref,
vehicle_ref=vehicle_ref,
scheduled_at=b.ends_at, scheduled_at=b.ends_at,
) )
) )
today_items.sort(key=lambda item: item.scheduled_at) today_items.sort(key=lambda item: item.scheduled_at)
recent = db.scalars( recent = db.scalars(select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)).all()
select(OutboxEvent).order_by(OutboxEvent.occurred_at.desc()).limit(5)
).all()
recent_automation = [ recent_automation = [
AutomationRunOut( AutomationRunOut(
event_id=str(r.event_id), event_id=str(r.event_id),
@@ -108,6 +137,7 @@ def get_dashboard(
status=r.delivery_status, status=r.delivery_status,
attempts=r.attempts, attempts=r.attempts,
last_error=r.last_error, last_error=r.last_error,
last_error_code=r.last_error_code,
occurred_at=r.occurred_at, occurred_at=r.occurred_at,
) )
for r in recent for r in recent
+179 -11
View File
@@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import case, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
@@ -9,23 +11,32 @@ from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult, ApplyRecommendedStatusResult,
BulkDataQualityWorkRequest,
BulkDataQualityWorkResult,
CurrentUser, CurrentUser,
DataQualityIssueDetailOut, DataQualityIssueDetailOut,
DataQualityIssueOut, DataQualityIssueOut,
DataQualityIssuePageOut,
MergeCustomersRequest, MergeCustomersRequest,
MergeCustomersResult, MergeCustomersResult,
ProvideFieldsRequest, ProvideFieldsRequest,
ResolveOdometerRegressionRequest, ResolveOdometerRegressionRequest,
ResolveOverlapRequest, ResolveOverlapRequest,
ScanResultOut, ScanResultOut,
StatusRecommendationOut,
VehicleStatusFactsOut,
) )
from app.services.audit import record_audit_event
from app.services.data_quality import ( from app.services.data_quality import (
apply_recommended_status, apply_recommended_status,
defer_issue, defer_issue,
merge_customers, merge_customers,
preview_vehicle_status_recommendation,
provide_missing_fields, provide_missing_fields,
reject_issue, reject_issue,
resolve_booking_overlap, resolve_booking_overlap,
@@ -37,6 +48,7 @@ router = APIRouter(prefix="/api/v1/data-quality", tags=["data-quality"])
def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut: def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
assignee = issue.assigned_to_user
return DataQualityIssueOut( return DataQualityIssueOut(
public_ref=issue.public_ref, public_ref=issue.public_ref,
rule_type=issue.rule_type, rule_type=issue.rule_type,
@@ -46,27 +58,160 @@ def _to_out(issue: DataQualityIssue) -> DataQualityIssueOut:
status=issue.status, status=issue.status,
evidence=issue.evidence_json, evidence=issue.evidence_json,
detected_at=issue.detected_at, detected_at=issue.detected_at,
due_at=issue.due_at,
assigned_to_ref=assignee.public_ref if assignee else None,
assigned_to_name=assignee.display_name if assignee else None,
overdue=(
issue.status == "open" and issue.due_at is not None and issue.due_at < datetime.now(UTC)
),
resolved_at=issue.resolved_at, resolved_at=issue.resolved_at,
) )
@router.get("/issues", response_model=list[DataQualityIssueOut]) @router.get("/issues", response_model=list[DataQualityIssueOut] | DataQualityIssuePageOut)
def list_issues( def list_issues(
status: str | None = Query(default=None), status: str | None = Query(default=None),
rule_type: str | None = Query(default=None), rule_type: str | None = Query(default=None),
severity: str | None = Query(default=None), severity: str | None = Query(default=None),
assigned_to_ref: str | None = Query(default=None),
overdue: bool | None = Query(default=None),
demo_only: bool | None = Query(default=None),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager), _user: CurrentUser = Depends(require_operations_manager),
) -> list[DataQualityIssueOut]: ) -> list[DataQualityIssueOut] | DataQualityIssuePageOut:
stmt = select(DataQualityIssue).order_by(DataQualityIssue.detected_at.desc()) severity_order = case(
(DataQualityIssue.severity == "high", 0),
(DataQualityIssue.severity == "medium", 1),
else_=2,
)
stmt = select(DataQualityIssue).order_by(
DataQualityIssue.due_at.asc().nulls_last(),
severity_order,
DataQualityIssue.detected_at.desc(),
)
if status: if status:
stmt = stmt.where(DataQualityIssue.status == status) stmt = stmt.where(DataQualityIssue.status == status)
if rule_type: if rule_type:
stmt = stmt.where(DataQualityIssue.rule_type == rule_type) stmt = stmt.where(DataQualityIssue.rule_type == rule_type)
if severity: if severity:
stmt = stmt.where(DataQualityIssue.severity == severity) stmt = stmt.where(DataQualityIssue.severity == severity)
issues = db.scalars(stmt).all() if assigned_to_ref == "unassigned":
return [_to_out(i) for i in issues] stmt = stmt.where(DataQualityIssue.assigned_to_user_id.is_(None))
elif assigned_to_ref:
stmt = stmt.join(DataQualityIssue.assigned_to_user).where(
User.public_ref == assigned_to_ref
)
if overdue is True:
stmt = stmt.where(
DataQualityIssue.status == "open",
DataQualityIssue.due_at < datetime.now(UTC),
)
if demo_only is True:
# Server-side so the guided demo scenarios are found on any page, not only the
# 25 rows currently loaded in the browser.
stmt = stmt.where(DataQualityIssue.public_ref.like("DQ-DEMO-%"))
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
issues = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
items = [_to_out(i) for i in issues]
if page is None:
return items
total_pages = max(1, (total + page_size - 1) // page_size)
return DataQualityIssuePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.post("/issues/bulk-work", response_model=BulkDataQualityWorkResult)
def update_issue_work_queue(
body: BulkDataQualityWorkRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> BulkDataQualityWorkResult:
refs = list(dict.fromkeys(body.issue_refs))
if (
body.assigned_to_ref is None
and not body.clear_assignment
and body.due_at is None
and not body.clear_due_at
):
raise HTTPException(status_code=422, detail="No work queue change was requested")
if body.assigned_to_ref is not None and body.clear_assignment:
raise HTTPException(status_code=422, detail="Choose an assignee or clear assignment")
if body.due_at is not None and body.clear_due_at:
raise HTTPException(status_code=422, detail="Choose a due date or clear the due date")
if body.due_at is not None and body.due_at.tzinfo is None:
raise HTTPException(status_code=422, detail="Due date must include a timezone")
assignee = None
if body.assigned_to_ref is not None:
assignee = db.scalar(
select(User).where(
User.public_ref == body.assigned_to_ref,
User.active.is_(True),
)
)
if assignee is None:
raise HTTPException(status_code=422, detail="Active assignee not found")
issues = list(
db.scalars(
select(DataQualityIssue).where(DataQualityIssue.public_ref.in_(refs)).with_for_update()
).all()
)
if len(issues) != len(refs):
found = {issue.public_ref for issue in issues}
missing = next(ref for ref in refs if ref not in found)
raise HTTPException(status_code=404, detail=f"Data quality issue {missing} not found")
for issue in issues:
if issue.status != "open":
raise HTTPException(
status_code=409,
detail=f"Data quality issue {issue.public_ref} is not open",
)
before = {
"assigned_to_ref": issue.assigned_to_user.public_ref
if issue.assigned_to_user
else None,
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
if body.assigned_to_ref is not None:
issue.assigned_to_user = assignee
elif body.clear_assignment:
issue.assigned_to_user = None
if body.due_at is not None:
issue.due_at = body.due_at
elif body.clear_due_at:
issue.due_at = None
after = {
"assigned_to_ref": assignee.public_ref
if body.assigned_to_ref is not None and assignee
else (None if body.clear_assignment else before["assigned_to_ref"]),
"due_at": issue.due_at.isoformat() if issue.due_at else None,
}
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="data_quality_work_updated",
entity_type="data_quality_issue",
entity_id=issue.id,
before=before,
after=after,
)
db.commit()
for issue in issues:
db.refresh(issue)
return BulkDataQualityWorkResult(updated=[_to_out(issue) for issue in issues])
# Every public reference in this system carries its entity type in its own prefix # Every public reference in this system carries its entity type in its own prefix
@@ -111,6 +256,7 @@ def _snapshot(entity_type: str, ref: str, db: Session) -> dict | None:
return { return {
"entity_type": "vehicle", "entity_type": "vehicle",
"public_ref": vehicle.public_ref, "public_ref": vehicle.public_ref,
"registration_number": vehicle.registration_number,
"make": vehicle.make, "make": vehicle.make,
"model": vehicle.model, "model": vehicle.model,
"location": vehicle.location, "location": vehicle.location,
@@ -217,9 +363,7 @@ def provide_fields(
return _to_out(issue) return _to_out(issue)
@router.post( @router.post("/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut)
"/issues/{public_ref}/resolve-odometer-regression", response_model=DataQualityIssueOut
)
def resolve_odometer( def resolve_odometer(
public_ref: str, public_ref: str,
body: ResolveOdometerRegressionRequest, body: ResolveOdometerRegressionRequest,
@@ -241,17 +385,41 @@ def resolve_overlap(
return _to_out(issue) return _to_out(issue)
@router.post("/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut)
def status_recommendation(
public_ref: str,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> StatusRecommendationOut:
"""Non-mutating preview: computes the recommendation without changing anything,
resolving no issue and writing no audit event. Safe to call repeatedly."""
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
return StatusRecommendationOut(
current_status=recommendation.current_status,
recommended_status=recommendation.recommended_status,
recommendation_code=recommendation.recommendation_code,
safe_to_apply=recommendation.safe_to_apply,
manual_review_required=recommendation.manual_review_required,
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
blocking_reasons=recommendation.blocking_reasons,
recommendation_token=token,
)
@router.post( @router.post(
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult "/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
) )
def apply_status( def apply_status(
public_ref: str, public_ref: str,
body: ApplyRecommendedStatusRequest,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> ApplyRecommendedStatusResult: ) -> ApplyRecommendedStatusResult:
issue, applied_status, reason = apply_recommended_status(db, public_ref, user) issue, applied_status, reason_code = apply_recommended_status(
db, public_ref, user, body.recommendation_token
)
return ApplyRecommendedStatusResult( return ApplyRecommendedStatusResult(
issue=_to_out(issue), applied_status=applied_status, reason=reason issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
) )
+37 -6
View File
@@ -1,10 +1,11 @@
from __future__ import annotations from __future__ import annotations
import threading
import time import time
import uuid import uuid
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db, require_operations_manager from app.api.deps import get_current_user, get_db, require_operations_manager
@@ -15,13 +16,19 @@ from app.schemas import CurrentUser, DemoLoginRequest, DemoManifestOut
from app.seed_loader import reset_and_seed from app.seed_loader import reset_and_seed
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report from app.services.demo_manifest import build_demo_manifest, scenario_integrity_report
from app.services.sessions import revoke_session
router = APIRouter(prefix="/api/v1/demo", tags=["demo"]) router = APIRouter(prefix="/api/v1/demo", tags=["demo"])
settings = get_settings() settings = get_settings()
_reset_guard = threading.Lock()
_last_reset_monotonic = 0.0
_RESET_ADVISORY_LOCK_ID = 706_533_149
@router.get("/manifest", response_model=DemoManifestOut) @router.get("/manifest", response_model=DemoManifestOut)
def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut: def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
if not settings.mobilityops_demo_mode:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
# Deliberately unauthenticated: the demo-entry screen and the permanent demo badge # Deliberately unauthenticated: the demo-entry screen and the permanent demo badge
# both need this before any session exists. Nothing here is sensitive — it's the same # both need this before any session exists. Nothing here is sensitive — it's the same
# honest "what is this demo" summary a logged-in user would see. # honest "what is this demo" summary a logged-in user would see.
@@ -32,6 +39,8 @@ def demo_manifest(db: Session = Depends(get_db)) -> DemoManifestOut:
def demo_login( def demo_login(
body: DemoLoginRequest, response: Response, db: Session = Depends(get_db) body: DemoLoginRequest, response: Response, db: Session = Depends(get_db)
) -> CurrentUser: ) -> CurrentUser:
if not settings.mobilityops_demo_mode:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP" public_ref = "USR-OPS" if body.role == "operations_manager" else "USR-EMP"
user = db.scalar(select(User).where(User.public_ref == public_ref)) user = db.scalar(select(User).where(User.public_ref == public_ref))
if user is None: if user is None:
@@ -44,6 +53,7 @@ def demo_login(
role=user.role, role=user.role,
display_name=user.display_name, display_name=user.display_name,
issued_at=int(time.time()), issued_at=int(time.time()),
session_id=str(uuid.uuid4()),
) )
) )
response.set_cookie( response.set_cookie(
@@ -68,9 +78,7 @@ def demo_login(
@router.get("/session", response_model=CurrentUser) @router.get("/session", response_model=CurrentUser)
def get_session( def get_session(response: Response, user: CurrentUser = Depends(get_current_user)) -> CurrentUser:
response: Response, user: CurrentUser = Depends(get_current_user)
) -> CurrentUser:
# Never let the browser (or an intermediary) cache an authentication check — a stale # Never let the browser (or an intermediary) cache an authentication check — a stale
# cached 200 here would keep showing a logged-out browser as authenticated. # cached 200 here would keep showing a logged-out browser as authenticated.
response.headers["Cache-Control"] = "no-store" response.headers["Cache-Control"] = "no-store"
@@ -81,7 +89,8 @@ def get_session(
def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict: def demo_logout(request: Request, response: Response, db: Session = Depends(get_db)) -> dict:
token = request.cookies.get(settings.session_cookie_name) token = request.cookies.get(settings.session_cookie_name)
payload = read_session_token(token) if token else None payload = read_session_token(token) if token else None
if payload is not None: if payload is not None and token is not None:
revoke_session(db, token, payload)
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
@@ -101,12 +110,31 @@ def demo_reset(
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager), user: CurrentUser = Depends(require_operations_manager),
) -> dict: ) -> dict:
global _last_reset_monotonic
if not settings.mobilityops_demo_mode:
# Outside demo mode the reset endpoint must not exist at all: it wipes
# operational data and replaces it with synthetic records.
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Demo mode is disabled")
if not settings.demo_allow_reset: if not settings.demo_allow_reset:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Demo reset is disabled on this deployment.", detail="Demo reset is disabled on this deployment.",
) )
result = reset_and_seed(db) if not _reset_guard.acquire(blocking=False):
raise HTTPException(status_code=409, detail="A demo reset is already running.")
try:
elapsed = time.monotonic() - _last_reset_monotonic
if _last_reset_monotonic and elapsed < settings.demo_reset_cooldown_seconds:
retry_after = max(1, int(settings.demo_reset_cooldown_seconds - elapsed + 0.999))
raise HTTPException(
status_code=429,
detail=f"Demo reset is cooling down. Retry in {retry_after} seconds.",
headers={"Retry-After": str(retry_after)},
)
locked = db.scalar(select(func.pg_try_advisory_xact_lock(_RESET_ADVISORY_LOCK_ID)))
if not locked:
raise HTTPException(status_code=409, detail="A demo reset is already running.")
result = reset_and_seed(db, preserve_integration_telemetry=True)
integrity = scenario_integrity_report(db) integrity = scenario_integrity_report(db)
record_audit_event( record_audit_event(
db, db,
@@ -121,6 +149,9 @@ def demo_reset(
}, },
) )
db.commit() db.commit()
_last_reset_monotonic = time.monotonic()
finally:
_reset_guard.release()
response.delete_cookie(settings.session_cookie_name) response.delete_cookie(settings.session_cookie_name)
return { return {
"status": "reset", "status": "reset",
+3 -12
View File
@@ -4,16 +4,10 @@ from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings from app.schemas import CurrentUser, IntegrationStatusOut
from app.schemas import ( from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
CurrentUser,
IntegrationStatusOut,
McpHubIntegrationStatus,
)
from app.services.integration_status import derive_n8n_status
router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"]) router = APIRouter(prefix="/api/v1/integrations", tags=["integrations"])
settings = get_settings()
@router.get("/status", response_model=IntegrationStatusOut) @router.get("/status", response_model=IntegrationStatusOut)
@@ -23,8 +17,5 @@ def integration_status(
) -> IntegrationStatusOut: ) -> IntegrationStatusOut:
return IntegrationStatusOut( return IntegrationStatusOut(
n8n=derive_n8n_status(db), n8n=derive_n8n_status(db),
mcp_hub=McpHubIntegrationStatus( mcp_hub=derive_mcp_hub_status(db),
registration_enabled=settings.mcp_hub_registration_enabled,
state="configured" if settings.mcp_hub_registration_enabled else "not_configured",
),
) )
+197 -11
View File
@@ -1,8 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hmac
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from typing import Any from pathlib import Path
from fastapi import APIRouter, Depends, Header from fastapi import APIRouter, Depends, Header
from sqlalchemy import select from sqlalchemy import select
@@ -13,23 +14,93 @@ from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.models.audit import AuditEvent from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import ScanResultOut from app.schemas import (
N8nHeartbeatIn,
N8nHeartbeatResult,
ProcedureDocumentOut,
ProcedureListOut,
ProcedureSyncResultIn,
ProcedureSyncResultResult,
ReturnCallbackIn,
ScanResultOut,
WorkflowErrorReportIn,
WorkflowErrorReportResult,
)
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.data_quality import run_scan from app.services.data_quality import run_scan
from app.services.knowledge.procedures import iter_procedure_documents
router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"]) router = APIRouter(prefix="/api/v1/integrations/n8n", tags=["integrations"])
settings = get_settings() settings = get_settings()
_CANONICAL_WORKFLOW_NAMES = frozenset(
{
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
}
)
def _require_service_token(service_token: str) -> None:
# Constant-time comparison: a plain ``!=`` leaks how many leading bytes matched.
if not hmac.compare_digest(
service_token.encode("utf-8"), settings.n8n_callback_token.encode("utf-8")
):
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
@router.post("/heartbeat", response_model=N8nHeartbeatResult)
def workflow_heartbeat(
body: N8nHeartbeatIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> N8nHeartbeatResult:
"""Authenticated, idempotent execution evidence from a canonical n8n workflow."""
_require_service_token(service_token)
if body.workflow_name not in _CANONICAL_WORKFLOW_NAMES:
raise AppError("UNKNOWN_WORKFLOW", "Unknown Fleet Ops workflow.", status_code=422)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_workflow_heartbeat",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
AuditEvent.after_json["status"].astext == body.status,
)
)
is not None
)
if not already_recorded:
record_audit_event(
db,
actor_type="service",
actor_label="n8n workflow heartbeat",
action="n8n_workflow_heartbeat",
entity_type="automation",
after={
"workflow_id": body.workflow_id,
"workflow_name": body.workflow_name,
"status": body.status,
},
metadata={"execution_id": body.execution_id},
)
db.commit()
return N8nHeartbeatResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
@router.post("/return-callback") @router.post("/return-callback")
def return_callback( def return_callback(
body: dict[str, Any], body: ReturnCallbackIn,
idempotency_key: str = Header(..., alias="Idempotency-Key"), idempotency_key: str = Header(..., alias="Idempotency-Key"),
service_token: str = Header(..., alias="X-Service-Token"), service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
try: try:
event_id = uuid.UUID(idempotency_key) event_id = uuid.UUID(idempotency_key)
@@ -60,10 +131,8 @@ def return_callback(
actor_label="n8n", actor_label="n8n",
action="n8n_return_followup_recorded", action="n8n_return_followup_recorded",
entity_type="booking", entity_type="booking",
correlation_id=uuid.UUID(body.get("correlation_id")) correlation_id=body.correlation_id,
if body.get("correlation_id") after={"follow_up": body.follow_up, "summary": body.summary},
else None,
after={"follow_up": body.get("follow_up"), "summary": body.get("summary")},
metadata={"event_id": str(event_id)}, metadata={"event_id": str(event_id)},
) )
db.commit() db.commit()
@@ -84,8 +153,125 @@ def scheduled_scan(
safe to call repeatedly: run_scan() only ever creates an issue for a condition that safe to call repeatedly: run_scan() only ever creates an issue for a condition that
doesn't already have one open, so a duplicate or overlapping trigger does no doesn't already have one open, so a duplicate or overlapping trigger does no
duplicate domain work -- it just reports zero new issues for anything already known.""" duplicate domain work -- it just reports zero new issues for anything already known."""
if service_token != settings.n8n_callback_token: _require_service_token(service_token)
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service") result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
return ScanResultOut(created=result.created) return ScanResultOut(created=result.created)
@router.post("/workflow-error", response_model=WorkflowErrorReportResult)
def workflow_error(
body: WorkflowErrorReportIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> WorkflowErrorReportResult:
"""Receives a bounded, secret-free failure report from the central n8n "Fleet Ops --
Workflow Error Handler" workflow, which is attached as the Error Workflow on every
other Fleet Ops n8n workflow. Idempotent on execution_id: n8n may redeliver the same
error report (e.g. after a timed-out response), so this must not double-record."""
_require_service_token(service_token)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_workflow_failure_registered",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
)
)
is not None
)
if not already_recorded:
correlation_id: uuid.UUID | None = None
if body.correlation_id:
try:
correlation_id = uuid.UUID(body.correlation_id)
except ValueError:
correlation_id = None
record_audit_event(
db,
actor_type="service",
actor_label="n8n error handler",
action="n8n_workflow_failure_registered",
entity_type="automation",
correlation_id=correlation_id,
after={
"workflow_id": body.workflow_id,
"workflow_name": body.workflow_name,
"error_category": body.error_category,
"error_summary": body.error_summary,
"trigger_context": body.trigger_context,
"attempt": body.attempt,
"retry_action": body.retry_action,
"failed_at": body.failed_at.isoformat(),
},
metadata={"execution_id": body.execution_id},
)
db.commit()
return WorkflowErrorReportResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
@router.get("/procedures", response_model=ProcedureListOut)
def list_procedures(service_token: str = Header(..., alias="X-Service-Token")) -> ProcedureListOut:
"""Read-only source list for the RAGcore Procedure Sync workflow: every procedure
Markdown file Fleet Ops ships, across every supported language, with a stable
per-document id (source_id) and a content hash so the caller can detect changes
without re-fetching content it already has."""
_require_service_token(service_token)
documents = [
ProcedureDocumentOut(
id=doc.source_id,
language=doc.language,
document_id=doc.document_id,
title=doc.title,
version=doc.version,
content=doc.content,
content_hash=doc.content_hash,
)
for doc in iter_procedure_documents(Path(settings.knowledge_dir))
]
return ProcedureListOut(documents=documents)
@router.post("/procedures-sync-result", response_model=ProcedureSyncResultResult)
def procedures_sync_result(
body: ProcedureSyncResultIn,
service_token: str = Header(..., alias="X-Service-Token"),
db: Session = Depends(get_db),
) -> ProcedureSyncResultResult:
"""Receives a summary (counts only, no document content) from the n8n "Fleet Ops --
RAGcore Procedure Sync" workflow once it finishes uploading procedures to RAGcore.
Idempotent on execution_id, matching the workflow-error and return-callback pattern."""
_require_service_token(service_token)
already_recorded = (
db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "n8n_procedures_synced",
AuditEvent.metadata_json["execution_id"].astext == body.execution_id,
)
)
is not None
)
if not already_recorded:
record_audit_event(
db,
actor_type="service",
actor_label="n8n procedure sync",
action="n8n_procedures_synced",
entity_type="automation",
after={"synced": body.synced, "failed": body.failed},
metadata={"execution_id": body.execution_id},
)
db.commit()
return ProcedureSyncResultResult(
status="already_registered" if already_recorded else "registered",
execution_id=body.execution_id,
occurred_at=datetime.now(UTC),
)
+110 -2
View File
@@ -1,18 +1,32 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import uuid import uuid
from typing import Literal from typing import Literal
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db
from app.core.config import get_settings
from app.core.ratelimit import SlidingWindowLimiter
from app.models.audit import AuditEvent
from app.schemas import CurrentUser from app.schemas import CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider from app.services.knowledge import GroundedAnswer, KnowledgeHealth, get_knowledge_provider
router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"]) router = APIRouter(prefix="/api/v1/knowledge", tags=["knowledge"])
settings = get_settings()
_question_limiter = (
SlidingWindowLimiter(
max_requests=settings.knowledge_max_requests,
window_seconds=settings.knowledge_rate_limit_window_seconds,
)
if settings.knowledge_max_requests > 0
else None
)
SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"] SupportedLanguage = Literal["nl-BE", "en-GB", "fr-BE"]
@@ -22,12 +36,37 @@ class AskQuestionRequest(BaseModel):
language: SupportedLanguage = "en-GB" language: SupportedLanguage = "en-GB"
class KnowledgeFeedbackRequest(BaseModel):
correlation_id: uuid.UUID
helpful: bool
@router.post("/questions", response_model=GroundedAnswer) @router.post("/questions", response_model=GroundedAnswer)
def ask_question( def ask_question(
body: AskQuestionRequest, body: AskQuestionRequest,
request: Request,
db: Session = Depends(get_db), db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user), user: CurrentUser = Depends(get_current_user),
) -> GroundedAnswer: ) -> GroundedAnswer:
if _question_limiter is not None:
forwarded = request.headers.get("x-forwarded-for", "")
client_ip = (
forwarded.split(",")[-1].strip()
if forwarded
else request.client.host if request.client else "unknown"
)
token = request.cookies.get(settings.session_cookie_name, "")
session_key = hashlib.sha256(token.encode("utf-8")).hexdigest()
retry_after = max(
_question_limiter.consume(f"ip:{client_ip}"),
_question_limiter.consume(f"session:{session_key}"),
)
if retry_after:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many knowledge questions. Try again later.",
headers={"Retry-After": str(retry_after)},
)
correlation_id = str(uuid.uuid4()) correlation_id = str(uuid.uuid4())
provider = get_knowledge_provider() provider = get_knowledge_provider()
answer = provider.ask(body.question, correlation_id, body.language) answer = provider.ask(body.question, correlation_id, body.language)
@@ -51,9 +90,78 @@ def ask_question(
return answer return answer
@router.post("/feedback")
def record_feedback(
body: KnowledgeFeedbackRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict[str, str]:
question_event = db.scalar(
select(AuditEvent.id).where(
AuditEvent.action == "knowledge_question_asked",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if question_event is None:
raise HTTPException(status_code=404, detail="Knowledge exchange not found")
existing = db.scalar(
select(AuditEvent).where(
AuditEvent.action == "knowledge_feedback_recorded",
AuditEvent.correlation_id == body.correlation_id,
AuditEvent.actor_label == user.display_name,
)
)
if existing is not None:
existing.metadata_json = {"helpful": body.helpful}
else:
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="knowledge_feedback_recorded",
entity_type="knowledge",
correlation_id=body.correlation_id,
metadata={"helpful": body.helpful},
)
db.commit()
return {"status": "recorded"}
@router.get("/status", response_model=KnowledgeHealth) @router.get("/status", response_model=KnowledgeHealth)
def knowledge_status( def knowledge_status(
language: SupportedLanguage = "en-GB", language: SupportedLanguage = "en-GB",
db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> KnowledgeHealth: ) -> KnowledgeHealth:
return get_knowledge_provider().health(language) health = get_knowledge_provider().health(language)
if health.provider != "ragcore":
return health
latest_sync = db.scalar(
select(AuditEvent)
.where(AuditEvent.action == "n8n_procedures_synced")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
)
if latest_sync is None:
return health
reported = latest_sync.after_json or {}
synced = reported.get("synced")
failed = reported.get("failed")
return health.model_copy(
update={
"reported_synced_document_count": synced if isinstance(synced, int) else None,
"reported_failed_document_count": failed if isinstance(failed, int) else None,
"last_sync_at": latest_sync.occurred_at,
# A persisted sync callback is useful additional provenance, but must not
# downgrade stronger provider-side verification to merely "reported".
"statistics_state": (
health.statistics_state
if health.statistics_state == "verified"
else "sync_reported"
),
}
)
+90 -20
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
import uuid import uuid
from datetime import date from datetime import date
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Header, Query, Response
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_db, require_mcp_service_token from app.api.deps import McpClientContext, get_db, require_mcp_service_token
from app.core.config import get_settings from app.core.config import get_settings
from app.core.errors import AppError from app.core.errors import AppError
from app.models.booking import Booking from app.models.booking import Booking
@@ -27,44 +27,94 @@ router = APIRouter(prefix="/api/v1/integrations/mcp", tags=["mcp"])
settings = get_settings() settings = get_settings()
def _audit_service_request(db: Session, *, client_id: str, tool: str, status_label: str) -> None: def get_correlation_id(
x_correlation_id: str | None = Header(default=None, alias="X-Correlation-Id"),
) -> str:
"""Preserve the Hub's own inbound correlation ID through MCP client -> Hub -> Fleet
Ops -> RAGcore -> Fleet Ops Audit; only mint a fresh one when none was supplied or
it isn't a valid UUID (per the task's own correlation-propagation contract)."""
if x_correlation_id:
try:
return str(uuid.UUID(x_correlation_id))
except ValueError:
pass
return str(uuid.uuid4())
def _audit_service_request(
db: Session,
*,
reported_client_id: str,
tool: str,
status_label: str,
correlation_id: str,
metadata: dict[str, object] | None = None,
) -> None:
record_audit_event( record_audit_event(
db, db,
actor_type="service", actor_type="service",
actor_label=client_id, # The shared service token authenticates the Hub, not the caller identity that
# the Hub reports in a header. Keep attribution authoritative and retain the
# reported value only as explicitly non-authenticated diagnostic metadata.
actor_label="itworx-mcp-hub",
action="mcp_tool_request", action="mcp_tool_request",
entity_type="mcp_tool", entity_type="mcp_tool",
correlation_id=uuid.uuid4(), correlation_id=uuid.UUID(correlation_id),
metadata={"tool": tool, "status": status_label}, metadata={
"tool": tool,
"status": status_label,
"reported_client_id": reported_client_id,
**(metadata or {}),
},
) )
db.commit() db.commit()
def _set_trace_headers(response: Response, correlation_id: str, tenant: str) -> None:
response.headers["X-Correlation-Id"] = correlation_id
response.headers["X-Tenant-Id"] = tenant
response.headers["Cache-Control"] = "no-store"
@router.get("/operations-summary", response_model=OperationsSummaryOut) @router.get("/operations-summary", response_model=OperationsSummaryOut)
def operations_summary( def operations_summary(
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> OperationsSummaryOut: ) -> OperationsSummaryOut:
metrics = compute_metrics(db) metrics = compute_metrics(db)
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_operations_summary", status_label="ok" db,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_operations_summary",
status_label="ok",
correlation_id=correlation_id,
) )
return OperationsSummaryOut(tenant=settings.ragcore_tenant, metrics=metrics) return OperationsSummaryOut(tenant=client.tenant, metrics=metrics)
@router.get("/attention-vehicles", response_model=list[AttentionVehicleOut]) @router.get("/attention-vehicles", response_model=list[AttentionVehicleOut])
def attention_vehicles( def attention_vehicles(
response: Response,
minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"), minimum_severity: str = Query(default="medium", pattern="^(low|medium|high)$"),
date_filter: date | None = Query(default=None, alias="date"), date_filter: date | None = Query(default=None, alias="date"),
limit: int = Query(default=20, ge=1, le=50), limit: int = Query(default=20, ge=1, le=50),
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> list[AttentionVehicleOut]: ) -> list[AttentionVehicleOut]:
results = list_attention_vehicles( results = list_attention_vehicles(
db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit db, minimum_severity=minimum_severity, on_or_before=date_filter, limit=limit
) )
_set_trace_headers(response, correlation_id, client.tenant)
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_list_attention_vehicles", status_label="ok" db,
reported_client_id=client.reported_client_id,
tool="fleet_ops_list_attention_vehicles",
status_label="ok",
correlation_id=correlation_id,
) )
return [AttentionVehicleOut(**r) for r in results] return [AttentionVehicleOut(**r) for r in results]
@@ -72,16 +122,20 @@ def attention_vehicles(
@router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut) @router.get("/vehicles/{vehicle_ref}", response_model=McpVehicleDetailOut)
def vehicle_details( def vehicle_details(
vehicle_ref: str, vehicle_ref: str,
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> McpVehicleDetailOut: ) -> McpVehicleDetailOut:
_set_trace_headers(response, correlation_id, client.tenant)
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref)) vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
if vehicle is None: if vehicle is None:
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="mobilityops_get_vehicle_details", tool="fleet_ops_get_vehicle_details",
status_label="not_found", status_label="not_found",
correlation_id=correlation_id,
) )
raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404) raise AppError("VEHICLE_NOT_FOUND", "Vehicle not found.", status_code=404)
@@ -99,7 +153,11 @@ def vehicle_details(
) )
_audit_service_request( _audit_service_request(
db, client_id=client_id, tool="mobilityops_get_vehicle_details", status_label="ok" db,
reported_client_id=client.reported_client_id,
tool="fleet_ops_get_vehicle_details",
status_label="ok",
correlation_id=correlation_id,
) )
return McpVehicleDetailOut( return McpVehicleDetailOut(
public_ref=vehicle.public_ref, public_ref=vehicle.public_ref,
@@ -118,17 +176,29 @@ def vehicle_details(
@router.post("/search-knowledge", response_model=GroundedAnswer) @router.post("/search-knowledge", response_model=GroundedAnswer)
def search_knowledge( def search_knowledge(
body: McpKnowledgeSearchRequest, body: McpKnowledgeSearchRequest,
response: Response,
db: Session = Depends(get_db), db: Session = Depends(get_db),
client_id: str = Depends(require_mcp_service_token), client: McpClientContext = Depends(require_mcp_service_token),
correlation_id: str = Depends(get_correlation_id),
) -> GroundedAnswer: ) -> GroundedAnswer:
provider = get_knowledge_provider() provider = get_knowledge_provider()
correlation_id = str(uuid.uuid4()) answer = provider.ask(body.question, correlation_id, language=body.locale)
answer = provider.ask(body.question, correlation_id) source_count_available = len(answer.sources)
answer.sources = answer.sources[: body.max_sources] answer.sources = answer.sources[: body.max_sources]
_set_trace_headers(response, correlation_id, client.tenant)
response.headers["X-Sources-Available"] = str(source_count_available)
response.headers["X-Sources-Returned"] = str(len(answer.sources))
_audit_service_request( _audit_service_request(
db, db,
client_id=client_id, reported_client_id=client.reported_client_id,
tool="mobilityops_search_knowledge", tool="fleet_ops_search_knowledge",
status_label=answer.evidence_state, status_label=answer.evidence_state,
correlation_id=correlation_id,
metadata={
"tenant": client.tenant,
"locale": body.locale,
"sources_available": source_count_available,
"sources_returned": len(answer.sources),
},
) )
return answer return answer
+46
View File
@@ -0,0 +1,46 @@
from __future__ import annotations
import hmac
from fastapi import APIRouter, Header, HTTPException
from fastapi.responses import Response
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
from sqlalchemy import func, select
from app.core.config import get_settings
from app.core.db import SessionLocal
from app.core.observability import OUTBOX_EVENTS
from app.models.outbox import DELIVERY_STATUSES, DEMO_SCENARIO_ERROR_CODE, OutboxEvent
router = APIRouter(tags=["observability"])
settings = get_settings()
def _refresh_database_metrics() -> None:
with SessionLocal() as db:
rows = db.execute(
select(
OutboxEvent.delivery_status,
(OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE).label("demo"),
func.count(),
).group_by(OutboxEvent.delivery_status, "demo")
).all()
OUTBOX_EVENTS.clear()
# Keep every time series present even when a state currently contains no rows.
# Stable zero-valued series make dashboards and alerts deterministic after resets,
# restores and fresh installations instead of turning "zero" into "no data".
for scenario in ("synthetic", "operational"):
for status in DELIVERY_STATUSES:
OUTBOX_EVENTS.labels(scenario, status).set(0)
for status, is_demo, count in rows:
OUTBOX_EVENTS.labels("synthetic" if is_demo else "operational", str(status)).set(count)
@router.get("/metrics", include_in_schema=False)
def metrics(authorization: str | None = Header(default=None)) -> Response:
if settings.metrics_bearer_token:
supplied = authorization.removeprefix("Bearer ") if authorization else ""
if not hmac.compare_digest(supplied, settings.metrics_bearer_token):
raise HTTPException(status_code=401, detail="Metrics token required")
_refresh_database_metrics()
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
+166
View File
@@ -0,0 +1,166 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.config import get_settings
from app.models.booking import Booking
from app.models.customer import Customer
from app.schemas import (
CurrentUser,
CustomerAnonymizeRequest,
CustomerAnonymizeResult,
PrivacyRetentionOut,
)
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/privacy", tags=["privacy"])
settings = get_settings()
def _retention_cutoff() -> datetime:
return datetime.now(UTC) - timedelta(days=settings.privacy_minimum_booking_retention_days)
def _customer_is_eligible(db: Session, customer_id) -> bool:
blocking = db.scalar(
select(func.count())
.select_from(Booking)
.where(
Booking.customer_id == customer_id,
or_(
Booking.status.in_(("reserved", "active")),
Booking.ends_at > _retention_cutoff(),
),
)
)
return not blocking
@router.get("/retention", response_model=PrivacyRetentionOut)
def retention_status(
db: Session = Depends(get_db),
_user: CurrentUser = Depends(require_operations_manager),
) -> PrivacyRetentionOut:
customers = db.scalars(select(Customer)).all()
return PrivacyRetentionOut(
minimum_booking_retention_days=settings.privacy_minimum_booking_retention_days,
audit_retention_days=settings.privacy_audit_retention_days,
customers_total=len(customers),
customers_anonymized=sum(customer.anonymized_at is not None for customer in customers),
customers_eligible=sum(
customer.anonymized_at is None and _customer_is_eligible(db, customer.id)
for customer in customers
),
)
@router.get("/customers/{public_ref}/export")
def export_customer_data(
public_ref: str,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> JSONResponse:
customer = db.scalar(select(Customer).where(Customer.public_ref == public_ref))
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
bookings = db.scalars(
select(Booking).where(Booking.customer_id == customer.id).order_by(Booking.starts_at)
).all()
payload = {
"generated_at": datetime.now(UTC).isoformat(),
"customer": {
"public_ref": customer.public_ref,
"first_name": customer.first_name,
"last_name": customer.last_name,
"email": customer.email,
"phone": customer.phone,
"postal_code": customer.postal_code,
"city": customer.city,
"date_of_birth": customer.date_of_birth.isoformat() if customer.date_of_birth else None,
"anonymized_at": customer.anonymized_at.isoformat() if customer.anonymized_at else None,
},
"bookings": [
{
"public_ref": booking.public_ref,
"starts_at": booking.starts_at.isoformat(),
"ends_at": booking.ends_at.isoformat(),
"status": booking.status,
}
for booking in bookings
],
}
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_exported",
entity_type="customer",
entity_id=customer.id,
metadata={"customer_ref": customer.public_ref, "booking_count": len(bookings)},
)
db.commit()
return JSONResponse(
payload,
headers={"Content-Disposition": f'attachment; filename="{public_ref}-privacy.json"'},
)
@router.post("/customers/{public_ref}/anonymize", response_model=CustomerAnonymizeResult)
def anonymize_customer(
public_ref: str,
body: CustomerAnonymizeRequest,
db: Session = Depends(get_db),
actor: CurrentUser = Depends(require_operations_manager),
) -> CustomerAnonymizeResult:
customer = db.scalar(
select(Customer).where(Customer.public_ref == public_ref).with_for_update()
)
if customer is None:
raise HTTPException(status_code=404, detail="Customer not found")
if body.confirmation != public_ref:
raise HTTPException(
status_code=422, detail="Customer reference confirmation does not match"
)
if customer.anonymized_at is not None:
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=customer.anonymized_at,
status="already_anonymized",
)
if not _customer_is_eligible(db, customer.id):
raise HTTPException(
status_code=409,
detail="Customer has an active/recent booking within the minimum retention period",
)
anonymized_at = datetime.now(UTC)
customer.first_name = "Anoniem"
customer.last_name = public_ref
customer.email = None
customer.phone = None
customer.postal_code = None
customer.city = None
customer.date_of_birth = None
customer.anonymized_at = anonymized_at
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="privacy_customer_anonymized",
entity_type="customer",
entity_id=customer.id,
before={"anonymized": False},
after={"anonymized": True},
metadata={"reason": body.reason, "customer_ref": public_ref},
)
db.commit()
return CustomerAnonymizeResult(
public_ref=public_ref,
anonymized_at=anonymized_at,
status="anonymized",
)
+68 -30
View File
@@ -12,53 +12,87 @@ from app.schemas import CurrentUser, SearchResponse, SearchResultItem
router = APIRouter(prefix="/api/v1/search", tags=["search"]) router = APIRouter(prefix="/api/v1/search", tags=["search"])
# Static application sections. Manager-only sections are filtered by role, mirroring the # Static application sections. `id` is a stable code matching navigation.json's
# same nav visibility rule Layout.tsx applies -- search must never surface a destination # `items.*` keys -- the frontend localizes both the section label and its one-line
# the current role can't actually reach. # detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
# search must never surface a destination the current role can't actually reach.
_SECTIONS: list[dict] = [ _SECTIONS: list[dict] = [
{ {
"label": "Overview", "id": "overview",
"detail": "Operations dashboard",
"link": "/dashboard", "link": "/dashboard",
"terms": ["overview", "dashboard", "readiness"], # Search terms deliberately span all three supported UI languages (not just
# English) so a query never depends on the operator's selected locale.
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
}, },
{ {
"label": "Fleet", "id": "fleet",
"detail": "Vehicle registry",
"link": "/vehicles", "link": "/vehicles",
"terms": ["fleet", "vehicle", "vehicles"], "terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
}, },
{ {
"label": "Bookings", "id": "bookings",
"detail": "Rental bookings",
"link": "/bookings", "link": "/bookings",
"terms": ["booking", "bookings", "rental"], "terms": [
"booking",
"bookings",
"rental",
"boeking",
"boekingen",
"verhuur",
"réservation",
"réservations",
"location",
],
}, },
{ {
"label": "Data quality", "id": "quality",
"detail": "Quality workbench",
"link": "/data-quality", "link": "/data-quality",
"terms": ["quality", "data quality", "issues"], "terms": [
"quality",
"data quality",
"issues",
"kwaliteit",
"datakwaliteit",
"problemen",
"qualité",
"problèmes",
],
"role": "operations_manager", "role": "operations_manager",
}, },
{ {
"label": "Knowledge", "id": "knowledge",
"detail": "Procedure assistant",
"link": "/knowledge", "link": "/knowledge",
"terms": ["knowledge", "procedures"], "terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
}, },
{ {
"label": "Integrations", "id": "integrations",
"detail": "Automation and integration status",
"link": "/automation", "link": "/automation",
"terms": ["automation", "integrations", "systems", "n8n"], "terms": [
"automation",
"integrations",
"systems",
"n8n",
"automatisering",
"integraties",
"systemen",
"automatisation",
"intégrations",
"systèmes",
],
"role": "operations_manager", "role": "operations_manager",
}, },
{ {
"label": "Audit trail", "id": "audit",
"detail": "Audit history",
"link": "/audit", "link": "/audit",
"terms": ["audit", "history"], "terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager",
},
{
"id": "privacy",
"link": "/privacy",
"terms": ["privacy", "retention", "anonymise", "anonimiseren", "confidentialité"],
"role": "operations_manager", "role": "operations_manager",
}, },
] ]
@@ -83,8 +117,8 @@ def search(
results.append( results.append(
SearchResultItem( SearchResultItem(
type="section", type="section",
label=section["label"], label=section["id"],
detail=section["detail"], detail_code=section["id"],
link=section["link"], link=section["link"],
) )
) )
@@ -108,19 +142,23 @@ def search(
SearchResultItem( SearchResultItem(
type="vehicle", type="vehicle",
label=v.public_ref, label=v.public_ref,
detail=f"{v.make} {v.model} · {v.location}", detail_code="vehicleSummary",
detail_params={"make": v.make, "model": v.model, "location": v.location},
link=f"/vehicles/{v.public_ref}", link=f"/vehicles/{v.public_ref}",
) )
) )
for b in db.scalars( for b in db.scalars(
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5) select(Booking)
.where(Booking.public_ref.ilike(like))
.order_by(Booking.starts_at.desc())
.limit(5)
).all(): ).all():
results.append( results.append(
SearchResultItem( SearchResultItem(
type="booking", type="booking",
label=b.public_ref, label=b.public_ref,
detail=b.status, detail_code=b.status,
link=f"/bookings/{b.public_ref}", link=f"/bookings/{b.public_ref}",
) )
) )
@@ -138,7 +176,7 @@ def search(
SearchResultItem( SearchResultItem(
type="data_quality_issue", type="data_quality_issue",
label=i.public_ref, label=i.public_ref,
detail=i.rule_type.replace("_", " "), detail_code=i.rule_type,
link=f"/data-quality/{i.public_ref}", link=f"/data-quality/{i.public_ref}",
) )
) )
+100
View File
@@ -0,0 +1,100 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager
from app.core.security import hash_password
from app.models.user import User
from app.schemas import CreateUserRequest, CurrentUser, UpdateUserRequest, UserOut
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/users", tags=["users"])
def _to_out(user: User) -> UserOut:
return UserOut(
public_ref=user.public_ref,
email=user.email,
display_name=user.display_name,
role=user.role, # type: ignore[arg-type]
active=user.active,
)
@router.get("", response_model=list[UserOut])
def list_users(
db: Session = Depends(get_db),
_manager: CurrentUser = Depends(require_operations_manager),
) -> list[UserOut]:
return [_to_out(user) for user in db.scalars(select(User).order_by(User.display_name)).all()]
@router.post("", response_model=UserOut, status_code=201)
def create_user(
body: CreateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
email = body.email.strip().lower()
if db.scalar(select(User.id).where(User.email == email)) is not None:
raise HTTPException(status_code=409, detail="A user with this email already exists")
user = User(
public_ref=f"USR-{uuid.uuid4().hex[:8].upper()}",
email=email,
password_hash=hash_password(body.password),
display_name=body.display_name.strip(),
role=body.role,
active=True,
)
db.add(user)
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_created",
entity_type="user",
entity_id=user.id,
after={"public_ref": user.public_ref, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
@router.patch("/{public_ref}", response_model=UserOut)
def update_user(
public_ref: str,
body: UpdateUserRequest,
db: Session = Depends(get_db),
manager: CurrentUser = Depends(require_operations_manager),
) -> UserOut:
user = db.scalar(select(User).where(User.public_ref == public_ref).with_for_update())
if user is None:
raise HTTPException(status_code=404, detail="User not found")
if user.public_ref == manager.public_ref and body.active is False:
raise HTTPException(status_code=409, detail="You cannot deactivate your own account")
if user.public_ref == manager.public_ref and body.role not in (None, "operations_manager"):
raise HTTPException(status_code=409, detail="You cannot remove your own manager role")
before = {"display_name": user.display_name, "role": user.role, "active": user.active}
if body.display_name is not None:
user.display_name = body.display_name.strip()
if body.role is not None:
user.role = body.role
if body.active is not None:
user.active = body.active
if body.password is not None:
user.password_hash = hash_password(body.password)
record_audit_event(
db,
actor_type="user",
actor_label=manager.display_name,
action="user_updated",
entity_type="user",
entity_id=user.id,
before=before,
after={"display_name": user.display_name, "role": user.role, "active": user.active},
)
db.commit()
return _to_out(user)
+220 -11
View File
@@ -1,10 +1,13 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select from sqlalchemy import func, or_, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.api.deps import get_current_user, get_db from app.api.deps import get_current_user, get_db, require_operations_manager
from app.models.booking import Booking from app.models.booking import Booking
from app.models.customer import Customer from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
@@ -13,13 +16,17 @@ from app.models.maintenance import MaintenanceRecord
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import ( from app.schemas import (
BookingSummaryOut, BookingSummaryOut,
CreateMaintenanceRequest,
CurrentUser, CurrentUser,
DataQualityIssueOut, DataQualityIssueOut,
InspectionOut, InspectionOut,
MaintenanceOut, MaintenanceOut,
ReleaseVehicleRequest,
VehicleDetailOut, VehicleDetailOut,
VehicleOut, VehicleOut,
VehiclePageOut,
) )
from app.services.audit import record_audit_event
router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"]) router = APIRouter(prefix="/api/v1/vehicles", tags=["vehicles"])
@@ -34,19 +41,61 @@ def _attention_vehicle_ids(db: Session) -> set:
return set(rows) return set(rows)
@router.get("", response_model=list[VehicleOut]) @router.get("", response_model=list[VehicleOut] | VehiclePageOut)
def list_vehicles( def list_vehicles(
status: str | None = Query(default=None), status: str | None = Query(default=None),
attention_only: bool = Query(default=False), attention_only: bool = Query(default=False),
location: str | None = Query(default=None, min_length=1, max_length=120),
query: str | None = Query(default=None, min_length=1, max_length=100),
page: int | None = Query(default=None, ge=1),
page_size: int = Query(default=25, ge=1, le=25),
db: Session = Depends(get_db), db: Session = Depends(get_db),
_user: CurrentUser = Depends(get_current_user), _user: CurrentUser = Depends(get_current_user),
) -> list[VehicleOut]: ) -> list[VehicleOut] | VehiclePageOut:
stmt = select(Vehicle).order_by(Vehicle.public_ref) stmt = select(Vehicle).order_by(Vehicle.public_ref)
if status: if status:
stmt = stmt.where(Vehicle.operational_status == status) stmt = stmt.where(Vehicle.operational_status == status)
vehicles = db.scalars(stmt).all() if location:
stmt = stmt.where(Vehicle.location.ilike(location.strip()))
if query:
term = f"%{query.strip()}%"
stmt = stmt.where(
or_(
Vehicle.public_ref.ilike(term),
Vehicle.make.ilike(term),
Vehicle.model.ilike(term),
Vehicle.location.ilike(term),
Vehicle.registration_number.ilike(term),
)
)
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
out = [ if attention_only:
stmt = stmt.where(
or_(
Vehicle.id.in_(attention_ids),
Vehicle.operational_status == "blocked",
Vehicle.next_service_km <= Vehicle.odometer_km,
)
)
total = db.scalar(select(func.count()).select_from(stmt.subquery())) or 0
page_number = page or 1
vehicles = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
vehicle_ids = [vehicle.id for vehicle in vehicles]
next_bookings: dict[uuid.UUID, Booking] = {}
if vehicle_ids:
for booking in db.scalars(
select(Booking)
.where(
Booking.vehicle_id.in_(vehicle_ids),
Booking.status == "reserved",
Booking.starts_at >= datetime.now(UTC),
)
.order_by(Booking.starts_at.asc())
).all():
next_bookings.setdefault(booking.vehicle_id, booking)
items = [
VehicleOut( VehicleOut(
public_ref=v.public_ref, public_ref=v.public_ref,
make=v.make, make=v.make,
@@ -58,13 +107,36 @@ def list_vehicles(
odometer_km=v.odometer_km, odometer_km=v.odometer_km,
next_service_km=v.next_service_km, next_service_km=v.next_service_km,
active=v.active, active=v.active,
attention=v.id in attention_ids or v.operational_status == "blocked", attention=(
v.id in attention_ids
or v.operational_status == "blocked"
or v.next_service_km <= v.odometer_km
),
attention_reason=(
"blocked_status"
if v.operational_status == "blocked"
else "service_due"
if v.next_service_km <= v.odometer_km
else "data_quality"
if v.id in attention_ids
else None
),
service_remaining_km=v.next_service_km - v.odometer_km,
next_booking_ref=(next_bookings[v.id].public_ref if v.id in next_bookings else None),
next_booking_at=(next_bookings[v.id].starts_at if v.id in next_bookings else None),
) )
for v in vehicles for v in vehicles
] ]
if attention_only: if page is None:
out = [v for v in out if v.attention] return items
return out total_pages = max(1, (total + page_size - 1) // page_size)
return VehiclePageOut(
items=items,
page=min(page_number, total_pages),
page_size=page_size,
total=total,
total_pages=total_pages,
)
@router.get("/{public_ref}", response_model=VehicleDetailOut) @router.get("/{public_ref}", response_model=VehicleDetailOut)
@@ -98,6 +170,14 @@ def get_vehicle(
).all() ).all()
booking_by_id = {b.id: b.public_ref for b in bookings} booking_by_id = {b.id: b.public_ref for b in bookings}
next_booking = next(
(
booking
for booking in sorted(bookings, key=lambda item: item.starts_at)
if booking.status == "reserved" and booking.starts_at >= datetime.now(UTC)
),
None,
)
attention_ids = _attention_vehicle_ids(db) attention_ids = _attention_vehicle_ids(db)
return VehicleDetailOut( return VehicleDetailOut(
@@ -111,7 +191,23 @@ def get_vehicle(
odometer_km=vehicle.odometer_km, odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km, next_service_km=vehicle.next_service_km,
active=vehicle.active, active=vehicle.active,
attention=vehicle.id in attention_ids or vehicle.operational_status == "blocked", attention=(
vehicle.id in attention_ids
or vehicle.operational_status == "blocked"
or vehicle.next_service_km <= vehicle.odometer_km
),
attention_reason=(
"blocked_status"
if vehicle.operational_status == "blocked"
else "service_due"
if vehicle.next_service_km <= vehicle.odometer_km
else "data_quality"
if vehicle.id in attention_ids
else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=next_booking.public_ref if next_booking else None,
next_booking_at=next_booking.starts_at if next_booking else None,
bookings=[ bookings=[
BookingSummaryOut( BookingSummaryOut(
public_ref=b.public_ref, public_ref=b.public_ref,
@@ -157,8 +253,121 @@ def get_vehicle(
status=q.status, status=q.status,
evidence=q.evidence_json, evidence=q.evidence_json,
detected_at=q.detected_at, detected_at=q.detected_at,
due_at=q.due_at,
assigned_to_ref=(q.assigned_to_user.public_ref if q.assigned_to_user else None),
assigned_to_name=(q.assigned_to_user.display_name if q.assigned_to_user else None),
overdue=(
q.status == "open" and q.due_at is not None and q.due_at < datetime.now(UTC)
),
resolved_at=q.resolved_at, resolved_at=q.resolved_at,
) )
for q in issues for q in issues
], ],
) )
@router.post("/{public_ref}/maintenance", response_model=MaintenanceOut, status_code=201)
def create_maintenance_record(
public_ref: str,
body: CreateMaintenanceRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> MaintenanceOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
record = MaintenanceRecord(
public_ref=f"MAINT-{uuid.uuid4().hex[:8].upper()}",
vehicle_id=vehicle.id,
occurred_at=body.occurred_at,
odometer_km=body.odometer_km,
category=body.category,
summary=body.summary.strip(),
)
db.add(record)
vehicle.odometer_km = max(vehicle.odometer_km, body.odometer_km)
if body.next_service_km is not None:
if body.next_service_km < vehicle.odometer_km:
raise HTTPException(status_code=422, detail="Next service must not be below odometer")
vehicle.next_service_km = body.next_service_km
if body.mark_maintenance:
vehicle.operational_status = "maintenance"
vehicle.version += 1
db.flush()
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="maintenance_record_created",
entity_type="vehicle",
entity_id=vehicle.id,
after={"maintenance_ref": record.public_ref, "status": vehicle.operational_status},
)
db.commit()
return MaintenanceOut(
public_ref=record.public_ref,
occurred_at=record.occurred_at,
odometer_km=record.odometer_km,
category=record.category,
summary=record.summary,
)
@router.post("/{public_ref}/release", response_model=VehicleOut)
def release_vehicle(
public_ref: str,
body: ReleaseVehicleRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> VehicleOut:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == public_ref).with_for_update())
if vehicle is None:
raise HTTPException(status_code=404, detail="Vehicle not found")
if vehicle.operational_status not in {"cleaning", "maintenance", "blocked"}:
raise HTTPException(status_code=409, detail="Vehicle does not require release")
active_booking = db.scalar(
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
)
open_high_issue = db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
)
if active_booking is not None or open_high_issue is not None:
raise HTTPException(status_code=409, detail="Vehicle still has a blocking condition")
before = {"status": vehicle.operational_status}
vehicle.operational_status = "available"
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="vehicle_released",
entity_type="vehicle",
entity_id=vehicle.id,
before=before,
after={"status": "available", "reason": body.reason.strip()},
)
db.commit()
return VehicleOut(
public_ref=vehicle.public_ref,
make=vehicle.make,
model=vehicle.model,
model_year=vehicle.model_year,
registration_number=vehicle.registration_number,
location=vehicle.location,
operational_status=vehicle.operational_status,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
active=vehicle.active,
attention=vehicle.next_service_km <= vehicle.odometer_km,
attention_reason=(
"service_due" if vehicle.next_service_km <= vehicle.odometer_km else None
),
service_remaining_km=vehicle.next_service_km - vehicle.odometer_km,
next_booking_ref=None,
next_booking_at=None,
)
+16 -2
View File
@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.api.deps import get_db, require_operations_manager from app.api.deps import get_db, require_operations_manager
from app.core.errors import AppError from app.core.errors import AppError
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent, is_demo_scenario_failure
from app.schemas import AutomationRunOut, CurrentUser from app.schemas import AutomationRunOut, CurrentUser
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -23,6 +23,8 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
status=event.delivery_status, status=event.delivery_status,
attempts=event.attempts, attempts=event.attempts,
last_error=event.last_error, last_error=event.last_error,
last_error_code=event.last_error_code,
is_demo_scenario=is_demo_scenario_failure(event),
occurred_at=event.occurred_at, occurred_at=event.occurred_at,
) )
@@ -62,15 +64,27 @@ def retry_workflow(
status_code=409, status_code=409,
) )
# Captured before the status flips, so the audit records what was actually retried.
was_demo_scenario = is_demo_scenario_failure(event)
event.delivery_status = "pending" event.delivery_status = "pending"
event.next_attempt_at = None event.next_attempt_at = None
# The retry itself is real either way: the event goes back on the outbox and the
# dispatcher delivers it to the configured n8n webhook like any other. The only
# difference recorded here is *what* was retried -- a staged demo failure or a real
# one -- so the audit trail never implies a production incident was resolved when a
# prop was.
record_audit_event( record_audit_event(
db, db,
actor_type="user", actor_type="user",
actor_label=user.display_name, actor_label=user.display_name,
action="workflow_retry", action="workflow_retry",
entity_type="outbox_event", entity_type="outbox_event",
metadata={"event_id": event_id, "previous_attempts": event.attempts}, metadata={
"event_id": event_id,
"previous_attempts": event.attempts,
"demo_scenario": was_demo_scenario,
},
) )
db.commit() db.commit()
return _to_out(event) return _to_out(event)
+83 -1
View File
@@ -2,6 +2,12 @@ from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
# The visible product name is fixed and never translated or configured per-deployment --
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
# (package name, Compose project, database name, repository) intentionally remain
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
PRODUCT_NAME = "Fleet Ops"
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore") model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -15,8 +21,15 @@ class Settings(BaseSettings):
ragcore_workspace: str = "mobilityops" ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures" ragcore_collection: str = "internal-procedures"
ragcore_api_token: str = "" ragcore_api_token: str = ""
ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0 ragcore_http_timeout_seconds: float = 5.0
ragcore_answers_circuit_breaker_seconds: float = 60.0
# Search fallback is only labelled grounded above this explicit retrieval threshold.
# RAGcore's fused score is reciprocal-rank based (top ranks are ~1/61), so this
# accepts only leading results while still rejecting absent and low-ranked evidence.
ragcore_min_search_score: float = 0.016
n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return" n8n_webhook_url: str = "http://n8n:5678/webhook/mobilityops-return"
n8n_webhook_trigger_token: str = "replace-me-n8n-webhook-trigger-token"
n8n_callback_token: str = "replace-me-n8n-callback-token" n8n_callback_token: str = "replace-me-n8n-callback-token"
n8n_dispatch_enabled: bool = True n8n_dispatch_enabled: bool = True
n8n_dispatch_interval_seconds: float = 3.0 n8n_dispatch_interval_seconds: float = 3.0
@@ -31,12 +44,81 @@ class Settings(BaseSettings):
knowledge_dir: str = "/app/knowledge/procedures" knowledge_dir: str = "/app/knowledge/procedures"
mcp_hub_service_token: str = "replace-me-mcp-hub-token" mcp_hub_service_token: str = "replace-me-mcp-hub-token"
mcp_hub_registration_enabled: bool = False mcp_hub_registration_enabled: bool = False
# MCP Hub's own registration is catalog-driven on the Hub side (the Hub reconciles
# its catalog into the gateway; Fleet Ops never pushes a registration call), so
# these are only used for an honest reachability health check, not self-registration.
mcp_hub_base_url: str = ""
mcp_provider_id: str = "fleet-ops"
cors_allow_origins: str = "http://localhost:1228" cors_allow_origins: str = "http://localhost:1228"
demo_organization_name: str = "Northstar Mobility" demo_organization_name: str = "Northstar Mobility"
demo_timezone: str = "Europe/Brussels" demo_timezone: str = "Europe/Brussels"
demo_allow_reset: bool = True demo_allow_reset: bool = True
demo_reset_cooldown_seconds: int = 60
mcp_hub_health_cache_seconds: int = 60
initial_admin_email: str = ""
initial_admin_password: str = ""
initial_admin_display_name: str = "Operations Manager"
mobilityops_public_url: str = "http://localhost:1228"
oidc_enabled: bool = False
oidc_provider_name: str = "Organisatieaccount"
oidc_issuer_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_allowed_email_domains: str = ""
oidc_auto_provision: bool = True
oidc_default_role: str = "rental_employee"
log_level: str = "INFO"
# Failed password logins per client IP before a temporary 429 (0 disables).
login_max_failures: int = 10
login_failure_window_seconds: int = 900
knowledge_max_requests: int = 30
knowledge_rate_limit_window_seconds: int = 60
metrics_bearer_token: str = ""
privacy_minimum_booking_retention_days: int = 30
privacy_audit_retention_days: int = 2555
privacy_audit_export_max_rows: int = 10000
# Secrets that guard *inbound* trust (session cookies, service callbacks). Running
# production with any of these at their placeholder value means forged sessions or
# unauthenticated writes, so startup refuses.
INSECURE_DEFAULT_SECRETS: tuple[tuple[str, str], ...] = (
("app_secret", "replace-in-production"),
("n8n_callback_token", "replace-me-n8n-callback-token"),
("mcp_hub_service_token", "replace-me-mcp-hub-token"),
)
def insecure_default_secrets(settings: "Settings") -> list[str]:
"""Return the names of secret settings that still carry their placeholder value.
MCP routes are always mounted, independently of the Hub reachability-status flag, so
their inbound token must always be non-placeholder in production.
"""
insecure: list[str] = []
for name, placeholder in INSECURE_DEFAULT_SECRETS:
value = getattr(settings, name)
if not value or value == placeholder or value.startswith("replace-me"):
insecure.append(name)
return insecure
@lru_cache @lru_cache
def get_settings() -> Settings: def get_settings() -> Settings:
return Settings() settings = Settings()
if settings.mobilityops_env.lower() == "production":
insecure = insecure_default_secrets(settings)
if insecure:
# Refuse to boot rather than run production with forgeable session cookies
# or guessable service tokens. Development/test/demo keep the defaults.
raise RuntimeError(
"Refusing to start in production with placeholder secrets: "
+ ", ".join(insecure)
+ ". Set real values in the environment (see .env.example)."
)
if not settings.mobilityops_public_url.lower().startswith("https://"):
raise RuntimeError("Production MOBILITYOPS_PUBLIC_URL must use HTTPS.")
if not settings.session_cookie_secure:
raise RuntimeError("Production SESSION_COOKIE_SECURE must be true.")
return settings
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import json
import logging
import time
import uuid
from contextvars import ContextVar
from datetime import UTC, datetime
from fastapi import Request
from prometheus_client import Counter, Gauge, Histogram
correlation_id_context: ContextVar[str] = ContextVar("correlation_id", default="")
HTTP_REQUESTS = Counter(
"mobilityops_http_requests_total",
"Completed MobilityOps HTTP requests.",
("method", "route", "status"),
)
HTTP_DURATION = Histogram(
"mobilityops_http_request_duration_seconds",
"MobilityOps HTTP request duration.",
("method", "route"),
buckets=(0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10),
)
HTTP_IN_PROGRESS = Gauge(
"mobilityops_http_requests_in_progress",
"MobilityOps HTTP requests currently executing.",
)
OUTBOX_EVENTS = Gauge(
"mobilityops_outbox_events",
"Persisted outbox events by state and scenario type.",
("scenario", "status"),
)
DATABASE_READY = Gauge(
"mobilityops_database_ready",
"Whether the canonical PostgreSQL database answered the most recent readiness probe.",
)
KNOWLEDGE_PROVIDER_REQUESTS = Counter(
"mobilityops_knowledge_provider_requests_total",
"RAGcore adapter requests by stage and outcome.",
("stage", "outcome"),
)
KNOWLEDGE_RETRIEVAL_SCORE = Histogram(
"mobilityops_knowledge_retrieval_score",
"Observed RAGcore fused/rerank retrieval scores.",
buckets=(0.005, 0.01, 0.015, 0.016, 0.0162, 0.0164, 0.02, 0.05, 0.1, 0.5, 1.0),
)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, object] = {
"timestamp": datetime.now(UTC).isoformat(),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
correlation_id = correlation_id_context.get()
if correlation_id:
payload["correlation_id"] = correlation_id
for key in ("method", "path", "status_code", "duration_ms", "client_ip"):
value = getattr(record, key, None)
if value is not None:
payload[key] = value
if record.exc_info:
payload["exception"] = self.formatException(record.exc_info)
return json.dumps(payload, separators=(",", ":"), default=str)
def configure_logging(level: str) -> None:
handler = logging.StreamHandler()
handler.setFormatter(JsonFormatter())
root = logging.getLogger()
root.handlers = [handler]
root.setLevel(level.upper())
def correlation_id_for(request: Request) -> str:
candidate = request.headers.get("X-Correlation-Id", "").strip()
try:
return str(uuid.UUID(candidate)) if candidate else str(uuid.uuid4())
except ValueError:
return str(uuid.uuid4())
UNMATCHED_ROUTE_LABEL = "<unmatched>"
def route_label(request: Request) -> str:
"""Return the route *template* for metrics labels.
Unmatched paths (404 probes, scanners) must not become their own label value:
every distinct URL would otherwise create a new Prometheus time series and the
metric cardinality would grow without bound.
"""
route = request.scope.get("route")
path = getattr(route, "path", None)
return str(path) if path else UNMATCHED_ROUTE_LABEL
def request_started() -> float:
HTTP_IN_PROGRESS.inc()
return time.perf_counter()
def request_finished(request: Request, status_code: int, started_at: float) -> float:
duration = time.perf_counter() - started_at
route = route_label(request)
HTTP_REQUESTS.labels(request.method, route, str(status_code)).inc()
HTTP_DURATION.labels(request.method, route).observe(duration)
HTTP_IN_PROGRESS.dec()
return duration
+72
View File
@@ -0,0 +1,72 @@
"""Small in-process failed-attempt limiter for credential endpoints.
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
and the automated test suite are never throttled.
"""
from __future__ import annotations
import threading
import time
from collections import deque
class FailedAttemptLimiter:
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
self.max_failures = max_failures
self.window_seconds = window_seconds
self._failures: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def _prune(self, key: str, now: float) -> deque[float]:
bucket = self._failures.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if not bucket:
self._failures.pop(key, None)
return bucket
def retry_after_seconds(self, key: str) -> int:
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
now = time.monotonic()
with self._lock:
bucket = self._prune(key, now)
if len(bucket) < self.max_failures:
return 0
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
def record_failure(self, key: str) -> None:
now = time.monotonic()
with self._lock:
self._prune(key, now)
self._failures.setdefault(key, deque()).append(now)
def reset(self, key: str) -> None:
with self._lock:
self._failures.pop(key, None)
class SlidingWindowLimiter:
"""Thread-safe request limiter where every accepted request consumes capacity."""
def __init__(self, *, max_requests: int, window_seconds: float) -> None:
self.max_requests = max_requests
self.window_seconds = window_seconds
self._requests: dict[str, deque[float]] = {}
self._lock = threading.Lock()
def consume(self, key: str) -> int:
"""Record an accepted request, or return the seconds until capacity is available."""
now = time.monotonic()
with self._lock:
bucket = self._requests.setdefault(key, deque())
cutoff = now - self.window_seconds
while bucket and bucket[0] <= cutoff:
bucket.popleft()
if len(bucket) >= self.max_requests:
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
bucket.append(now)
return 0
+35
View File
@@ -4,6 +4,7 @@ import base64
import hashlib import hashlib
import hmac import hmac
import json import json
import os
import time import time
from dataclasses import dataclass from dataclasses import dataclass
@@ -19,6 +20,7 @@ class SessionPayload:
role: str role: str
display_name: str display_name: str
issued_at: int issued_at: int
session_id: str = ""
def _sign(data: bytes) -> str: def _sign(data: bytes) -> str:
@@ -26,6 +28,11 @@ def _sign(data: bytes) -> str:
return base64.urlsafe_b64encode(digest).decode().rstrip("=") return base64.urlsafe_b64encode(digest).decode().rstrip("=")
def session_token_hash(token: str) -> str:
"""Return a non-reversible identifier safe to persist for token revocation."""
return hashlib.sha256(token.encode()).hexdigest()
def create_session_token(payload: SessionPayload) -> str: def create_session_token(payload: SessionPayload) -> str:
body = json.dumps(payload.__dict__, separators=(",", ":")).encode() body = json.dumps(payload.__dict__, separators=(",", ":")).encode()
encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=") encoded_body = base64.urlsafe_b64encode(body).decode().rstrip("=")
@@ -50,3 +57,31 @@ def read_session_token(token: str) -> SessionPayload | None:
if time.time() - payload.issued_at > settings.session_ttl_seconds: if time.time() - payload.issued_at > settings.session_ttl_seconds:
return None return None
return payload return payload
def hash_password(password: str) -> str:
salt = os.urandom(16)
derived = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)
encoded_salt = base64.urlsafe_b64encode(salt).decode()
encoded_hash = base64.urlsafe_b64encode(derived).decode()
return f"scrypt$16384$8$1${encoded_salt}${encoded_hash}"
def verify_password(password: str, encoded: str | None) -> bool:
if not encoded:
return False
try:
algorithm, n, r, p, salt, expected = encoded.split("$")
if algorithm != "scrypt":
return False
derived = hashlib.scrypt(
password.encode(),
salt=base64.urlsafe_b64decode(salt.encode()),
n=int(n),
r=int(r),
p=int(p),
dklen=32,
)
return hmac.compare_digest(derived, base64.urlsafe_b64decode(expected.encode()))
except (ValueError, TypeError):
return False
+113 -6
View File
@@ -1,13 +1,18 @@
import logging
import uuid import uuid
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, Request from fastapi import FastAPI, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from sqlalchemy import text
from starlette.middleware.sessions import SessionMiddleware
from app.api.routers import ( from app.api.routers import (
audit, audit,
auth,
bookings, bookings,
customers,
dashboard, dashboard,
data_quality, data_quality,
demo, demo,
@@ -15,25 +20,92 @@ from app.api.routers import (
integrations, integrations,
knowledge, knowledge,
mcp_integrations, mcp_integrations,
observability,
privacy,
search, search,
users,
vehicles, vehicles,
workflows, workflows,
) )
from app.core.config import get_settings from app.api.routers.auth import bootstrap_initial_admin
from app.core.config import PRODUCT_NAME, get_settings
from app.core.db import SessionLocal
from app.core.errors import AppError, error_body from app.core.errors import AppError, error_body
from app.core.observability import (
DATABASE_READY,
configure_logging,
correlation_id_context,
correlation_id_for,
request_finished,
request_started,
)
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level)
request_logger = logging.getLogger("mobilityops.request")
@asynccontextmanager @asynccontextmanager
async def lifespan(_app: FastAPI): async def lifespan(_app: FastAPI):
with SessionLocal() as db:
bootstrap_initial_admin(db)
start_background_dispatcher() start_background_dispatcher()
yield yield
stop_background_dispatcher() stop_background_dispatcher()
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan) production = settings.mobilityops_env.lower() == "production"
app = FastAPI(
title=f"{PRODUCT_NAME} API",
version="0.1.0",
description=(
"Generated contract for Fleet Ops. The visible product name is Fleet Ops; "
"MobilityOps remains the technical repository and service identifier."
),
servers=[{"url": "http://localhost:8128"}],
lifespan=lifespan,
docs_url=None if production else "/docs",
redoc_url=None if production else "/redoc",
openapi_url=None if production else "/openapi.json",
)
app.add_middleware(
SessionMiddleware,
secret_key=settings.app_secret,
session_cookie="mobilityops_oidc_state",
max_age=600,
same_site="lax",
https_only=settings.session_cookie_secure,
)
@app.middleware("http")
async def request_observability(request: Request, call_next):
correlation_id = correlation_id_for(request)
request.state.correlation_id = correlation_id
token = correlation_id_context.set(correlation_id)
started_at = request_started()
status_code = 500
try:
response = await call_next(request)
status_code = response.status_code
response.headers["X-Correlation-Id"] = correlation_id
return response
finally:
duration = request_finished(request, status_code, started_at)
request_logger.info(
"request_completed",
extra={
"method": request.method,
"path": request.url.path,
"status_code": status_code,
"duration_ms": round(duration * 1000, 2),
"client_ip": request.client.host if request.client else None,
},
)
correlation_id_context.reset(token)
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
@@ -45,23 +117,29 @@ app.add_middleware(
@app.exception_handler(AppError) @app.exception_handler(AppError)
def handle_app_error(_request: Request, exc: AppError) -> JSONResponse: def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=error_body(exc.code, exc.message, exc.correlation_id, exc.details), content=error_body(
exc.code,
exc.message,
request.state.correlation_id,
exc.details,
),
) )
@app.exception_handler(HTTPException) @app.exception_handler(HTTPException)
def handle_http_exception(_request: Request, exc: HTTPException) -> JSONResponse: def handle_http_exception(request: Request, exc: HTTPException) -> JSONResponse:
return JSONResponse( return JSONResponse(
status_code=exc.status_code, status_code=exc.status_code,
content=error_body( content=error_body(
code=str(exc.status_code), code=str(exc.status_code),
message=str(exc.detail), message=str(exc.detail),
correlation_id=str(uuid.uuid4()), correlation_id=getattr(request.state, "correlation_id", str(uuid.uuid4())),
details={}, details={},
), ),
headers=exc.headers,
) )
@@ -70,6 +148,28 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "mobilityops-api"} return {"status": "ok", "service": "mobilityops-api"}
@app.get("/health/live")
def liveness() -> dict[str, str]:
"""Process liveness only; external dependencies deliberately do not affect it."""
return {"status": "ok", "service": "mobilityops-api"}
@app.get("/health/ready")
def readiness() -> JSONResponse:
"""Traffic readiness: the API is useful only while its canonical database responds."""
try:
with SessionLocal() as db:
db.execute(text("SELECT 1"))
except Exception: # noqa: BLE001 -- readiness must convert infrastructure errors to 503
DATABASE_READY.set(0)
return JSONResponse(
status_code=503,
content={"status": "not_ready", "service": "mobilityops-api", "database": "down"},
)
DATABASE_READY.set(1)
return JSONResponse(content={"status": "ready", "service": "mobilityops-api", "database": "up"})
@app.get("/api/v1/system/status") @app.get("/api/v1/system/status")
def system_status() -> dict[str, object]: def system_status() -> dict[str, object]:
return { return {
@@ -77,13 +177,17 @@ def system_status() -> dict[str, object]:
"environment": settings.mobilityops_env, "environment": settings.mobilityops_env,
"demo_mode": settings.mobilityops_demo_mode, "demo_mode": settings.mobilityops_demo_mode,
"knowledge_provider": settings.knowledge_provider, "knowledge_provider": settings.knowledge_provider,
"oidc_enabled": auth.oidc_status().enabled,
"oidc_provider_name": auth.oidc_status().provider_name,
} }
app.include_router(demo.router) app.include_router(demo.router)
app.include_router(auth.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(vehicles.router) app.include_router(vehicles.router)
app.include_router(bookings.router) app.include_router(bookings.router)
app.include_router(customers.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(data_quality.router) app.include_router(data_quality.router)
app.include_router(workflows.router) app.include_router(workflows.router)
@@ -92,3 +196,6 @@ app.include_router(knowledge.router)
app.include_router(mcp_integrations.router) app.include_router(mcp_integrations.router)
app.include_router(search.router) app.include_router(search.router)
app.include_router(integration_status.router) app.include_router(integration_status.router)
app.include_router(users.router)
app.include_router(observability.router)
app.include_router(privacy.router)
+2
View File
@@ -7,6 +7,7 @@ from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.models.revoked_session import RevokedSession
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
@@ -20,6 +21,7 @@ __all__ = [
"Inspection", "Inspection",
"MaintenanceRecord", "MaintenanceRecord",
"OutboxEvent", "OutboxEvent",
"RevokedSession",
"User", "User",
"Vehicle", "Vehicle",
] ]
+6 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, String from sqlalchemy import CheckConstraint, DateTime, Index, String
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,6 +13,11 @@ ACTOR_TYPES = ("user", "service", "system")
class AuditEvent(UUIDPrimaryKeyMixin, Base): class AuditEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_events" __tablename__ = "audit_events"
__table_args__ = (
CheckConstraint("actor_type IN ('user','service','system')", name="ck_audit_actor_type"),
Index("ix_audit_action_occurred", "action", "occurred_at"),
Index("ix_audit_entity", "entity_type", "entity_id"),
)
actor_type: Mapped[str] = mapped_column(String(20), nullable=False) actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True)) actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
+18 -2
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, CheckConstraint, DateTime, ForeignKey, Index, Integer, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -13,6 +13,22 @@ BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked")
class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base): class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "bookings" __tablename__ = "bookings"
__table_args__ = (
CheckConstraint(
"status IN ('reserved','active','returned','cancelled','blocked')",
name="ck_bookings_status",
),
CheckConstraint("ends_at > starts_at", name="ck_bookings_time_window"),
CheckConstraint(
"start_odometer_km IS NULL OR start_odometer_km >= 0",
name="ck_bookings_start_odometer",
),
CheckConstraint(
"end_odometer_km IS NULL OR end_odometer_km >= 0",
name="ck_bookings_end_odometer",
),
Index("ix_bookings_vehicle_status_window", "vehicle_id", "status", "starts_at", "ends_at"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
customer_id: Mapped[uuid.UUID] = mapped_column( customer_id: Mapped[uuid.UUID] = mapped_column(
@@ -26,4 +42,4 @@ class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
status: Mapped[str] = mapped_column(String(20), nullable=False) status: Mapped[str] = mapped_column(String(20), nullable=False)
start_odometer_km: Mapped[int | None] = mapped_column(Integer) start_odometer_km: Mapped[int | None] = mapped_column(Integer)
end_odometer_km: Mapped[int | None] = mapped_column(Integer) end_odometer_km: Mapped[int | None] = mapped_column(Integer)
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
+3 -2
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import date from datetime import date, datetime
from sqlalchemy import Date, ForeignKey, String from sqlalchemy import Date, DateTime, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -23,3 +23,4 @@ class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column( merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id") UUID(as_uuid=True), ForeignKey("customers.id")
) )
anonymized_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+32 -2
View File
@@ -1,13 +1,17 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, String from sqlalchemy import CheckConstraint, DateTime, ForeignKey, Index, String, text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.db import Base from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from app.models.user import User
RULE_TYPES = ( RULE_TYPES = (
"possible_duplicate_customer", "possible_duplicate_customer",
"missing_required_field", "missing_required_field",
@@ -21,6 +25,27 @@ ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected")
class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base): class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "data_quality_issues" __tablename__ = "data_quality_issues"
__table_args__ = (
CheckConstraint(
"rule_type IN ('possible_duplicate_customer','missing_required_field',"
"'odometer_regression','booking_overlap','vehicle_status_conflict')",
name="ck_data_quality_rule_type",
),
CheckConstraint("severity IN ('low','medium','high')", name="ck_data_quality_severity"),
CheckConstraint(
"status IN ('open','deferred','resolved','rejected')",
name="ck_data_quality_status",
),
Index("ix_data_quality_work_queue", "status", "due_at", "severity"),
Index(
"uq_data_quality_one_open_condition",
"rule_type",
"entity_type",
"entity_id",
unique=True,
postgresql_where=text("status = 'open'"),
),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
rule_type: Mapped[str] = mapped_column(String(40), nullable=False) rule_type: Mapped[str] = mapped_column(String(40), nullable=False)
@@ -31,5 +56,10 @@ class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
due_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
assigned_to_user_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), index=True
)
assigned_to_user: Mapped["User | None"] = relationship(lazy="selectin")
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120)) resolved_by: Mapped[str | None] = mapped_column(String(120))
+3
View File
@@ -15,5 +15,8 @@ class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
booking_id: Mapped[uuid.UUID] = mapped_column( booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
) )
# SHA-256 of the canonical request body. Replaying a key with a *different* body is
# a client bug and must be rejected instead of silently answered with the old result.
request_fingerprint: Mapped[str | None] = mapped_column(String(64), nullable=True)
response_status: Mapped[int] = mapped_column(Integer, nullable=False) response_status: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False) response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
+1 -3
View File
@@ -7,9 +7,7 @@ from sqlalchemy.orm import Mapped, mapped_column
class UUIDPrimaryKeyMixin: class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column( id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin: class TimestampMixin:
+33 -1
View File
@@ -1,7 +1,7 @@
import uuid import uuid
from datetime import datetime from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text from sqlalchemy import CheckConstraint, DateTime, Index, Integer, String, Text
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -10,9 +10,36 @@ from app.models.mixins import TimestampMixin
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed") DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
# The one delivery failure the demo seed deliberately plants (BK-H-0020, see
# seed/workflow_runs.csv). It exists to show retry and audit working, so it must never
# be read as an integration-health problem: it is a scripted prop, not evidence that
# n8n is unhealthy. A dedicated error code -- rather than the generic
# "connectionError" a real timeout produces -- is what lets every reader tell the two
# apart without guessing from the message text.
#
# It is deliberately a `last_error_code` value and not a new column: the code is
# already persisted, already surfaced to the UI, and already localizable, so no schema
# change or migration is needed. A genuine later failure of this same event overwrites
# the code with the real one, which is exactly right -- from that moment it *is* a real
# failure.
DEMO_SCENARIO_ERROR_CODE = "demoScenarioTimeout"
def is_demo_scenario_failure(event: "OutboxEvent") -> bool:
"""True for the prepared demo failure, false for every real one."""
return event.delivery_status == "failed" and event.last_error_code == DEMO_SCENARIO_ERROR_CODE
class OutboxEvent(TimestampMixin, Base): class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events" __tablename__ = "outbox_events"
__table_args__ = (
CheckConstraint(
"delivery_status IN ('pending','delivering','succeeded','failed')",
name="ck_outbox_delivery_status",
),
CheckConstraint("attempts >= 0", name="ck_outbox_attempts"),
Index("ix_outbox_delivery_next_attempt", "delivery_status", "next_attempt_at"),
)
event_id: Mapped[uuid.UUID] = mapped_column( event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4 UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
@@ -26,4 +53,9 @@ class OutboxEvent(TimestampMixin, Base):
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text) last_error: Mapped[str | None] = mapped_column(Text)
# Stable, localizable classification of last_error -- the frontend renders a
# localized summary from this code as the primary text and shows last_error itself
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
last_error_code: Mapped[str | None] = mapped_column(String(60))
external_run_id: Mapped[str | None] = mapped_column(String(120)) external_run_id: Mapped[str | None] = mapped_column(String(120))
+16
View File
@@ -0,0 +1,16 @@
from datetime import datetime
from sqlalchemy import DateTime, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class RevokedSession(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "revoked_sessions"
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True
)
+8 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, String from sqlalchemy import Boolean, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base from app.core.db import Base
@@ -9,8 +9,15 @@ ROLES = ("operations_manager", "rental_employee")
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users" __tablename__ = "users"
__table_args__ = (
UniqueConstraint("identity_provider", "external_subject", name="uq_user_external_identity"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
email: Mapped[str | None] = mapped_column(String(320), unique=True, nullable=True)
password_hash: Mapped[str | None] = mapped_column(String(512), nullable=True)
display_name: Mapped[str] = mapped_column(String(120), nullable=False) display_name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False) role: Mapped[str] = mapped_column(String(30), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
identity_provider: Mapped[str | None] = mapped_column(String(80), nullable=True)
external_subject: Mapped[str | None] = mapped_column(String(255), nullable=True)
+11 -1
View File
@@ -1,4 +1,4 @@
from sqlalchemy import Boolean, Integer, String from sqlalchemy import Boolean, CheckConstraint, Integer, String
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base from app.core.db import Base
@@ -9,6 +9,16 @@ OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "block
class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base): class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "vehicles" __tablename__ = "vehicles"
__table_args__ = (
CheckConstraint(
"operational_status IN ('available','rented','cleaning','maintenance','blocked')",
name="ck_vehicles_operational_status",
),
CheckConstraint("model_year BETWEEN 1900 AND 2100", name="ck_vehicles_model_year"),
CheckConstraint("odometer_km >= 0", name="ck_vehicles_odometer"),
CheckConstraint("next_service_km >= 0", name="ck_vehicles_next_service"),
CheckConstraint("version >= 1", name="ck_vehicles_version"),
)
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False) public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
make: Mapped[str] = mapped_column(String(80), nullable=False) make: Mapped[str] = mapped_column(String(80), nullable=False)
+326 -6
View File
@@ -1,9 +1,10 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime from datetime import datetime
from typing import Annotated, Any, Literal from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, ConfigDict, Field
Role = Literal["operations_manager", "rental_employee"] Role = Literal["operations_manager", "rental_employee"]
@@ -12,6 +13,33 @@ class DemoLoginRequest(BaseModel):
role: Role role: Role
class PasswordLoginRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
password: str = Field(min_length=8, max_length=256)
class UserOut(BaseModel):
public_ref: str
email: str | None
display_name: str
role: Role
active: bool
class CreateUserRequest(BaseModel):
email: str = Field(min_length=3, max_length=320)
display_name: str = Field(min_length=2, max_length=120)
role: Role
password: str = Field(min_length=8, max_length=256)
class UpdateUserRequest(BaseModel):
display_name: str | None = Field(default=None, min_length=2, max_length=120)
role: Role | None = None
active: bool | None = None
password: str | None = Field(default=None, min_length=8, max_length=256)
class CurrentUser(BaseModel): class CurrentUser(BaseModel):
public_ref: str public_ref: str
display_name: str display_name: str
@@ -30,6 +58,18 @@ class VehicleOut(BaseModel):
next_service_km: int next_service_km: int
active: bool active: bool
attention: bool = False attention: bool = False
attention_reason: str | None = None
service_remaining_km: int
next_booking_ref: str | None = None
next_booking_at: datetime | None = None
class VehiclePageOut(BaseModel):
items: list[VehicleOut]
page: int
page_size: int
total: int
total_pages: int
class BookingSummaryOut(BaseModel): class BookingSummaryOut(BaseModel):
@@ -48,6 +88,70 @@ class BookingOut(BookingSummaryOut):
customer_name: str customer_name: str
class CreateBookingRequest(BaseModel):
customer_ref: str = Field(min_length=3, max_length=20)
vehicle_ref: str = Field(min_length=3, max_length=20)
starts_at: datetime
ends_at: datetime
requirements_complete: bool = False
class CompleteBookingRequirementsRequest(BaseModel):
confirmation: str = Field(min_length=3, max_length=500)
class RescheduleBookingRequest(BaseModel):
starts_at: datetime
ends_at: datetime
reason: str = Field(min_length=3, max_length=500)
class CustomerOptionOut(BaseModel):
public_ref: str
display_name: str
email: str | None
class AvailableVehicleOut(BaseModel):
public_ref: str
make: str
model: str
registration_number: str
location: str
operational_status: str
class CancelBookingRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class CheckoutBookingRequest(BaseModel):
start_odometer_km: Annotated[int, Field(ge=0)]
fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
cleanliness_ok: bool
damage_reported: bool = False
technical_warning: bool = False
notes: str | None = Field(default=None, max_length=2000)
class CheckoutBookingResult(BaseModel):
booking_ref: str
vehicle_ref: str
inspection_ref: str
booking_status: str
resulting_vehicle_status: str
activated: bool
attention_reasons: list[str]
class BookingPageOut(BaseModel):
items: list[BookingOut]
page: int
page_size: int
total: int
total_pages: int
class RegisterReturnRequest(BaseModel): class RegisterReturnRequest(BaseModel):
end_odometer_km: Annotated[int, Field(ge=0)] end_odometer_km: Annotated[int, Field(ge=0)]
fuel_level_percent: Annotated[int, Field(ge=0, le=100)] fuel_level_percent: Annotated[int, Field(ge=0, le=100)]
@@ -71,6 +175,7 @@ class RegisterReturnResult(BaseModel):
odometer_regression: bool odometer_regression: bool
quality_issue_ref: str | None quality_issue_ref: str | None
workflow_event_id: str workflow_event_id: str
correlation_id: str
next_booking_risk: NextBookingRisk | None next_booking_risk: NextBookingRisk | None
@@ -83,6 +188,8 @@ class ReturnPreviewResult(BaseModel):
resulting_odometer_km: int resulting_odometer_km: int
resulting_vehicle_status: str resulting_vehicle_status: str
status_reason: str status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int] = {}
would_create_quality_issue: bool would_create_quality_issue: bool
attention_reasons: list[str] attention_reasons: list[str]
next_booking_risk: NextBookingRisk | None next_booking_risk: NextBookingRisk | None
@@ -108,6 +215,19 @@ class MaintenanceOut(BaseModel):
summary: str summary: str
class CreateMaintenanceRequest(BaseModel):
occurred_at: datetime
odometer_km: Annotated[int, Field(ge=0)]
category: Literal["periodic_service", "repair", "inspection", "tyres", "other"]
summary: str = Field(min_length=3, max_length=2000)
next_service_km: Annotated[int | None, Field(default=None, ge=0)]
mark_maintenance: bool = True
class ReleaseVehicleRequest(BaseModel):
reason: str = Field(min_length=3, max_length=500)
class DataQualityIssueOut(BaseModel): class DataQualityIssueOut(BaseModel):
public_ref: str public_ref: str
rule_type: str rule_type: str
@@ -117,14 +237,38 @@ class DataQualityIssueOut(BaseModel):
status: str status: str
evidence: dict[str, Any] evidence: dict[str, Any]
detected_at: datetime detected_at: datetime
due_at: datetime | None = None
assigned_to_ref: str | None = None
assigned_to_name: str | None = None
overdue: bool = False
resolved_at: datetime | None = None resolved_at: datetime | None = None
class DataQualityIssuePageOut(BaseModel):
items: list[DataQualityIssueOut]
page: int
page_size: int
total: int
total_pages: int
class DataQualityIssueDetailOut(DataQualityIssueOut): class DataQualityIssueDetailOut(DataQualityIssueOut):
entity_snapshot: dict[str, Any] | None = None entity_snapshot: dict[str, Any] | None = None
related_snapshots: list[dict[str, Any]] = Field(default_factory=list) related_snapshots: list[dict[str, Any]] = Field(default_factory=list)
class BulkDataQualityWorkRequest(BaseModel):
issue_refs: list[str] = Field(min_length=1, max_length=25)
assigned_to_ref: str | None = Field(default=None, min_length=3, max_length=20)
clear_assignment: bool = False
due_at: datetime | None = None
clear_due_at: bool = False
class BulkDataQualityWorkResult(BaseModel):
updated: list[DataQualityIssueOut]
class MergeCustomersRequest(BaseModel): class MergeCustomersRequest(BaseModel):
survivor_ref: str survivor_ref: str
field_overrides: dict[str, str] | None = None field_overrides: dict[str, str] | None = None
@@ -141,6 +285,53 @@ class ScanResultOut(BaseModel):
created: dict[str, int] created: dict[str, int]
class WorkflowErrorReportIn(BaseModel):
workflow_id: str = Field(max_length=120)
workflow_name: str = Field(max_length=200)
execution_id: str = Field(max_length=120)
failed_at: datetime
error_category: Literal[
"timeout", "authError", "connectionError", "httpError", "validationError", "unknown"
]
error_summary: str = Field(max_length=500)
trigger_context: str | None = Field(default=None, max_length=200)
correlation_id: str | None = None
attempt: int = Field(default=1, ge=1, le=1000)
retry_action: str | None = Field(default=None, max_length=200)
class WorkflowErrorReportResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class ProcedureDocumentOut(BaseModel):
id: str
language: str
document_id: str
title: str
version: str
content: str
content_hash: str
class ProcedureListOut(BaseModel):
documents: list[ProcedureDocumentOut]
class ProcedureSyncResultIn(BaseModel):
execution_id: str = Field(max_length=120)
synced: int = Field(ge=0)
failed: int = Field(default=0, ge=0)
class ProcedureSyncResultResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class ProvideFieldsRequest(BaseModel): class ProvideFieldsRequest(BaseModel):
fields: dict[str, str] fields: dict[str, str]
@@ -157,16 +348,41 @@ class ResolveOverlapRequest(BaseModel):
note: str | None = Field(default=None, max_length=500) note: str | None = Field(default=None, max_length=500)
class VehicleStatusFactsOut(BaseModel):
active_booking_refs: list[str]
overlapping_booking_pairs: list[list[str]]
service_threshold_reached: bool
odometer_km: int
next_service_km: int
open_booking_overlap_issue_ref: str | None = None
class StatusRecommendationOut(BaseModel):
current_status: str
recommended_status: str | None
recommendation_code: str
safe_to_apply: bool
manual_review_required: bool
facts: VehicleStatusFactsOut
blocking_reasons: list[str]
recommendation_token: str
class ApplyRecommendedStatusRequest(BaseModel):
recommendation_token: str
class ApplyRecommendedStatusResult(BaseModel): class ApplyRecommendedStatusResult(BaseModel):
issue: DataQualityIssueOut issue: DataQualityIssueOut
applied_status: str applied_status: str
reason: str reason_code: str
class SearchResultItem(BaseModel): class SearchResultItem(BaseModel):
type: Literal["vehicle", "booking", "data_quality_issue", "section"] type: Literal["vehicle", "booking", "data_quality_issue", "section"]
label: str label: str
detail: str detail_code: str
detail_params: dict[str, str] = {}
link: str link: str
@@ -175,21 +391,106 @@ class SearchResponse(BaseModel):
results: list[SearchResultItem] results: list[SearchResultItem]
class OidcStatusOut(BaseModel):
enabled: bool
provider_name: str | None = None
class PrivacyRetentionOut(BaseModel):
minimum_booking_retention_days: int
audit_retention_days: int
customers_total: int
customers_anonymized: int
customers_eligible: int
class CustomerAnonymizeRequest(BaseModel):
confirmation: str = Field(min_length=1, max_length=20)
reason: str = Field(min_length=8, max_length=500)
class CustomerAnonymizeResult(BaseModel):
public_ref: str
anonymized_at: datetime
status: Literal["anonymized", "already_anonymized"]
class N8nWorkflowEvidence(BaseModel):
name: str
built: bool
last_seen_at: datetime | None
state: Literal["no_evidence", "healthy", "stale", "failed"] = "no_evidence"
last_status: Literal["succeeded", "failed"] | None = None
last_execution_id: str | None = None
class ReturnCallbackIn(BaseModel):
"""Body of the n8n return follow-up callback.
n8n forwards its whole item (``JSON.stringify($json)``), so unknown keys are ignored;
only the fields we persist are validated and bounded.
"""
model_config = ConfigDict(extra="ignore")
correlation_id: uuid.UUID | None = None
follow_up: str | None = Field(default=None, max_length=200)
summary: str | None = Field(default=None, max_length=2000)
class N8nHeartbeatIn(BaseModel):
workflow_id: str = Field(min_length=1, max_length=120)
workflow_name: str = Field(min_length=1, max_length=200)
execution_id: str = Field(min_length=1, max_length=120)
status: Literal["succeeded", "failed"]
class N8nHeartbeatResult(BaseModel):
status: Literal["registered", "already_registered"]
execution_id: str
occurred_at: datetime
class N8nErrorHandlerStatus(BaseModel):
total_failures_registered: int
latest_failure_at: datetime | None
latest_failure_workflow: str | None
class N8nIntegrationStatus(BaseModel): class N8nIntegrationStatus(BaseModel):
configured: bool configured: bool
dispatch_enabled: bool dispatch_enabled: bool
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
pending: int pending: int
delivering: int #: Every failed delivery, staged and real together -- the number a viewer sees in
#: the run list.
failed: int failed: int
#: Failures that were not planted by the demo seed. This is the only failure count
#: that may influence `state`.
unexpected_failed: int = 0
#: Prepared demo failures (see `app.models.outbox.DEMO_SCENARIO_ERROR_CODE`).
#: Present so the UI can label them instead of implying the automation is broken.
demo_scenario_failed: int = 0
delivering: int
succeeded: int succeeded: int
latest_success_at: datetime | None latest_success_at: datetime | None
#: Most recent *real* failure; a staged one never sets this.
latest_failure_at: datetime | None latest_failure_at: datetime | None
latest_demo_scenario_at: datetime | None = None
expected_workflow_count: int
known_workflow_count: int
workflows: list[N8nWorkflowEvidence]
error_handler: N8nErrorHandlerStatus
class McpHubIntegrationStatus(BaseModel): class McpHubIntegrationStatus(BaseModel):
registration_enabled: bool registration_enabled: bool
state: Literal["not_configured", "configured"] state: Literal["not_configured", "no_evidence", "operational"]
total_calls: int
last_tool: str | None = None
last_client: str | None = None
last_called_at: datetime | None = None
hub_reachable: bool | None = None
class IntegrationStatusOut(BaseModel): class IntegrationStatusOut(BaseModel):
@@ -245,11 +546,16 @@ class DashboardMetrics(BaseModel):
pending_or_failed_workflows: int pending_or_failed_workflows: int
class EvidenceSignalOut(BaseModel):
code: str
params: dict[str, Any] = Field(default_factory=dict)
class AttentionItem(BaseModel): class AttentionItem(BaseModel):
kind: Literal["quality_issue", "vehicle"] kind: Literal["quality_issue", "vehicle"]
severity: str severity: str
rule_type: str rule_type: str
detail: str evidence_signals: list[EvidenceSignalOut] = Field(default_factory=list)
link_type: Literal["vehicle", "booking", "customer"] link_type: Literal["vehicle", "booking", "customer"]
link_ref: str link_ref: str
issue_ref: str | None = None issue_ref: str | None = None
@@ -269,6 +575,11 @@ class AutomationRunOut(BaseModel):
status: str status: str
attempts: int attempts: int
last_error: str | None last_error: str | None
last_error_code: str | None
#: True for the deliberately seeded demo failure. The UI uses this to label the run
#: as a prepared scenario and to offer the demo retry, instead of presenting it as
#: an unexplained production error.
is_demo_scenario: bool = False
occurred_at: datetime occurred_at: datetime
@@ -308,6 +619,7 @@ class McpVehicleDetailOut(BaseModel):
class McpKnowledgeSearchRequest(BaseModel): class McpKnowledgeSearchRequest(BaseModel):
question: str = Field(min_length=3, max_length=1000) question: str = Field(min_length=3, max_length=1000)
max_sources: int = Field(default=4, ge=1, le=8) max_sources: int = Field(default=4, ge=1, le=8)
locale: Literal["nl-BE", "en-GB", "fr-BE"] = "en-GB"
class AuditEventOut(BaseModel): class AuditEventOut(BaseModel):
@@ -324,3 +636,11 @@ class AuditEventOut(BaseModel):
before: dict[str, Any] | None = None before: dict[str, Any] | None = None
after: dict[str, Any] | None = None after: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None metadata: dict[str, Any] | None = None
class AuditEventPageOut(BaseModel):
items: list[AuditEventOut]
page: int
page_size: int
total: int
total_pages: int
+157 -18
View File
@@ -4,6 +4,7 @@ import csv
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path from pathlib import Path
from sqlalchemy import delete, insert, update from sqlalchemy import delete, insert, update
@@ -17,7 +18,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.models.user import User from app.models.user import User
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
@@ -79,9 +80,20 @@ def _read_csv(name: str) -> list[dict[str, str]]:
return list(csv.DictReader(handle)) return list(csv.DictReader(handle))
def clear_all(db: Session) -> None: _PERSISTENT_TELEMETRY_ACTIONS = (
"mcp_tool_request",
"n8n_return_followup_recorded",
"n8n_workflow_failure_registered",
"n8n_procedures_synced",
"n8n_workflow_heartbeat",
"knowledge_question_asked",
)
def clear_all(db: Session, *, preserve_integration_telemetry: bool = False) -> None:
# RevokedSession is intentionally NOT cleared: it has no FK to users and wiping it
# would silently re-validate cookies that were logged out before the reset.
for model in ( for model in (
AuditEvent,
OutboxEvent, OutboxEvent,
IdempotencyRecord, IdempotencyRecord,
DataQualityIssue, DataQualityIssue,
@@ -93,6 +105,12 @@ def clear_all(db: Session) -> None:
User, User,
): ):
db.execute(delete(model)) db.execute(delete(model))
if preserve_integration_telemetry:
db.execute(
delete(AuditEvent).where(AuditEvent.action.not_in(_PERSISTENT_TELEMETRY_ACTIONS))
)
else:
db.execute(delete(AuditEvent))
def load_seed(db: Session) -> SeedResult: def load_seed(db: Session) -> SeedResult:
@@ -100,19 +118,17 @@ def load_seed(db: Session) -> SeedResult:
today = datetime.now(UTC).date() today = datetime.now(UTC).date()
shift = _seed_anchor_shift(today) shift = _seed_anchor_shift(today)
user_rows = [ user_rows = [{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS]
{"id": uuid.uuid4(), **user, "active": True} for user in DEMO_USERS
]
db.execute(insert(User), user_rows) db.execute(insert(User), user_rows)
counts["users"] = len(user_rows) counts["users"] = len(user_rows)
customer_id_by_ref: dict[str, uuid.UUID] = {} customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = [] customer_rows = []
customer_row_by_ref: dict[str, dict] = {}
for row in _read_csv("customers.csv"): for row in _read_csv("customers.csv"):
cid = uuid.uuid4() cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append( customer_row = {
{
"id": cid, "id": cid,
"public_ref": row["public_ref"], "public_ref": row["public_ref"],
"first_name": row["first_name"], "first_name": row["first_name"],
@@ -122,7 +138,8 @@ def load_seed(db: Session) -> SeedResult:
"postal_code": row["postal_code"] or None, "postal_code": row["postal_code"] or None,
"city": row["city"] or None, "city": row["city"] or None,
} }
) customer_rows.append(customer_row)
customer_row_by_ref[row["public_ref"]] = customer_row
db.execute(insert(Customer), customer_rows) db.execute(insert(Customer), customer_rows)
counts["customers"] = len(customer_rows) counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first. # Second pass for merged_into (self-referencing FK) since target must exist first.
@@ -137,11 +154,11 @@ def load_seed(db: Session) -> SeedResult:
vehicle_id_by_ref: dict[str, uuid.UUID] = {} vehicle_id_by_ref: dict[str, uuid.UUID] = {}
vehicle_rows = [] vehicle_rows = []
vehicle_row_by_ref: dict[str, dict] = {}
for row in _read_csv("vehicles.csv"): for row in _read_csv("vehicles.csv"):
vid = uuid.uuid4() vid = uuid.uuid4()
vehicle_id_by_ref[row["public_ref"]] = vid vehicle_id_by_ref[row["public_ref"]] = vid
vehicle_rows.append( vehicle_row = {
{
"id": vid, "id": vid,
"public_ref": row["public_ref"], "public_ref": row["public_ref"],
"make": row["make"], "make": row["make"],
@@ -155,17 +172,18 @@ def load_seed(db: Session) -> SeedResult:
"active": _parse_bool(row["active"]), "active": _parse_bool(row["active"]),
"version": 1, "version": 1,
} }
) vehicle_rows.append(vehicle_row)
vehicle_row_by_ref[row["public_ref"]] = vehicle_row
db.execute(insert(Vehicle), vehicle_rows) db.execute(insert(Vehicle), vehicle_rows)
counts["vehicles"] = len(vehicle_rows) counts["vehicles"] = len(vehicle_rows)
booking_id_by_ref: dict[str, uuid.UUID] = {} booking_id_by_ref: dict[str, uuid.UUID] = {}
booking_rows = [] booking_rows = []
booking_row_by_ref: dict[str, dict] = {}
for row in _read_csv("bookings.csv"): for row in _read_csv("bookings.csv"):
bid = uuid.uuid4() bid = uuid.uuid4()
booking_id_by_ref[row["public_ref"]] = bid booking_id_by_ref[row["public_ref"]] = bid
booking_rows.append( booking_row = {
{
"id": bid, "id": bid,
"public_ref": row["public_ref"], "public_ref": row["public_ref"],
"customer_id": customer_id_by_ref[row["customer_ref"]], "customer_id": customer_id_by_ref[row["customer_ref"]],
@@ -177,7 +195,8 @@ def load_seed(db: Session) -> SeedResult:
"end_odometer_km": _parse_optional_int(row["end_odometer_km"]), "end_odometer_km": _parse_optional_int(row["end_odometer_km"]),
"requirements_complete": _parse_bool(row["requirements_complete"]), "requirements_complete": _parse_bool(row["requirements_complete"]),
} }
) booking_rows.append(booking_row)
booking_row_by_ref[row["public_ref"]] = booking_row
db.execute(insert(Booking), booking_rows) db.execute(insert(Booking), booking_rows)
counts["bookings"] = len(booking_rows) counts["bookings"] = len(booking_rows)
@@ -223,11 +242,123 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref] return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref] return "vehicle", vehicle_id_by_ref[entity_ref]
def _vehicle_conflict_facts(vehicle_ref: str, *, service_threshold_reached: bool) -> dict:
# Mirrors app.services.vehicle_status.VehicleStatusFacts.as_dict() for the
# handful of seed-only rows below -- none of them carry an active rental or a
# real booking conflict (verified against the fixed seed dataset), only a
# genuinely-crossed service threshold or none at all, so those two fields are
# the only ones that vary per vehicle.
vehicle = vehicle_row_by_ref[vehicle_ref]
return {
"active_booking_refs": [],
"overlapping_booking_pairs": [],
"service_threshold_reached": service_threshold_reached,
"odometer_km": vehicle["odometer_km"],
"next_service_km": vehicle["next_service_km"],
"open_booking_overlap_issue_ref": None,
}
def _odometer_regression_signal(later_ref: str, earlier_ref: str) -> list[dict]:
later = booking_row_by_ref[later_ref]
earlier = booking_row_by_ref[earlier_ref]
return [
{
"code": "odometer.regression",
"params": {
"later_ref": later_ref,
"later_km": later["end_odometer_km"],
"earlier_ref": earlier_ref,
"earlier_km": earlier["end_odometer_km"],
},
}
]
def _missing_field_signal(field: str) -> list[dict]:
return [{"code": "missing_field", "params": {"field": field}}]
# Every seed-only row below (i.e. not one of the four named DQ-DEMO-* scenarios)
# used to carry no structured signal at all -- just the placeholder summary
# "Synthetic deterministic seed issue". Each now cites a real fact about its actual
# entity (a genuinely-crossed service threshold, a genuinely-blank field, or a real
# pair of booking odometer readings engineered into seed/bookings.csv), using the
# exact same signal vocabulary the live scan (app.services.data_quality) already
# renders through -- see docs/fleet-ops-correction/current-gap-audit.md §6.
_SEED_SIGNALS_BY_REF: dict[str, list[dict]] = {
"DQ-0005": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-036", service_threshold_reached=True),
}
],
"DQ-0006": _missing_field_signal("location"),
"DQ-0007": _odometer_regression_signal("BK-H-0007", "BK-H-0057"),
"DQ-0008": [
{
"code": "vehicle.rental_ended",
"params": _vehicle_conflict_facts("MO-007", service_threshold_reached=False),
}
],
"DQ-0009": _missing_field_signal("location"),
"DQ-0010": _odometer_regression_signal("BK-H-0010", "BK-H-0060"),
"DQ-0011": [
{
"code": "vehicle.service_threshold_reached",
"params": _vehicle_conflict_facts("MO-028", service_threshold_reached=True),
}
],
"DQ-0012": _missing_field_signal("registration_number"),
"DQ-0013": _missing_field_signal("location"),
"DQ-0014": _missing_field_signal("registration_number"),
"DQ-0015": _missing_field_signal("location"),
"DQ-0016": _missing_field_signal("location"),
"DQ-0017": _missing_field_signal("location"),
"DQ-0018": _missing_field_signal("registration_number"),
"DQ-0019": _missing_field_signal("location"),
"DQ-0020": _missing_field_signal("location"),
"DQ-0021": _missing_field_signal("location"),
}
def _seed_signals(public_ref: str, entity_ref: str, related_refs: list[str]) -> list[dict]:
# The four named DQ-DEMO-* rows anchor the guided demo's scripted scenarios, so
# they carry real, accurate structured signals (not just a legacy English
# sentence) -- the frontend renders these as the primary, localized evidence;
# see docs/fleet-ops-correction/current-gap-audit.md §6.
if public_ref == "DQ-DEMO-DUPLICATE":
a = customer_row_by_ref[entity_ref]
b = customer_row_by_ref[related_refs[0]]
name_a = f"{a['first_name']} {a['last_name']}".strip().lower()
name_b = f"{b['first_name']} {b['last_name']}".strip().lower()
ratio = SequenceMatcher(None, name_a, name_b).ratio()
return [
{"code": "duplicate.exact_email"},
{"code": "duplicate.exact_phone"},
{"code": "duplicate.same_postal_code"},
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}},
]
if public_ref == "DQ-DEMO-OVERLAP":
return [{"code": "overlap.reserved_bookings", "params": {"refs": related_refs}}]
if public_ref == "DQ-DEMO-STATUS":
return [{"code": "vehicle.booking_conflict"}]
if public_ref == "DQ-DEMO-ATTENTION":
return [
{
"code": "attention.upcoming_booking_missing_inspection",
"params": {"booking_ref": related_refs[0] if related_refs else ""},
}
]
return _SEED_SIGNALS_BY_REF.get(public_ref, [])
dq_rows = [] dq_rows = []
now = datetime.now(UTC) now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"): for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"]) entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or "" related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
severity_due_delta = {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(row["severity"], timedelta(days=1))
dq_rows.append( dq_rows.append(
{ {
"id": uuid.uuid4(), "id": uuid.uuid4(),
@@ -240,10 +371,12 @@ def load_seed(db: Session) -> SeedResult:
"evidence_json": { "evidence_json": {
"summary": row["evidence"], "summary": row["evidence"],
"entity_ref": row["entity_ref"], "entity_ref": row["entity_ref"],
"related_refs": related_ref.split("|") if related_ref else [], "related_refs": related_refs,
"signals": _seed_signals(row["public_ref"], row["entity_ref"], related_refs),
}, },
"proposed_action_json": {}, "proposed_action_json": {},
"detected_at": now, "detected_at": now,
"due_at": now + severity_due_delta if row["status"] == "open" else None,
"resolved_at": now if row["status"] == "resolved" else None, "resolved_at": now if row["status"] == "resolved" else None,
"resolved_by": "USR-OPS" if row["status"] == "resolved" else None, "resolved_by": "USR-OPS" if row["status"] == "resolved" else None,
} }
@@ -287,6 +420,12 @@ def load_seed(db: Session) -> SeedResult:
"attempts": int(row["attempts"]), "attempts": int(row["attempts"]),
"next_attempt_at": None, "next_attempt_at": None,
"last_error": row["last_error"] or None, "last_error": row["last_error"] or None,
# The seed dataset's one synthetic failure (BK-H-0020) models a
# connection-timeout-style delivery failure -- see workflow_runs.csv.
# It is coded as a *prepared demo scenario*, not as a real
# connectionError, so integration health never degrades because of a
# prop and a viewer is told plainly that this failure is staged.
"last_error_code": DEMO_SCENARIO_ERROR_CODE if row["last_error"] else None,
"external_run_id": None, "external_run_id": None,
} }
) )
@@ -310,10 +449,10 @@ def load_seed(db: Session) -> SeedResult:
return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at) return SeedResult(counts=counts, anchor_date=today, seeded_at=seeded_at)
def reset_and_seed(db: Session) -> SeedResult: def reset_and_seed(db: Session, *, preserve_integration_telemetry: bool = False) -> SeedResult:
from app.services.data_quality import run_scan from app.services.data_quality import run_scan
clear_all(db) clear_all(db, preserve_integration_telemetry=preserve_integration_telemetry)
result = load_seed(db) result = load_seed(db)
db.commit() db.commit()
scan = run_scan(db) scan = run_scan(db)
+203 -166
View File
@@ -2,10 +2,9 @@ from __future__ import annotations
import uuid import uuid
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from difflib import SequenceMatcher
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.errors import AppError from app.core.errors import AppError
@@ -15,10 +14,27 @@ from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
from app.services.data_quality_duplicate_scan import scan_duplicate_customers
from app.services.vehicle_status import (
RECOMMENDATION_CODE_NO_CONFLICT,
VehicleStatusRecommendation,
compute_recommendation_token,
evaluate_vehicle_status,
gather_vehicle_status_facts,
)
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name") REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location") REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
DUPLICATE_THRESHOLD = 70 DATA_QUALITY_SCAN_LOCK_ID = 6_138_493_717_091_029_491
def issue_due_at(detected_at: datetime, severity: str) -> datetime:
"""Return the local operational SLA deadline for a newly detected issue."""
return detected_at + {
"high": timedelta(hours=4),
"medium": timedelta(days=1),
"low": timedelta(days=3),
}.get(severity, timedelta(days=1))
@dataclass @dataclass
@@ -29,10 +45,6 @@ class ScanResult:
self.created[rule_type] = self.created.get(rule_type, 0) + 1 self.created[rule_type] = self.created.get(rule_type, 0) + 1
def _normalize(value: str | None) -> str:
return (value or "").strip().lower()
def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool: def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uuid.UUID) -> bool:
return ( return (
db.scalar( db.scalar(
@@ -47,15 +59,9 @@ def _has_open_issue(db: Session, rule_type: str, entity_type: str, entity_id: uu
) )
def _next_public_ref(db: Session, prefix: str) -> str: def _new_scan_ref(prefix: str) -> str:
existing = db.execute(select(DataQualityIssue.public_ref)).scalars().all() """Generate a stable human-readable prefix with a concurrent-safe suffix."""
numbers = [ return f"{prefix}-{uuid.uuid4().hex[:10].upper()}"
int(ref.rsplit("-", 1)[-1])
for ref in existing
if ref.startswith(f"{prefix}-") and ref.rsplit("-", 1)[-1].isdigit()
]
next_number = (max(numbers) + 1) if numbers else 1
return f"{prefix}-{next_number:04d}"
def _open_issue( def _open_issue(
@@ -69,6 +75,7 @@ def _open_issue(
summary: str, summary: str,
entity_ref: str, entity_ref: str,
related_refs: list[str], related_refs: list[str],
signals: list[dict] | None = None,
) -> None: ) -> None:
if _has_open_issue(db, rule_type, entity_type, entity_id): if _has_open_issue(db, rule_type, entity_type, entity_id):
return return
@@ -87,17 +94,21 @@ def _open_issue(
) )
.order_by(DataQualityIssue.detected_at.desc()) .order_by(DataQualityIssue.detected_at.desc())
) )
# `summary` is kept as a technical-fallback string (shown only under "Technical
# details"); `signals` is the stable, localizable structure the frontend renders as
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
evidence: dict = { evidence: dict = {
"summary": summary, "summary": summary,
"entity_ref": entity_ref, "entity_ref": entity_ref,
"related_refs": related_refs, "related_refs": related_refs,
"signals": signals or [],
} }
if previous is not None: if previous is not None:
evidence["reopened_from"] = previous.public_ref evidence["reopened_from"] = previous.public_ref
evidence["previous_decision"] = previous.status evidence["previous_decision"] = previous.status
issue = DataQualityIssue( issue = DataQualityIssue(
public_ref=_next_public_ref(db, "DQ-SCAN"), public_ref=_new_scan_ref("DQ-SCAN"),
rule_type=rule_type, rule_type=rule_type,
entity_type=entity_type, entity_type=entity_type,
entity_id=entity_id, entity_id=entity_id,
@@ -106,55 +117,21 @@ def _open_issue(
evidence_json=evidence, evidence_json=evidence,
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=issue_due_at(now, severity),
) )
db.add(issue) db.add(issue)
db.flush() db.flush()
scan.bump(rule_type) scan.bump(rule_type)
def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
customers = list(
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
)
customers.sort(key=lambda c: c.public_ref)
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(signals) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
)
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None: def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
# Anonymised customers have had their contact data removed on purpose; flagging
# them as "missing required field" would only be resolvable by re-entering PII.
for customer in db.scalars( for customer in db.scalars(
select(Customer).where(Customer.merged_into_customer_id.is_(None)) select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all(): ).all():
missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)] missing = [f for f in REQUIRED_CUSTOMER_FIELDS if not getattr(customer, f)]
if not customer.email and not customer.phone: if not customer.email and not customer.phone:
@@ -170,6 +147,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}", summary=f"Missing: {', '.join(missing)}",
entity_ref=customer.public_ref, entity_ref=customer.public_ref,
related_refs=[], related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
) )
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all(): for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
@@ -185,6 +163,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}", summary=f"Missing: {', '.join(missing)}",
entity_ref=vehicle.public_ref, entity_ref=vehicle.public_ref,
related_refs=[], related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
) )
@@ -213,39 +192,32 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}", summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
entity_ref=vehicle.public_ref, entity_ref=vehicle.public_ref,
related_refs=[first.public_ref, second.public_ref], related_refs=[first.public_ref, second.public_ref],
signals=[
{
"code": "overlap.reserved_bookings",
"params": {"refs": [first.public_ref, second.public_ref]},
}
],
) )
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None: def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
# so detection and resolution can never structurally disagree -- see
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
vehicles = db.scalars(select(Vehicle)).all() vehicles = db.scalars(select(Vehicle)).all()
active_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars(select(Booking).where(Booking.status == "active")).all():
active_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
open_high_by_vehicle = {
row[0]
for row in db.execute(
select(DataQualityIssue.entity_id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
)
).all()
}
for vehicle in vehicles: for vehicle in vehicles:
has_active_booking = vehicle.id in active_by_vehicle facts = gather_vehicle_status_facts(db, vehicle)
reason = None recommendation = evaluate_vehicle_status(vehicle, facts)
if vehicle.operational_status == "available" and has_active_booking: if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
reason = "marked available while an active booking exists" continue
elif vehicle.operational_status == "rented" and not has_active_booking:
reason = "marked rented without an active booking"
elif vehicle.operational_status == "available" and vehicle.id in open_high_by_vehicle:
reason = "marked available while a high-severity quality issue is open"
elif vehicle.operational_status == "maintenance" and has_active_booking:
reason = "marked maintenance while an active booking exists"
if reason: signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
summary = (
f"Recommended status: {recommendation.recommended_status}"
if recommendation.recommended_status
else "Manual review required: active rental conflicts with a blocking condition"
)
_open_issue( _open_issue(
db, db,
scan, scan,
@@ -253,9 +225,13 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
entity_type="vehicle", entity_type="vehicle",
entity_id=vehicle.id, entity_id=vehicle.id,
severity="high", severity="high",
summary=f"Vehicle {reason}", summary=summary,
entity_ref=vehicle.public_ref, entity_ref=vehicle.public_ref,
related_refs=[], related_refs=[
*facts.active_booking_refs,
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
],
signals=signals,
) )
@@ -267,9 +243,7 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()} vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {} bookings_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
for booking in db.scalars( for booking in db.scalars(
select(Booking).where( select(Booking).where(Booking.status == "returned", Booking.end_odometer_km.is_not(None))
Booking.status == "returned", Booking.end_odometer_km.is_not(None)
)
).all(): ).all():
bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking) bookings_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
@@ -295,6 +269,17 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
), ),
entity_ref=vehicle.public_ref, entity_ref=vehicle.public_ref,
related_refs=[earlier.public_ref, later.public_ref], related_refs=[earlier.public_ref, later.public_ref],
signals=[
{
"code": "odometer.regression",
"params": {
"later_ref": later.public_ref,
"later_km": later.end_odometer_km,
"earlier_ref": earlier.public_ref,
"earlier_km": earlier.end_odometer_km,
},
}
],
) )
break break
@@ -302,8 +287,11 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
def run_scan( def run_scan(
db: Session, *, actor_label: str | None = None, actor_type: str = "user" db: Session, *, actor_label: str | None = None, actor_type: str = "user"
) -> ScanResult: ) -> ScanResult:
# The check-then-insert work below spans several rules. Serialise whole scans at the
# database boundary so API and n8n triggers cannot both observe an empty condition.
db.scalar(select(func.pg_advisory_xact_lock(DATA_QUALITY_SCAN_LOCK_ID)))
scan = ScanResult() scan = ScanResult()
_scan_duplicate_customers(db, scan) scan_duplicate_customers(db, scan, _open_issue)
_scan_missing_required_fields(db, scan) _scan_missing_required_fields(db, scan)
_scan_odometer_regressions(db, scan) _scan_odometer_regressions(db, scan)
_scan_booking_overlaps(db, scan) _scan_booking_overlaps(db, scan)
@@ -321,10 +309,13 @@ def run_scan(
return scan return scan
def _load_open_issue(db: Session, public_ref: str) -> DataQualityIssue: def _load_open_issue(
issue = db.scalar( db: Session, public_ref: str, *, lock: bool = True
select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref) ) -> DataQualityIssue:
) statement = select(DataQualityIssue).where(DataQualityIssue.public_ref == public_ref)
if lock:
statement = statement.with_for_update()
issue = db.scalar(statement)
if issue is None: if issue is None:
raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404) raise AppError("ISSUE_NOT_FOUND", "Data quality issue not found.", status_code=404)
if issue.status != "open": if issue.status != "open":
@@ -475,6 +466,30 @@ def resolve_odometer_regression(
"This issue is not an odometer_regression issue.", "This issue is not an odometer_regression issue.",
status_code=409, status_code=409,
) )
# Lock order is booking -> vehicle everywhere (checkout, return, reschedule); taking
# the vehicle lock first here would be a deadlock waiting to happen under concurrency.
booking: Booking | None = None
if body.decision != "retain_canonical":
related_refs = issue.evidence_json.get("related_refs", [])
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update()) vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None: if vehicle is None:
raise AppError( raise AppError(
@@ -495,19 +510,7 @@ def resolve_odometer_regression(
metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km}, metadata={"issue_ref": issue.public_ref, "canonical_odometer_km": vehicle.odometer_km},
) )
else: else:
related_refs = issue.evidence_json.get("related_refs", []) assert booking is not None and body.corrected_odometer_km is not None
if body.booking_ref not in related_refs:
raise AppError(
"INVALID_BOOKING_REFERENCE",
"booking_ref must be one of this issue's related bookings.",
status_code=422,
)
if body.corrected_odometer_km is None:
raise AppError(
"CORRECTED_VALUE_REQUIRED",
"corrected_odometer_km is required when correcting a reading.",
status_code=422,
)
# Never silently lower the canonical odometer: a correction must be at or above # Never silently lower the canonical odometer: a correction must be at or above
# the current canonical value, otherwise it would just create a new regression. # the current canonical value, otherwise it would just create a new regression.
if body.corrected_odometer_km < vehicle.odometer_km: if body.corrected_odometer_km < vehicle.odometer_km:
@@ -519,13 +522,6 @@ def resolve_odometer_regression(
), ),
status_code=422, status_code=422,
) )
booking = db.scalar(
select(Booking).where(Booking.public_ref == body.booking_ref).with_for_update()
)
if booking is None:
raise AppError(
"BOOKING_NOT_FOUND", "The booking to correct was not found.", status_code=404
)
before = { before = {
"booking_end_odometer_km": booking.end_odometer_km, "booking_end_odometer_km": booking.end_odometer_km,
@@ -650,76 +646,85 @@ def resolve_booking_overlap(
return issue return issue
def _recommend_vehicle_status( def _load_vehicle_status_conflict_issue(
operational_status: str, has_active_booking: bool, has_open_high_issue: bool db: Session, public_ref: str, *, lock: bool = True
) -> tuple[str, str] | None: ) -> DataQualityIssue:
"""The single authoritative recommendation function for vehicle_status_conflict, issue = _load_open_issue(db, public_ref, lock=lock)
mirroring the exact conditions `_scan_vehicle_status_conflicts` flags."""
if operational_status == "available" and has_active_booking:
return "rented", "An active booking exists; the vehicle should be marked rented."
if operational_status == "rented" and not has_active_booking:
return "available", "No active booking exists; the vehicle should be marked available."
if operational_status == "available" and has_open_high_issue:
return "blocked", "A high-severity quality issue is open; the vehicle should be blocked."
if operational_status == "maintenance" and has_active_booking:
return (
"rented",
"An active booking exists despite the maintenance status; it should be rented.",
)
return None
def apply_recommended_status(
db: Session, public_ref: str, actor: CurrentUser
) -> tuple[DataQualityIssue, str, str]:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "vehicle_status_conflict": if issue.rule_type != "vehicle_status_conflict":
raise AppError( raise AppError(
"NOT_A_STATUS_CONFLICT_ISSUE", "NOT_A_STATUS_CONFLICT_ISSUE",
"This issue is not a vehicle_status_conflict issue.", "This issue is not a vehicle_status_conflict issue.",
status_code=409, status_code=409,
) )
return issue
def preview_vehicle_status_recommendation(
db: Session, public_ref: str
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
"""Non-mutating: computes and returns the recommendation only. Never resolves the
issue, never writes an audit event, never queues automation -- safe to call as often
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
issue = _load_vehicle_status_conflict_issue(db, public_ref, lock=False)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
token = compute_recommendation_token(vehicle, facts)
return issue, vehicle, recommendation, token
def apply_recommended_status(
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
) -> tuple[DataQualityIssue, str, str]:
issue = _load_vehicle_status_conflict_issue(db, public_ref)
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
# (or return/checkout) can't race between our fact-gathering and the write below.
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update()) vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
if vehicle is None: if vehicle is None:
raise AppError( raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404 "VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
) )
has_active_booking = ( facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
db.scalar( recommendation = evaluate_vehicle_status(vehicle, facts)
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active") current_token = compute_recommendation_token(vehicle, facts)
if current_token != expected_token:
raise AppError(
"RECOMMENDATION_STALE",
"The underlying facts changed since this recommendation was shown; "
"review the recommendation again before applying it.",
status_code=409,
) )
is not None if recommendation.manual_review_required or not recommendation.safe_to_apply:
raise AppError(
"MANUAL_REVIEW_REQUIRED",
"This vehicle's state requires manual review; no automatic status change is safe.",
status_code=409,
) )
has_open_high_issue = ( if recommendation.recommended_status is None:
db.scalar(
select(DataQualityIssue.id).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.severity == "high",
DataQualityIssue.id != issue.id,
)
)
is not None
)
recommendation = _recommend_vehicle_status(
vehicle.operational_status, has_active_booking, has_open_high_issue
)
if recommendation is None:
raise AppError( raise AppError(
"NO_CONFLICT_DETECTED", "NO_CONFLICT_DETECTED",
"The current vehicle state no longer conflicts; nothing to apply.", "The current vehicle state no longer conflicts; nothing to apply.",
status_code=409, status_code=409,
) )
new_status, reason = recommendation new_status = recommendation.recommended_status
reason_code = recommendation.recommendation_code
before = {"operational_status": vehicle.operational_status} before = {"operational_status": vehicle.operational_status}
vehicle.operational_status = new_status vehicle.operational_status = new_status
vehicle.version += 1 vehicle.version += 1
# Re-validate: the same recommendation function must find no further conflict. # Re-validate against the same shared evaluator, over freshly-gathered facts, that
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None: # applying this change actually leaves no conflict -- never trust the pre-computed
# recommendation alone for the post-condition.
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
post_check = evaluate_vehicle_status(vehicle, post_facts)
if post_check.recommendation_code not in (RECOMMENDATION_CODE_NO_CONFLICT,):
raise AppError( raise AppError(
"CONFLICT_STILL_PRESENT", "CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.", "Applying the recommended status did not resolve the conflict.",
@@ -737,7 +742,7 @@ def apply_recommended_status(
correlation_id=correlation_id, correlation_id=correlation_id,
before=before, before=before,
after={"operational_status": vehicle.operational_status}, after={"operational_status": vehicle.operational_status},
metadata={"issue_ref": issue.public_ref, "reason": reason}, metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
) )
issue.status = "resolved" issue.status = "resolved"
@@ -755,10 +760,20 @@ def apply_recommended_status(
after={"status": "resolved"}, after={"status": "resolved"},
) )
db.commit() db.commit()
return issue, new_status, reason return issue, new_status, reason_code
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city") MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
# Mirrors the column lengths in app/models/customer.py so an override can never fail with
# a database DataError (500) instead of a validation error.
_MERGEABLE_FIELD_MAX_LENGTH = {
"first_name": 80,
"last_name": 80,
"email": 200,
"phone": 40,
"postal_code": 20,
"city": 120,
}
def merge_customers( def merge_customers(
@@ -788,12 +803,26 @@ def merge_customers(
) )
loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref) loser_ref = next(ref for ref in candidate_refs if ref != survivor_ref)
survivor = db.scalar(select(Customer).where(Customer.public_ref == survivor_ref)) # Lock both rows in a deterministic order (by public_ref) so two concurrent merges
loser = db.scalar(select(Customer).where(Customer.public_ref == loser_ref)) # touching the same customers serialise instead of deadlocking or double-merging.
survivor = None
loser = None
for ref in sorted((survivor_ref, loser_ref)):
customer = db.scalar(select(Customer).where(Customer.public_ref == ref).with_for_update())
if ref == survivor_ref:
survivor = customer
else:
loser = customer
if survivor is None or loser is None: if survivor is None or loser is None:
raise AppError( raise AppError(
"CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404 "CUSTOMER_NOT_FOUND", "One of the customers could not be found.", status_code=404
) )
if survivor.merged_into_customer_id is not None or loser.merged_into_customer_id is not None:
raise AppError(
"CUSTOMER_ALREADY_MERGED",
"One of the customers has already been merged into another record.",
status_code=409,
)
before = { before = {
"survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS}, "survivor": {f: getattr(survivor, f) for f in MERGEABLE_FIELDS},
@@ -805,7 +834,15 @@ def merge_customers(
raise AppError( raise AppError(
"INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422 "INVALID_FIELD_OVERRIDE", f"Field '{field_name}' cannot be merged.", status_code=422
) )
setattr(survivor, field_name, value) cleaned = value.strip() if isinstance(value, str) else value
max_length = _MERGEABLE_FIELD_MAX_LENGTH[field_name]
if not cleaned or len(cleaned) > max_length:
raise AppError(
"INVALID_FIELD_OVERRIDE",
f"Field '{field_name}' must be 1 to {max_length} characters.",
status_code=422,
)
setattr(survivor, field_name, cleaned)
rewired = db.execute( rewired = db.execute(
update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id) update(Booking).where(Booking.customer_id == loser.id).values(customer_id=survivor.id)
@@ -0,0 +1,110 @@
from __future__ import annotations
import uuid
from difflib import SequenceMatcher
from typing import Protocol, TypeVar
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.customer import Customer
DUPLICATE_THRESHOLD = 70
class ScanAccumulator(Protocol):
def bump(self, rule_type: str) -> None: ...
ScanTypeContra = TypeVar("ScanTypeContra", bound=ScanAccumulator, contravariant=True)
class OpenIssue(Protocol[ScanTypeContra]):
def __call__(
self,
db: Session,
scan: ScanTypeContra,
*,
rule_type: str,
entity_type: str,
entity_id: uuid.UUID,
severity: str,
summary: str,
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
) -> None: ...
def _normalize(value: str | None) -> str:
return (value or "").strip().lower()
def scan_duplicate_customers[ScanType: ScanAccumulator](
db: Session, scan: ScanType, open_issue: OpenIssue[ScanType]
) -> None:
customers = list(
db.scalars(
select(Customer).where(
Customer.merged_into_customer_id.is_(None),
Customer.anonymized_at.is_(None),
)
).all()
)
customers.sort(key=lambda customer: customer.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone
# (50 points). Block on normalized identifiers so this remains linear for the
# overwhelmingly common case and only scores plausible pairs.
candidate_pairs: set[tuple[int, int]] = set()
for attribute in ("email", "phone"):
blocks: dict[str, list[int]] = {}
for index, customer in enumerate(customers):
key = _normalize(getattr(customer, attribute))
if key:
blocks.setdefault(key, []).append(index)
for indices in blocks.values():
for offset, left in enumerate(indices):
candidate_pairs.update((left, right) for right in indices[offset + 1 :])
for left, right in sorted(candidate_pairs):
a = customers[left]
b = customers[right]
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
signals.append({"code": "duplicate.exact_phone"})
summary_parts.append("exact phone")
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
score += 10
signals.append({"code": "duplicate.same_postal_code"})
summary_parts.append("exact postal code")
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
+39 -21
View File
@@ -11,7 +11,7 @@ from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent from app.models.outbox import OutboxEvent
from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut from app.schemas import DemoIntegrationSummaryOut, DemoManifestOut, DemoScenarioOut
from app.services.integration_status import derive_n8n_status from app.services.integration_status import derive_mcp_hub_status, derive_n8n_status
from app.services.knowledge import get_knowledge_provider from app.services.knowledge import get_knowledge_provider
settings = get_settings() settings = get_settings()
@@ -39,18 +39,14 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
overlap_issue = db.scalar( overlap_issue = db.scalar(
select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP") select(DataQualityIssue).where(DataQualityIssue.public_ref == "DQ-DEMO-OVERLAP")
) )
failed_run = db.scalar( failed_run = db.scalar(select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID))
select(OutboxEvent).where(OutboxEvent.event_id == _FAILED_DEMO_EVENT_ID)
)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
# Human copy (title, problem statement, "demonstrates" summary) lives entirely in the # Human copy (title, problem statement, "demonstrates" summary) lives entirely in the
# frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI # frontend's demo.json (scenarios.items.<id>.*) so it's available in all three UI
# languages. This service only emits stable identifiers and message codes -- never # languages. This service only emits stable identifiers and message codes -- never
# display prose -- per the message_code + params architecture used across the app. # display prose -- per the message_code + params architecture used across the app.
return_ready = bool( return_ready = bool(booking and booking.status == "active" and booking.end_odometer_km is None)
booking and booking.status == "active" and booking.end_odometer_km is None
)
duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open") duplicate_ready = bool(duplicate_issue and duplicate_issue.status == "open")
overlap_ready = bool(overlap_issue and overlap_issue.status == "open") overlap_ready = bool(overlap_issue and overlap_issue.status == "open")
automation_ready = bool(failed_run and failed_run.delivery_status == "failed") automation_ready = bool(failed_run and failed_run.delivery_status == "failed")
@@ -65,7 +61,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if return_ready if return_ready
else "bookingNotFound" if booking is None else "bookingAlreadyProcessed" else "bookingNotFound"
if booking is None
else "bookingAlreadyProcessed"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -81,7 +79,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if duplicate_ready if duplicate_ready
else "duplicateIssueNotFound" if duplicate_issue is None else "issueAlreadyResolved" else "duplicateIssueNotFound"
if duplicate_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -95,7 +95,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if overlap_ready if overlap_ready
else "overlapIssueNotFound" if overlap_issue is None else "issueAlreadyResolved" else "overlapIssueNotFound"
if overlap_issue is None
else "issueAlreadyResolved"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -107,7 +109,9 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
blocked_reason_code=( blocked_reason_code=(
None None
if automation_ready if automation_ready
else "failedEventNotFound" if failed_run is None else "eventAlreadyRecovered" else "failedEventNotFound"
if failed_run is None
else "eventAlreadyRecovered"
), ),
), ),
DemoScenarioOut( DemoScenarioOut(
@@ -124,6 +128,7 @@ def _scenarios(db: Session) -> list[DemoScenarioOut]:
def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]: def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
n8n = derive_n8n_status(db) n8n = derive_n8n_status(db)
knowledge_health = get_knowledge_provider().health() knowledge_health = get_knowledge_provider().health()
mcp_hub = derive_mcp_hub_status(db)
return [ return [
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
@@ -138,20 +143,35 @@ def _integrations(db: Session) -> list[DemoIntegrationSummaryOut]:
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="ragcore", key="ragcore",
status_code="operational" if knowledge_health.provider == "ragcore" else "demoMode", status_code=(
detail_code="ragcoreDetail", "operational"
if knowledge_health.provider == "ragcore" and knowledge_health.available
else "demoMode"
if knowledge_health.provider == "demo" and knowledge_health.available
else "unavailable"
),
detail_code=(
"ragcoreDetail"
if knowledge_health.document_count is not None
else "ragcoreSourceDetail"
),
detail_params={ detail_params={
"count": knowledge_health.document_count, "count": knowledge_health.document_count or 0,
"source_count": knowledge_health.source_document_count,
"collection": knowledge_health.collection, "collection": knowledge_health.collection,
}, },
), ),
DemoIntegrationSummaryOut( DemoIntegrationSummaryOut(
key="mcp_hub", key="mcp_hub",
status_code="operational" if settings.mcp_hub_registration_enabled else "notConnected", # `MCP_HUB_REGISTRATION_ENABLED` on its own proves nothing: registration is
# catalog-driven on the Hub's side, so the flag only says Fleet Ops expects
# to be called. Only real recorded `mcp_tool_request` calls make this
# "operational" -- same evidence rule the integration status page uses.
status_code="operational" if mcp_hub.state == "operational" else "notConnected",
detail_code=( detail_code=(
"mcpDetailEnabled" "mcpDetailOperational"
if settings.mcp_hub_registration_enabled if mcp_hub.state == "operational"
else "mcpDetailNotConnected" else "mcpDetailPrepared"
), ),
detail_params={}, detail_params={},
), ),
@@ -165,9 +185,7 @@ def scenario_integrity_report(db: Session) -> dict:
overview already use, so this can never drift from what a visitor actually sees.""" overview already use, so this can never drift from what a visitor actually sees."""
scenarios = _scenarios(db) scenarios = _scenarios(db)
not_ready = [ not_ready = [
{"id": s.id, "reason_code": s.blocked_reason_code} {"id": s.id, "reason_code": s.blocked_reason_code} for s in scenarios if not s.ready
for s in scenarios
if not s.ready
] ]
return {"all_ready": len(not_ready) == 0, "not_ready": not_ready} return {"all_ready": len(not_ready) == 0, "not_ready": not_ready}
+23
View File
@@ -48,6 +48,7 @@ def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; " f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}." f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
)[:2000] )[:2000]
row.last_error_code = "staleLeaseRecovered"
db.commit() db.commit()
return len(rows) return len(rows)
finally: finally:
@@ -111,22 +112,42 @@ def _deliver_one(event_id: uuid.UUID) -> None:
finally: finally:
db.close() db.close()
error_code: str | None
if wire_event is None: if wire_event is None:
success, error, body = False, payload_error, None success, error, body = False, payload_error, None
error_code = "malformedPayload"
else: else:
try: try:
response = httpx.post( response = httpx.post(
settings.n8n_webhook_url, settings.n8n_webhook_url,
json=wire_event, json=wire_event,
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
timeout=settings.n8n_http_timeout_seconds, timeout=settings.n8n_http_timeout_seconds,
) )
response.raise_for_status() response.raise_for_status()
try:
body = response.json() body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
success = bool(body.get("ok", True)) success = bool(body.get("ok", True))
error = None if success else f"n8n reported failure: {body}" error = None if success else f"n8n reported failure: {body}"
error_code = None if success else "remoteReportedFailure"
else:
# A 2xx status with a non-object (or unparsable) body means the workflow
# itself errored before its "Respond to Webhook" node ran -- n8n's default
# error response still carries a 2xx-looking status here. Treat it as a
# failure so the event is retried rather than lost or wrongly marked
# succeeded.
success = False
error = (
f"Unexpected non-JSON-object response from n8n (status {response.status_code})"
)
error_code = "malformedResponse"
except httpx.HTTPError as exc: except httpx.HTTPError as exc:
success = False success = False
error = f"{type(exc).__name__}: {exc}" error = f"{type(exc).__name__}: {exc}"
error_code = "connectionError"
body = None body = None
db = SessionLocal() db = SessionLocal()
@@ -138,10 +159,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
if success: if success:
event.delivery_status = "succeeded" event.delivery_status = "succeeded"
event.last_error = None event.last_error = None
event.last_error_code = None
event.next_attempt_at = None event.next_attempt_at = None
event.external_run_id = str((body or {}).get("event_id", event_id)) event.external_run_id = str((body or {}).get("event_id", event_id))
else: else:
event.last_error = (error or "delivery failed")[:2000] event.last_error = (error or "delivery failed")[:2000]
event.last_error_code = error_code or "unknownError"
if event.attempts >= settings.n8n_max_attempts: if event.attempts >= settings.n8n_max_attempts:
event.delivery_status = "failed" event.delivery_status = "failed"
event.next_attempt_at = None event.next_attempt_at = None
+258 -7
View File
@@ -1,15 +1,58 @@
from __future__ import annotations from __future__ import annotations
from typing import Literal import threading
import time
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta
from typing import Any, Literal
from sqlalchemy import func, select import httpx
from sqlalchemy import Row, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.config import get_settings from app.core.config import get_settings
from app.models.outbox import OutboxEvent from app.models.audit import AuditEvent
from app.schemas import N8nIntegrationStatus from app.models.outbox import DEMO_SCENARIO_ERROR_CODE, OutboxEvent
from app.schemas import (
McpHubIntegrationStatus,
N8nErrorHandlerStatus,
N8nIntegrationStatus,
N8nWorkflowEvidence,
)
settings = get_settings() settings = get_settings()
_hub_health_lock = threading.Lock()
_hub_health_cached_at = 0.0
_hub_health_cached_value: bool | None = None
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). All 4 are
# built (all with their full node set saved).
_CANONICAL_WORKFLOWS = (
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
)
_STALE_AFTER = {
"Fleet Ops — Scheduled Data Quality Scan": timedelta(hours=2, minutes=30),
"Fleet Ops — RAGcore Procedure Sync": timedelta(hours=30),
}
def _latest_rows_per_workflow(db: Session, action: str) -> Sequence[Row[Any]]:
"""Return the most recent audit row per workflow name for one action.
Heartbeats arrive on every scheduled run, so loading *all* rows and picking the
latest in Python would grow linearly with deployment age. ``DISTINCT ON`` lets
PostgreSQL return exactly one (latest) row per workflow instead.
"""
workflow_name = AuditEvent.after_json["workflow_name"].astext
return db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json, AuditEvent.metadata_json)
.where(AuditEvent.action == action)
.distinct(workflow_name)
.order_by(workflow_name, AuditEvent.occurred_at.desc())
).all()
def derive_n8n_status(db: Session) -> N8nIntegrationStatus: def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
@@ -23,25 +66,163 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
failed = counts.get("failed", 0) failed = counts.get("failed", 0)
succeeded = counts.get("succeeded", 0) succeeded = counts.get("succeeded", 0)
# Prepared demo failures are props, not health signals. They stay visible and
# counted -- hiding them would be its own kind of lie -- but they are counted
# *separately*, and only genuinely unexpected failures are allowed to move n8n off
# "operational". Without this split the demo seed's single staged failure pins the
# integration to "degraded" forever, which tells a viewer something untrue about
# the automation.
demo_scenario_failed = (
db.scalar(
select(func.count())
.select_from(OutboxEvent)
.where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
)
or 0
)
unexpected_failed = max(failed - demo_scenario_failed, 0)
latest_success_at = db.scalar( latest_success_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded") select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "succeeded")
) )
# Health talks about real failures only, so the "latest failure" a health reader
# sees must exclude the staged one too.
latest_failure_at = db.scalar( latest_failure_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(OutboxEvent.delivery_status == "failed") select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code != DEMO_SCENARIO_ERROR_CODE,
)
)
latest_demo_scenario_at = db.scalar(
select(func.max(OutboxEvent.updated_at)).where(
OutboxEvent.delivery_status == "failed",
OutboxEvent.last_error_code == DEMO_SCENARIO_ERROR_CODE,
)
) )
state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"] state: Literal["disabled", "unavailable", "degraded", "operational", "no_evidence"]
if not settings.n8n_dispatch_enabled: if not settings.n8n_dispatch_enabled:
state = "disabled" state = "disabled"
elif failed > 0 and succeeded == 0: elif unexpected_failed > 0 and succeeded == 0:
state = "unavailable" state = "unavailable"
elif failed > 0: elif unexpected_failed > 0:
state = "degraded" state = "degraded"
elif succeeded > 0 or pending > 0 or delivering > 0: elif succeeded > 0 or pending > 0 or delivering > 0:
state = "operational" state = "operational"
else: else:
state = "no_evidence" state = "no_evidence"
# Scheduled scan evidence: only service-triggered runs count as n8n evidence, not
# runs an operator triggered manually from the Data Quality page.
latest_scan_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(
AuditEvent.action == "data_quality_scan_run",
AuditEvent.actor_type == "service",
)
)
# RAGcore Procedure Sync evidence: result reports posted by the workflow itself once
# it finishes uploading procedures to RAGcore (app/api/routers/integrations.py::
# procedures_sync_result), the same "the workflow's own callback is the evidence"
# pattern the scheduled scan and error handler already use below.
latest_procedure_sync_at = db.scalar(
select(func.max(AuditEvent.occurred_at)).where(AuditEvent.action == "n8n_procedures_synced")
)
# Error handler evidence: registrations posted by the "Fleet Ops — Workflow Error
# Handler" n8n workflow itself, which also doubles as proof that workflow is wired
# up and firing correctly.
total_failures_registered = (
db.scalar(
select(func.count(AuditEvent.id)).where(
AuditEvent.action == "n8n_workflow_failure_registered"
)
)
or 0
)
latest_failure_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.after_json)
.where(AuditEvent.action == "n8n_workflow_failure_registered")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
latest_handler_failure_at = latest_failure_row[0] if latest_failure_row else None
latest_handler_failure_workflow = (
(latest_failure_row[1] or {}).get("workflow_name") if latest_failure_row else None
)
legacy_evidence_by_workflow = {
"Fleet Ops — Vehicle Return Orchestration": latest_success_at,
"Fleet Ops — Scheduled Data Quality Scan": latest_scan_at,
"Fleet Ops — RAGcore Procedure Sync": latest_procedure_sync_at,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
}
heartbeat_by_workflow: dict[str, tuple[datetime, str, str | None]] = {}
heartbeat_rows = _latest_rows_per_workflow(db, "n8n_workflow_heartbeat")
for occurred_at, after, metadata in heartbeat_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in heartbeat_by_workflow:
heartbeat_by_workflow[workflow_name] = (
occurred_at,
(after or {}).get("status", "succeeded"),
(metadata or {}).get("execution_id"),
)
failure_by_workflow: dict[str, tuple[datetime, str | None]] = {}
failure_rows = _latest_rows_per_workflow(db, "n8n_workflow_failure_registered")
for occurred_at, after, metadata in failure_rows:
workflow_name = (after or {}).get("workflow_name")
if workflow_name in _CANONICAL_WORKFLOWS and workflow_name not in failure_by_workflow:
failure_by_workflow[workflow_name] = (
occurred_at,
(metadata or {}).get("execution_id"),
)
now = datetime.now(UTC)
workflows: list[N8nWorkflowEvidence] = []
for name in _CANONICAL_WORKFLOWS:
legacy_seen = legacy_evidence_by_workflow[name]
heartbeat = heartbeat_by_workflow.get(name)
failure_signal = failure_by_workflow.get(name)
seen_at = heartbeat[0] if heartbeat else legacy_seen
last_status: Literal["succeeded", "failed"] | None = (
"succeeded" if seen_at is not None else None
)
execution_id = heartbeat[2] if heartbeat else None
if heartbeat and heartbeat[1] == "failed":
last_status = "failed"
if failure_signal and (seen_at is None or failure_signal[0] > seen_at):
seen_at = failure_signal[0]
last_status = "failed"
execution_id = failure_signal[1]
workflow_state: Literal["no_evidence", "healthy", "stale", "failed"]
if seen_at is None:
workflow_state = "no_evidence"
elif last_status == "failed":
workflow_state = "failed"
elif name in _STALE_AFTER and now - seen_at > _STALE_AFTER[name]:
workflow_state = "stale"
else:
workflow_state = "healthy"
workflows.append(
N8nWorkflowEvidence(
name=name,
built=True,
last_seen_at=seen_at,
state=workflow_state,
last_status=last_status,
last_execution_id=execution_id,
)
)
if state in {"operational", "no_evidence"} and any(
workflow.state in {"failed", "stale"} for workflow in workflows
):
state = "degraded"
return N8nIntegrationStatus( return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url), configured=bool(settings.n8n_webhook_url),
dispatch_enabled=settings.n8n_dispatch_enabled, dispatch_enabled=settings.n8n_dispatch_enabled,
@@ -49,7 +230,77 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
pending=pending, pending=pending,
delivering=delivering, delivering=delivering,
failed=failed, failed=failed,
unexpected_failed=unexpected_failed,
demo_scenario_failed=demo_scenario_failed,
succeeded=succeeded, succeeded=succeeded,
latest_success_at=latest_success_at, latest_success_at=latest_success_at,
latest_failure_at=latest_failure_at, latest_failure_at=latest_failure_at,
latest_demo_scenario_at=latest_demo_scenario_at,
expected_workflow_count=len(_CANONICAL_WORKFLOWS),
known_workflow_count=sum(1 for w in workflows if w.last_seen_at is not None),
workflows=workflows,
error_handler=N8nErrorHandlerStatus(
total_failures_registered=total_failures_registered,
latest_failure_at=latest_handler_failure_at,
latest_failure_workflow=latest_handler_failure_workflow,
),
) )
def derive_mcp_hub_status(db: Session) -> McpHubIntegrationStatus:
"""Evidence-based MCP Hub status: real tool-call audit history, not just the
`MCP_HUB_REGISTRATION_ENABLED` flag flipped on. Every `mcp_tool_request` call
already writes an `AuditEvent` (see `app/api/routers/mcp_integrations.py`)."""
total_calls = (
db.scalar(select(func.count(AuditEvent.id)).where(AuditEvent.action == "mcp_tool_request"))
or 0
)
latest_call_row = db.execute(
select(AuditEvent.occurred_at, AuditEvent.actor_label, AuditEvent.metadata_json)
.where(AuditEvent.action == "mcp_tool_request")
.order_by(AuditEvent.occurred_at.desc())
.limit(1)
).first()
last_called_at = latest_call_row[0] if latest_call_row else None
last_client = latest_call_row[1] if latest_call_row else None
last_tool = (latest_call_row[2] or {}).get("tool") if latest_call_row else None
state: Literal["not_configured", "no_evidence", "operational"]
if not settings.mcp_hub_registration_enabled:
state = "not_configured"
elif total_calls > 0:
state = "operational"
else:
state = "no_evidence"
hub_reachable = _check_hub_reachable()
return McpHubIntegrationStatus(
registration_enabled=settings.mcp_hub_registration_enabled,
state=state,
total_calls=total_calls,
last_tool=last_tool,
last_client=last_client,
last_called_at=last_called_at,
hub_reachable=hub_reachable,
)
def _check_hub_reachable() -> bool | None:
"""Real Hub-side health signal (MCP Hub's own registration is catalog-driven on
its side, so this is the only thing Fleet Ops itself can honestly check).
`None` means not configured / not checked, never a guess."""
if not settings.mcp_hub_base_url:
return None
global _hub_health_cached_at, _hub_health_cached_value
now = time.monotonic()
with _hub_health_lock:
if now - _hub_health_cached_at < settings.mcp_hub_health_cache_seconds:
return _hub_health_cached_value
try:
response = httpx.get(f"{settings.mcp_hub_base_url.rstrip('/')}/health", timeout=1.5)
_hub_health_cached_value = response.status_code == 200
except httpx.HTTPError:
_hub_health_cached_value = False
_hub_health_cached_at = time.monotonic()
return _hub_health_cached_value
+10 -1
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime
from functools import lru_cache from functools import lru_cache
from typing import Literal, Protocol from typing import Literal, Protocol
@@ -8,6 +9,7 @@ from pydantic import BaseModel
from app.core.config import get_settings from app.core.config import get_settings
EvidenceState = Literal["grounded", "insufficient", "unavailable"] EvidenceState = Literal["grounded", "insufficient", "unavailable"]
KnowledgeStatisticsState = Literal["verified", "sync_reported", "not_reported"]
class SourceCard(BaseModel): class SourceCard(BaseModel):
@@ -33,7 +35,14 @@ class KnowledgeHealth(BaseModel):
tenant: str tenant: str
workspace: str workspace: str
collection: str collection: str
document_count: int # A provider may be healthy without exposing a corpus-size endpoint. `None` means
# unknown, never "zero procedures".
document_count: int | None
source_document_count: int
reported_synced_document_count: int | None
reported_failed_document_count: int | None
last_sync_at: datetime | None
statistics_state: KnowledgeStatisticsState
class KnowledgeProvider(Protocol): class KnowledgeProvider(Protocol):
+164 -39
View File
@@ -7,35 +7,161 @@ from pathlib import Path
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import parse_frontmatter
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE") SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
DEFAULT_LANGUAGE = "en-GB" DEFAULT_LANGUAGE = "en-GB"
STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = { STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
"en-GB": { "en-GB": {
"a", "an", "the", "is", "are", "was", "were", "be", "been", "being", "a",
"to", "of", "in", "on", "at", "for", "and", "or", "but", "if", "then", "an",
"do", "does", "did", "must", "may", "can", "could", "should", "would", "the",
"i", "you", "it", "we", "they", "my", "your", "what", "when", "how", "is",
"with", "without", "this", "that", "these", "those", "not", "no", "are",
"was",
"were",
"be",
"been",
"being",
"to",
"of",
"in",
"on",
"at",
"for",
"and",
"or",
"but",
"if",
"then",
"do",
"does",
"did",
"must",
"may",
"can",
"could",
"should",
"would",
"i",
"you",
"it",
"we",
"they",
"my",
"your",
"what",
"when",
"how",
"with",
"without",
"this",
"that",
"these",
"those",
"not",
"no",
}, },
"nl-BE": { "nl-BE": {
"een", "de", "het", "is", "zijn", "was", "waren", "worden", "wordt", "een",
"van", "in", "op", "voor", "en", "of", "maar", "als", "dan", "de",
"moet", "mag", "kan", "kunnen", "zou", "zouden", "het",
"ik", "jij", "u", "we", "wij", "zij", "mijn", "jouw", "wat", "wanneer", "hoe", "is",
"met", "zonder", "dit", "dat", "deze", "die", "niet", "geen", "zijn",
"was",
"waren",
"worden",
"wordt",
"van",
"in",
"op",
"voor",
"en",
"of",
"maar",
"als",
"dan",
"moet",
"mag",
"kan",
"kunnen",
"zou",
"zouden",
"ik",
"jij",
"u",
"we",
"wij",
"zij",
"mijn",
"jouw",
"wat",
"wanneer",
"hoe",
"met",
"zonder",
"dit",
"dat",
"deze",
"die",
"niet",
"geen",
}, },
"fr-BE": { "fr-BE": {
"un", "une", "le", "la", "les", "des", "est", "sont", "était", "être", "un",
"de", "du", "en", "sur", "pour", "et", "ou", "mais", "si", "alors", "une",
"doit", "peut", "peuvent", "pourrait", "devrait", "le",
"je", "tu", "vous", "il", "elle", "nous", "ils", "mon", "votre", "quoi", "quand", "comment", "la",
"avec", "sans", "ce", "cette", "ces", "cela", "pas", "non", "les",
"des",
"est",
"sont",
"était",
"être",
"de",
"du",
"en",
"sur",
"pour",
"et",
"ou",
"mais",
"si",
"alors",
"doit",
"peut",
"peuvent",
"pourrait",
"devrait",
"je",
"tu",
"vous",
"il",
"elle",
"nous",
"ils",
"mon",
"votre",
"quoi",
"quand",
"comment",
"avec",
"sans",
"ce",
"cette",
"ces",
"cela",
"pas",
"non",
}, },
} }
_WORD_RE = re.compile(r"[a-z0-9]+") # Includes the Latin-1 accented-letter range (à-ö, ø-ÿ) so French/Dutch words with
# diacritics (véhicule, réservation, geëscaleerd) tokenize as one word instead of
# splitting apart at the accented character -- a plain [a-z0-9]+ pattern silently
# drops every accent and fragments the word either side of it.
_WORD_RE = re.compile(r"[a-zà-öø-ÿ0-9]+")
def _stem(word: str) -> str: def _stem(word: str) -> str:
@@ -70,23 +196,6 @@ class ScoredSection:
body_tokens: set[str] body_tokens: set[str]
def _parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
if not raw.startswith("---"):
return {}, raw
end = raw.find("\n---", 3)
if end == -1:
return {}, raw
block = raw[3:end].strip()
body = raw[end + 4 :].lstrip("\n")
meta: dict[str, str] = {}
for line in block.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, body
def _split_sections(body: str) -> list[tuple[str, str]]: def _split_sections(body: str) -> list[tuple[str, str]]:
sections: list[tuple[str, str]] = [] sections: list[tuple[str, str]] = []
current_heading = "Overview" current_heading = "Overview"
@@ -110,7 +219,7 @@ def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
sections: list[ScoredSection] = [] sections: list[ScoredSection] = []
for path in sorted(procedures_dir.glob("*.md")): for path in sorted(procedures_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8") raw = path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(raw) meta, body = parse_frontmatter(raw)
title = meta.get("title", path.stem) title = meta.get("title", path.stem)
doc = Document( doc = Document(
document_id=meta.get("document_id", path.stem), document_id=meta.get("document_id", path.stem),
@@ -153,7 +262,7 @@ _LOW_CONFIDENCE_TEXT = {
_LEAD_ANSWER_TEMPLATE = { _LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}', "en-GB": 'Per "{title}" (v{version}), section "{heading}": {excerpt}',
"nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}', "nl-BE": 'Volgens "{title}" (v{version}), sectie "{heading}": {excerpt}',
"fr-BE": 'Selon « {title} » (v{version}), section « {heading} » : {excerpt}', "fr-BE": "Selon « {title} » (v{version}), section « {heading} » : {excerpt}",
} }
@@ -213,22 +322,38 @@ class DemoKnowledgeProvider:
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
document_count=self._document_count_by_language[language], document_count=self._document_count_by_language[language],
source_document_count=self._document_count_by_language[language],
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state="verified",
) )
def _score( def _score(
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float] self, query_tokens: set[str], section: ScoredSection, idf: dict[str, float]
) -> float: ) -> float:
# The section body is the strongest relevance signal -- it's the actual
# substance a heading or title can only hint at -- so a body match is weighted
# *above* heading/title matches, not below them. The previous 3x/2x/1x
# (heading/title/body) ordering let a single generic word in a heading (e.g.
# "vehicle", present in nearly every section) or a document's own title
# outrank a section whose body genuinely covers multiple, more distinctive
# query terms -- confirmed to misrank the brief's exact validation question in
# every one of the three languages (see docs/fleet-ops-correction/
# current-gap-audit.md and i18n-inventory.md): nl-BE picked a checkout section
# over the damage procedure, en-GB and fr-BE picked the return procedure over
# the damage procedure, purely from heading/title overlap on common words.
score = 0.0 score = 0.0
for token in query_tokens: for token in query_tokens:
token_idf = idf.get(token, 0.0) token_idf = idf.get(token, 0.0)
if token_idf == 0.0: if token_idf == 0.0:
continue continue
if token in section.heading_tokens: if token in section.body_tokens:
score += 3 * token_idf score += 3 * token_idf
elif token in section.document.title_tokens: elif token in section.heading_tokens:
score += 2 * token_idf score += 2 * token_idf
elif token in section.body_tokens: elif token in section.document.title_tokens:
score += token_idf score += 1.5 * token_idf
return score return score
def ask( def ask(
@@ -0,0 +1,73 @@
from __future__ import annotations
import hashlib
import uuid
from dataclasses import dataclass
from pathlib import Path
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
# Stable across runs (and across which language ships first) so a document's RAGcore
# source_id never changes just because the sync ran on a different day or in a
# different order -- required for RAGcore's upload idempotency to work per document.
_SOURCE_ID_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL, "https://mobilityops.internal/knowledge/procedures"
)
def parse_frontmatter(raw: str) -> tuple[dict[str, str], str]:
if not raw.startswith("---"):
return {}, raw
end = raw.find("\n---", 3)
if end == -1:
return {}, raw
block = raw[3:end].strip()
body = raw[end + 4 :].lstrip("\n")
meta: dict[str, str] = {}
for line in block.splitlines():
if ":" not in line:
continue
key, _, value = line.partition(":")
meta[key.strip()] = value.strip().strip('"')
return meta, body
@dataclass(frozen=True)
class ProcedureDocument:
source_id: str
language: str
document_id: str
title: str
version: str
content: str
content_hash: str
def iter_procedure_documents(knowledge_dir: Path) -> list[ProcedureDocument]:
"""Read every procedure Markdown file Fleet Ops ships, across every supported
language, as a flat list ready for external sync (e.g. into RAGcore). Frontmatter
fields (title, version) come from the same files the demo knowledge provider
already reads -- see parse_frontmatter -- so the two never drift apart."""
documents: list[ProcedureDocument] = []
for language in SUPPORTED_LANGUAGES:
language_dir = knowledge_dir / language
if not language_dir.is_dir():
continue
for path in sorted(language_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta, body = parse_frontmatter(raw)
document_id = meta.get("document_id", path.stem)
content = body.strip()
documents.append(
ProcedureDocument(
source_id=str(uuid.uuid5(_SOURCE_ID_NAMESPACE, f"{language}:{document_id}")),
language=language,
document_id=document_id,
title=meta.get("title", path.stem),
version=meta.get("version", "1.0"),
content=content,
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
)
)
return documents
+398 -42
View File
@@ -1,26 +1,159 @@
from __future__ import annotations from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from threading import Lock
from time import monotonic
import httpx import httpx
from app.core.config import get_settings from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard from app.core.observability import KNOWLEDGE_PROVIDER_REQUESTS, KNOWLEDGE_RETRIEVAL_SCORE
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import ProcedureDocument, iter_procedure_documents
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
# Mirrors DemoKnowledgeProvider's own extractive template in spirit: a real cited
# excerpt wrapped in a fixed sentence, never a generated summary. Used only as a
# fallback when RAGcore's own /v1/answers (generation + citation validation) is
# unavailable but its retrieval (/v1/search) still returns real, relevant, cited
# results -- see ask() below. Unlike the demo corpus's own markdown frontmatter, RAGcore's
# `document_version_id` is an opaque UUID, not a human-meaningful version string, so it
# is deliberately left out of this sentence (it still appears on the source card itself).
_LEAD_ANSWER_TEMPLATE = {
"en-GB": 'Per "{title}": {excerpt}',
"nl-BE": 'Volgens "{title}": {excerpt}',
"fr-BE": "Selon « {title} » : {excerpt}",
}
_DEFAULT_LANGUAGE = "en-GB"
_MAX_SOURCE_CARDS = 3
_INDEX_LOOKUP_TIMEOUT_SECONDS = 2.0
_INDEX_VERIFICATION_TTL_SECONDS = 300.0
_INDEX_VERIFICATION_WORKERS = 6
_DOMAIN_CONCEPTS: dict[str, tuple[str, ...]] = {
"damage": ("damage", "damaged", "schade", "beschadigd", "dommage", "endommagé"),
"vehicle": ("vehicle", "car", "voertuig", "wagen", "véhicule", "voiture"),
"return": ("return", "returned", "retour", "terugbrengen", "restitution"),
"fuel": ("fuel", "brandstof", "carburant"),
"booking": ("booking", "reservation", "boeking", "réservation"),
"odometer": ("odometer", "mileage", "kilometer", "kilométrage", "compteur"),
"customer": ("customer", "client", "klant"),
"cleaning": ("cleaning", "reiniging", "poetsen", "nettoyage"),
"maintenance": ("maintenance", "service", "onderhoud", "entretien"),
"conflict": ("conflict", "overlap", "overlapping", "conflit", "chevauchement"),
"checkout": ("checkout", "departure", "vertrek", "départ"),
"availability": ("available", "availability", "beschikbaar", "disponible", "disponibilité"),
"technical": ("technical", "warning", "technisch", "waarschuwing", "technique", "alerte"),
}
def _question_concepts(question: str) -> set[str]:
normalized = question.casefold()
return {
concept
for concept, terms in _DOMAIN_CONCEPTS.items()
if any(term in normalized for term in terms)
}
def _deduplicate_sources(sources: list[SourceCard]) -> list[SourceCard]:
"""Collapse duplicate chunks and re-uploaded document versions.
RAGcore document/version UUIDs change across uploads, so they are not useful
deduplication keys. Human-visible citation identity is the normalized title,
section. Chunks from the same unsectioned document collapse into one card; distinct
named sections remain independently citable.
"""
seen: set[tuple[str, str]] = set()
unique: list[SourceCard] = []
for source in sources:
key = (
source.title.strip().casefold(),
source.section.strip().casefold(),
)
if key in seen:
continue
seen.add(key)
unique.append(source)
if len(unique) == _MAX_SOURCE_CARDS:
break
return unique
def _rank_sources_for_concepts(sources: list[SourceCard], concepts: set[str]) -> list[SourceCard]:
if "damage" not in concepts:
return sources
return sorted(
sources,
key=lambda source: (
0 if "damage" in f"{source.document_id} {source.title}".casefold() else 1
),
)
def _retrieval_score(result: dict) -> float | None:
scores = result.get("scores")
if not isinstance(scores, dict):
return None
for name in ("rerank", "fused"):
value = scores.get(name)
if isinstance(value, int | float) and not isinstance(value, bool):
return float(value)
return None
class RAGcoreKnowledgeProvider: class RAGcoreKnowledgeProvider:
"""Adapter for the central RAGcore service. """Adapter for the central RAGcore service, against its real `/v1/*` contract
(see `docs/contracts/openapi.yaml` in the RAGcore checkout -- RAGcore is built and
owned separately, MobilityOps only ever talks to its documented HTTP API).
RAGcore is built and owned separately (see contracts/ragcore-contract-assumptions.md). Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
No live RAGcore instance was reachable during this build, so the exact request/response session-cookie auth is for its own browser admin UI only). Any connection error,
shape below is a best-effort guess at a REST contract; any failure (connection, timeout, timeout, non-2xx response, or malformed body degrades to `evidence_state:
malformed response) degrades to `unavailable` rather than raising, per the architecture's "unavailable"` rather than raising -- this is the adapter that actually exercises the
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of architecture's reliability boundary: RAGcore failure disables knowledge answers only,
the app, and never fabricates an answer. never fabricates an answer, never affects the rest of the app.
`/v1/answers` (RAGcore's own generation + citation-validation step) is tried first;
if it is itself unavailable (non-2xx or unreachable -- as opposed to a real 200
classifying the question as insufficiently answerable), `ask()` falls back to
RAGcore's `/v1/search` retrieval, which is a materially different, simpler pipeline
stage with no generation step to fail. The fallback answer is always an extractive
excerpt RAGcore's own search actually found, wrapped in the same fixed citation
template `DemoKnowledgeProvider` uses -- never a fabricated summary.
Known gap, not fixable from this side: RAGcore's ingest pipeline currently tags every
chunk's `language` payload field as `"en"` regardless of actual document language (the
`/v1/uploads` contract has no per-file language field for a caller to set correctly).
Filtering search/answer requests by requested UI language would therefore silently
exclude genuinely-relevant nl-BE/fr-BE content, so this adapter deliberately does not
filter by language -- retrieval relies on the embedding model's cross-lingual matching.
""" """
name = "ragcore" name = "ragcore"
def __init__(self) -> None: def __init__(self) -> None:
self._settings = get_settings() self._settings = get_settings()
self._verification_cache: dict[str, tuple[float, int]] = {}
self._verification_lock = Lock()
self._answers_circuit_lock = Lock()
self._answers_circuit_open_until = 0.0
def _answers_circuit_is_open(self) -> bool:
with self._answers_circuit_lock:
return monotonic() < self._answers_circuit_open_until
def _open_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = monotonic() + max(
0.0, self._settings.ragcore_answers_circuit_breaker_seconds
)
def _close_answers_circuit(self) -> None:
with self._answers_circuit_lock:
self._answers_circuit_open_until = 0.0
def _client(self) -> httpx.Client: def _client(self) -> httpx.Client:
headers = {} headers = {}
@@ -33,13 +166,36 @@ class RAGcoreKnowledgeProvider:
) )
def health(self, language: str = "en-GB") -> KnowledgeHealth: def health(self, language: str = "en-GB") -> KnowledgeHealth:
documents = [
document
for document in iter_procedure_documents(Path(self._settings.knowledge_dir))
if document.language == language
]
verified_document_count: int | None = None
try: try:
with self._client() as client: with self._client() as client:
response = client.get("/health") response = client.get("/health/ready")
response.raise_for_status() body = response.json()
available = True if not isinstance(body, dict):
detail = "RAGcore reachable." raise ValueError("health response is not a JSON object")
except httpx.HTTPError as exc: available = response.status_code == 200 and body.get("status") == "ok"
detail = (
"RAGcore reachable and ready."
if available
else f"RAGcore degraded: {body.get('status', 'unknown')}"
)
if available:
verified_document_count = self._verify_indexed_documents(
client, language, documents
)
if verified_document_count is None:
detail += " Index verification is temporarily unavailable."
else:
detail += (
f" {verified_document_count}/{len(documents)} managed sources have "
"an exact active published document in the configured space."
)
except (httpx.HTTPError, ValueError) as exc:
available = False available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}" detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
return KnowledgeHealth( return KnowledgeHealth(
@@ -49,51 +205,251 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant, tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace, workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection, collection=self._settings.ragcore_collection,
document_count=0, # RAGcore deliberately has no browse/count endpoint. Fleet Ops instead
# verifies each managed source through its exact identity lookup and only
# counts an active document with a published active version. RAGcore's
# content_sha256 describes its canonical parsed artifact, not the uploaded
# source bytes, so comparing it with Fleet Ops's source hash would be false.
document_count=verified_document_count,
source_document_count=len(documents),
reported_synced_document_count=None,
reported_failed_document_count=None,
last_sync_at=None,
statistics_state=(
"verified" if verified_document_count is not None else "not_reported"
),
) )
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer: def _verify_indexed_documents(
try: self, client: httpx.Client, language: str, documents: list[ProcedureDocument]
with self._client() as client: ) -> int | None:
response = client.post( if not self._settings.ragcore_space_id or not documents:
"/api/v1/ask", return None
json={
"tenant": self._settings.ragcore_tenant, now = monotonic()
"workspace": self._settings.ragcore_workspace, with self._verification_lock:
"collection": self._settings.ragcore_collection, cached = self._verification_cache.get(language)
"question": question, if cached is not None and now - cached[0] < _INDEX_VERIFICATION_TTL_SECONDS:
"correlation_id": correlation_id, return cached[1]
"language": language,
def is_verified(document: ProcedureDocument) -> bool:
response = client.get(
"/v1/documents",
params={
"source_id": document.source_id,
"external_id": f"{document.document_id}.md",
}, },
timeout=_INDEX_LOOKUP_TIMEOUT_SECONDS,
) )
response.raise_for_status() if response.status_code != 200:
raise RuntimeError("RAGcore document verification failed")
body = response.json() body = response.json()
except (httpx.HTTPError, ValueError): items = body.get("items") if isinstance(body, dict) else None
return GroundedAnswer( if not isinstance(items, list) or len(items) != 1 or not isinstance(items[0], dict):
return False
item = items[0]
active_version = item.get("active_version")
return bool(
item.get("space_id") == self._settings.ragcore_space_id
and item.get("source_id") == document.source_id
and item.get("external_id") == f"{document.document_id}.md"
and item.get("status") == "active"
and isinstance(active_version, dict)
and active_version.get("status") == "published"
)
try:
with ThreadPoolExecutor(
max_workers=min(_INDEX_VERIFICATION_WORKERS, len(documents))
) as executor:
verified_count = sum(executor.map(is_verified, documents))
except (httpx.HTTPError, RuntimeError, TypeError, ValueError):
return None
with self._verification_lock:
self._verification_cache[language] = (monotonic(), verified_count)
return verified_count
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="", answer="",
evidence_state="unavailable", evidence_state="unavailable",
sources=[], sources=[],
provider=self.name, provider=self.name,
correlation_id=correlation_id, correlation_id=correlation_id,
) )
if not self._settings.ragcore_space_id:
return unavailable
if self._answers_circuit_is_open():
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "circuit_open").inc()
else:
answered = self._ask_via_answers(question, correlation_id)
if answered is not None:
return answered
# /v1/answers itself is unavailable (non-2xx or unreachable) -- fall back to
# real retrieval rather than degrading straight to "unavailable". This never
# fabricates an answer to the question: it only ever shows an actually-cited
# excerpt RAGcore's own search already found, using the same extractive
# citation-wrapper template DemoKnowledgeProvider uses, never RAGcore's
# generation step.
return self._ask_via_search_fallback(question, correlation_id, language)
def _ask_via_answers(self, question: str, correlation_id: str) -> GroundedAnswer | None:
"""Returns None (not a GroundedAnswer) when /v1/answers itself is unavailable,
so the caller can fall back to search -- as opposed to a real 200 response
classifying the question as insufficiently answerable, which is a genuine,
final result, not a reason to fall back."""
try:
with self._client() as client:
response = client.post(
"/v1/answers",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
},
)
if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "non_2xx").inc()
self._open_answers_circuit()
return None
body = response.json()
except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "error").inc()
self._open_answers_circuit()
return None
try: try:
sources = [SourceCard(**s) for s in body.get("sources", [])] citations = {c["id"]: c for c in body.get("citations", [])}
evidence_state = body.get("evidence_state", "insufficient") sources = [
if evidence_state not in ("grounded", "insufficient", "unavailable"): SourceCard(
evidence_state = "insufficient" document_id=str(citation["document_id"]),
title=citation["title"],
version=str(citation["document_version_id"]),
section=citation.get("section") or "",
excerpt=citation["excerpt"],
)
for citation in citations.values()
]
sources = _deduplicate_sources(sources)
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", evidence_state).inc()
self._close_answers_circuit()
return GroundedAnswer( return GroundedAnswer(
answer=body.get("answer", ""), answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state, evidence_state=evidence_state,
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("answers", "malformed").inc()
self._open_answers_circuit()
return None
def _ask_via_search_fallback(
self, question: str, correlation_id: str, language: str
) -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
try:
with self._client() as client:
response = client.post(
"/v1/search",
json={
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
"max_results": 5,
},
)
if response.status_code != 200:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "non_2xx").inc()
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "error").inc()
return unavailable
try:
results = body.get("results", [])
if not isinstance(results, list):
raise TypeError("results must be a list")
sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
]
except (TypeError, KeyError, ValueError):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "malformed").inc()
return unavailable
for result in results:
score = _retrieval_score(result)
if score is not None:
KNOWLEDGE_RETRIEVAL_SCORE.observe(score)
all_sources = _deduplicate_sources(sources)
concepts = _question_concepts(question)
qualified_sources = [
SourceCard(
document_id=str(result["citation"]["document_id"]),
title=result["citation"]["title"],
version=str(result["citation"]["document_version_id"]),
section=result["citation"].get("section") or "",
excerpt=result["citation"]["excerpt"],
)
for result in results
if (_retrieval_score(result) or 0.0) >= self._settings.ragcore_min_search_score
]
sources = _rank_sources_for_concepts(_deduplicate_sources(qualified_sources), concepts)
if not all_sources:
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
damage_evidence = any(
term
in (
f"{source.document_id} {source.title} {source.section} {source.excerpt}"
).casefold()
for source in sources
for term in _DOMAIN_CONCEPTS["damage"]
)
if not concepts or not sources or ("damage" in concepts and not damage_evidence):
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "insufficient").inc()
return GroundedAnswer(
answer="",
evidence_state="insufficient",
sources=all_sources,
provider=self.name,
correlation_id=correlation_id,
)
template = _LEAD_ANSWER_TEMPLATE.get(language, _LEAD_ANSWER_TEMPLATE[_DEFAULT_LANGUAGE])
lead = sources[0]
answer = template.format(title=lead.title, excerpt=lead.excerpt)
KNOWLEDGE_PROVIDER_REQUESTS.labels("search", "grounded").inc()
return GroundedAnswer(
answer=answer,
evidence_state="grounded",
sources=sources, sources=sources,
provider=self.name, provider=self.name,
correlation_id=correlation_id, correlation_id=correlation_id,
) )
except (TypeError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
+78 -23
View File
@@ -1,8 +1,10 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime, timedelta
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -18,29 +20,53 @@ from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, RegisterReturnRequest from app.schemas import CurrentUser, RegisterReturnRequest
from app.services.audit import record_audit_event from app.services.audit import record_audit_event
REF_PREFIX = "INSP"
def _new_inspection_ref() -> str:
"""Generate a collision-resistant public reference without reading mutable counts.
def _next_public_ref(db: Session) -> str: Return commands for different bookings can commit concurrently. A count-based
existing = db.execute(select(Inspection.public_ref)).scalars().all() reference made those independent transactions race for the same unique value.
return f"{REF_PREFIX}-{len(existing) + 1:04d}" """
return f"INSP-{uuid.uuid4().hex[:10].upper()}"
def _derive_vehicle_status_with_reason( def _derive_vehicle_status_with_reason(
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
) -> tuple[str, str]: ) -> tuple[str, str, dict[str, str | int]]:
# Stable, localizable codes + params -- the backend never emits prose here. The
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
# raw-English fallback strings live only in status_reason (shown under "Technical
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
if body.damage_reported and body.technical_warning: if body.damage_reported and body.technical_warning:
return "blocked", "Damage and a technical warning were both reported on return." return (
"blocked",
"returnBlockedDamageAndTechnical",
{},
)
if body.damage_reported: if body.damage_reported:
return "blocked", "Damage was reported on return." return "blocked", "returnBlockedDamage", {}
if body.technical_warning: if body.technical_warning:
return "blocked", "A technical warning was reported on return." return "blocked", "returnBlockedTechnicalWarning", {}
if new_odometer >= vehicle.next_service_km: if new_odometer >= vehicle.next_service_km:
return ( return (
"maintenance", "maintenance",
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.", "returnServiceThresholdReached",
{"threshold_km": vehicle.next_service_km},
) )
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning." return "cleaning", "returnRoutedToCleaning", {}
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
"returnBlockedDamageAndTechnical": (
"Damage and a technical warning were both reported on return."
),
"returnBlockedDamage": "Damage was reported on return.",
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
"returnServiceThresholdReached": "Odometer reached the service threshold.",
"returnRoutedToCleaning": (
"No damage, technical warning or service threshold; routed to cleaning."
),
}
@dataclass @dataclass
@@ -51,6 +77,8 @@ class ReturnEvaluation:
resulting_odometer_km: int resulting_odometer_km: int
resulting_vehicle_status: str resulting_vehicle_status: str
status_reason: str status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int]
would_create_quality_issue: bool would_create_quality_issue: bool
attention_reasons: list[str] attention_reasons: list[str]
next_booking_risk: dict | None next_booking_risk: dict | None
@@ -64,9 +92,10 @@ def evaluate_return(
preview and commit can never drift apart.""" preview and commit can never drift apart."""
odometer_regression = body.end_odometer_km < vehicle.odometer_km odometer_regression = body.end_odometer_km < vehicle.odometer_km
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
resulting_status, status_reason = _derive_vehicle_status_with_reason( resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
body, vehicle, resulting_odometer_km body, vehicle, resulting_odometer_km
) )
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
attention_reasons = [] attention_reasons = []
if body.damage_reported: if body.damage_reported:
@@ -101,6 +130,8 @@ def evaluate_return(
resulting_odometer_km=resulting_odometer_km, resulting_odometer_km=resulting_odometer_km,
resulting_vehicle_status=resulting_status, resulting_vehicle_status=resulting_status,
status_reason=status_reason, status_reason=status_reason,
status_reason_code=status_reason_code,
status_reason_params=status_reason_params,
would_create_quality_issue=odometer_regression, would_create_quality_issue=odometer_regression,
attention_reasons=attention_reasons, attention_reasons=attention_reasons,
next_booking_risk=next_booking_risk, next_booking_risk=next_booking_risk,
@@ -142,17 +173,17 @@ def preview_vehicle_return(
return booking, vehicle, evaluation return booking, vehicle, evaluation
def register_vehicle_return( def request_fingerprint(body: RegisterReturnRequest) -> str:
canonical = json.dumps(body.model_dump(mode="json"), sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
def _replay_or_reject(
db: Session, db: Session,
existing: IdempotencyRecord,
booking_ref: str, booking_ref: str,
body: RegisterReturnRequest, fingerprint: str,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]: ) -> tuple[int, dict]:
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
booking = db.get(Booking, existing.booking_id) booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref: if booking is None or booking.public_ref != booking_ref:
raise AppError( raise AppError(
@@ -160,8 +191,29 @@ def register_vehicle_return(
"This idempotency key was already used for a different booking.", "This idempotency key was already used for a different booking.",
status_code=409, status_code=409,
) )
if existing.request_fingerprint is not None and existing.request_fingerprint != fingerprint:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used with a different request body.",
status_code=409,
)
return existing.response_status, existing.response_body return existing.response_status, existing.response_body
def register_vehicle_return(
db: Session,
booking_ref: str,
body: RegisterReturnRequest,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]:
fingerprint = request_fingerprint(body)
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return _replay_or_reject(db, existing, booking_ref, fingerprint)
booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True) booking, vehicle = _load_active_booking_and_vehicle(db, booking_ref, lock=True)
# Re-check after acquiring the row lock: a concurrent identical-key request may have # Re-check after acquiring the row lock: a concurrent identical-key request may have
@@ -170,7 +222,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
) )
if existing is not None: if existing is not None:
return existing.response_status, existing.response_body return _replay_or_reject(db, existing, booking_ref, fingerprint)
if booking.status != "active": if booking.status != "active":
raise AppError( raise AppError(
@@ -184,7 +236,7 @@ def register_vehicle_return(
evaluation = evaluate_return(db, booking, vehicle, body, now=now) evaluation = evaluate_return(db, booking, vehicle, body, now=now)
inspection = Inspection( inspection = Inspection(
public_ref=_next_public_ref(db), public_ref=_new_inspection_ref(),
booking_id=booking.id, booking_id=booking.id,
vehicle_id=vehicle.id, vehicle_id=vehicle.id,
type="return", type="return",
@@ -226,6 +278,7 @@ def register_vehicle_return(
}, },
proposed_action_json={}, proposed_action_json={},
detected_at=now, detected_at=now,
due_at=now + timedelta(days=1),
) )
db.add(issue) db.add(issue)
db.flush() db.flush()
@@ -298,6 +351,7 @@ def register_vehicle_return(
"odometer_regression": evaluation.odometer_regression, "odometer_regression": evaluation.odometer_regression,
"quality_issue_ref": quality_issue_ref, "quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id), "workflow_event_id": str(event.event_id),
"correlation_id": str(correlation_id),
"next_booking_risk": evaluation.next_booking_risk, "next_booking_risk": evaluation.next_booking_risk,
} }
@@ -305,6 +359,7 @@ def register_vehicle_return(
IdempotencyRecord( IdempotencyRecord(
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
booking_id=booking.id, booking_id=booking.id,
request_fingerprint=fingerprint,
response_status=201, response_status=201,
response_body=response_body, response_body=response_body,
) )
@@ -318,7 +373,7 @@ def register_vehicle_return(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key) select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
) )
if existing is not None: if existing is not None:
return existing.response_status, existing.response_body return _replay_or_reject(db, existing, booking_ref, fingerprint)
raise raise
return 201, response_body return 201, response_body
+34
View File
@@ -0,0 +1,34 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.core.security import SessionPayload, session_token_hash
from app.models.revoked_session import RevokedSession
settings = get_settings()
def is_session_revoked(db: Session, token: str) -> bool:
token_hash = session_token_hash(token)
revoked_id = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
return revoked_id is not None
def revoke_session(db: Session, token: str, payload: SessionPayload) -> None:
db.execute(delete(RevokedSession).where(RevokedSession.expires_at < datetime.now(UTC)))
token_hash = session_token_hash(token)
existing = db.scalar(select(RevokedSession.id).where(RevokedSession.token_hash == token_hash))
if existing is not None:
return
db.add(
RevokedSession(
token_hash=token_hash,
expires_at=datetime.fromtimestamp(
payload.issued_at + settings.session_ttl_seconds, UTC
),
)
)

Some files were not shown because too many files have changed in this diff Show More