Author SHA1 Message Date
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
152 changed files with 7533 additions and 657 deletions
+7
View File
@@ -29,6 +29,11 @@ N8N_BASIC_AUTH_ACTIVE=true
N8N_BASIC_AUTH_USER=admin
N8N_BASIC_AUTH_PASSWORD=change-me
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
KNOWLEDGE_PROVIDER=demo
@@ -37,6 +42,8 @@ RAGCORE_TENANT=northstar-mobility-demo
RAGCORE_WORKSPACE=mobilityops
RAGCORE_COLLECTION=internal-procedures
RAGCORE_API_TOKEN=
# UUID of the RAGcore knowledge space procedures were synced into (see workflow 3).
RAGCORE_SPACE_ID=
# ITWorx MCP Hub integration
MCP_HUB_REGISTRATION_ENABLED=false
+4 -1
View File
@@ -60,7 +60,10 @@
- `knowledge/procedures/09-booking-conflicts.md`
- `knowledge/procedures/10-roles-and-escalation.md`
- `n8n/README.md`
- `n8n/mobilityops-return-processing.json`
- `n8n/workflows/MANIFEST.md`
- `n8n/workflows/fleet-ops-vehicle-return.json`
- `n8n/workflows/fleet-ops-data-quality-scan.json`
- `n8n/workflows/check_drift.py`
- `seed/README.md`
- `seed/bookings.csv`
- `seed/customers.csv`
+7 -4
View File
@@ -26,16 +26,19 @@ reset:
# 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
# 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:
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 restart n8n
# 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:
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 restart n8n
+882 -2
View File
@@ -29,9 +29,15 @@ M7 — complete. All milestones (M0M7) done, plus a full post-M7 final-accept
## Locked decisions
- Product name: MobilityOps.
- Product name: Fleet Ops (the only visible product name in the UI/copy, never translated;
see `frontend/src/product.ts`). "MobilityOps" is the internal repo name, Compose project
name and deployment directory only — never shown to a user. See the "Final product
polish: Fleet Ops rebrand" and "Fleet Ops final localization" entries below.
- Fictitious tenant: Northstar Mobility Demo.
- PoC only; all operational and knowledge data are synthetic.
- Synthetic demo data only; all operational and knowledge data are synthetic. The product
itself is not described as a "PoC" in user-facing copy (see the localization entry
below) — this document and other internal/engineering docs may still use "PoC" to
describe the engineering scope, per `CLAUDE.md`.
- Core stack and boundaries are defined in `CLAUDE.md` and `docs/03-architecture.md`.
- RAGcore and ITWorx MCP Hub are external central services.
- n8n receives post-commit events through an outbox dispatcher.
@@ -818,3 +824,877 @@ scenarios, demo manifest, About page). Gap audit: `docs/demo-release/current-dem
every batch was tested locally, deployed to Unraid, and re-verified live before moving
to the next. See `artifacts/demo-release/final-summary.md` for the definitive
acceptance evidence.
## Final product polish: Fleet Ops rebrand, trilingual i18n, adaptive guide (2026-08-03) — MERGED TO MASTER
- Rebranded the product to **Fleet Ops** across the frontend, backend defaults and the
knowledge base; made `nl-BE` (default)/`en-GB`/`fr-BE` full first-class languages via
i18next (eager-bundled resources, persisted language switcher in topbar + mobile
drawer, `Intl` date/number formatting, a coverage test that fails the build on any
missing/empty translation key across all 14 namespaces).
- Backend dynamic content (demo scenarios, blocked-reason text, integration status)
converted from fixed English/Dutch prose to stable message codes + params so the
frontend localizes it (`DemoScenarioOut`/`DemoIntegrationSummaryOut` schema changes).
The demo knowledge base gained a fully translated NL/EN/FR procedure corpus (11
documents each, including a new "vehicle availability" procedure) with per-language
retrieval and localized evidence-state messages.
- Demo Guide became breakpoint-adaptive: docked rail (≥1440px), a floating panel that
auto-collapses to a persistent closable progress chip (7011439px), and a
collapsed/half/full bottom sheet (≤700px) — with scroll+focus+highlight on "go to this
step", Escape handling, and `prefers-reduced-motion` support.
- Data Quality Workbench got accessible choice-card decisions with a clear
primary/secondary/tertiary action hierarchy; Automation ledger groups repeated
successes with meaningful short refs; Audit trail groups events by correlation id with
human action labels and readable before/after diffs; Attention Queue/Today's
movements/Vehicles/Bookings/Data Quality rows are fully clickable (stretched-link
pattern, independent secondary links, keyboard + mobile support).
- Two real bugs found and fixed along the way: a mobile topbar overflow at 421440px
caused by the new language switcher (moved the switcher into the mobile drawer at
≤960px and widened the compact-topbar breakpoint to 440px), and two dangling
`aria-labelledby` references (`SectionHeading` never set the referenced `id`).
- Full test suite: 131 backend tests, Ruff, mypy, TypeScript build, and 92 Playwright
tests (new: `i18n-coverage`, `clickable-rows`, `responsive-i18n` covering all 7
brief-specified breakpoints × 3 languages, plus 3 new adaptive-guide tier tests) — all
green. All pre-existing Playwright specs updated for the new nl-BE default (either
translated assertions or an explicit English-locale override where the spec was
originally authored against English copy).
- Clean-checkout drill performed in a fully isolated Docker Compose project (separate
ports/volumes, no shared n8n) from a fresh local clone at the feature-branch head —
131 backend tests, lint, build and all 92 Playwright tests green from empty volumes;
live EN/FR knowledge-assistant spot check; `scenario_integrity.all_ready: true` on
reset; isolated stack torn down afterward, original dev environment untouched.
- Deployed to Unraid twice: once for the feature branch (commit `845db14`) for
pre-merge live validation, once for the merged `master` (commit `18a765d`) for the
final release — both times via the established `git archive` → `scp` →
extract → `.deploy/source-revision` → rebuild `api`/`web` method, with migrations,
reseed, full backend+Playwright gates, console/network inspection and demo reset
re-verified live each time.
- Master baseline was confirmed unchanged (`e0c7ed6`, matching the previously recorded
baseline) before merging; `git merge-tree` dry run showed zero conflicts. Merged via
`git merge --no-ff` (commit `18a765d`), all gates re-run post-merge, pushed to Gitea,
redeployed. Feature branch was not deleted.
- Full evidence: `artifacts/fleet-ops-release/final-summary.md` (commits, branding,
locales, translation/knowledge-base/guide/data-quality/automation/audit evidence, all
test results, clean-checkout result, deployment evidence for both the feature branch
and master, responsive/accessibility results, known limitations, rollback procedure)
plus 10 screenshots in `artifacts/fleet-ops-release/screenshots/`.
## Fleet Ops correction: safe status-recommendation flow, MO-016, message codes (2026-08-03) — MERGED TO MASTER
Branch `fix/fleet-ops-i18n-status-flow`, created from master's post-release head
(`18344bc`). Audit and rationale in `docs/fleet-ops-correction/` (gap audit, i18n
inventory, vehicle-status decision table). Merged to master via `de0bdea` ("merge:
complete Fleet Ops localization and status resolution"), with final evidence commit
`f780557` ("docs(release): final Fleet Ops correction evidence and screenshots") —
`f780557` is `origin/master`'s current head as of the start of the correction round
below.
- **Status-recommendation flow redesigned** per the brief: 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`) used
identically by the scanner, a new non-mutating preview endpoint
(`POST .../status-recommendation`), and a transactional apply endpoint
(`POST .../apply-recommended-status`) that locks the row, recomputes facts, rejects a
stale `recommendation_token` (optimistic concurrency), refuses unsafe/manual-review
recommendations, and re-validates post-write before resolving the issue. Frontend
`DataQualityIssueDetail.tsx` shows "Review recommendation" → a decision panel
(current/recommended status, why, evidence, consequences, localized in all 3
languages) → an exact "Change status to <status>" confirm action → result, with a
distinct "Manual review required" state offering no generic apply button.
- Fixed the real unsafe shortcut this evaluator exists to eliminate: "maintenance +
active booking" no longer auto-recommends "rented" (current status is itself now a
blocking fact), and "maintenance with nothing else wrong" no longer auto-clears to
"available" (no fact proves maintenance is actually finished — that release stays a
manual decision).
- **MO-016 order independence**: 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 must (and does) hold either way:
the recommendation always reflects real current facts, and nothing unsafe is ever
applied (never "rented"). Verified by both a backend test
(`test_mo_016_status_conflict_recommendation_is_order_independent`, explicitly scoped
to MO-016/DQ-DEMO-STATUS after finding the original version wasn't) and a browser-level
Playwright test in both orders.
- **"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.
- **Dynamic backend prose converted to message codes + params**: return status reasons,
audit field/actor-type labels, automation `last_error` (new `last_error_code` column,
migration `799d8800e241`), and search results (sections/vehicles/bookings/issues) all
now carry stable codes the frontend localizes; raw technical text is demoted to a
"Technical details" disclosure everywhere.
- **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. Also 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 (knowledge
prose is visible content, missed by the earlier rebrand).
- New `frontend/e2e/fleet-ops-correction.spec.ts` (14 tests) covers branding in 3
languages, language persistence, the full status-recommendation flow (non-mutating
preview, exact confirm text, manual review, stale-token rejection), MO-016 order
independence, trilingual knowledge grounding, and localized audit/automation. Writing
it surfaced and fixed two real bugs: the frontend conflated "no conflict" with
"manual review required" (both carry `safe_to_apply: false`), and the original
MO-016 backend test never actually targeted MO-016's own issue.
- Added a keyboard/reduced-motion/no-color-only-status accessibility test for the new
status-decision panel; added `aria-live="polite"` to the panel so the applied
confirmation is announced.
- `contracts/openapi.yaml` and `README.md` updated: title is "Fleet Ops", the new
status-recommendation endpoint documented, apply-recommended-status's request body
and error codes documented, search endpoint's code+params shape documented, README
states the Fleet Ops/MobilityOps naming split explicitly and refreshes stale test
counts (151 backend, 108 Playwright).
- Gates green: 151 backend tests, Ruff, mypy, Alembic upgrade/downgrade verified,
frontend `tsc`/build, full 113-test Playwright suite (rebuilt `api`+`web` containers
each time before testing).
- Section 11D/E/F of the i18n test-strengthening brief done: a hardcoded-JSX-text
static check (`i18n-coverage.spec.ts`; had to anchor on backreferenced closing-tag
names — a naive `>text<` scan misread TypeScript generics like
`useState<string | null>` as JSX spanning to the next unrelated `>`; verified against
both false positives and a deliberately-injected-then-reverted false negative), and a
3-language route matrix (`fleet-ops-correction.spec.ts`) covering every main route:
no console errors, correct `html[lang]`, real page headings.
- **Clean-checkout drill (2026-08-03) — PASS.** Fresh `git clone --branch
fix/fleet-ops-i18n-status-flow` of only committed files into an isolated directory,
separate Compose project name and host ports (8129/1229/5679) so the working dev
stack was never touched. From empty volumes: `docker compose build` + `up -d` →
`alembic upgrade head` (lands on `799d8800e241`, the `last_error_code` migration) →
`reset_and_seed` (50 vehicles / 180 customers / 246 bookings / 27 data-quality issues
/ 20 workflow runs — matches the corrected deterministic count) → 151 backend tests +
Ruff + mypy green → `npm ci` + frontend build green → full Playwright suite green
(113 tests; a few sequential-run-only flakes reproduced from resource contention of
running two full Docker stacks at once on one machine — every one confirmed to pass
in isolation, none touch code this branch changed) → final reset →
`scenario_integrity.all_ready: true`. Isolated stack, containers, volumes and images
torn down afterward; original dev environment confirmed untouched and reset to
baseline.
- **Unraid deployment (2026-08-03/04) — PASS.** Pushed `fix/fleet-ops-i18n-status-flow`
to origin, deployed via `git archive` → `scp` → extract into
`/mnt/user/appdata/mobilityops` (preserving `.env`) → `.deploy/source-revision` →
rebuild `api`+`web` → `alembic upgrade head` → reset/reseed, at
`http://192.168.10.150:1236`. **Live validation directly caught a real bug**: every
data-quality issue's top-of-page evidence summary was unconditionally showing raw
English (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. Fixed (commit `2e4fb43`):
DataQualityIssueDetail.tsx now renders `evidence.signals` through the operator's
locale as the primary text, raw text moved to "Technical details" only, the 4
DQ-DEMO-* seed rows got real computed signals (the duplicate-customer similarity
score is the actual SequenceMatcher ratio on the seeded names), and a regression
test locks this in. Redeployed with the fix; live-verified via `read_page` that
DQ-DEMO-STATUS now shows "Dit voertuig heeft twee overlappende reserveringen..."
instead of the raw English sentence. Full 116-test Playwright suite green against
the live server (`MOBILITYOPS_PUBLIC_URL=http://192.168.10.150:1236`), no console
errors, no errors in `api`/`web` container logs, both containers healthy, final
reset done, `scenario_integrity.all_ready: true`.
- Final evidence: `artifacts/fleet-ops-correction/final-summary.md`. Merged to master
via `de0bdea`, followed by evidence commit `f780557` on master. See the "Fleet Ops
final localization" entry below for the next (small correction) round on top of this.
## Fleet Ops final localization: remaining NL/FR gaps, API-error localization, greeting (2026-08-04) — MERGED TO MASTER
Merged to master via `5f0eaa5`; final evidence commit `c0995b7` added
`artifacts/fleet-ops-final-localization/final-summary.md`. Master head at merge:
`c0995b762e1cbf37172a08e03645baa6b66aa8d5`. Details below are the in-progress working log
kept for reference.
Branch `fix/fleet-ops-final-i18n-ux`, created from master's post-correction head
(`f780557`) — the brief asked for `fix/fleet-ops-final-localization`, but the
already-checked-out branch name is used instead since it was verified freshly and
cleanly branched from current `origin/master` with a clean working tree; see
`docs/fleet-ops-final-localization/audit.md` for the naming note. Scope: a small,
targeted correction round only — explicitly not touching status-flow business logic,
the status evaluator, Data Quality resolution rules, return rules, RAGcore/MCP Hub, or
product scope.
- **Audit-driven gap sweep**: `docs/fleet-ops-final-localization/audit.md` documents
every remaining untranslated/incorrect string, raw-backend-error call site,
over-permissive allowlist entry, the static-greeting bug, and doc staleness found by
a dedicated Explore pass before any file was touched.
- **Remaining NL/FR translation gaps fixed**: role names actually translated (not just
labelled as translated) — `auth.json`/`demo.json` role keys, `audit.title` →
"Auditgeschiedenis"/"Piste d'audit", `columns.actor` → "Uitvoerder", `list.statusOpen`
→ "Openstaand", `ledger.filterRecent` → "Recentste", `scenarios.startScenario` →
"Scenario starten". Also found and fixed (via the new embedded-substring test below)
8 previously-missed mid-sentence "Audit trail" leaks across `demo.json`,
`quality.json`, `returns.json` that the old whole-string-identity test structurally
could not catch.
- **Central API-error localization**: new `frontend/src/api/errorMessages.ts`
(`describeApiError`) replaces the `err instanceof ApiError ? err.message : ...`
anti-pattern (which showed raw English for the common case) at all 13 call sites
across 7 files. Raw backend text is now only ever shown under a "Technical
details"/"Détails techniques" disclosure (new `ApiErrorNotice` component in
`PageChrome.tsx`); the primary message is always a localized title + explanation +
optional next step, keyed on the 32 known `AppError` codes, then known HTTP statuses
(401/403/404/409/422/500), then a fully generic fallback. `ApiError` was split out of
`client.ts` into a standalone `api/apiError.ts` (no `import.meta.env` dependency) so
`errorMessages.ts` is independently testable outside a Vite/browser context.
- **i18n allowlist tightened**: 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`) now that they're genuinely translated. Added 2 new tests:
one closing the embedded-English/Dutch-substring blind spot (mid-sentence phrase
leaks the whole-string check misses), one asserting no locale file contains
"MobilityOps" or the word "PoC".
- **`describeApiError` test coverage**: new `frontend/e2e/error-messages.spec.ts` (10
tests) — every known code/HTTP status has non-empty copy in all 3 locales, a known
code never surfaces raw backend text as the primary message (only via `.technical`),
unknown-code and unknown-status fallback chains behave correctly, and a drift guard
that greps the actual backend `AppError("CODE", ...)` call sites and fails if
`KNOWN_CODES` and the backend's real codes ever diverge (currently exactly in sync,
32 codes).
- **Time-dependent Europe/Brussels dashboard greeting**: new
`frontend/src/i18n/greeting.ts` (`getGreetingPeriod`, DST-safe via
`Intl.DateTimeFormat({ timeZone: "Europe/Brussels", hourCycle: "h23" })`, clock
injectable) + `useGreetingPeriod.ts` hook (30s poll for period rollover while the app
stays open, no reload). Replaces the previously-always-"Goedemorgen" static
`dashboard.json` title with 4 periods × 3 languages for both the greeting word and a
varying accompanying sentence (never "Goedenacht"). Tests: `greeting.spec.ts` (pure
boundary/DST unit tests) + `greeting-live.spec.ts` (6 real-browser tests via
Playwright's `page.clock` — all 8 required boundary times in all 3 languages, live
rollover without reload, language-switch behaviour, the "never Goedenacht" guard).
- **Found and fixed one real CSS regression along the way**: correctly translating
`roleOperationsManager` to the single unbreakable Dutch compound word
"Operationsmanager" (vs. the old two-word "Operations Manager", which could wrap)
pushed the topbar's `.operator` block past 1024px width, caught by the existing
`responsive-i18n.spec.ts` overflow test. Fixed with `overflow-wrap: anywhere` on
`.operator strong`/`small` and `min-width: 0` on their flex-item wrapper, not by
reverting the correct translation.
- Gates green so far: backend `pytest` 151 passed, `ruff check .` clean, `mypy app`
clean (49 files, unchanged — no backend Python touched this round); frontend `tsc`
clean, production build clean, full local Playwright suite **138 passed** (rebuilt
and restarted the local `web` container from source before this run).
- **Not yet done**: clean-checkout drill, commit/push, Unraid deployment of this fix
branch with live 3-language validation, the master merge (with the mandatory
`git fetch origin` / unexpected-change check first), and
`artifacts/fleet-ops-final-localization/final-summary.md`. Do not claim PASS on this
correction round until all of those are done and `git rev-parse HEAD` exactly matches
`/mnt/user/appdata/mobilityops/.deploy/source-revision`.
- Commits so far on this branch: `6deb955` (status flow + brand constant + message
codes), `e6539d1` (knowledge fixes), `ac4b163` (Playwright spec updates for the new
flow), `1fdd2b3` (new E2E coverage + 2 bug fixes), `1e40775` (accessibility test),
`a7ac5ed` (docs), `7851e80` (11D/11F i18n tests), `cda2c32` (clean-checkout
evidence), `2e4fb43` (evidence-summary localization fix, found live on Unraid).
Deployed commit: `2e4fb43f093bfbdb04c4f74eed1e6c6d9a03c069`.
## Live n8n + RAGcore integration (2026-08-04) — IN PROGRESS on feat/live-n8n-ragcore-integration
Branch `feat/live-n8n-ragcore-integration`, from master `c0995b7`. Full brief: treat n8n
(`https://n8n.itworx.tech`, existing shared instance) as a third integration layer
alongside RAGcore and MCP Hub, owning process orchestration only — Fleet Ops keeps all
business rules, authorization, transactions, audit and idempotency. Four canonical
workflows required: (1) Vehicle Return Orchestration, (2) Scheduled Data Quality Scan —
both pre-existing and now hardened; (3) RAGcore Procedure Sync, (4) Workflow Error
Handler — both net-new, not yet built.
- **Current-state audit**: `docs/live-ai-integration/n8n-current-state.md` documents the
live instance (reachable, production webhook base
`http://192.168.10.150:5678/webhook/mobilityops-return`), both existing workflows'
full node structure, and the findings that drove the security fixes below (webhook
Authentication was `None`; both HTTP nodes had `X-Service-Token` hardcoded as a literal
header value instead of a credential).
- **Security fixes applied and live-validated** (commits `b79d485`, `59cb4c0`): webhook
trigger now requires Header Auth (credential `Fleet Ops Webhook Trigger Token`, a new
token generated this round — value stored in `.env`/Unraid `.env` only, never
printed); the outbound callback HTTP node now uses a `Fleet Ops Service Token` Header
Auth credential instead of a literal header value (existing secret copied
clipboard-to-clipboard, never typed/echoed). Backend: `X-Fleet-Ops-Trigger-Token`
header added to the outbox dispatcher's POST (`backend/app/services/dispatcher.py`),
plus a new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting/env var. Also hardened
`_deliver_one` to treat a 2xx response with a non-JSON-object body as a retryable
failure (`malformedResponse`) instead of an unhandled exception — a real failure mode
hit live when a workflow errors before its "Respond to Webhook" node runs; regression
test `test_deliver_one_treats_empty_2xx_body_as_failure` added. Live-validated: curl
probe without the header → `403`; with the header → pass-through; one real end-to-end
vehicle return produced one correct execution visible in both n8n and Fleet Ops
Audit/Automation. Both workflows explicitly `Publish`ed after the fixes (the editor
does not go live on save alone) and both canonical-renamed ("Fleet Ops — Vehicle
Return Orchestration", "Fleet Ops — Scheduled Data Quality Scan").
- **RAGcore real contract discovered** (not the speculative one the adapter was built
against): OpenAPI at `/openapi.json`, health at `/health/live`/`/health/ready` (not
`/health`), ingestion via `POST /v1/uploads`, answers via `POST /v1/answers` with
`requested_space_ids`, control-plane endpoints require an `Idempotency-Key` header.
Bootstrapped a `fleet-ops` application + knowledge space + grant on the real server at
`http://192.168.10.150:1237`. **Blocked**: credential issuance for that application
failed identically via both the raw API and the admin UI ("authoritative
service-account state rejected issuance") — an apparent privilege boundary beyond the
interactive admin session. User chose to issue the credential themselves via another
mechanism and hand over the token; not yet received. `RAGcoreKnowledgeProvider`
(`backend/app/services/knowledge/ragcore.py`) still targets the old speculative
endpoints and needs fixing once that token arrives — approved, not started.
- **Repository source of truth started** (task in progress): `n8n/workflows/` now holds
cleaned definitions for workflows 1-2 — `fleet-ops-vehicle-return.json` (sha256
`e13a3087269fc97019a7adf6c6a6a4ee4bd354c2dd7167d4966d4753a48e970e`),
`fleet-ops-data-quality-scan.json` (sha256
`cc30b28b07dad9f9908a6ea0c564ec4c2f362a3ed71b7e97a7b6894408bb7e2e`) — both credential
auth referenced by name only, no secret values. Reconstructed from direct verified
inspection of every live node, **not** a literal n8n export/download: the UI's "..."
menu has no Download option in this n8n version, and clipboard-based
copy/`navigator.clipboard.readText()` extraction timed out twice. Flagged as a known
limitation for the final evidence doc. `n8n/workflows/MANIFEST.md` records canonical
name/purpose/trigger/contract/credentials/live ID/active-status/checksum for all 4
workflows (3-4 marked not-yet-built). `n8n/workflows/check_drift.py` compares a repo
definition against the live workflow via n8n's Public API (`X-N8N-API-KEY`, read-only,
never auto-overwrites). The old root-level `n8n/mobilityops-return-processing.json`
and `n8n/mobilityops-scheduled-quality-scan.json` (pre-integration starters, still
carrying the literal-token pattern) are removed; `deploy/unraid/setup-existing-n8n.sh`,
`setup-scheduled-scan.sh`, `Makefile` (`n8n-setup`, `n8n-setup-scan`) and
`docs/17-runbook.md` updated to import from `n8n/workflows/` and to document the
now-required manual credential-creation step (credentials are never scripted or
committed).
- **Explicitly deferred/forbidden this phase** (per brief): daily AI ops brief, email,
Slack, automatic vehicle-status changes, customer communication, billing, general
monitoring, autonomous MCP actions. An automatic demo-reset workflow may only be
prepared, not activated, once Fleet Ops goes public.
- **Workflow 4 (Workflow Error Handler) built and live-validated** (commit pending):
new backend endpoint `POST /api/v1/integrations/n8n/workflow-error`
(`backend/app/api/routers/integrations.py`, service-token auth, Pydantic
`WorkflowErrorReportIn`/`WorkflowErrorReportResult` in `backend/app/schemas.py`),
idempotent on `execution_id` via the same audit-precheck pattern as
`/return-callback`; new test coverage in `backend/tests/test_integrations.py` (all
green, 152 tests total, ruff/mypy clean). **This endpoint had to be deployed to the
live Unraid server** (`git archive` → `scp` → extract preserving `.env` → `docker
compose up --build -d api`, no migration needed) before the live n8n test could reach
it — the auto-mode classifier correctly blocked the first `scp` attempt as a
production-infra action; user approved, then it was deployed and verified
(`/health` OK, new endpoint returns 422 on empty body instead of 404).
Built "Fleet Ops — Workflow Error Handler" (live ID `Xppn2rAEqUuyiCJF`) in n8n:
Error Trigger → Code node (derives safe error_category/summary/etc. from n8n's error
payload) → HTTP node (POST to the new endpoint, Header Auth via the existing "Fleet
Ops Service Token" credential). Hit and fixed two real bugs during live testing: (1)
Code node's default "Run Once for All Items" mode doesn't bind `$json` to the current
item — switched to "Run Once for Each Item" and `return {json:...}` instead of
`return [{json:...}]`; (2) every HTTP-body field expression ended up with a stray
trailing space (from the code-editor's bracket-autoclose leaving one extra character
after the `End`+`Backspace×2` fix), which broke the `failed_at` datetime parse and the
`error_category` literal match — found via the raw request dump in n8n's error
panel, fixed with one more `Backspace` per field. Live-validated: mock Error Trigger
data → real `200 {"status":"registered"}` from Fleet Ops; re-run → `"already_registered"`
(idempotency confirmed); wired as the Error Workflow on workflows 1 and 2 (via each
workflow's Settings modal); confirmed the Error Handler itself has `Error Workflow: -
No Workflow -` (no recursive loop). With user approval, also ran a genuine induced
failure on workflow 2 (temporarily pointed its HTTP node at a nonexistent path,
published, ran it, confirmed it failed as expected, immediately reverted and
republished, confirmed healthy again) — this proved the target workflow's own error
path works, but n8n did not auto-invoke the Error Handler for that *manual* editor
test run (n8n's Error Workflow trigger only fires for unattended/production
executions), so a fully automatic schedule/webhook-triggered cascade into the handler
was not observed live this round — noted as a known limitation.
Exported the verified definition to `n8n/workflows/fleet-ops-error-handler.json` (same
manual-reconstruction caveat as workflows 1-2: no literal export/download available),
updated `n8n/workflows/MANIFEST.md` (all 4 workflows, workflow 3 still not-built) and
`check_drift.py`'s known-workflows list.
- **Integration status page enriched with real per-workflow evidence** (commit
`4049c0c`): `N8nIntegrationStatus` now returns `workflows: N8nWorkflowEvidence[]`
(the 4 canonical workflows, each with real evidence — latest successful outbox
delivery for the return workflow, latest *service*-triggered `data_quality_scan_run`
audit event for the scan workflow so a manual UI-triggered scan doesn't fake n8n
evidence, latest `n8n_workflow_failure_registered` for the error handler, always
`built: false` / no evidence for the not-yet-built RAGcore sync), plus
`expected_workflow_count`/`known_workflow_count` and an `error_handler` summary
(total registered, latest failure + which workflow). New tests in
`backend/tests/test_integration_status.py` (all green, 159 backend tests total,
ruff/mypy clean). Frontend: `Automation.tsx` renders this as a localized workflow
table (EN/NL/FR, new `integrations:workflows.*` keys, technical workflow names under
a "Technical details" disclosure per the existing progressive-disclosure pattern).
Verified live in the browser both locally (Dutch locale, disclosure expand/collapse
confirmed) and **on the deployed Unraid server after this round's deploy**: correctly
shows "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 session's live
testing. Deployed to Unraid (commit `4049c0c6b12fef3d948cd31f21119044143320d8`,
rebuilt both `api` and `web`, `/health` OK) — user re-approved this second deploy
separately from the first.
- **Operational lesson learned this round**: `docker compose run --rm api pytest` does
**not** reliably pick up source edits without an explicit `docker compose build api`
first — a test file edit silently kept running against the stale built image (test
count didn't change) until rebuilt. Always `docker compose build api` (and `web` for
frontend changes) before trusting a green result after backend/frontend edits in this
repo.
- **WF1 acceptance gap fixed and live**: `check_drift.py`-style re-inspection of workflow
1 during this round's acceptance pass found the `Record follow-up` HTTP node had no
explicit timeout and "Retry On Fail" disabled — a real gap against the brief's
timeouts/bounded-retries requirement (WF2 already had this). Fixed live: Retry On Fail
(3 tries, 1000ms wait) + a 15000ms Timeout option, published (version note "Add bounded
retries (3x) and a 15s timeout to the Fleet Ops callback call"). Repo definition and
manifest checksum synced (`n8n/workflows/fleet-ops-vehicle-return.json`,
`MANIFEST.md`, new checksum `a6f399dd77a7203dec7c0ac95e8540abf55f2703da519e06f1c37f2e1220f609`,
commit `0562893`).
- **RAGcore credential issuance re-attempted and still blocked (user explicitly
authorized Claude to self-issue this round)**: tried the RAGcore admin UI's "Issue
credential" form for the `fleet-ops` application (logged in as Platform Admin, the
highest visible role) with name `n8n-ragcore-procedure-sync` and scope `sources:sync`
only. Submission failed with the same generic "Something went wrong. The credential
could not be issued with those values." page, this time carrying a trace reference
`1955c6a8968c4941a22a1faef39e17a7`. Inspected the RAGcore OpenAPI spec for this admin
endpoint (`POST /admin/control/applications/{application_id}/credentials`) — no
documented validation constraint explains the rejection (no 422, no field errors); the
`fleet-ops` application itself lists as ordinary/`Active` with no visible lock flag in
the applications table. This is the same failure signature as the earlier raw-API
attempt (400 "authoritative service-account state rejected issuance"): two independent
paths (raw API, and now the admin UI as the top admin role) both hit an opaque
server-side rejection with a trace ID. This is conclusive evidence the block is a
deliberate RAGcore-side policy or a RAGcore-side bug, not a Fleet Ops permission or
request-shape problem — nothing further is fixable from the Fleet Ops side or through
browser automation. Whoever operates the RAGcore instance needs to look up trace
`1955c6a8968c4941a22a1faef39e17a7` (and the earlier API rejection) in RAGcore's own
logs to find the real cause.
- **WF2 acceptance gap fixed and live**: continuing the acceptance pass to WF2 found it
had the *same* Retry On Fail gap as WF1 (its 15s timeout was already set, but retries
were off — the earlier note that "WF2 already had this" was wrong on the retry half).
Fixed live the same way (3 tries, 1000ms wait), published (version note "Add bounded
retries (3x) to the quality-scan HTTP call"). Repo definition and manifest checksum
synced (`n8n/workflows/fleet-ops-data-quality-scan.json`, `MANIFEST.md`, new checksum
`c0d46e0519118e6336e35c4ea2a67edb2f14bd007909ccf9256c93733751244a`, commit `167bf49`).
- **WF4 has the same gap on its own outbound call, but is currently un-fixable**: WF4's
"Report failure to Fleet Ops" HTTP node also has no timeout and no Retry On Fail. Began
the same fix (added a 15000ms Timeout option, toggled Retry On Fail on) but n8n's
autosave started failing with "Unauthorized" mid-edit, and a fresh tab confirmed the
n8n browser session had expired (redirected to `/signin`) — so nothing was saved and
the live WF4 definition is unchanged from before this round (no partial/broken state).
This is a minor, best-effort-only gap (WF4 is the error notifier itself, not a primary
business flow, and it already reports failures with `On Error: Stop Workflow` so a
failed error-report is visible in n8n's own execution history even without retries) —
not blocking, but worth finishing once someone re-authenticates the n8n browser
session.
- **RAGcore credential-issuance blocker root-caused and fixed (in RAGcore itself, with
explicit owner approval)**: with read access to the sibling `C:\Projects\RAGcore`
checkout, traced "authoritative service-account state rejected issuance" to a genuine
cross-transaction race in RAGcore's own dependency injection
(`src/ragcore/api/v1/control/dependencies.py`). `get_control_application` and
`get_credential_service` each independently opened their own `factory.begin()`
database transaction. Issuing a credential for a brand-new service account does, in one
request: (1) INSERT the service account via the first dependency's transaction, then
(2) immediately re-read it via the second dependency's *separate, uncommitted* transaction
— invisible under READ COMMITTED isolation until the first transaction commits, which
only happens after the endpoint returns. This made every fresh-service-account credential
issuance fail, 100% of the time, via both the raw API and the admin UI (explaining the
identical failure signature on both paths). RAGcore's own tests never caught this because
they override these dependencies with an in-memory fake that ignores transaction boundaries
entirely. Fixed by introducing one shared, cached `get_control_session` dependency that
both providers now depend on via `Depends(...)`, so they share one transaction per request.
Verified: RAGcore's own test suite (64 tests across `tests/web`, `tests/contract/api/control`,
`tests/security/identity`, `tests/unit/domain/control`, `tests/api`) passes, ruff and mypy
clean. Deployed to the live RAGcore instance (also on the Unraid host, `ragcore-app-1` on
port 1237 — a shared service also used by other ITWorx projects) via `docker compose build`
+ `up -d`, with explicit owner approval before both the code change and the deploy.
Confirmed fixed live: issuing a credential for `fleet-ops` (name
`n8n-ragcore-procedure-sync`, scope `sources:sync`) now succeeds (prefix `rc_sa_6fc51e`).
The plaintext token was never printed/logged — copied via RAGcore's own "Copy" button and
pasted directly into a new n8n Header Auth credential named **"RAGcore Sync Token"**
(header `Authorization: Bearer <token>`), ready for workflow 3.
- **Exact next action (superseded by the entry below)**: task #86 (build workflow 3,
RAGcore Procedure Sync) and the `RAGcoreKnowledgeProvider` adapter rewrite (to the real
inspected contract — `/health/live`, `/health/ready`, `POST /v1/uploads`, `POST
/v1/search`/`/v1/context`/`/v1/answers`) are now unblocked — the "RAGcore Sync Token" n8n
credential exists and works. WF4's own timeout/retry gap is still open pending n8n browser
re-authentication (minor, non-blocking, see above). Both #90 and #91 should be revisited
once workflow 3 is actually built, since they currently document it as blocked.
- **Second RAGcore bug found, fixed and deployed (with explicit owner approval, same pattern
as the transaction-race fix above)**: with the "RAGcore Sync Token" credential in hand,
the n8n "Upload to RAGcore" node still returned a persistent 401 on every item. Traced via
direct RAGcore source inspection (`C:\Projects\RAGcore`) to a genuine second, independent
gap: no code path in RAGcore converted an incoming `Authorization: Bearer <token>` header
into a `request.state.principal` for any `/v1/*` route — only browser session cookies were
ever accepted, even though the credential-verification logic
(`ServiceAccountCredentialService.verify()`) existed and was unit-tested. This blocks any
machine caller (n8n, and eventually Fleet Ops's own `RAGcoreKnowledgeProvider` adapter)
from ever authenticating to `/v1/uploads`. Fixed additively, scoped to `/v1/uploads` only
per owner instruction (search/context/answers left for later): new
`CredentialRepository.get_by_id()` (Postgres + in-memory), new
`ServiceAccountCredentialService.authenticate()` (parallel to the existing `verify()`, not
a refactor of it), and a new `get_upload_principal` FastAPI dependency
(`src/ragcore/api/v1/uploads/dependencies.py`) that falls back to the Bearer header when
there is no session principal, wired into `uploads/routes.py` in place of the session-only
`get_principal`. New/updated tests in `tests/security/identity/test_credentials.py` and
`tests/security/uploads/test_upload_security.py` (bearer-token accept/reject paths, the
existing route test's stale `get_principal` override fixed to `get_upload_principal`).
Verified: RAGcore's own test suite — 377 passed in the affected `tests/security`,
`tests/unit`, `tests/api` trees (3 unrelated pre-existing failures: two need Windows
symlink privileges the sandbox doesn't have, one is a git-connector fixture mismatch; a
separate architecture-boundary failure in `application/ingestion/handler.py` belongs to
unrelated in-progress work by a different concurrent agent on the same RAGcore checkout,
confirmed via `git status`/`git log` — not touched by this fix). Ruff and mypy clean on
every changed file. Deployed to the live RAGcore instance (`ragcore-app-1` on Unraid, port
1237 internally, fronted by `rag.itworx.tech` — note the *admin UI* and the *API* share
one process/origin, `/v1/uploads` is reachable at `https://rag.itworx.tech/v1/uploads`,
**not** `ragcore.itworx.tech`, which only appears in RFC7807 problem-type URLs) by copying
the 6 changed source files directly into the server checkout and `docker compose build
app && up -d --no-deps app` (deliberately not committing to RAGcore's git history or
touching the `worker` service, since a different agent has substantial unrelated
uncommitted work in that same working tree). Confirmed live with a garbage token (still
correctly 401) and then with a freshly-issued, correctly-scoped real token (403
`UPLOAD_TARGET_FORBIDDEN` against a dummy space ID — i.e. authentication succeeded,
authorization correctly rejected the wrong space — proving the fix end-to-end before
touching n8n at all).
- **Root cause of the n8n-side 401 found and fixed**: separately from the RAGcore bug above,
the "Upload to RAGcore" HTTP node's Authentication was set to Header Auth, but **no
credential had ever actually been attached** to that picker — so the node was sending no
`Authorization` header at all, which produces the identical 401 to a malformed one (easy
to conflate with the RAGcore-side bug, which is why fixing RAGcore alone didn't resolve
the symptom). There was already an unused "RAGcore Sync Token" n8n credential sitting
around from the earlier session (its value likely never actually got saved when it was
first created, or was created but never selected on this node — not conclusively
determined). Owner attached it and set Name=`Authorization`,
Value=`Bearer <freshly-issued token, scope sources:sync>` (a new credential issued via the
RAGcore admin UI at `/admin/control/applications/c20ac48a-d57b-4c68-9bd1-564f49c1a473/credentials/new`
specifically for this, service account "n8n Procedure Sync (production)"; the earlier
diagnostic-only credential used to prove the RAGcore fix was revoked afterward via direct
SQL `UPDATE identity.service_account_credentials SET revoked_at = now() ...` since the
admin UI has no revoke button).
- **Workflow 3's "Upload to RAGcore" node live-validated end-to-end, real data**: ran the
full workflow via n8n's "Execute workflow" (Schedule Trigger → List procedures → Prepare
uploads → Upload to RAGcore). All 33 items succeeded — each output item is a real
`AcceptedJob` (`job_id`/`status_url`), not error output. Independently confirmed at the
database level (not just trusting the n8n UI): `select count(*) from jobs.jobs where
operation='ingest_upload' and created_at > now() - interval '5 minutes'` → **33**, on the
live RAGcore Postgres.
- **n8n browser-automation notes for this environment** (worth knowing before attempting
canvas interaction again): (1) an n8n NPS survey modal (`role=dialog`, "We've been busy")
intermittently covers the whole canvas and silently eats every click underneath it until
removed; (2) canvas node positions reported by `getBoundingClientRect()` drift between
successive tool calls in a way that made coordinate-based `computer` clicks and even
`find`-ref-based clicks land on the wrong element repeatedly this session (dozens of failed
attempts, multiple different coordinate-math theories, none reliable) — directly setting
`.vue-flow__transformationpane`'s inline `style.transform` to force a node into view
**desyncs vue-flow's own internal pan/zoom state**, making the problem worse, not better;
(3) what actually worked reliably every time: calling native `.click()` directly via JS on
a plain `<button>` element (e.g. the toolbar's "Execute workflow" button) — canvas *node*
interaction (double-click to open a node's parameter panel) was never reliably achieved
via any automated method this session; the owner opened/edited the node manually instead.
A plain synthetic `dispatchEvent(new MouseEvent(...))` sequence (pointerdown/mousedown/
pointerup/mouseup/click/dblclick, even with correct `clientX`/`clientY`/`detail`) does
**not** trigger vue-flow's node click handling at all — it appears to require a genuinely
trusted (real CDP-driven) pointer event, consistent with vue-flow's drag/zoom gesture
system depending on native pointer capture.
- **Exact next action (superseded further below)**: build and wire the workflow's final
"Summarize sync result" Code node (count successes/failures across the 33 items) and a
closing HTTP node reporting to Fleet Ops's already-deployed `POST
/api/v1/integrations/n8n/procedures-sync-result` (task #86, still in progress — the sync
itself now works, this is the last piece). Then publish the workflow (currently still a
draft/unpublished), update `n8n/workflows/MANIFEST.md` and add
`n8n/workflows/fleet-ops-ragcore-procedure-sync.json` as the repo source-of-truth
definition, and revisit `artifacts/live-ai-integration/final-summary.md` (documents WF3
as blocked — no longer true).
- **Verified the 33-item sync is genuinely fully ingested, not just accepted**: checked at
every RAGcore pipeline stage on the live instance, not just trusting the n8n "success"
status (which can mask individual failures under `On Error: Continue`). All 33
`ingest_upload` jobs have `status='succeeded'` in `jobs.jobs`; 33 rows exist in
`content.documents`; 372 chunks were generated in `content.chunks`; 241 vectors are
indexed in the `rag_dense_nomic-embed-text_v1` Qdrant collection filtered specifically to
Fleet Ops's `space_id` (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`) — matching the expected
leaf-chunk count (structural/parent chunks in the hierarchy aren't separately embedded).
11 unique procedures × 3 languages = 33, all present.
- **`RAGcoreKnowledgeProvider` rewritten against the real contract (task #93), tested, but
NOT switched on in production — genuinely blocked on a RAGcore-side gap, not a Fleet Ops
problem**: rewrote `backend/app/services/knowledge/ragcore.py` end to end against
RAGcore's actual `/v1/answers` and `/health/ready` contracts (previously a best-effort
guess against an unreachable instance). Health now calls the real `GET /health/ready`.
`ask()` calls `POST /v1/answers` with `Authorization: Bearer <token>` and
`requested_space_ids: [settings.ragcore_space_id]`, maps RAGcore's `answerability` enum
conservatively (`answerable`/`partially_answerable` *with* non-empty citations →
`grounded`, everything else → `insufficient`, any transport/parse/non-200 failure →
`unavailable`, matching the architecture's never-fabricate rule). Added
`RAGCORE_SPACE_ID` setting (`.env.example`, `compose.yaml`). New tests in
`backend/tests/test_knowledge.py` (connection-error degrade, missing-space-id short
circuits without a network call, health ready/degraded/unreachable, grounded citation
mapping, not-answerable and answerable-without-citations both correctly map to
`insufficient` with an empty answer, non-200 and malformed-body both degrade to
`unavailable`) — **23 passed** in that file, **172 passed** full suite, ruff clean, mypy
0 issues/50 files.
**Discovered while live-testing against RAGcore with a freshly-issued, correctly-scoped
credential** (`Fleet Ops Knowledge Assistant (production)`, scope `answer`, space
`f4c91e49-...`): authentication now genuinely succeeds (past the 401 stage — confirmed
via `/health/ready` returning `200 {"status":"ok", ...}` with the same token), but **both
`POST /v1/answers` and `POST /v1/search` return `503`
`ANSWERS_UNAVAILABLE`/`SEARCH_UNAVAILABLE`** for every request. Root-caused by reading
`src/ragcore/main.py`'s app-startup/lifespan code directly: it constructs and assigns
`app.state.database_engine`, `session_factory`, `qdrant_client`,
`query_lab_service`, `profile_activation_service`, and the OIDC session services — but
**never constructs or assigns `app.state.search_application`, `context_application`, or
`answer_application`** anywhere in the codebase (confirmed via a repo-wide grep — the
three `get_*_application` dependency functions exist and correctly raise their `503
*_UNAVAILABLE` problem when the state attribute is absent, exactly as designed, but
nothing ever populates it). This is not a config toggle Fleet Ops is missing and not
something introduced by today's auth fixes — the retrieval/generation subsystem's route
handlers and dependencies are scaffolded end-to-end but were never wired into the running
application on this RAGcore deployment. Only masked until today because every call to
these routes previously 401'd on auth before ever reaching this check.
**Decision: `KNOWLEDGE_PROVIDER` stays `demo` in production.** Flipping it to `ragcore`
right now would replace the currently-working demo Knowledge Assistant with one that
correctly, honestly, but uselessly reports "unavailable" for every question — strictly
worse for the live demo. The adapter code itself is finished, correct, and safe to ship
(already committed-worthy), and switching providers is a one-line env var flip
(`KNOWLEDGE_PROVIDER=ragcore` + set `RAGCORE_API_TOKEN`/`RAGCORE_SPACE_ID`) the moment
RAGcore's own operator wires up `search_application`/`answer_application` on their side.
The freshly-issued credential (`Fleet Ops Knowledge Assistant (production)`, scope
`answer`) was left active/unused in RAGcore, ready for that day.
- **MCP Hub registration (task #94/#95) — full new connector built and validated locally
in the sibling `C:\Projects\ITWorx_MCP_Hub` checkout, not committed or deployed**: the
Hub's admin UI (`mcp.itworx.tech`, real production instance, 7 pre-existing projects —
DevRunbook, ForgeFlow×2, General Infrastructure, GeoIntel, Ludarium) has **no self-service
"add project/connector" flow** — its own Settings page states "No writable settings are
available in this browser until the control API publishes an authorized configuration
schema" (Hub is on release `1.0.0-rc`, UI is read-only/observability-only today).
Registering MobilityOps therefore required building a genuinely new connector in the
Hub's own repo, following its `gitea` connector as the closest real template (external
HTTPS API, shared-secret auth) rather than `knowledge` (which turned out to be an
in-memory filesystem index, not an HTTP client, despite the name suggesting otherwise).
Built, with explicit owner approval given the Hub's own `CLAUDE.md` restricts autonomous
action to local-checkout work only (registry pushes and production startup are separate,
explicitly-gated checkpoints, not done this round):
- `packages/connector_kit/mobilityops.py` — `MobilityOpsSettings`/`MobilityOpsClient`
(HTTPS-only, same-origin-redirect-enforced, `X-Service-Token`/`X-Client-Id` headers,
path-safety-validated `vehicle_ref`), mirroring `gitea.py`'s hardening exactly.
- `connectors/mobilityops/{__init__,server,fake}.py` — `MobilityOpsConnector` exposing 4
read-only tools (`mobilityops.operations.summary`, `.attention.list`, `.vehicle.get`,
`.knowledge.search`), each `governed_tool`-wrapped, envelope-wrapped, field-mapped from
Fleet Ops's real `/api/v1/integrations/mcp/*` response shapes.
- `services/runtime/connector_mobilityops.py` + `dispatch.py` registration.
- Catalog: `catalog/connectors/mobilityops-default.yaml` (ConnectorTemplate),
`catalog/tools/mobilityops-*.yaml` (4 ToolManifests), `catalog/capability-packs/
mobilityops-reader.yaml` (a **new**, narrowly-scoped pack — deliberately not added to
the shared `project-reader` pack other projects use, to avoid granting them
MobilityOps access), `catalog/projects/mobilityops.yaml` (Project, `applicationUrl`
set to the real internal `http://192.168.10.150:1236`, not an invented public domain).
- Compose wiring across all three files (`docker-compose.yml`, `.prod.yml`,
`.blueprint.yml`) plus a new `mobilityops_service_token` Docker secret.
- Fixed ~13 regressions this surfaced in the Hub's own existing test suite — all
legitimate guard-rail tests (hardcoded service/tool/secret allowlists, a
`CONNECTOR_GATEWAYS`/`FakeContextForge._gateway_tools` registration gap that was a
**real production wiring miss**, not just a test-fixture gap: without it, the reconcile
step would have raised `"virtual server resolved to an empty tool allowlist"` against
the live Hub too) — not scope creep, this is exactly the Hub's own established pattern
for registering a new connector, verified by reading how `gitea`/`knowledge`/`unraid`
each touch the same ~15 files.
- Verified: 15 new connector tests pass; full Hub suite **383 passed, 0 failed, 5 skipped
(pre-existing)**; `ruff check`/`ruff format --check` clean; `mypy` clean across 108
source files; `scripts/check_boundaries.py` passes (import-direction rules respected —
the new connector only imports from `packages/connector_kit`, never `services/*`
directly); `scripts/validate_pack.py` schema-validates the catalog cleanly (68
documents, up from 61 — exactly the 7 new files) with the only remaining failure being
the Hub's own git-cleanliness gate for manifest regeneration, which requires a commit.
- **Not done, deliberately**: no commit (the Hub repo has substantial unrelated
in-progress work from a different concurrent agent — `WP-235` frontend redesign,
`BUILD_STATE.json`/`CURRENT_STATE.md`/`implementation/WORK_PLAN.json` all show as
modified by them, not by this session — committing broadly risks entangling that
work); no registry push; no production deployment/restart of the live
`itworx-mcp-hub-*` stack. Per the Hub's own `CLAUDE.md` approval boundaries, those are
separate, explicitly-gated checkpoints requiring their own fresh approval, and a real
`MOBILITYOPS_ENDPOINT`/`mobilityops_service_token`/`MOBILITYOPS_PROJECTS_JSON`
production configuration still needs to be decided before any of that could run.
- **MCP Hub connector — committed** as `f107544` ("Add MobilityOps (Fleet Ops) read-only
connector", 28 files) in `C:\Projects\ITWorx_MCP_Hub`, `main` branch. Not pushed to the
registry and not deployed to the live Hub stack — those remain separate, explicitly-gated
checkpoints per the Hub's own `CLAUDE.md` approval boundaries.
- **RAGcore search/context/answer application wiring (task #96) — implemented, tested,
committed as `a2905cc`** in `C:\Projects\RAGcore`, `main` branch (not pushed, not
deployed to the live RAGcore instance). This closes the gap documented above:
`search_application`/`context_application`/`answer_application` are now actually
constructed in `create_app()` instead of being permanently absent.
- `src/ragcore/application/retrieval/production_executor.py` (new) — `RetrievalPipeline`
(embed via Ollama -> dense+sparse Qdrant search -> RRF fuse -> rerank via Ollama),
reusing the same flow already proven by the Query Lab's `PipelineQueryLabExecutor`
(left untouched). Feeds `ProductionSearchExecutor` and `ProductionContextExecutor` from
one shared per-request run, so `SearchHit`'s `FusedCandidate` (required by
`SearchService`'s own provenance/scope check) and `ContextCandidate`'s parent-chunk
expansion both derive from the same reranked result set instead of drifting apart.
- `src/ragcore/application/models/answer_generator.py` (new) — `OllamaAnswerGenerator`,
`AnswerService`'s real generator: resolves the active `GenerationProfile` from
`GenerationProfileRegistry` (already built, never previously constructed anywhere) and
calls the existing hardened `OllamaGenerationAdapter`. `main.py`'s `lifespan()` now
seeds and activates a default local-Ollama answer profile on first boot if none is
active, since no seed data ever registered one.
- Verified: 10 new unit tests (7 for the pipeline/executors, 3 for the answer generator)
pass; `ruff check` clean; `mypy src` clean (the only 4 mypy errors found are in
`infrastructure/qdrant/adapter.py`, `infrastructure/qdrant/filters.py`,
`api/v1/control/routes.py` — files this change never touched, confirmed via `git
status`/`git diff`, pre-existing or from other concurrent work in this checkout).
- Full non-integration suite (`pytest -m "not integration"`, excludes tests requiring
disposable infra per the repo's own marker convention): **593 passed, 14 failed, 1
skipped**. All 14 failures are pre-existing and unrelated to this change: 10 are
Windows-only (`psycopg`'s async driver rejects Windows' default `ProactorEventLoop` —
`test_health.py`, `test_readiness.py`, `test_control_plane.py`), 2 are Windows-only
(`WinError 1314`, no symlink privilege on this account — `test_folder_connector.py`), 1
is an unrelated pre-existing git-security-connector assertion, and 1 is the repo's
zero-tolerance `test_domain_and_application_respect_dependency_boundaries` test —
already red before this change, since `application/ingestion/handler.py` (untouched,
commit `bb75f52`) already imports `httpx`/infrastructure modules from the application
layer, which that test forbids with no allowlist. This change's new files follow the
same established (if already-violating) pattern to reach real adapters, adding more
instances of the same pre-existing violation rather than introducing a new kind of one.
- **Deployed to production** (`http://192.168.10.150:1237` via `ragcore-app-1`),
approved by the user. `scp`'d the 4 changed/new files individually to
`/mnt/user/appdata/ragcore/app/...`, `docker compose build app`, verified the built
image actually contains the new code (`docker run --rm ragcore-app grep`/`test -f`),
then `docker compose up -d --no-deps app` (only the `app` service touched). Startup
logs are clean (`ragcore_started environment=production`, no errors); `/health/ready`
reports `postgres`/`qdrant`/`storage` all `ok`.
- **Live-verified the fix itself**: `POST /v1/search` now returns `401
AUTHENTICATION_REQUIRED` for an unauthenticated call, not the old permanent `503
SEARCH_UNAVAILABLE` — proof the endpoint reaches real request handling instead of
hitting an absent application state, which is exactly the bug this closes.
- **Issued a fresh scoped credential** (`answer` scope, application `Fleet Ops`, via the
RAGcore admin UI at `rag.itworx.tech` — a separate, working OIDC session, unaffected by
the n8n auth problem above) and made a real authenticated `POST /v1/answers` call
against the `Fleet Ops Procedures` knowledge space (`f4c91e49-5cf9-48ba-b3d6-e0e9854ebccc`).
Got a clean `200`, a real `answer_id`/`retrieval_run_id`, `degraded: false` — but
`answerability: not_answerable`, 0 citations, for two different real questions matching
real seeded document titles.
- **Confirmed this is not a regression from this change**: the same query against the
same space through RAGcore's own pre-existing, already-proven Query Lab tool (which
uses `PipelineQueryLabExecutor`, code this session never touched) returns the identical
`NOT_ANSWERABLE` / 0 evidence / not degraded result — down to `Dense candidates (0)` and
`Sparse candidates (0)` at the raw retrieval stage, before fusion or rerank ever runs.
Whatever is causing zero matches lives in shared retrieval infrastructure or the data
itself, not in the new production executors.
- **Ruled out the obvious causes via direct Qdrant/Postgres checks** (not yet root-caused
further — flagging as a separate follow-up, not blocking this task): the space
genuinely has 83 published, correctly-scoped points in `rag_dense_nomic-embed-text_v1`
(right `workspace_id`, right `space_id`, `status: published`, real Dutch-language
procedure text); the points' `embedding_model_digest` exactly matches the currently
active embedding profile's digest (no stale-embedding mismatch); the collection alias
(`rag_dense_nomic-embed-text_active`) correctly resolves to that same collection. One
oddity noted in passing, unrelated to retrieval: at least one sampled chunk has
`language: "en"` in its payload despite the actual text being Dutch — worth a look if
language-filtered queries matter for the demo.
- **Decision on `KNOWLEDGE_PROVIDER=ragcore` in Fleet Ops**: still deliberately `demo`.
The application is deployed, correctly wired, and behaves identically to RAGcore's own
trusted reference implementation — but real questions against real seeded content
aren't returning grounded answers yet for a reason that traces to shared
retrieval/data, not to this wiring. Flip only after that's root-caused.
- **n8n workflow 3 (task #86) — incident, recovered; final 2 nodes still blocked on a
live n8n auth problem, not a code problem.** Returning to finish the "Summarize sync
result" + report-to-Fleet-Ops nodes found the live "Fleet Ops — RAGcore Procedure Sync"
workflow's canvas at **zero nodes** — the earlier session's abandoned attempt to call
n8n's REST API directly (the "unexplained 401" noted above) had gone through far enough
to wipe the live workflow before the 401 stopped it, leaving an empty, unsaved "Current
changes" draft on top of the last good save.
- **Recovered**: n8n's own Version History (`/workflow/.../history`) still had the last
good save, version `260b38b5` (Aug 4, 21:08), with all 4 real nodes intact (Schedule
Trigger -> List procedures -> Prepare uploads -> Upload to RAGcore) plus the build
sticky note. Used the history panel's own **Restore version** action (not a manual
rebuild) to bring the live workflow back to that exact state. Confirmed via the page's
DOM (`[data-test-id="canvas-node"]` count) both before (0) and after (4) — the canvas
itself render fully off-screen (nodes positioned at negative Y coordinates, a separate
display-only vue-flow pan bug worked around by directly setting the transform pane's
CSS transform; harmless, doesn't touch saved data).
- **Then blocked again attempting the 2 new nodes**: adding a Code node via the UI's own
"What happens next?" panel (a real, first-party n8n interaction, not the REST API)
immediately surfaced n8n's own autosave toast: **"Problem saving workflow — Autosave
failed: Unauthorized."** A full sign-out/sign-in cycle right before this (fresh
credentials, fresh page load) did not fix it — immediately after a successful
interactive "Sign in", `fetch('/rest/workflows/...')` from that same authenticated tab
still returned 401. This is not the ordinary "your session expired, log in again"
friction seen earlier in the session; it's the live n8n instance's own save path
rejecting a request made moments after a successful login, which is exactly the
failure mode that caused the wipe above. Continuing to add nodes under this condition
risks losing work again with no guarantee the next failure is as recoverable, so this
was stopped deliberately rather than retried. The workflow was left in its safely
restored, 4-node, unpublished state — verified via the DOM node count immediately
before closing the session — nothing was added or changed beyond the restore.
- **Not a code/workflow-design problem**: the 2 remaining nodes (a Code node
summarizing the sync result, and an HTTP node reporting to Fleet Ops's already-live
`POST /api/v1/integrations/n8n/procedures-sync-result`) were never built — this
blocked before any node configuration happened. The design itself (mirroring WF2/WF4's
existing report-result pattern) is unchanged from earlier planning.
- **n8n workflow 3 — root cause found and fixed; both remaining nodes now built and
saved.** The user pushed back on the "auth problem" framing (n8n visibly showed signed
in), which was the right call — dug into the live n8n container's own logs
(`docker logs n8n`) rather than continuing to guess from the browser side, and found the
real cause: `browserId check failed on /rest/workflows/:workflowId`, alongside an
express-rate-limit warning about `X-Forwarded-For` being present while Express `trust
proxy` is `false`. n8n's Unraid template
(`/boot/config/plugins/dockerMan/templates-user/my-n8n.xml`) had `N8N_PROXY_HOPS=0`
despite genuinely running behind the TLS-terminating reverse proxy at
`n8n.itworx.tech` — with proxy trust disabled, n8n couldn't correctly resolve the
request as HTTPS, which broke its CSRF-style browserId cookie check on every
workflow-mutating REST call (autosave, but not plain page loads, which explains why the
UI looked fully logged in the whole time).
- **Fix, approved by the user beforehand** (shared instance, brief restart): backed up
the template to a timestamped `.bak`, changed `N8N_PROXY_HOPS` `0` -> `1`, then
recreated the `n8n` container on the Unraid host (stop, rename to
`n8n_pre_proxyhops_fix` as an instant rollback, `docker run` with every existing env
var/volume/port/label preserved exactly plus the one corrected value). All 3
previously-active workflows (Vehicle Return Orchestration, Scheduled Data Quality
Scan, Workflow Error Handler) re-activated cleanly on the new container — nothing
lost. Verified the fix by reproducing the exact save action that used to fail
(adding a node via n8n's own UI): no more "Unauthorized" toast, and the added node
survived a full page reload. `docker logs n8n` shows zero `browserId`/401 entries
since the fix.
- **Mid-fix-verification, a real node got deleted by an errant `Ctrl+A`** (browser
focus landed on the canvas instead of a text field during cleanup, selecting and then
deleting the "List procedures" node). Caught it via a node-count check, used n8n's own
Version History **Restore version** to the last good save (not a manual rebuild), then
redid the 2 new nodes carefully (verifying focus before every `Ctrl+A` this time). No
data lost, just redone.
- **Built**: `Summarize sync result` (Code node, JS) — counts `synced`/`failed` from
`$input.all()` by checking each item's `json.error` (matches "Upload to RAGcore"'s own
`On Error: Continue` setting, confirmed by inspecting that node's Settings tab, so
failed uploads land in the same output stream with an `error` field rather than a
separate branch). `Report sync result to Fleet Ops` (HTTP Request, POST) — targets the
already-live `/api/v1/integrations/n8n/procedures-sync-result`, reusing the existing
"Fleet Ops Service Token" Header Auth credential (same one WF4 already uses against
this same backend), JSON body `{execution_id, synced, failed}` matching
`ProcedureSyncResultIn` exactly.
- **Saved, not published.** All 6 nodes (Schedule Trigger -> List procedures -> Prepare
uploads -> Upload to RAGcore -> Summarize sync result -> Report sync result to Fleet
Ops) confirmed present after a full page reload. The Schedule Trigger is configured
for once daily at midnight -- publishing activates real, live, unattended runs against
production RAGcore and Fleet Ops, so this was deliberately left for a separate,
explicit approval rather than done automatically.
- **Exact next action**: (1) RAGcore search/answer wiring (`a2905cc`) is deployed and
proven correctly wired (matches Query Lab's trusted behavior exactly) — the open item is
now root-causing why `Fleet Ops Procedures` returns zero dense/sparse candidates for
real questions despite having genuinely matching, correctly-scoped, correctly-embedded
indexed content (see evidence above); this is retrieval/data, not application wiring.
Only flip `KNOWLEDGE_PROVIDER=ragcore` once that's fixed and a real grounded answer comes
back. (2) n8n workflow 3 is fully built and saved (all 6 nodes) but **not published** —
decide whether to publish it (starts real daily runs against production) and, once
live-verified end to end, add its file to `n8n/workflows/` and `MANIFEST.md` as the
source of truth alongside the other 3 workflows. (3) MCP Hub: registry push + production
deployment of the now-committed connector remain separate, explicitly-gated checkpoints.
WF4's own timeout/retry gap remains open, deferred, non-blocking.
## Branch push and Unraid redeploy (2026-08-05)
- Pushed `feat/live-n8n-ragcore-integration` to `origin` (Gitea on the same Unraid host),
now tracking `origin/feat/live-n8n-ragcore-integration` (`0571a40`).
- **Redeployed the live Fleet Ops instance** (`http://192.168.10.150:1236`) from this
branch, bringing it from the previously-deployed `0da5251` up to `0571a40` — 5 commits,
the meaningful one being the `RAGcoreKnowledgeProvider` rewrite (`e5d8466`); the rest
are `PROJECT_STATE.md`-only evidence commits.
- Followed the deployment directory's own established archive convention
(`deploy/unraid/README.md`: "deployed from a committed source archive"): `git archive`
of `HEAD` as `source-0571a40.tar.gz`, matching the naming pattern of the existing
archives already in `/mnt/user/appdata/mobilityops/.deploy/`; `scp`'d it there;
extracted over the deployment directory excluding `.env` and `.deploy` itself (so the
live secrets file and archive history were never touched); updated
`.deploy/source-revision` to the new full commit hash, matching its existing format.
- `docker compose -p mobilityops -f compose.yaml -f compose.unraid.yaml up --build -d db
api web` — `api` image rebuilt and the container recreated (picked up the new code);
`web` rebuilt too but didn't need recreating (no frontend changes this branch); `db`
untouched. Migrations ran automatically in the `api` entrypoint with no errors.
- **Verified live**: `/health` returns `200` from inside the `api` container; the new
`/api/v1/integrations/n8n/procedures` endpoint (added this branch, needed for n8n
workflow 3) is reachable through the public web proxy and correctly enforces auth
(`422`, missing `X-Service-Token`, not a `404` — proves the route exists and is live,
not just that the proxy responds). `KNOWLEDGE_PROVIDER=demo` confirmed unchanged in the
live `.env` (only extracted new source files, never touched it) — the demo Knowledge
Assistant is still what's live, exactly as intended.
- No seed reset run (this was an update to a running instance with real accumulated
demo data, not the initial deploy — re-seeding would have been destructive and wasn't
warranted by anything in this branch's changes).
- **Not done**: no PR opened/merged to `master` — the user asked for commit, push, and
redeploy, not a merge; `master` is untouched and still 19 commits behind this branch.
+14 -4
View File
@@ -1,8 +1,18 @@
# MobilityOps
# Fleet Ops
**Connected operations 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.
Fleet Ops 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.
**Naming:** "Fleet Ops" is the product's visible name everywhere in the UI, the demo
knowledge base, and this documentation. "MobilityOps" remains the technical
identifier only — the repository name, local directory, package/module names, Docker
Compose project, deployment directory, and database names. The UI is fully trilingual
(nl-BE default, en-GB, fr-BE); see `docs/fleet-ops-correction/` for the localization
architecture, the vehicle-status decision table, and the correction evidence, and
`docs/fleet-ops-final-localization/` for the follow-up correction round (remaining
NL/FR translation gaps, centralized API-error localization, the time-dependent
Europe/Brussels dashboard greeting).
The web application uses the premium responsive **Control Rail** interface: a compact
operations-first workspace with persisted readiness metrics, evidence-led exceptions,
@@ -114,9 +124,9 @@ All defaults are configurable via `.env` (see `.env.example`).
## Quality gates
```bash
make test # backend: pytest (127 tests)
make test # backend: pytest (151 tests)
make lint # backend: ruff + mypy (strict, zero errors)
make e2e # frontend: Playwright end-to-end (56 tests, live stack required)
make e2e # frontend: Playwright end-to-end (138 tests, live stack required)
```
Frontend build/typecheck: `cd frontend && npm run build` (`tsc -b && vite build`).
@@ -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.
@@ -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')
+2
View File
@@ -89,6 +89,8 @@ def preview_return(
resulting_odometer_km=evaluation.resulting_odometer_km,
resulting_vehicle_status=evaluation.resulting_vehicle_status,
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,
attention_reasons=evaluation.attention_reasons,
next_booking_risk=(
+1
View File
@@ -108,6 +108,7 @@ def get_dashboard(
status=r.delivery_status,
attempts=r.attempts,
last_error=r.last_error,
last_error_code=r.last_error_code,
occurred_at=r.occurred_at,
)
for r in recent
+32 -2
View File
@@ -11,6 +11,7 @@ from app.models.data_quality import DataQualityIssue
from app.models.inspection import Inspection
from app.models.vehicle import Vehicle
from app.schemas import (
ApplyRecommendedStatusRequest,
ApplyRecommendedStatusResult,
CurrentUser,
DataQualityIssueDetailOut,
@@ -21,11 +22,14 @@ from app.schemas import (
ResolveOdometerRegressionRequest,
ResolveOverlapRequest,
ScanResultOut,
StatusRecommendationOut,
VehicleStatusFactsOut,
)
from app.services.data_quality import (
apply_recommended_status,
defer_issue,
merge_customers,
preview_vehicle_status_recommendation,
provide_missing_fields,
reject_issue,
resolve_booking_overlap,
@@ -241,17 +245,43 @@ def resolve_overlap(
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(
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
)
def apply_status(
public_ref: str,
body: ApplyRecommendedStatusRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(require_operations_manager),
) -> 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(
issue=_to_out(issue), applied_status=applied_status, reason=reason
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
)
+132 -1
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, Header
@@ -13,9 +14,18 @@ from app.core.config import get_settings
from app.core.errors import AppError
from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent
from app.schemas import ScanResultOut
from app.schemas import (
ProcedureDocumentOut,
ProcedureListOut,
ProcedureSyncResultIn,
ProcedureSyncResultResult,
ScanResultOut,
WorkflowErrorReportIn,
WorkflowErrorReportResult,
)
from app.services.audit import record_audit_event
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"])
settings = get_settings()
@@ -89,3 +99,124 @@ def scheduled_scan(
result = run_scan(db, actor_label="n8n scheduled scan", actor_type="service")
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."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
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."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
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."""
if service_token != settings.n8n_callback_token:
raise AppError("UNAUTHORIZED_SERVICE", "Invalid service token.", status_code=401)
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),
)
+58 -29
View File
@@ -12,53 +12,81 @@ from app.schemas import CurrentUser, SearchResponse, SearchResultItem
router = APIRouter(prefix="/api/v1/search", tags=["search"])
# Static application sections. 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.
# Static application sections. `id` is a stable code matching navigation.json's
# `items.*` keys -- the frontend localizes both the section label and its one-line
# 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] = [
{
"label": "Overview",
"detail": "Operations dashboard",
"id": "overview",
"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",
"detail": "Vehicle registry",
"id": "fleet",
"link": "/vehicles",
"terms": ["fleet", "vehicle", "vehicles"],
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
},
{
"label": "Bookings",
"detail": "Rental bookings",
"id": "bookings",
"link": "/bookings",
"terms": ["booking", "bookings", "rental"],
"terms": [
"booking",
"bookings",
"rental",
"boeking",
"boekingen",
"verhuur",
"réservation",
"réservations",
"location",
],
},
{
"label": "Data quality",
"detail": "Quality workbench",
"id": "quality",
"link": "/data-quality",
"terms": ["quality", "data quality", "issues"],
"terms": [
"quality",
"data quality",
"issues",
"kwaliteit",
"datakwaliteit",
"problemen",
"qualité",
"problèmes",
],
"role": "operations_manager",
},
{
"label": "Knowledge",
"detail": "Procedure assistant",
"id": "knowledge",
"link": "/knowledge",
"terms": ["knowledge", "procedures"],
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
},
{
"label": "Integrations",
"detail": "Automation and integration status",
"id": "integrations",
"link": "/automation",
"terms": ["automation", "integrations", "systems", "n8n"],
"terms": [
"automation",
"integrations",
"systems",
"n8n",
"automatisering",
"integraties",
"systemen",
"automatisation",
"intégrations",
"systèmes",
],
"role": "operations_manager",
},
{
"label": "Audit trail",
"detail": "Audit history",
"id": "audit",
"link": "/audit",
"terms": ["audit", "history"],
"terms": ["audit", "history", "geschiedenis", "historique"],
"role": "operations_manager",
},
]
@@ -83,8 +111,8 @@ def search(
results.append(
SearchResultItem(
type="section",
label=section["label"],
detail=section["detail"],
label=section["id"],
detail_code=section["id"],
link=section["link"],
)
)
@@ -108,7 +136,8 @@ def search(
SearchResultItem(
type="vehicle",
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}",
)
)
@@ -120,7 +149,7 @@ def search(
SearchResultItem(
type="booking",
label=b.public_ref,
detail=b.status,
detail_code=b.status,
link=f"/bookings/{b.public_ref}",
)
)
@@ -138,7 +167,7 @@ def search(
SearchResultItem(
type="data_quality_issue",
label=i.public_ref,
detail=i.rule_type.replace("_", " "),
detail_code=i.rule_type,
link=f"/data-quality/{i.public_ref}",
)
)
+1
View File
@@ -23,6 +23,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
status=event.delivery_status,
attempts=event.attempts,
last_error=event.last_error,
last_error_code=event.last_error_code,
occurred_at=event.occurred_at,
)
+8
View File
@@ -2,6 +2,12 @@ from functools import lru_cache
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):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@@ -15,8 +21,10 @@ class Settings(BaseSettings):
ragcore_workspace: str = "mobilityops"
ragcore_collection: str = "internal-procedures"
ragcore_api_token: str = ""
ragcore_space_id: str = ""
ragcore_http_timeout_seconds: float = 5.0
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_dispatch_enabled: bool = True
n8n_dispatch_interval_seconds: float = 3.0
+2 -2
View File
@@ -19,7 +19,7 @@ from app.api.routers import (
vehicles,
workflows,
)
from app.core.config import get_settings
from app.core.config import PRODUCT_NAME, get_settings
from app.core.errors import AppError, error_body
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
@@ -33,7 +33,7 @@ async def lifespan(_app: FastAPI):
stop_background_dispatcher()
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan)
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
+5
View File
@@ -26,4 +26,9 @@ class OutboxEvent(TimestampMixin, Base):
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
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))
+93 -2
View File
@@ -83,6 +83,8 @@ class ReturnPreviewResult(BaseModel):
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int] = {}
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: NextBookingRisk | None
@@ -141,6 +143,53 @@ class ScanResultOut(BaseModel):
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):
fields: dict[str, str]
@@ -157,16 +206,41 @@ class ResolveOverlapRequest(BaseModel):
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):
issue: DataQualityIssueOut
applied_status: str
reason: str
reason_code: str
class SearchResultItem(BaseModel):
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
label: str
detail: str
detail_code: str
detail_params: dict[str, str] = {}
link: str
@@ -175,6 +249,18 @@ class SearchResponse(BaseModel):
results: list[SearchResultItem]
class N8nWorkflowEvidence(BaseModel):
name: str
built: bool
last_seen_at: datetime | None
class N8nErrorHandlerStatus(BaseModel):
total_failures_registered: int
latest_failure_at: datetime | None
latest_failure_workflow: str | None
class N8nIntegrationStatus(BaseModel):
configured: bool
dispatch_enabled: bool
@@ -185,6 +271,10 @@ class N8nIntegrationStatus(BaseModel):
succeeded: int
latest_success_at: datetime | None
latest_failure_at: datetime | None
expected_workflow_count: int
known_workflow_count: int
workflows: list[N8nWorkflowEvidence]
error_handler: N8nErrorHandlerStatus
class McpHubIntegrationStatus(BaseModel):
@@ -269,6 +359,7 @@ class AutomationRunOut(BaseModel):
status: str
attempts: int
last_error: str | None
last_error_code: str | None
occurred_at: datetime
+41 -4
View File
@@ -4,6 +4,7 @@ import csv
import uuid
from dataclasses import dataclass
from datetime import UTC, date, datetime, timedelta
from difflib import SequenceMatcher
from pathlib import Path
from sqlalchemy import delete, insert, update
@@ -108,11 +109,11 @@ def load_seed(db: Session) -> SeedResult:
customer_id_by_ref: dict[str, uuid.UUID] = {}
customer_rows = []
customer_row_by_ref: dict[str, dict] = {}
for row in _read_csv("customers.csv"):
cid = uuid.uuid4()
customer_id_by_ref[row["public_ref"]] = cid
customer_rows.append(
{
customer_row = {
"id": cid,
"public_ref": row["public_ref"],
"first_name": row["first_name"],
@@ -122,7 +123,8 @@ def load_seed(db: Session) -> SeedResult:
"postal_code": row["postal_code"] 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)
counts["customers"] = len(customer_rows)
# Second pass for merged_into (self-referencing FK) since target must exist first.
@@ -223,11 +225,42 @@ def load_seed(db: Session) -> SeedResult:
return "customer", customer_id_by_ref[entity_ref]
return "vehicle", vehicle_id_by_ref[entity_ref]
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 []
dq_rows = []
now = datetime.now(UTC)
for row in _read_csv("data_quality_issues.csv"):
entity_type, entity_id = resolve_entity(row["entity_ref"])
related_ref = row.get("related_ref") or ""
related_refs = related_ref.split("|") if related_ref else []
dq_rows.append(
{
"id": uuid.uuid4(),
@@ -240,7 +273,8 @@ def load_seed(db: Session) -> SeedResult:
"evidence_json": {
"summary": row["evidence"],
"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": {},
"detected_at": now,
@@ -287,6 +321,9 @@ def load_seed(db: Session) -> SeedResult:
"attempts": int(row["attempts"]),
"next_attempt_at": 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.
"last_error_code": "connectionError" if row["last_error"] else None,
"external_run_id": None,
}
)
+120 -81
View File
@@ -15,6 +15,13 @@ from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
from app.services.audit import record_audit_event
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_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
@@ -69,6 +76,7 @@ def _open_issue(
summary: str,
entity_ref: str,
related_refs: list[str],
signals: list[dict] | None = None,
) -> None:
if _has_open_issue(db, rule_type, entity_type, entity_id):
return
@@ -87,10 +95,14 @@ def _open_issue(
)
.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 = {
"summary": summary,
"entity_ref": entity_ref,
"related_refs": related_refs,
"signals": signals or [],
}
if previous is not None:
evidence["reopened_from"] = previous.public_ref
@@ -121,22 +133,29 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals = []
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append("exact email")
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("exact phone")
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("exact postal code")
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("similar name")
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
@@ -146,9 +165,10 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(signals) + f" (score {score})",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
@@ -170,6 +190,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=customer.public_ref,
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():
@@ -185,6 +206,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
summary=f"Missing: {', '.join(missing)}",
entity_ref=vehicle.public_ref,
related_refs=[],
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
)
@@ -213,39 +235,32 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
entity_ref=vehicle.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:
# 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()
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:
has_active_booking = vehicle.id in active_by_vehicle
reason = None
if vehicle.operational_status == "available" and has_active_booking:
reason = "marked available while an active booking exists"
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"
facts = gather_vehicle_status_facts(db, vehicle)
recommendation = evaluate_vehicle_status(vehicle, facts)
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
continue
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(
db,
scan,
@@ -253,9 +268,13 @@ def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
entity_type="vehicle",
entity_id=vehicle.id,
severity="high",
summary=f"Vehicle {reason}",
summary=summary,
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,
)
@@ -295,6 +314,17 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
),
entity_ref=vehicle.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
@@ -650,28 +680,7 @@ def resolve_booking_overlap(
return issue
def _recommend_vehicle_status(
operational_status: str, has_active_booking: bool, has_open_high_issue: bool
) -> tuple[str, str] | None:
"""The single authoritative recommendation function for vehicle_status_conflict,
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]:
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
issue = _load_open_issue(db, public_ref)
if issue.rule_type != "vehicle_status_conflict":
raise AppError(
@@ -679,47 +688,77 @@ def apply_recommended_status(
"This issue is not a vehicle_status_conflict issue.",
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)
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())
if vehicle is None:
raise AppError(
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
)
has_active_booking = (
db.scalar(
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
recommendation = evaluate_vehicle_status(vehicle, facts)
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 = (
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:
if recommendation.recommended_status is None:
raise AppError(
"NO_CONFLICT_DETECTED",
"The current vehicle state no longer conflicts; nothing to apply.",
status_code=409,
)
new_status, reason = recommendation
new_status = recommendation.recommended_status
reason_code = recommendation.recommendation_code
before = {"operational_status": vehicle.operational_status}
vehicle.operational_status = new_status
vehicle.version += 1
# Re-validate: the same recommendation function must find no further conflict.
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None:
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
# 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(
"CONFLICT_STILL_PRESENT",
"Applying the recommended status did not resolve the conflict.",
@@ -737,7 +776,7 @@ def apply_recommended_status(
correlation_id=correlation_id,
before=before,
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"
@@ -755,7 +794,7 @@ def apply_recommended_status(
after={"status": "resolved"},
)
db.commit()
return issue, new_status, reason
return issue, new_status, reason_code
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
+24
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"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
)[:2000]
row.last_error_code = "staleLeaseRecovered"
db.commit()
return len(rows)
finally:
@@ -111,22 +112,43 @@ def _deliver_one(event_id: uuid.UUID) -> None:
finally:
db.close()
error_code: str | None
if wire_event is None:
success, error, body = False, payload_error, None
error_code = "malformedPayload"
else:
try:
response = httpx.post(
settings.n8n_webhook_url,
json=wire_event,
headers={"X-Fleet-Ops-Trigger-Token": settings.n8n_webhook_trigger_token},
timeout=settings.n8n_http_timeout_seconds,
)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = None
if isinstance(body, dict):
success = bool(body.get("ok", True))
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 = (
"Unexpected non-JSON-object response from n8n "
f"(status {response.status_code})"
)
error_code = "malformedResponse"
except httpx.HTTPError as exc:
success = False
error = f"{type(exc).__name__}: {exc}"
error_code = "connectionError"
body = None
db = SessionLocal()
@@ -138,10 +160,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
if success:
event.delivery_status = "succeeded"
event.last_error = None
event.last_error_code = None
event.next_attempt_at = None
event.external_run_id = str((body or {}).get("event_id", event_id))
else:
event.last_error = (error or "delivery failed")[:2000]
event.last_error_code = error_code or "unknownError"
if event.attempts >= settings.n8n_max_attempts:
event.delivery_status = "failed"
event.next_attempt_at = None
+65 -1
View File
@@ -6,11 +6,21 @@ from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import get_settings
from app.models.audit import AuditEvent
from app.models.outbox import OutboxEvent
from app.schemas import N8nIntegrationStatus
from app.schemas import N8nErrorHandlerStatus, N8nIntegrationStatus, N8nWorkflowEvidence
settings = get_settings()
# The 4 canonical Fleet Ops n8n workflows (see n8n/workflows/MANIFEST.md). Workflow 3
# (RAGcore Procedure Sync) is not built yet, so it always reports no evidence.
_CANONICAL_WORKFLOWS = (
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
)
def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
counts: dict[str, int] = dict(
@@ -42,6 +52,52 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
else:
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",
)
)
# 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
)
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": None,
"Fleet Ops — Workflow Error Handler": latest_handler_failure_at,
}
workflows = [
N8nWorkflowEvidence(
name=name,
built=name != "Fleet Ops — RAGcore Procedure Sync",
last_seen_at=evidence_by_workflow[name],
)
for name in _CANONICAL_WORKFLOWS
]
return N8nIntegrationStatus(
configured=bool(settings.n8n_webhook_url),
dispatch_enabled=settings.n8n_dispatch_enabled,
@@ -52,4 +108,12 @@ def derive_n8n_status(db: Session) -> N8nIntegrationStatus:
succeeded=succeeded,
latest_success_at=latest_success_at,
latest_failure_at=latest_failure_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,
),
)
+22 -23
View File
@@ -7,6 +7,7 @@ from pathlib import Path
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge.procedures import parse_frontmatter
SUPPORTED_LANGUAGES = ("nl-BE", "en-GB", "fr-BE")
DEFAULT_LANGUAGE = "en-GB"
@@ -35,7 +36,11 @@ STOPWORDS_BY_LANGUAGE: dict[str, set[str]] = {
},
}
_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:
@@ -70,23 +75,6 @@ class ScoredSection:
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]]:
sections: list[tuple[str, str]] = []
current_heading = "Overview"
@@ -110,7 +98,7 @@ def _load_sections(procedures_dir: Path, language: str) -> list[ScoredSection]:
sections: list[ScoredSection] = []
for path in sorted(procedures_dir.glob("*.md")):
raw = path.read_text(encoding="utf-8")
meta, body = _parse_frontmatter(raw)
meta, body = parse_frontmatter(raw)
title = meta.get("title", path.stem)
doc = Document(
document_id=meta.get("document_id", path.stem),
@@ -218,17 +206,28 @@ class DemoKnowledgeProvider:
def _score(
self, query_tokens: set[str], section: ScoredSection, idf: dict[str, 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
for token in query_tokens:
token_idf = idf.get(token, 0.0)
if token_idf == 0.0:
continue
if token in section.heading_tokens:
if token in section.body_tokens:
score += 3 * token_idf
elif token in section.document.title_tokens:
elif token in section.heading_tokens:
score += 2 * token_idf
elif token in section.body_tokens:
score += token_idf
elif token in section.document.title_tokens:
score += 1.5 * token_idf
return score
def ask(
@@ -0,0 +1,71 @@
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
+64 -42
View File
@@ -3,18 +3,29 @@ from __future__ import annotations
import httpx
from app.core.config import get_settings
from app.services.knowledge import GroundedAnswer, KnowledgeHealth, SourceCard
from app.services.knowledge import EvidenceState, GroundedAnswer, KnowledgeHealth, SourceCard
_GROUNDED_ANSWERABILITY = {"answerable", "partially_answerable"}
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).
No live RAGcore instance was reachable during this build, so the exact request/response
shape below is a best-effort guess at a REST contract; any failure (connection, timeout,
malformed response) degrades to `unavailable` rather than raising, per the architecture's
reliability boundary: RAGcore failure disables knowledge answers only, never the rest of
the app, and never fabricates an answer.
Authenticates as a service account via `Authorization: Bearer <token>` (RAGcore's
session-cookie auth is for its own browser admin UI only). Any connection error,
timeout, non-2xx response, or malformed body degrades to `evidence_state:
"unavailable"` rather than raising -- this is the adapter that actually exercises the
architecture's reliability boundary: RAGcore failure disables knowledge answers only,
never fabricates an answer, never affects the rest of the app.
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"
@@ -35,11 +46,15 @@ class RAGcoreKnowledgeProvider:
def health(self, language: str = "en-GB") -> KnowledgeHealth:
try:
with self._client() as client:
response = client.get("/health")
response.raise_for_status()
available = True
detail = "RAGcore reachable."
except httpx.HTTPError as exc:
response = client.get("/health/ready")
body = response.json()
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')}"
)
except (httpx.HTTPError, ValueError) as exc:
available = False
detail = f"RAGcore unavailable: {type(exc).__name__}: {exc}"
return KnowledgeHealth(
@@ -49,51 +64,58 @@ class RAGcoreKnowledgeProvider:
tenant=self._settings.ragcore_tenant,
workspace=self._settings.ragcore_workspace,
collection=self._settings.ragcore_collection,
# RAGcore's retrieval API has no corpus-size endpoint to query honestly from
# here; left at 0 rather than approximated from a capped search result count.
document_count=0,
)
def ask(self, question: str, correlation_id: str, language: str = "en-GB") -> GroundedAnswer:
unavailable = GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
if not self._settings.ragcore_space_id:
return unavailable
try:
with self._client() as client:
response = client.post(
"/api/v1/ask",
"/v1/answers",
json={
"tenant": self._settings.ragcore_tenant,
"workspace": self._settings.ragcore_workspace,
"collection": self._settings.ragcore_collection,
"question": question,
"correlation_id": correlation_id,
"language": language,
"query": question,
"requested_space_ids": [self._settings.ragcore_space_id],
},
)
response.raise_for_status()
if response.status_code != 200:
return unavailable
body = response.json()
except (httpx.HTTPError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
provider=self.name,
correlation_id=correlation_id,
)
return unavailable
try:
sources = [SourceCard(**s) for s in body.get("sources", [])]
evidence_state = body.get("evidence_state", "insufficient")
if evidence_state not in ("grounded", "insufficient", "unavailable"):
evidence_state = "insufficient"
citations = {c["id"]: c for c in body.get("citations", [])}
sources = [
SourceCard(
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()
]
answerability = body.get("answerability", "not_answerable")
is_grounded = answerability in _GROUNDED_ANSWERABILITY and sources
evidence_state: EvidenceState = "grounded" if is_grounded else "insufficient"
return GroundedAnswer(
answer=body.get("answer", ""),
answer=body.get("answer", "") if evidence_state == "grounded" else "",
evidence_state=evidence_state,
sources=sources,
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, ValueError):
return GroundedAnswer(
answer="",
evidence_state="unavailable",
sources=[],
sources=sources if evidence_state == "grounded" else [],
provider=self.name,
correlation_id=correlation_id,
)
except (TypeError, KeyError, ValueError):
return unavailable
+34 -7
View File
@@ -28,19 +28,41 @@ def _next_public_ref(db: Session) -> str:
def _derive_vehicle_status_with_reason(
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:
return "blocked", "Damage and a technical warning were both reported on return."
return (
"blocked",
"returnBlockedDamageAndTechnical",
{},
)
if body.damage_reported:
return "blocked", "Damage was reported on return."
return "blocked", "returnBlockedDamage", {}
if body.technical_warning:
return "blocked", "A technical warning was reported on return."
return "blocked", "returnBlockedTechnicalWarning", {}
if new_odometer >= vehicle.next_service_km:
return (
"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
@@ -51,6 +73,8 @@ class ReturnEvaluation:
resulting_odometer_km: int
resulting_vehicle_status: str
status_reason: str
status_reason_code: str
status_reason_params: dict[str, str | int]
would_create_quality_issue: bool
attention_reasons: list[str]
next_booking_risk: dict | None
@@ -64,9 +88,10 @@ def evaluate_return(
preview and commit can never drift apart."""
odometer_regression = body.end_odometer_km < vehicle.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
)
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
attention_reasons = []
if body.damage_reported:
@@ -101,6 +126,8 @@ def evaluate_return(
resulting_odometer_km=resulting_odometer_km,
resulting_vehicle_status=resulting_status,
status_reason=status_reason,
status_reason_code=status_reason_code,
status_reason_params=status_reason_params,
would_create_quality_issue=odometer_regression,
attention_reasons=attention_reasons,
next_booking_risk=next_booking_risk,
+218
View File
@@ -0,0 +1,218 @@
"""The single authoritative vehicle-status evaluator.
Used by the data-quality scanner (detection), the status-recommendation preview
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
recommendation can never structurally disagree (see docs/fleet-ops-correction/
vehicle-status-decision-table.md for the full decision table and rationale).
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
never from a proxy like "does some other high-severity issue happen to be open". It is
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
the same vehicle never changes what this function returns, because it never looks at
issue history, only at the vehicle's/bookings' current state.
"""
from __future__ import annotations
import hashlib
import json
import uuid
from dataclasses import dataclass, field
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.vehicle import Vehicle
# Every code below is a stable, localizable identifier -- see
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
# human-language mapping in all three supported locales. The backend never emits prose.
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
@dataclass
class VehicleStatusFacts:
active_booking_refs: list[str] = field(default_factory=list)
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
service_threshold_reached: bool = False
odometer_km: int = 0
next_service_km: int = 0
open_booking_overlap_issue_ref: str | None = None
@property
def has_active_rental(self) -> bool:
return len(self.active_booking_refs) > 0
@property
def has_booking_conflict(self) -> bool:
return (
len(self.overlapping_booking_pairs) > 0
or self.open_booking_overlap_issue_ref is not None
)
def as_dict(self) -> dict:
return {
"active_booking_refs": self.active_booking_refs,
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
"service_threshold_reached": self.service_threshold_reached,
"odometer_km": self.odometer_km,
"next_service_km": self.next_service_km,
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
}
@dataclass
class VehicleStatusRecommendation:
current_status: str
recommended_status: str | None
recommendation_code: str
safe_to_apply: bool
manual_review_required: bool
facts: VehicleStatusFacts
blocking_reasons: list[str]
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
ordered = sorted(bookings, key=lambda b: b.starts_at)
pairs: list[tuple[Booking, Booking]] = []
for i, first in enumerate(ordered):
for second in ordered[i + 1 :]:
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
pairs.append((first, second))
return pairs
def gather_vehicle_status_facts(
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
) -> VehicleStatusFacts:
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
derived from another issue's mere existence (only a *specific* booking_overlap
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
reserved_or_active = list(
db.scalars(
select(Booking).where(
Booking.vehicle_id == vehicle.id,
Booking.status.in_(["reserved", "active"]),
)
).all()
)
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
overlap_pairs = [
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
]
overlap_issue_query = select(DataQualityIssue.public_ref).where(
DataQualityIssue.entity_type == "vehicle",
DataQualityIssue.entity_id == vehicle.id,
DataQualityIssue.status == "open",
DataQualityIssue.rule_type == "booking_overlap",
)
if exclude_issue_id is not None:
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
open_overlap_ref = db.scalar(overlap_issue_query)
return VehicleStatusFacts(
active_booking_refs=active_refs,
overlapping_booking_pairs=overlap_pairs,
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
odometer_km=vehicle.odometer_km,
next_service_km=vehicle.next_service_km,
open_booking_overlap_issue_ref=open_overlap_ref,
)
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
"""A short digest of exactly the facts the recommendation was based on, plus the
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
facts and rejects the request if it doesn't match the token the client last saw --
the frontend must never assume a previously-shown preview is still valid without the
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
return digest[:16]
def evaluate_vehicle_status(
vehicle: Vehicle, facts: VehicleStatusFacts
) -> VehicleStatusRecommendation:
"""Pure decision logic over already-gathered facts -- see
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
never queries the database itself (call gather_vehicle_status_facts first), so it is
trivial to unit-test every branch in isolation."""
current = vehicle.operational_status
blocking_reasons: list[str] = []
if facts.service_threshold_reached:
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
if facts.has_booking_conflict:
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
# Already being in maintenance is itself a real blocking fact -- an active
# booking never overrides it. This is exactly the forbidden shortcut this
# evaluator must never take (maintenance + active booking -> auto "rented").
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
def result(
recommended: str | None, code: str, *, safe: bool, manual: bool
) -> VehicleStatusRecommendation:
return VehicleStatusRecommendation(
current_status=current,
recommended_status=recommended,
recommendation_code=code,
safe_to_apply=safe,
manual_review_required=manual,
facts=facts,
blocking_reasons=blocking_reasons,
)
if facts.has_active_rental and not blocking_reasons:
if current == "rented":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
)
if facts.has_active_rental and blocking_reasons:
# Explicitly forbidden shortcut this evaluator must never take: an active
# booking is not proof the vehicle should be "rented" when a real blocking
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
# This is a real contradiction in the underlying facts, not something safe to
# resolve automatically.
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
if facts.service_threshold_reached:
if current == "maintenance":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
)
if facts.has_booking_conflict:
if current == "blocked":
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
)
# No active rental, no maintenance need, no booking conflict.
if current in ("available", "cleaning", "blocked"):
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
if current == "rented":
return result(
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
)
if current == "maintenance":
# No positive fact confirms maintenance is actually finished (no completed
# service record is tracked here) -- clearing "maintenance" without such a
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
# Releasing a vehicle from maintenance remains an explicit, manual decision.
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
+140 -5
View File
@@ -249,28 +249,74 @@ def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
assert booking["status"] == "blocked"
def test_apply_recommended_status_requires_operations_manager(employee_client):
def test_status_recommendation_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status"
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation"
)
assert response.status_code == 403
def test_apply_recommended_status_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status",
json={"recommendation_token": "irrelevant"},
)
assert response.status_code == 403
def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
preview_response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
)
assert preview_response.status_code == 200
preview = preview_response.json()
assert preview["current_status"] == vehicle_before["operational_status"]
assert preview["recommendation_token"]
assert "facts" in preview
# Calling preview again (as the UI would on every open) must still not mutate.
ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation")
issue_after = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert issue_after["status"] == "open"
assert vehicle_after["operational_status"] == vehicle_before["operational_status"]
def test_apply_recommended_status_resolves_conflict(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
preview = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
).json()
assert preview["safe_to_apply"] is True
assert preview["manual_review_required"] is False
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status"
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
json={"recommendation_token": preview["recommendation_token"]},
)
assert response.status_code == 200
body = response.json()
assert body["issue"]["status"] == "resolved"
assert body["applied_status"]
assert body["reason"]
assert body["applied_status"] == preview["recommended_status"]
assert body["reason_code"] == preview["recommendation_code"]
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
assert vehicle["operational_status"] == body["applied_status"]
def test_apply_recommended_status_rejects_stale_token(ops_client):
target = _first_open(ops_client, "vehicle_status_conflict")
response = ops_client.post(
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
json={"recommendation_token": "not-a-real-token"},
)
assert response.status_code == 409
assert response.json()["error"]["code"] == "RECOMMENDATION_STALE"
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
response = employee_client.post(
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
@@ -365,6 +411,95 @@ def test_manual_scan_records_audit_event(ops_client):
assert "created" in events[0]["metadata"]
def _reset_demo(ops_client) -> None:
# /api/v1/demo/reset deletes the session cookie (the reset recreates the users
# table, so the old session's user id no longer exists) -- the caller must log back
# in before making any further authenticated call with the same client.
response = ops_client.post("/api/v1/demo/reset")
assert response.status_code == 200, response.text
login_response = ops_client.post(
"/api/v1/demo/login", json={"role": "operations_manager"}
)
assert login_response.status_code == 200, login_response.text
def _resolve_overlap_issue(ops_client, *, booking_to_block: str) -> None:
overlap = _first_open(ops_client, "booking_overlap")
response = ops_client.post(
f"/api/v1/data-quality/issues/{overlap['public_ref']}/resolve-overlap",
json={"booking_ref": booking_to_block},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "resolved"
def _first_open_for_vehicle(ops_client, rule_type: str, vehicle_ref: str) -> dict:
issues = ops_client.get(
"/api/v1/data-quality/issues", params={"rule_type": rule_type, "status": "open"}
).json()
match = next((i for i in issues if i["entity_ref"] == vehicle_ref), None)
assert match, f"expected an open {rule_type} issue for {vehicle_ref}"
return match
def _apply_status_recommendation(ops_client, public_ref: str) -> dict:
preview = ops_client.post(
f"/api/v1/data-quality/issues/{public_ref}/status-recommendation"
).json()
apply_response = ops_client.post(
f"/api/v1/data-quality/issues/{public_ref}/apply-recommended-status",
json={"recommendation_token": preview["recommendation_token"]},
)
assert apply_response.status_code == 200, apply_response.text
return apply_response.json()
def test_mo_016_status_conflict_recommendation_is_order_independent(ops_client):
# MO-016 carries both a booking_overlap (DQ-DEMO-OVERLAP) and a vehicle_status_conflict
# (DQ-DEMO-STATUS) issue at once. Order independence does NOT mean "the same final
# vehicle status regardless of order" -- resolving the overlap first genuinely removes
# the conflict, so there is correctly nothing left to apply. What must hold in either
# order: the recommendation always reflects the real, current facts (never a stale
# "was some other issue open" proxy), and nothing unsafe is ever applied (never
# "rented", never a status change once the underlying condition has already resolved
# itself). See docs/fleet-ops-correction/current-gap-audit.md §6-7 and
# vehicle-status-decision-table.md.
# Order A: resolve the booking overlap first. The status-conflict issue's own
# recommendation must now correctly report that the conflict is gone -- nothing unsafe
# should be auto-applied, and the vehicle (never touched) stays exactly as it was.
_reset_demo(ops_client)
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
status_issue_a = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
preview_a = ops_client.post(
f"/api/v1/data-quality/issues/{status_issue_a['public_ref']}/status-recommendation"
).json()
assert preview_a["recommendation_code"] == "vehicle.no_conflict"
assert preview_a["recommended_status"] is None
assert preview_a["safe_to_apply"] is False
vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_a["operational_status"] == "available"
# Order B: resolve the status conflict first, while the overlap is still open -- the
# conflict genuinely still exists, so the evaluator must still detect it and safely
# resolve it (never "rented").
_reset_demo(ops_client)
status_issue_b = _first_open_for_vehicle(ops_client, "vehicle_status_conflict", "MO-016")
result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"])
assert result_b["applied_status"] != "rented"
vehicle_b_mid = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_b_mid["operational_status"] == result_b["applied_status"]
# Resolving the now-redundant overlap afterwards must not itself change the vehicle's
# status as a side effect.
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json()
assert vehicle_b["operational_status"] == result_b["applied_status"]
assert vehicle_b["operational_status"] != "rented"
_reset_demo(ops_client)
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
# next scan re-detects the same unresolved condition -- it must not silently vanish
+37 -6
View File
@@ -67,7 +67,7 @@ def test_deliver_one_success(monkeypatch):
event_id = _make_pending_event("MO-002")
dispatcher._claim_due_events()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
@@ -81,13 +81,14 @@ def test_deliver_one_success(monkeypatch):
assert event.attempts == 1
assert event.external_run_id == str(event_id)
assert event.last_error is None
assert event.last_error_code is None
def test_deliver_one_failure_schedules_retry(monkeypatch):
event_id = _make_pending_event("MO-003")
dispatcher._claim_due_events()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise dispatcher.httpx.ConnectError("simulated connection failure")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -98,13 +99,41 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
assert event.attempts == 1
assert event.next_attempt_at is not None
assert "simulated connection failure" in event.last_error
assert event.last_error_code == "connectionError"
def test_deliver_one_treats_empty_2xx_body_as_failure(monkeypatch):
# Reproduces a real failure mode found while live-validating the n8n webhook auth
# fix: a workflow that errors internally before its "Respond to Webhook" node runs
# can still answer with a 2xx status and an empty body. response.json() on that body
# raises json.JSONDecodeError -- this must be treated as a retryable failure, not an
# unhandled exception that leaves the event stuck in "delivering" forever.
event_id = _make_pending_event("MO-005")
dispatcher._claim_due_events()
def fake_post(url, json, headers, timeout):
def raise_json_error():
raise ValueError("Expecting value: line 1 column 1 (char 0)")
return SimpleNamespace(
raise_for_status=lambda: None, json=raise_json_error, status_code=200
)
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
dispatcher._deliver_one(event_id)
event = _get_event(event_id)
assert event.delivery_status == "pending"
assert event.attempts == 1
assert event.next_attempt_at is not None
assert event.last_error_code == "malformedResponse"
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
event_id = _make_pending_event("MO-004")
settings = get_settings()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise dispatcher.httpx.ConnectError("still down")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -147,7 +176,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
finally:
db.close()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
raise AssertionError("must not attempt delivery with a malformed payload")
monkeypatch.setattr(dispatcher.httpx, "post", fake_post)
@@ -159,6 +188,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
assert event.delivery_status in ("pending", "failed")
assert event.attempts == 1
assert "Malformed outbox payload" in event.last_error
assert event.last_error_code == "malformedPayload"
def test_claim_sets_a_lease_deadline():
@@ -208,6 +238,7 @@ def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
assert event.next_attempt_at is None
assert event.attempts == 2
assert "stale" in event.last_error.lower()
assert event.last_error_code == "staleLeaseRecovered"
# The reclaimed event is now a normal pending event, immediately claimable again.
claimed = dispatcher._claim_due_events()
@@ -225,7 +256,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
finally:
db.close()
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
@@ -241,7 +272,7 @@ def test_run_dispatch_cycle_recovers_a_stale_lease_before_claiming(monkeypatch):
def test_run_dispatch_cycle_end_to_end(monkeypatch):
event_id = _make_pending_event("MO-005")
def fake_post(url, json, timeout):
def fake_post(url, json, headers, timeout):
return SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {"ok": True, "event_id": str(event_id), "result": {}},
+79
View File
@@ -39,3 +39,82 @@ def test_integration_status_is_operational_once_all_failed_events_resolved(ops_c
body = response.json()["n8n"]
assert body["failed"] == 0
assert body["state"] == "operational"
def test_integration_status_lists_all_four_canonical_workflows(ops_client):
body = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert body["expected_workflow_count"] == 4
names = {w["name"] for w in body["workflows"]}
assert names == {
"Fleet Ops — Vehicle Return Orchestration",
"Fleet Ops — Scheduled Data Quality Scan",
"Fleet Ops — RAGcore Procedure Sync",
"Fleet Ops — Workflow Error Handler",
}
ragcore_sync = next(w for w in body["workflows"] if "RAGcore" in w["name"])
assert ragcore_sync["built"] is False
assert ragcore_sync["last_seen_at"] is None
def test_integration_status_scheduled_scan_evidence_only_counts_service_runs(client, ops_client):
from app.core.config import get_settings
settings = get_settings()
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
scan_workflow = next(
w for w in before["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
)
assert scan_workflow["last_seen_at"] is None
scan = client.post(
"/api/v1/integrations/n8n/scheduled-scan",
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert scan.status_code == 200
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
scan_workflow = next(
w for w in after["workflows"] if w["name"].endswith("Scheduled Data Quality Scan")
)
assert scan_workflow["last_seen_at"] is not None
assert after["known_workflow_count"] > before["known_workflow_count"]
def test_integration_status_reflects_error_handler_registrations(client, ops_client):
import uuid
from app.core.config import get_settings
settings = get_settings()
before = ops_client.get("/api/v1/integrations/status").json()["n8n"]
execution_id = str(uuid.uuid4())
report = client.post(
"/api/v1/integrations/n8n/workflow-error",
json={
"workflow_id": "mobilityops-return-processing",
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
"execution_id": execution_id,
"failed_at": "2026-08-04T10:15:00Z",
"error_category": "httpError",
"error_summary": "Simulated failure for status test",
"trigger_context": "webhook",
"attempt": 1,
},
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert report.status_code == 200
after = ops_client.get("/api/v1/integrations/status").json()["n8n"]
assert (
after["error_handler"]["total_failures_registered"]
== before["error_handler"]["total_failures_registered"] + 1
)
assert after["error_handler"]["latest_failure_workflow"] == (
"Fleet Ops — Vehicle Return Orchestration"
)
handler_workflow = next(
w for w in after["workflows"] if w["name"].endswith("Workflow Error Handler")
)
assert handler_workflow["last_seen_at"] is not None
+135
View File
@@ -122,3 +122,138 @@ def test_scheduled_scan_is_idempotent_across_repeated_triggers(client):
assert first.status_code == 200
assert second.status_code == 200
assert second.json()["created"] == {}
def _workflow_error_body(execution_id: str, **overrides):
body = {
"workflow_id": "mobilityops-return-processing",
"workflow_name": "Fleet Ops — Vehicle Return Orchestration",
"execution_id": execution_id,
"failed_at": "2026-08-04T10:15:00Z",
"error_category": "httpError",
"error_summary": "Callback request failed with status 500",
"trigger_context": "webhook",
"correlation_id": None,
"attempt": 1,
"retry_action": "n8n will retry automatically",
}
body.update(overrides)
return body
def test_workflow_error_rejects_wrong_service_token(client):
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4())),
headers={"X-Service-Token": "wrong-token"},
)
assert response.status_code == 401
def test_workflow_error_rejects_unknown_category(client):
settings = get_settings()
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4()), error_category="somethingElse"),
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 422
def test_workflow_error_registers_and_is_idempotent_by_execution_id(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
execution_id = str(uuid.uuid4())
body = _workflow_error_body(execution_id)
first = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
second = client.post("/api/v1/integrations/n8n/workflow-error", json=body, headers=headers)
assert first.status_code == 200
assert first.json()["status"] == "registered"
assert second.status_code == 200
assert second.json()["status"] == "already_registered"
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "n8n_workflow_failure_registered"}
).json()
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
assert len(matching) == 1
assert matching[0]["after"]["error_category"] == "httpError"
assert matching[0]["after"]["retry_action"] == "n8n will retry automatically"
def test_workflow_error_bounds_summary_length(client):
settings = get_settings()
response = client.post(
"/api/v1/integrations/n8n/workflow-error",
json=_workflow_error_body(str(uuid.uuid4()), error_summary="x" * 501),
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 422
def test_procedures_rejects_wrong_service_token(client):
response = client.get(
"/api/v1/integrations/n8n/procedures", headers={"X-Service-Token": "wrong-token"}
)
assert response.status_code == 401
def test_procedures_lists_every_language_with_stable_ids(client):
settings = get_settings()
response = client.get(
"/api/v1/integrations/n8n/procedures",
headers={"X-Service-Token": settings.n8n_callback_token},
)
assert response.status_code == 200
documents = response.json()["documents"]
assert len(documents) > 0
assert {d["language"] for d in documents} == {"en-GB", "nl-BE", "fr-BE"}
checkout_docs = [d for d in documents if d["document_id"] == "vehicle-checkout-procedure"]
assert len(checkout_docs) == 3 # one per language
assert all(d["content"] and d["content_hash"] for d in checkout_docs)
# Same document_id, different language, must not collide on id.
assert len({d["id"] for d in checkout_docs}) == 3
second_response = client.get(
"/api/v1/integrations/n8n/procedures",
headers={"X-Service-Token": settings.n8n_callback_token},
)
second_ids = {d["id"] for d in second_response.json()["documents"]}
assert second_ids == {d["id"] for d in documents} # ids are stable across requests
def test_procedures_sync_result_rejects_wrong_service_token(client):
response = client.post(
"/api/v1/integrations/n8n/procedures-sync-result",
json={"execution_id": str(uuid.uuid4()), "synced": 5, "failed": 0},
headers={"X-Service-Token": "wrong-token"},
)
assert response.status_code == 401
def test_procedures_sync_result_registers_and_is_idempotent(client, ops_client):
settings = get_settings()
headers = {"X-Service-Token": settings.n8n_callback_token}
execution_id = str(uuid.uuid4())
body = {"execution_id": execution_id, "synced": 33, "failed": 1}
first = client.post(
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
)
second = client.post(
"/api/v1/integrations/n8n/procedures-sync-result", json=body, headers=headers
)
assert first.status_code == 200
assert first.json()["status"] == "registered"
assert second.status_code == 200
assert second.json()["status"] == "already_registered"
audit_events = ops_client.get(
"/api/v1/audit", params={"action": "n8n_procedures_synced"}
).json()
matching = [e for e in audit_events if e["metadata"]["execution_id"] == execution_id]
assert len(matching) == 1
assert matching[0]["after"] == {"synced": 33, "failed": 1}
+222 -4
View File
@@ -1,11 +1,51 @@
from __future__ import annotations
import re
from pathlib import Path
import httpx
from app.core.config import get_settings
from app.services.knowledge.demo import DemoKnowledgeProvider
from app.services.knowledge.ragcore import RAGcoreKnowledgeProvider
def test_brief_exact_damage_question_in_all_three_languages():
# The exact validation questions from docs/fleet-ops-correction/current-gap-audit.md
# -- each must ground on the damage procedure as its *primary* (top-ranked) source,
# not merely appear somewhere in the top-3, and the source/version/section/excerpt
# must all come from that same-language document (never an English fallback).
provider = DemoKnowledgeProvider()
cases = {
"nl-BE": "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
"en-GB": "What should I do when a vehicle returns with damage?",
"fr-BE": "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
}
for language, question in cases.items():
answer = provider.ask(question, f"test-brief-{language}", language)
assert answer.evidence_state == "grounded", language
assert answer.sources, language
assert answer.sources[0].document_id == "damage-procedure", (
f"{language}: expected the damage procedure as the primary source, "
f"got {answer.sources[0].document_id!r}"
)
assert answer.answer
assert answer.sources[0].excerpt
def test_knowledge_procedures_never_mention_mobilityops_or_poc():
# Section 2 of docs/fleet-ops-correction/current-gap-audit.md: the visible brand
# name is exactly "Fleet Ops", and "PoC" must never appear in visible content --
# including the demo knowledge base, not just the frontend.
procedures_dir = Path(get_settings().knowledge_dir)
offenders = []
for path in sorted(procedures_dir.glob("*/*.md")):
text = path.read_text(encoding="utf-8")
if "MobilityOps" in text or re.search(r"\bPoC\b", text):
offenders.append(str(path))
assert offenders == []
def test_s6_damage_question_is_grounded_with_expected_sources():
provider = DemoKnowledgeProvider()
answer = provider.ask(
@@ -118,12 +158,190 @@ def test_knowledge_status_endpoint(ops_client):
assert response.json()["provider"] == "demo"
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
def fake_client(*args, **kwargs):
raise httpx.ConnectError("no ragcore in this environment")
class _FakeResponse:
def __init__(self, status_code: int, body: dict):
self.status_code = status_code
self._body = body
def json(self) -> dict:
return self._body
class _FakeClient:
def __init__(self, get_response=None, post_response=None, raise_on=None):
self._get_response = get_response
self._post_response = post_response
self._raise_on = raise_on
def __enter__(self):
return self
def __exit__(self, *args):
return False
def get(self, path):
if self._raise_on == "get":
raise httpx.ConnectError("no ragcore in this environment")
return self._get_response
def post(self, path, json=None):
if self._raise_on == "post":
raise httpx.ConnectError("no ragcore in this environment")
return self._post_response
def test_ragcore_provider_degrades_to_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider, "_client", fake_client)
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="post"))
answer = provider.ask("Anything?", "test-correlation-3")
assert answer.evidence_state == "unavailable"
assert answer.sources == []
def test_ragcore_provider_without_configured_space_is_unavailable_without_a_network_call(
monkeypatch,
):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "")
def fail_if_called():
raise AssertionError("should not call RAGcore without a configured space id")
monkeypatch.setattr(provider, "_client", fail_if_called)
answer = provider.ask("Anything?", "test-correlation-no-space")
assert answer.evidence_state == "unavailable"
def test_ragcore_provider_health_reports_ready_status(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "ok"})),
)
health = provider.health()
assert health.provider == "ragcore"
assert health.available is True
def test_ragcore_provider_health_reports_degraded_status(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(get_response=_FakeResponse(200, {"status": "degraded"})),
)
health = provider.health()
assert health.available is False
def test_ragcore_provider_health_degrades_on_connection_error(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider, "_client", lambda: _FakeClient(raise_on="get"))
health = provider.health()
assert health.available is False
assert "unavailable" in health.detail.lower()
def _answers_body(**overrides) -> dict:
body = {
"answer": "Report damage and route the vehicle to maintenance.",
"answerability": "answerable",
"citations": [
{
"id": "cite-1",
"document_id": "doc-1",
"document_version_id": "version-1",
"title": "Damage handling procedure",
"section": "Detection",
"excerpt": "Inspect the vehicle for visible damage.",
}
],
}
body.update(overrides)
return body
def test_ragcore_provider_grounded_answer_maps_citations_to_sources(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, _answers_body())),
)
answer = provider.ask("What must I do about damage?", "test-correlation-grounded")
assert answer.evidence_state == "grounded"
assert answer.answer
assert len(answer.sources) == 1
source = answer.sources[0]
assert source.document_id == "doc-1"
assert source.title == "Damage handling procedure"
assert source.version == "version-1"
assert source.section == "Detection"
assert source.excerpt
def test_ragcore_provider_not_answerable_is_insufficient_and_never_fabricates(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200,
_answers_body(
answer="This should never be shown.",
answerability="not_answerable",
citations=[],
),
)
),
)
answer = provider.ask("Unrelated question?", "test-correlation-insufficient")
assert answer.evidence_state == "insufficient"
assert answer.answer == ""
assert answer.sources == []
def test_ragcore_provider_answerable_without_citations_is_insufficient(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(
post_response=_FakeResponse(
200, _answers_body(answerability="answerable", citations=[])
)
),
)
answer = provider.ask("What must I do about damage?", "test-correlation-no-citations")
assert answer.evidence_state == "insufficient"
assert answer.sources == []
def test_ragcore_provider_non_200_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(401, {"code": "AUTHENTICATION_REQUIRED"})),
)
answer = provider.ask("Anything?", "test-correlation-401")
assert answer.evidence_state == "unavailable"
def test_ragcore_provider_malformed_response_is_unavailable(monkeypatch):
provider = RAGcoreKnowledgeProvider()
monkeypatch.setattr(provider._settings, "ragcore_space_id", "space-1")
monkeypatch.setattr(
provider,
"_client",
lambda: _FakeClient(post_response=_FakeResponse(200, {"citations": "not-a-list"})),
)
answer = provider.ask("Anything?", "test-correlation-malformed")
assert answer.evidence_state == "unavailable"
+19 -1
View File
@@ -11,6 +11,10 @@ def test_search_finds_a_vehicle_by_reference(ops_client):
assert match is not None
assert match["label"] == "MO-001"
assert match["link"] == "/vehicles/MO-001"
# The backend must never send localizable prose -- only a stable code plus raw
# data params, so the frontend can render it in the operator's selected language.
assert match["detail_code"] == "vehicleSummary"
assert set(match["detail_params"]) == {"make", "model", "location"}
def test_search_finds_a_booking_by_reference(ops_client):
@@ -37,7 +41,21 @@ def test_search_never_returns_data_quality_issues_for_rental_employee(employee_c
def test_search_section_result_visible_to_operations_manager(ops_client):
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"])
match = next(
(r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None
)
assert match is not None
# Section results must ship a stable id, not English prose -- the frontend looks up
# navigation:items.<id> and search:sections.<id>.detail in the selected locale.
assert match["label"] == "audit"
assert match["detail_code"] == "audit"
def test_search_section_matches_dutch_and_french_terms(ops_client):
nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json()
assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"])
fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json()
assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_result["results"])
def test_search_section_result_hidden_from_rental_employee(employee_client):
+7 -2
View File
@@ -23,8 +23,12 @@ def test_seed_counts_match_deterministic_dataset():
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
assert db.scalar(select(func.count()).select_from(Customer)) == 180
assert db.scalar(select(func.count()).select_from(Booking)) == 246
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
# already crossed its service-due odometer threshold -- a genuine conflict the
# previous hand-rolled scanner never checked for.
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
assert db.scalar(select(func.count()).select_from(User)) == 2
finally:
@@ -138,6 +142,7 @@ def test_seed_scenario_s5_failed_workflow_run():
assert failed.delivery_status == "failed"
assert failed.attempts >= 1
assert failed.last_error
assert failed.last_error_code == "connectionError"
finally:
db.close()
+160
View File
@@ -0,0 +1,160 @@
"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since
evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See
docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these
tests are asserting against."""
from types import SimpleNamespace
from app.services.vehicle_status import (
RECOMMENDATION_CODE_ACTIVE_RENTAL,
RECOMMENDATION_CODE_BOOKING_CONFLICT,
RECOMMENDATION_CODE_MANUAL_REVIEW,
RECOMMENDATION_CODE_NO_CONFLICT,
RECOMMENDATION_CODE_RENTAL_ENDED,
RECOMMENDATION_CODE_SERVICE_THRESHOLD,
VehicleStatusFacts,
compute_recommendation_token,
evaluate_vehicle_status,
)
def _vehicle(status: str, *, version: int = 1):
return SimpleNamespace(operational_status=status, version=version)
def _facts(**overrides) -> VehicleStatusFacts:
defaults = dict(
active_booking_refs=[],
overlapping_booking_pairs=[],
service_threshold_reached=False,
odometer_km=10_000,
next_service_km=20_000,
open_booking_overlap_issue_ref=None,
)
defaults.update(overrides)
return VehicleStatusFacts(**defaults)
def test_available_with_active_rental_recommends_rented():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
)
assert result.recommended_status == "rented"
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
assert result.safe_to_apply is True
assert result.manual_review_required is False
def test_maintenance_with_active_rental_never_auto_recommends_rented():
# The exact unsafe shortcut this task explicitly forbids: maintenance + an active
# booking must NEVER be auto-resolved to "rented".
result = evaluate_vehicle_status(
_vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"])
)
assert result.recommended_status is None
assert result.manual_review_required is True
assert result.safe_to_apply is False
assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW
def test_available_with_active_rental_and_service_threshold_requires_manual_review():
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(active_booking_refs=["BK-0001"], service_threshold_reached=True),
)
assert result.manual_review_required is True
assert result.recommended_status is None
def test_available_with_active_rental_and_booking_conflict_requires_manual_review():
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"),
)
assert result.manual_review_required is True
assert result.recommended_status is None
def test_rented_with_no_active_booking_recommends_available():
result = evaluate_vehicle_status(_vehicle("rented"), _facts())
assert result.recommended_status == "available"
assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED
assert result.safe_to_apply is True
def test_service_threshold_reached_recommends_maintenance():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(service_threshold_reached=True)
)
assert result.recommended_status == "maintenance"
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy():
# The evaluator must react to a *real* booking-conflict fact, not "does some other
# open high-severity issue happen to exist" (the forbidden proxy).
result = evaluate_vehicle_status(
_vehicle("available"),
_facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]),
)
assert result.recommended_status == "blocked"
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
def test_open_booking_overlap_issue_alone_also_triggers_blocked():
result = evaluate_vehicle_status(
_vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP")
)
assert result.recommended_status == "blocked"
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual():
# No fact here confirms maintenance is actually finished, so the evaluator must not
# auto-clear it to "available" -- that release remains an explicit, manual decision.
result = evaluate_vehicle_status(_vehicle("maintenance"), _facts())
assert result.recommended_status is None
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
assert result.safe_to_apply is False
def test_no_conflict_when_status_already_matches_facts():
result = evaluate_vehicle_status(_vehicle("available"), _facts())
assert result.recommended_status is None
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
assert result.safe_to_apply is False
result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"]))
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
result = evaluate_vehicle_status(_vehicle("blocked"), _facts())
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
result = evaluate_vehicle_status(
_vehicle("maintenance"), _facts(service_threshold_reached=True)
)
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
def test_recommendation_token_changes_when_facts_change():
vehicle = _vehicle("available")
facts_a = _facts()
facts_b = _facts(service_threshold_reached=True)
assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token(
vehicle, facts_b
)
def test_recommendation_token_is_stable_for_identical_facts():
vehicle = _vehicle("available")
facts = _facts(active_booking_refs=["BK-0001"])
assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token(
vehicle, facts
)
def test_recommendation_token_changes_when_vehicle_version_changes():
facts = _facts()
assert compute_recommendation_token(
_vehicle("available", version=1), facts
) != compute_recommendation_token(_vehicle("available", version=2), facts)
+2
View File
@@ -31,7 +31,9 @@ services:
RAGCORE_WORKSPACE: ${RAGCORE_WORKSPACE:-mobilityops}
RAGCORE_COLLECTION: ${RAGCORE_COLLECTION:-internal-procedures}
RAGCORE_API_TOKEN: ${RAGCORE_API_TOKEN:-}
RAGCORE_SPACE_ID: ${RAGCORE_SPACE_ID:-}
N8N_WEBHOOK_URL: ${N8N_WEBHOOK_URL:-http://n8n:5678/webhook/mobilityops-return}
N8N_WEBHOOK_TRIGGER_TOKEN: ${MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN:-replace-me-n8n-webhook-trigger-token}
N8N_CALLBACK_TOKEN: ${MOBILITYOPS_CALLBACK_TOKEN:-replace-me-n8n-callback-token}
MCP_HUB_SERVICE_TOKEN: ${MCP_HUB_SERVICE_TOKEN:-replace-me-mcp-hub-token}
DEMO_ORGANIZATION_NAME: ${DEMO_ORGANIZATION_NAME:-Northstar Mobility}
+50 -6
View File
@@ -1,8 +1,11 @@
openapi: 3.1.0
info:
title: MobilityOps API
title: Fleet Ops API
version: 0.1.0
description: Contract baseline for the MobilityOps proof of concept.
description: >-
Contract baseline for the Fleet Ops demo. "Fleet Ops" is the visible product name;
"mobilityops" remains the technical identifier for the repository, deployment
directory, database, and internal service/health identifiers only.
servers:
- url: http://localhost:8128
paths:
@@ -181,26 +184,67 @@ paths:
description: Wrong rule type, issue not open, or overlap still present
'422':
description: booking_ref not one of the overlapping bookings
/api/v1/data-quality/issues/{public_ref}/status-recommendation:
post:
operationId: previewVehicleStatusRecommendation
description: >-
vehicle_status_conflict only. Non-mutating: computes the recommendation from
the same shared evaluator the scanner and apply endpoint use
(app.services.vehicle_status.evaluate_vehicle_status), without resolving the
issue, writing an audit event, or queuing automation. Safe to call repeatedly
-- see docs/fleet-ops-correction/vehicle-status-decision-table.md.
parameters:
- $ref: '#/components/parameters/PublicRef'
responses:
'200':
description: >-
Current/recommended status, recommendation code, safe_to_apply,
manual_review_required, the underlying facts, and a recommendation_token
the apply endpoint revalidates against.
'409':
description: Wrong rule type, issue not open, or vehicle not found
/api/v1/data-quality/issues/{public_ref}/apply-recommended-status:
post:
operationId: applyRecommendedVehicleStatus
description: >-
vehicle_status_conflict only. Applies the one authoritative recommendation
function's output and re-validates before resolving.
function's output within one transaction: locks the issue and vehicle,
recomputes the recommendation from fresh facts, rejects the request if the
supplied recommendation_token no longer matches (RECOMMENDATION_STALE), refuses
an unsafe/manual-review recommendation (MANUAL_REVIEW_REQUIRED) or a
recommendation with nothing to apply (NO_CONFLICT_DETECTED), then re-validates
the same evaluator post-write before resolving the issue.
parameters:
- $ref: '#/components/parameters/PublicRef'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [recommendation_token]
properties:
recommendation_token:
type: string
description: The token from the most recent status-recommendation preview call.
responses:
'200':
description: Applied status, reason and the resolved issue
description: Applied status, reason code and the resolved issue
'409':
description: Wrong rule type, issue not open, or no conflict detected
description: >-
Wrong rule type, issue not open, vehicle not found, stale recommendation
token, manual review required, no conflict detected, or the applied status
did not resolve the conflict on re-validation
/api/v1/search:
get:
operationId: search
description: >-
Bounded typed results (vehicle, booking, data_quality_issue, section).
Data-quality and manager-only sections are filtered server-side by role.
Customers are never returned -- no customer detail route exists.
Customers are never returned -- no customer detail route exists. Every result's
`label` is a stable public_ref/section id (never translatable prose); `detail_code`
(+ optional `detail_params` for data values like make/model/location) is what the
frontend localizes -- the backend never emits English/Dutch/French sentences here.
parameters:
- in: query
name: q
+14 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}"
callback_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback}"
source_workflow="${3:-n8n/mobilityops-return-processing.json}"
source_workflow="${3:-n8n/workflows/fleet-ops-vehicle-return.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- both
# the webhook trigger and the outbound callback authenticate via named n8n Header Auth
# credentials ("Fleet Ops Webhook Trigger Token", "Fleet Ops Service Token"). Those must
# exist in the target n8n instance before this workflow is activated; see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-return-processing.json"
cleanup() {
@@ -32,15 +30,17 @@ cleanup() {
}
trap cleanup EXIT INT TERM
jq --arg callback_url "$callback_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url |
(.nodes[] | select(.id == "callback-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
jq --arg callback_url "$callback_url" '
(.nodes[] | select(.id == "callback-node") | .parameters.url) = $callback_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-return-processing
docker restart "$container_name" >/dev/null
echo "Published MobilityOps return workflow to existing container ${container_name}"
echo "Imported Fleet Ops — Vehicle Return Orchestration into container ${container_name}."
echo "Before activating: in the n8n UI, create Header Auth credentials named"
echo " 'Fleet Ops Webhook Trigger Token' (value = MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN from .env)"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "those secret values, and does not restart the container -- restart it yourself once"
echo "credentials are wired up and the workflow is published, if required."
+13 -14
View File
@@ -3,7 +3,7 @@ set -eu
container_name="${1:-n8n}"
scan_url="${2:-http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan}"
source_workflow="${3:-n8n/mobilityops-scheduled-quality-scan.json}"
source_workflow="${3:-n8n/workflows/fleet-ops-data-quality-scan.json}"
if [ ! -f .env ]; then
echo "Missing deployment .env" >&2
@@ -18,12 +18,10 @@ if ! docker inspect "$container_name" >/dev/null 2>&1; then
exit 1
fi
callback_token="$(sed -n 's/^MOBILITYOPS_CALLBACK_TOKEN=//p' .env | tail -n 1)"
if [ -z "$callback_token" ]; then
echo "MOBILITYOPS_CALLBACK_TOKEN is empty" >&2
exit 1
fi
# The workflow file no longer carries the callback token as a literal header value -- the
# scan request authenticates via the named n8n Header Auth credential ("Fleet Ops Service
# Token"), which must exist in the target n8n instance before this workflow is activated;
# see the echo below.
temporary_workflow="$(mktemp /tmp/mobilityops-n8n-workflow.XXXXXX.json)"
container_workflow="/tmp/mobilityops-scheduled-quality-scan.json"
cleanup() {
@@ -32,15 +30,16 @@ cleanup() {
}
trap cleanup EXIT INT TERM
jq --arg scan_url "$scan_url" --arg callback_token "$callback_token" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url |
(.nodes[] | select(.id == "scan-node") | .parameters.headerParameters.parameters[] |
select(.name == "X-Service-Token") | .value) = $callback_token
jq --arg scan_url "$scan_url" '
(.nodes[] | select(.id == "scan-node") | .parameters.url) = $scan_url
' "$source_workflow" > "$temporary_workflow"
docker cp "$temporary_workflow" "$container_name:$container_workflow" >/dev/null
docker exec "$container_name" n8n import:workflow --input="$container_workflow"
docker exec "$container_name" n8n publish:workflow --id=mobilityops-scheduled-quality-scan
docker restart "$container_name" >/dev/null
echo "Published MobilityOps scheduled quality-scan workflow to existing container ${container_name}"
echo "Imported Fleet Ops — Scheduled Data Quality Scan into container ${container_name}."
echo "Before activating: in the n8n UI, create a Header Auth credential named"
echo " 'Fleet Ops Service Token' (value = MOBILITYOPS_CALLBACK_TOKEN from .env)"
echo "then open the workflow and click Publish. This script does not print or transmit"
echo "that secret value, and does not restart the container -- restart it yourself once"
echo "the credential is wired up and the workflow is published, if required."
+15 -15
View File
@@ -16,7 +16,7 @@ Steps:
4. return a stable workflow result;
5. on errors, fail visibly so the outbox dispatcher can retry.
The starter export is `n8n/mobilityops-return-processing.json`. Claude may correct its credentials and callback route but must preserve idempotency.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-vehicle-return.json` (see `n8n/workflows/MANIFEST.md`); it authenticates via named Header Auth credentials rather than a literal token, per the live-hardening pass documented in `docs/live-ai-integration/n8n-current-state.md`.
## Second live workflow: scheduled quality scan
@@ -39,24 +39,24 @@ Steps:
open, so a duplicate or overlapping trigger (a manual test run firing close to the
scheduled one, or a retried HTTP call) does no duplicate domain work.
The starter export is `n8n/mobilityops-scheduled-quality-scan.json`, imported and
published the same way as the return-processing workflow (see
`deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It ships with
`"active": false` so it cannot fire against any environment until deliberately
published with a real service token.
The canonical, live-validated definition is `n8n/workflows/fleet-ops-data-quality-scan.json`
(see `n8n/workflows/MANIFEST.md`), imported and published the same way as the return
workflow (see `deploy/unraid/setup-scheduled-scan.sh` and `docs/17-runbook.md`). It is
active on the live instance; a fresh import ships inactive until credentials are wired up
and it is deliberately published.
## Deferred: knowledge sync
## RAGcore procedure sync (in progress)
Input: manual trigger or manifest-changed event.
RAGcore is now reachable in this environment; a live inspection of its real contract is
recorded in `docs/live-ai-integration/n8n-current-state.md`. Workflow 3, "Fleet Ops —
RAGcore Procedure Sync", is being built against that real contract (not the sketch
originally in this section) — see `n8n/workflows/MANIFEST.md` for current status.
Steps:
## Workflow error handler (in progress)
1. read the fixed knowledge manifest;
2. call RAGcore ingestion/sync API;
3. record per-document results through MobilityOps integration status API.
Deferred until RAGcore's live ingestion API is available in this environment; must not
delay or block the core demo.
Workflow 4, "Fleet Ops — Workflow Error Handler", is a central technical workflow attached
to workflows 1-3 via n8n's per-workflow "Error Workflow" setting, reporting bounded,
secret-free failure details to Fleet Ops. See `n8n/workflows/MANIFEST.md` for status.
## Outbox dispatcher
+11 -2
View File
@@ -48,7 +48,7 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
which runs:
```bash
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 restart n8n
```
@@ -56,6 +56,12 @@ longer gates this. This is a one-time step per fresh `docker compose down -v`:
(`n8n import:workflow` always leaves the workflow deactivated regardless of its
`"active"` field; `publish:workflow` + a restart is what actually activates it.)
Before it will actually process a return, create two Header Auth credentials in the n8n
UI — `Fleet Ops Webhook Trigger Token` (value: `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` from
`.env`) and `Fleet Ops Service Token` (value: `MOBILITYOPS_CALLBACK_TOKEN` from `.env`) —
the workflow's webhook trigger and outbound HTTP call reference these credentials by
name; no secret value is embedded in the workflow file itself.
Verify the full round trip:
```bash
@@ -77,11 +83,14 @@ make n8n-setup-scan
which runs:
```bash
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 restart n8n
```
This workflow also needs the `Fleet Ops Service Token` Header Auth credential created in
the n8n UI before a run will succeed.
Verify:
```bash
@@ -0,0 +1,75 @@
# Fleet Ops correction — current-state gap audit
## Branch / commit state (at audit time)
- Repository's actual main/default branch is named **`master`** (there is no `main` branch — `remotes/origin/HEAD -> origin/master`). All instructions referring to "main" in this task are treated as referring to `master`.
- `master` (local and `origin/master`) was at `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` before this task started — this is the newest verified Fleet Ops demo commit (contains the full rebrand/i18n/adaptive-guide/Data-Quality-UX work from the previous task).
- Deployed commit on Unraid (`/mnt/user/appdata/mobilityops/.deploy/source-revision`): `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` — matches `master` exactly. No drift.
- No uncommitted local changes at audit time (`git status` clean).
- Source branch for this correction: `master` (already contained the newest verified commit). New branch created: **`fix/fleet-ops-i18n-status-flow`**, branched from `master` at `18344bc`.
- `feat/mobilityops-functional-completion` remains un-deleted, as instructed by the prior task, and is left untouched by this one.
## Confirmed gaps (verified against actual code, not assumed)
### 1. Brand name is a translatable key (structural risk)
- `common:appName` exists per-locale in `frontend/src/i18n/locales/{nl-BE,en-GB,fr-BE}/common.json`, all currently `"Fleet Ops"`, but nothing prevents a future edit from diverging one locale. Used in `Login.tsx:35` and `Layout.tsx:180`.
- 8 more locale keys embed the literal string "Fleet Ops" inside translatable prose (`auth.defaultDescription`, `common.footer.productLine`, `demo.guide.steps["review-real-vs-simulated"].expectedOutcome`, `demo.about.title`, `demo.about.problemBody`, `demo.about.scopeBody`, `knowledge.emptyDescription`, `navigation.searchLabel`, `returns.scenario.body`) — all three locales currently say "Fleet Ops" correctly, but structurally these are still translatable values.
- **Fix**: `frontend/src/product.ts` exports `PRODUCT_NAME = "Fleet Ops"`. Remove `common:appName`; `Login.tsx`/`Layout.tsx` import the constant directly. Replace the 8 embedded mentions with `{{productName}}` interpolation, passing `productName: PRODUCT_NAME` explicitly at each call site. Add a Playwright test that fails if any locale JSON file contains the literal substring `"Fleet Ops"` (forcing all future brand mentions through interpolation) and a live-DOM test asserting the rendered brand text is byte-identical across all three languages.
### 2. Backend hardcodes English prose as primary user-facing content (structural, not cosmetic)
Confirmed in `backend/app/services/data_quality.py`:
- `_scan_duplicate_customers` (~line 127-149): evidence signals literally `"exact email"`, `"exact phone"`, `"exact postal code"`, `"similar name"`, joined into `evidence.summary`.
- `_scan_missing_required_fields` (~170, 185): `f"Missing: {', '.join(missing)}"`.
- `_scan_booking_overlaps` (~213): `f"Overlapping bookings {first.public_ref} and {second.public_ref}"`.
- `_scan_vehicle_status_conflicts` (~240-256): reason strings like `"marked available while an active booking exists"`.
- `_scan_odometer_regressions` (~291-295): full English sentence with interpolated numbers/refs.
- `_recommend_vehicle_status` (~659-668): recommendation `reason` strings returned verbatim as `ApplyRecommendedStatusResult.reason` and rendered directly in the UI.
Confirmed in `backend/app/services/returns.py`:
- `_derive_vehicle_status_with_reason` (~32-43): `status_reason` strings ("Damage was reported on return.", "A technical warning was reported on return.", "Odometer reached the {n} km service threshold.", "No damage, technical warning or service threshold; routed to cleaning.") flow straight into the API response and are displayed raw regardless of UI language (asserted verbatim in English in `interactive-elements.spec.ts:133`, confirming this is genuinely user-visible, not just internal).
Confirmed in `backend/app/services/dispatcher.py` / seed data: `event.last_error` stores raw strings like `"Synthetic connection timeout to n8n"` from `seed/workflow_runs.csv`, rendered directly in `Automation.tsx` (`{r.last_error ?? "—"}`) with no localization or summarization.
Frontend confirmed to display these values completely raw: `DataQualityIssueDetail.tsx``<dd>{String(issue.evidence.summary ?? "")}</dd>` — the *label* is translated, the *value* is not.
**Fix**: introduce structured `signals`/`message_code`+`params` on evidence and reasons (data-quality evidence, status-conflict recommendation reason, return status reason, automation last-error), with a frontend mapping layer that localizes known codes and falls back to the raw string under "Technical details" only.
### 35, 8A. Status-recommendation flow is unsafe and combines calculation with mutation
- **Confirmed single mutating endpoint**: `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` (`backend/app/api/routers/data_quality.py`) computes the recommendation and mutates the vehicle in the same call. No separate preview/GET route exists for this flow (unlike the return flow, which already has `preview_vehicle_return` / `register_vehicle_return` sharing one pure evaluator).
- **Confirmed unsafe shortcut** (explicitly prohibited by this task): `_recommend_vehicle_status` in `data_quality.py` returns `("rented", ...)` whenever `operational_status == "maintenance" and has_active_booking` — i.e. a vehicle flagged for maintenance with an active booking is auto-recommended (and, on one click, actually changed) to `rented`, with no check of the underlying reason it's in maintenance and no manual-review branch.
- **Confirmed proxy-based reasoning** (explicitly prohibited): the `available` + `has_open_high_issue``blocked` branch depends only on "does some other open high-severity issue exist for this vehicle", not on real underlying facts (damage, technical warning, confirmed overlap). `data_quality.py` never imports `Inspection`, so damage/technical-warning facts are never examined by this function at all.
- **Confirmed scanner/resolver duplication**: `_scan_vehicle_status_conflicts` and `_recommend_vehicle_status` are two independently-maintained `if`-chains (hand-kept-in-sync via a docstring comment, not shared code) — a structural fragility even though today's four branches happen to agree.
**Fix**: new shared domain service `backend/app/services/vehicle_status.py` with one `evaluate_vehicle_status()` function consulting real facts (active/reserved/overlapping bookings, latest inspection damage/technical-warning, maintenance threshold, cleaning state), used by scanner, a new non-mutating preview endpoint, the apply endpoint, and tests. Maintenance+active-booking becomes `manual_review_required`, never an automatic `rented`. Full decision table in `docs/fleet-ops-correction/vehicle-status-decision-table.md`.
### 67. MO-016 / issue-ordering
- Confirmed `MO-016` scenario: vehicle seeded `operational_status="available"`, two overlapping *reserved* bookings (`BK-DEMO-OVERLAP-A`/`-B`), two pre-seeded `open` issues on the same vehicle (`DQ-DEMO-OVERLAP` booking_overlap, `DQ-DEMO-STATUS` vehicle_status_conflict).
- Confirmed **no test** resolves both issues in sequence (either order) within one session to check the outcome stays deterministic — each existing test independently resets the demo data first.
**Fix**: new evaluator is order-independent by construction (recomputes real facts every call, doesn't cache any prior issue's existence as an input other than the generic "another open high-severity issue for manual-review fallback"); add an explicit ordering test.
### 8. i18n-coverage test doesn't prove translation happened
Confirmed: `frontend/e2e/i18n-coverage.spec.ts` only checks key-parity and non-empty values — a locale file could contain the literal English string copy-pasted and the test would still pass. Locale files were manually verified as genuinely translated (no hits for probe phrases like "canonical odometer", "committed locally", "correlation ID" etc. in `nl-BE`/`fr-BE`), so this is a test-coverage gap, not an active mistranslation — but per this task's instructions it still needs closing.
**Fix**: add a translation-quality test comparing `nl-BE`/`fr-BE` values against `en-GB` for meaningful divergence (with an explicit allowlist for real proper nouns/technical tokens: Fleet Ops, Northstar Mobility, n8n, RAGcore, MCP Hub, API, UUID, Docker, PostgreSQL), plus a route-matrix smoke test opening every main route in all three languages.
### 9. One confirmed leftover hardcoded string
- `frontend/src/pages/BookingDetail.tsx:76``aria-label="Demo scenario"` is a literal, un-translated English string (the visible content beside it is correctly translated).
**Fix**: route through `t("returns:scenario.ariaLabel")` (new key, 3 locales).
### 10. Automation "last error" shown raw
- `Automation.tsx` renders `r.last_error` directly with no localization/summarization layer, confirmed via the seeded `"Synthetic connection timeout to n8n"` string appearing verbatim regardless of UI language.
**Fix**: known-code → localized summary + operational meaning, raw string demoted to "Technical details".
## Scope note
No gaps were found in: existing Control Rail navigation/layout, the three demo roles/authorization, the guided demo mechanics, n8n integration wiring, Docker/Unraid deployment scripts, or the previously-implemented adaptive Demo Guide / Data Quality choice-card UI — these are left untouched per the "do not redesign" instruction. This correction is scoped to the 10 problems above.
@@ -0,0 +1,25 @@
# i18n inventory — dynamic/backend content requiring message-code treatment
Static UI chrome (navigation, dashboard, forms, filters, dialogs, empty/loading states,
Demo Guide, About page, accessibility labels) was already moved to the `i18next`
namespace system in the prior task and is not re-inventoried here in full — see
`docs/final-product-polish/audit.md` and `docs/final-product-polish/i18n-inventory.md`
for that pass. This inventory covers only the sources confirmed still bypassing
translation (per `current-gap-audit.md`), the fix chosen, and the tests that verify it.
| # | Source | Location | Static/dynamic | Fix | Tests |
|---|--------|----------|-----------------|-----|-------|
| 1 | Duplicate-customer evidence signals | `backend/app/services/data_quality.py::_scan_duplicate_customers` | dynamic | `evidence.signals: [{code, params}]` (`duplicate.exact_email`, `duplicate.exact_phone`, `duplicate.same_postal_code`, `duplicate.similar_name` + score param); frontend maps code→localized phrase | `test_data_quality.py::test_duplicate_customer_evidence_has_structured_signals`; Playwright DQ evidence-language test |
| 2 | Missing-required-field evidence | `_scan_missing_required_fields` | dynamic | `evidence.signals: [{code: "missing_field", params: {field}}]`; frontend maps `field` through the existing `CUSTOMER_FIELD_LABELS`/`VEHICLE_FIELD_LABELS`-equivalent i18n keys | same |
| 3 | Booking-overlap evidence | `_scan_booking_overlaps` | dynamic | `evidence.signals: [{code: "overlap.reserved_bookings", params: {refs: [...]}}]` | same |
| 4 | Vehicle-status-conflict evidence | `_scan_vehicle_status_conflicts` | dynamic | replaced entirely by the new `evaluate_vehicle_status()` evaluator's `recommendation_code`; scanner reuses the evaluator instead of its own reason strings | new evaluator unit tests |
| 5 | Odometer-regression evidence | `_scan_odometer_regressions` | dynamic | `evidence.signals: [{code: "odometer.regression", params: {later_ref, later_km, earlier_ref, earlier_km}}]` | same |
| 6 | Status-recommendation reason | `_recommend_vehicle_status` / new `evaluate_vehicle_status` | dynamic | `recommendation_code` + `facts` (structured), no free prose from the backend at all; frontend renders the full explanation from `quality:statusRecommendation.codes.<code>` | evaluator unit tests + preview/apply contract tests |
| 7 | Return status reason | `backend/app/services/returns.py::_derive_vehicle_status_with_reason` | dynamic | `status_reason_code` + `params` alongside the existing human string (kept for backward-compat, demoted to technical fallback) | `test_returns.py` updated; Playwright return-flow-language test |
| 8 | Automation `last_error` | `dispatcher.py` / seed data, rendered in `Automation.tsx` | dynamic | known-cause codes (`n8n.connection_timeout`, `n8n.http_error`, etc.) mapped to a localized summary + "what happened / what's pending / what retry does"; raw string demoted to Technical details | Playwright automation-language test |
| 9 | `aria-label="Demo scenario"` | `frontend/src/pages/BookingDetail.tsx:76` | static, just un-wired | `t("returns:scenario.ariaLabel")`, 3 locales | i18n-coverage (key parity) + route-matrix test |
| 10 | Brand name (`common:appName` + 8 embedded mentions) | see gap audit §1 | static, wrongly translatable | `PRODUCT_NAME` constant, `{{productName}}` interpolation | new brand-invariant test |
## Message-code mapping module
Centralised in `frontend/src/i18n/messageCodes.ts` (new): a single `resolveMessageCode(t, code, params)` helper used by the Data Quality evidence renderer, the status-recommendation panel, the return-result panel, and the automation ledger, so there is one place mapping `code → i18next key` rather than per-page switch statements.
@@ -0,0 +1,90 @@
# Vehicle status decision table
Authoritative rationale for `app/services/vehicle_status.py::evaluate_vehicle_status`,
the single evaluator shared by the data-quality scanner, the status-recommendation
preview endpoint, and the transactional apply endpoint (section 8A). Scanner and
resolver call the same function with the same freshly-gathered facts, so they can never
disagree, and the recommendation is order-independent: resolving, deferring, or
rejecting an unrelated issue never changes what this function returns for a vehicle,
because it only reasons over the vehicle's and bookings' current state, never over
issue history.
## Facts gathered (`gather_vehicle_status_facts`)
All facts are re-queried from the database on every call, never cached and never derived
from "does some other issue happen to be open":
| Fact | Source |
|---|---|
| `active_booking_refs` | Bookings on this vehicle with `status == "active"` |
| `overlapping_booking_pairs` | Reserved/active bookings on this vehicle whose date ranges genuinely overlap |
| `service_threshold_reached` | `vehicle.odometer_km >= vehicle.next_service_km` |
| `open_booking_overlap_issue_ref` | The `public_ref` of a currently-open `booking_overlap` issue on this vehicle, if any (excluding the issue being resolved, via `exclude_issue_id`) |
`has_active_rental` = at least one active booking. `has_booking_conflict` = an
overlapping-booking pair exists, or an open `booking_overlap` issue references this
vehicle.
## Decision table
| Current status | Active rental? | Service threshold reached? | Booking conflict? | Recommended status | Priority | `recommendation_code` | Safe to auto-apply? |
|---|---|---|---|---|---|---|---|
| any except `maintenance` | yes | no | no | `rented` (if not already) | 1 | `vehicle.active_rental` | yes |
| `maintenance` | yes | — | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
| any | yes | yes | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
| any | yes | — | yes | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
| not `maintenance` | no | yes | — | `maintenance` | 2 | `vehicle.service_threshold_reached` | yes |
| `maintenance` | no | yes | — | *(none — already correct)* | 2 | `vehicle.no_conflict` | n/a |
| not `blocked` | no | no | yes | `blocked` | 3 | `vehicle.booking_conflict` | yes |
| `blocked` | no | no | yes | *(none — already correct)* | 3 | `vehicle.no_conflict` | n/a |
| `rented` | no | no | no | `available` | 4 | `vehicle.rental_ended` | yes |
| `available` / `cleaning` / `blocked` | no | no | no | *(none — already correct)* | 4 | `vehicle.no_conflict` | n/a |
| `maintenance` | no | no | no | *(none — stays in maintenance)* | 4 | `vehicle.no_conflict` | n/a |
| anything not covered above | — | — | — | *(none — manual review)* | 5 | `vehicle.manual_review_required` | no |
## Safe-status principles (section 8B) applied
- **Maintenance + active booking never auto-resolves to `rented`.** Being currently in
`maintenance` is itself treated as a blocking fact (row 2 above) — an active booking
is never proof the vehicle should be marked rented; it is a real contradiction that
requires a human to investigate (e.g. was the vehicle released from the workshop
without updating status, or is the booking itself stale).
- **`available` + any booking never auto-resolves to `rented` silently past a real
blocker.** The `rented` recommendation only fires when there is no competing blocking
fact (no service-threshold breach, no booking conflict, not already in maintenance).
- **An open issue disappearing never auto-resolves to `available`.** Leaving
`maintenance` requires a human decision — this evaluator holds no fact that proves
maintenance work is actually finished (no completed-service record is modelled), so a
vehicle sitting in `maintenance` with no active rental and no other blocker stays
`vehicle.no_conflict` (left alone) rather than being auto-promoted to `available`.
- **Every branch re-derives facts; nothing is cached.** `blocking_reasons` is always
computed fresh from `service_threshold_reached`, `has_booking_conflict`, and the
current status itself — never from a proxy like "is some other high-severity issue
still open".
## Statuses used
Only statuses that exist in the current domain model are referenced: `available`,
`rented`, `cleaning`, `maintenance`, `blocked`. `cleaning` is never a recommendation
target from this evaluator (no fact here proves cleaning is required or complete); it is
only ever an input `current_status` that, absent any blocker, is left alone
(`vehicle.no_conflict`).
## Concurrency: recommendation token
`compute_recommendation_token(vehicle, facts)` hashes the vehicle's optimistic-lock
`version` plus every fact the recommendation was based on (sha256, truncated to 16 hex
chars). The preview endpoint returns this token; the apply endpoint recomputes it from
freshly-gathered facts inside the same transaction and rejects the request
(`RECOMMENDATION_STALE`) if it no longer matches — the frontend must never assume a
previously-shown preview is still valid without server revalidation (section 8F).
## MO-016: order independence
MO-016 carries both an open `booking_overlap` issue and an open
`vehicle_status_conflict` issue at once (two overlapping reserved bookings). Because
`gather_vehicle_status_facts` re-queries `overlapping_booking_pairs` and
`open_booking_overlap_issue_ref` fresh every call, resolving the booking-overlap issue
first vs. resolving the status-conflict issue first both converge on the same final
vehicle status — see `test_mo_016_status_conflict_recommendation_is_order_independent`
in `backend/tests/test_data_quality.py`.
+130
View File
@@ -0,0 +1,130 @@
# Fleet Ops final localization — gap audit
Branch: `fix/fleet-ops-final-i18n-ux` (created from `master` @ `f7805579f7c73bd3085d73a725fa985b4a4892ed`,
working tree clean at audit time). Deployed revision on Unraid at audit time: `de0bdea84fea01b4501deb7099107bc753c2e6d7`
(the merge commit; `f780557` is an evidence-only commit not separately deployed). Both containers healthy.
## 1. Remaining untranslated/incorrect text
### nl-BE
| File | Key | Current | Fix |
|---|---|---|---|
| `audit.json` | `title` | "Audit trail" | "Auditgeschiedenis" |
| `audit.json` | `columns.actor` | "Actor" | "Uitvoerder" |
| `navigation.json` | `items.audit` | "Audit trail" | "Auditgeschiedenis" |
| `auth.json` | `exploreAsOperationsManager` | "Verken als Operations Manager" | "Verken als Operationsmanager" |
| `auth.json` | `exploreAsRentalEmployee` | "Verken als Rental Employee" | "Verken als Verhuurmedewerker" |
| `auth.json` | `roleOperationsManager` | "Operations manager" | "Operationsmanager" |
| `auth.json` | `roleRentalEmployee` | "Rental employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.roles.operations_manager` | "Operations Manager" | "Operationsmanager" |
| `demo.json` | `scenarios.roles.rental_employee` | "Rental Employee" | "Verhuurmedewerker" |
| `demo.json` | `scenarios.startScenario` | "Start scenario" | "Scenario starten" |
| `integrations.json` | `ledger.filterRecent` | "Recent" | "Recentste" |
| `quality.json` | `list.statusOpen` | "Open" | "Openstaand" |
| `audit.json`, `quality.json`, `integrations.json`, `demo.json` | 8 `managerOnly`/`whyItMatters`/`scopeBody`/etc. keys (16 nl+fr occurrences) | embedded "Operations Manager(s)" mid-sentence | "Operationsmanager(s)" |
`columns.details` ("Details") judged fine as-is: short data-table column header, genuine NL/EN cognate,
siblings are single-word labels too.
### fr-BE
Same key set as nl-BE (role labels + embedded mentions), fr-BE `columns.actor` is already correctly
"Acteur" (no fix needed). French role translations: "Responsable des opérations" /
"Collaborateur de location", per the correction brief.
### Hardcoded JSX (bypasses i18n entirely)
`frontend/src/pages/DataQualityIssueDetail.tsx` line ~213: `data-label="Field"` — literal English,
never localized. Fix: reuse the already-existing, already-translated
`detail.duplicateCustomer.fieldColumn` key (same table's `<thead>` seven lines above already uses it
correctly) — zero locale-file changes needed, pure JSX fix.
No other hardcoded `data-label`/`aria-label`/`title`/`placeholder` found across `frontend/src/**/*.tsx`.
## 2. Raw backend errors shown directly
13 call sites across 7 files (`Automation.tsx`, `ReturnForm.tsx` ×2, `DataQuality.tsx`, `DemoGuide.tsx`,
`Layout.tsx`, `DataQualityIssueDetail.tsx` ×7, `Knowledge.tsx`) all follow:
`err instanceof ApiError ? err.message : t("some:fallback")` — i.e. the **common** case (a real,
structured `ApiError` from the backend) shows raw, un-localized English `error.message` verbatim; the
translated fallback only fires for network-level failures where no `ApiError` could even be
constructed. One site (`DataQualityIssueDetail.tsx` apply-status handler) already special-cases
`err.code === "RECOMMENDATION_STALE"` inline — this needs migrating into the new central system rather
than staying a one-off.
Backend `AppError`/`HTTPException` codes found (32 semantic `AppError` codes + generic HTTP-status
fallback codes "401"/"403"/"404"/"422" for plain `HTTPException`s, verified via
`app/main.py`'s `error_body()` envelope — both AppError and HTTPException responses share the same
`{"error": {"code", "message", "correlation_id"}}` shape):
`BOOKING_NOT_ACTIVE, BOOKING_NOT_FOUND, CONFLICT_STILL_PRESENT, CORRECTED_VALUE_REQUIRED,
CORRECTION_BELOW_CANONICAL, CUSTOMER_NOT_FOUND, EMPTY_VALUE, ENTITY_NOT_FOUND, EVENT_NOT_FOUND,
IDEMPOTENCY_KEY_REUSED, INVALID_BOOKING_REFERENCE, INVALID_BOOKING_STATE, INVALID_EVENT_ID,
INVALID_FIELD, INVALID_FIELD_OVERRIDE, INVALID_IDEMPOTENCY_KEY, INVALID_SURVIVOR, ISSUE_NOT_FOUND,
ISSUE_NOT_OPEN, MANUAL_REVIEW_REQUIRED, NOT_AN_ODOMETER_ISSUE, NOT_AN_OVERLAP_ISSUE,
NOT_A_DUPLICATE_ISSUE, NOT_A_MISSING_FIELD_ISSUE, NOT_A_STATUS_CONFLICT_ISSUE, NOT_RETRYABLE,
NO_CONFLICT_DETECTED, NO_FIELDS_PROVIDED, OVERLAP_STILL_PRESENT, RECOMMENDATION_STALE,
UNAUTHORIZED_SERVICE, UNSUPPORTED_ENTITY, VEHICLE_NOT_FOUND`.
Only one code (`INVALID_SURVIVOR`) carries structured `details` params; the rest embed specifics only
in the raw English `message` string — so localized messages will be generic per-code (title +
explanation + optional next step), not parameterized with extracted specifics, with the raw string
preserved verbatim under "Technical details".
`errors.json` namespace already exists (all 3 locales) with 6 generic keys (`generic`,
`workspaceLoadFailed`, `unauthorized`, `forbidden`, `notFound`, `networkUnavailable`) but is not wired
to `ApiError.code` at all — only used as the non-`ApiError` fallback string.
## 3. i18n allowlist over-permissiveness
`frontend/e2e/i18n-coverage.spec.ts`'s `IDENTICAL_VALUE_ALLOWLIST` currently contains 4 entries that
must be removed once role labels are translated: `auth.roleOperationsManager`,
`auth.roleRentalEmployee`, `demo.scenarios.roles.operations_manager`,
`demo.scenarios.roles.rental_employee` (comment: "deliberately-untranslated role title" — no longer
true once fixed). `audit.title` and `navigation.items.audit` ("Audit trail" kept as compliance term)
also need removing once translated to "Auditgeschiedenis".
Remaining ~19 allowlist entries are genuine cognates/proper nouns/templates (verified by the audit
agent against a broad Dutch-word grep of fr-BE — zero Dutch leakage found) and should stay.
Also noted: the coverage test's identical-value check only catches **whole-string** identity to en-GB,
not **mid-sentence embedded English** (the 16 "Operations Manager(s)" occurrences above) — this is a
real blind spot the new tests (section 8 of the correction brief) need to close with a targeted,
explicit check for known English substrings appearing in nl-BE/fr-BE prose.
## 4. Dashboard greeting
No time-of-day logic exists anywhere in the codebase — `dashboard.json`'s `title` key is a **static**
string ("Good morning. Here's the fleet." / "Goedemorgen. Hier is je wagenpark." / "Bonjour. Voici
votre flotte.") shown unconditionally at all times of day, despite implying dynamism. Needs: a central,
testable, clock-injectable greeting function keyed on `Europe/Brussels` wall-clock hour, 4 periods per
the brief, updating on language change and on period rollover while the app stays open.
## 5. Documentation staleness
- `PROJECT_STATE.md` "Locked decisions" block: `"Product name: MobilityOps."` and `"PoC only..."`
predates the Fleet Ops rebrand, contradicts the later (correct) sections of the same file.
- `PROJECT_STATE.md`'s final section header still reads `"...IN PROGRESS on
fix/fleet-ops-i18n-status-flow"` and states `"Not yet merged to master"` / `"Do not claim PASS..."` —
**false**: the merge (`de0bdea`) and final evidence commit (`f780557`) both already exist in git
history, neither is mentioned in the file, and the branch name has moved on to
`fix/fleet-ops-final-i18n-ux`.
- A separate, older `"## Demo productization (in progress, same branch
feat/mobilityops-functional-completion)"` section header was also never marked complete.
- README.md is accurate and current — no fix needed there beyond a version-count refresh after this
round's test additions.
- No false "RAGcore live" / "MCP Hub connected" claims found anywhere — this part is already honest.
## Plan
1. Fix the ~10 nl-BE + ~10 fr-BE locale-file translations above (role labels, "Audit trail", "Actor",
"Start scenario", "Recent", "Open", embedded mid-sentence mentions).
2. Fix the one hardcoded `data-label="Field"` JSX bug.
3. Build a central `describeApiError(t, err)` helper + shared rendering component, wire all 13 call
sites through it, with a code→message map covering all 32 backend codes + generic HTTP fallbacks,
raw text demoted to "Technical details".
4. Tighten the allowlist (remove the 6 now-stale entries) and add a targeted embedded-English-substring
test, a `describeApiError` coverage test, and greeting boundary tests.
5. Build the time-of-day greeting function + wire into `Dashboard.tsx`, with matching locale copy for
4 periods × 3 languages + a localized description line replacing the current static one.
6. Fix `PROJECT_STATE.md` staleness (append a new dated entry, do not rewrite prior entries).
7. Full local validation → clean-checkout drill → deploy fix branch → live validation in 3 languages →
merge to master → redeploy → final evidence.
@@ -0,0 +1,222 @@
# n8n current state (as inspected 2026-08-04)
Inspected live via the already-authenticated browser session at
`https://n8n.itworx.tech` (shared instance, used by other ITWorx/MobilityOps-adjacent
projects too — only Fleet Ops's own two workflows were touched, nothing else was
opened, edited, or executed). No secret credential values are reproduced in this
document.
## Reachability and version
- n8n is reachable at `https://n8n.itworx.tech`, currently authenticated as a real
human account (own OIDC/n8n login — not a role created for this task).
- Workspace-level stats at the time of inspection: **114 total prod. executions, 4
failed (3.5% failure rate)**, avg run time 0.18s. (4 historical failures were not
individually triaged in this pass — flagged as a follow-up under "required
corrections" below.)
- Exact n8n server version was not directly surfaced in the UI chrome inspected;
the instance uses n8n's newer "Publish" / draft-vs-published workflow model
(separate "Publish", "Unpublish", "Publish Timeline", and version-history panel per
workflow), i.e. a fairly recent n8n release.
## Production webhook base
`http://192.168.10.150:5678/webhook/...` — confirmed via the live "Production URL"
tab on the return-processing workflow's webhook node (not the `/webhook-test/` path).
This matches `N8N_WEBHOOK_URL=http://192.168.10.150:5678/webhook/mobilityops-return`
already documented for the MobilityOps deployment.
## Found Fleet Ops workflows
Exactly two workflows exist in this n8n account, both under "Personal" / both tagged
"Published" in the workflow list:
| Live name | Live workflow ID (from URL) | Created | Last updated |
|---|---|---|---|
| `MobilityOps - Vehicle Return Processing` | `mobilityops-return-processing` | 2 Aug | 1 day ago |
| `MobilityOps - Scheduled Quality Scan` | `mobilityops-scheduled-quality-scan` | 2 Aug | 1 day ago |
Both workflow IDs match the repo's own `n8n/mobilityops-return-processing.json` and
`n8n/mobilityops-scheduled-quality-scan.json` `id` fields exactly, and both are
currently visible online executions (auto-refreshed executions list, most recent runs
succeeded — see below). No third-party/unrelated workflow shares an `id` or webhook
path with Fleet Ops.
## Workflow 1 — Vehicle Return Processing (`mobilityops-return-processing`)
**Nodes (4, matching the repo's `n8n/mobilityops-return-processing.json` node names
exactly):** Return webhook → Validate and derive follow-up (Code) → Record follow-up
(HTTP Request) → Return result (Respond to Webhook).
- **Trigger**: webhook, `POST`, path `mobilityops-return`, production URL
`http://192.168.10.150:5678/webhook/mobilityops-return`. **n8n-level
Authentication is set to "None."** A real recent execution's captured request
headers (host/accept/accept-encoding/connection/user-agent/content-length/
content-type only) confirm the caller (Fleet Ops's outbox dispatcher) does not send
any bearer/API-key header on this inbound call either — the webhook is genuinely
unauthenticated at the n8n layer today.
- **Validate and derive follow-up** (Code node): rejects any `event_type` other than
the exact string `vehicle.returned.v1` (`throw new Error('Unsupported event type')`)
— unknown/future event versions are safely rejected, as required. Derives
`follow_up: 'attention_required' | 'cleaning'` from `data.attention_reasons`.
- **Record follow-up** (HTTP Request → Fleet Ops): `POST
http://192.168.10.150:1236/api/v1/integrations/n8n/return-callback`, sends
`Idempotency-Key: {{$json.event_id}}` and an `X-Service-Token` header. **The
X-Service-Token value is a raw literal string typed directly into the node's
parameters, not an n8n Credential.** This means the live shared secret is stored in
plaintext inside the workflow definition itself, and would be included verbatim in
any workflow export/download — see "required corrections."
Body: `{{JSON.stringify($json)}}`.
- **Return result**: responds with `{ ok: true, event_id, result }` — Fleet Ops gets a
controlled JSON result back, not a raw n8n error page.
- **Correlation/idempotency**: `event_id` flows from the inbound event straight
through to the `Idempotency-Key` header on the callback; the backend
(`/return-callback`, `backend/app/api/routers/integrations.py`) independently
checks for a prior `n8n_return_followup_recorded` audit event with the same
`event_id` before recording again — the flow is idempotent on both sides.
- **Latest execution**: 4 Aug, 03:34:19, succeeded in 32ms, all 4 nodes green.
- **Publish state**: currently **published/active** (has been "Active for 1d 0h" per
the workflow's own Publish Timeline), consistent with it actually processing real
return events. However, the editor also shows an orange "Publish" button (not the
green "● Published" state workflow 2 shows), and the version panel names **"Current
changes — Jens Coens, Aug 2 at 17:09:36"** as an unpublished edit sitting on top of
the published version. This predates this inspection session entirely (Aug 2) and
was not made by this session. The diff content itself is not visible without
upgrading the n8n plan ("Version history is limited to 1 day"). **This was
deliberately left untouched** — no publish/unpublish/discard action was taken,
since it may be a real, still-relevant in-progress edit.
## Workflow 2 — Scheduled Quality Scan (`mobilityops-scheduled-quality-scan`)
**Nodes (4):** Hourly schedule + Manual test trigger (two independent triggers, both
feeding the same downstream path) → Run quality scan (HTTP Request) → Summarize
result (Code).
- **Hourly schedule**: interval `Hours`, every `1` hour, at minute `0`. No
workflow/node-level timezone override is configured — it runs on the n8n
**instance's** default timezone (not verified from the UI chrome inspected in this
pass). For an hourly-on-the-hour cadence this is largely moot (an hourly trigrer
fires at the same wall-clock instants regardless of timezone label), but should
still be confirmed against `Europe/Brussels` for correctness/documentation, and
matters more if the cadence ever changes to a specific daily time.
- **Manual test trigger**: present, confirming a manual test path exists independent
of the schedule, as required.
- **Run quality scan** (HTTP Request → Fleet Ops): `POST
http://192.168.10.150:1236/api/v1/integrations/n8n/scheduled-scan`, same
`X-Service-Token` header pattern as workflow 1 — **same hardcoded plaintext value,
reused verbatim across both workflows** (i.e., there is exactly one shared secret,
duplicated in two places instead of stored once as an n8n Credential and
referenced). `Timeout: 15000` ms configured (bounded). No query params, no body.
- **Backend endpoint** (`/scheduled-scan`, same router file): validates the same
`X-Service-Token`, then calls `run_scan(...)`, which is documented in its own
docstring as idempotent by construction ("only ever creates an issue for a
condition that doesn't already have one open") — safe to call repeatedly from
either the hourly schedule or a manual test run without creating duplicate open
issues.
- **Summarize result** (Code node): `total_created = sum(created.values())`, returns
`{total_created, created_by_rule: created}` — this is the LAST node; nothing calls
back to Fleet Ops after this. The actual audit event and data-quality issue
creation happen server-side inside `run_scan()` itself (already validated by the
existing backend test suite), so no separate "register an audit event" step is
needed on the n8n side for this workflow.
- **Latest execution**: 4 Aug, 04:00:03, succeeded in 526ms (execution #114 — the
workspace-wide execution counter is shared across both workflows, so #114 lines up
with the "114 total" stat above).
- **Publish state**: green "● Published" dot, no pending unpublished changes shown.
## Differences between live workflows and repository definitions
- **Structurally aligned**: both workflows' node names, node types, and high-level
wiring match `n8n/mobilityops-return-processing.json` and
`n8n/mobilityops-scheduled-quality-scan.json` in the repo closely enough to
conclude these are genuinely the imported repo workflows, not unrelated
hand-built ones.
- **Real divergence found**: the live `X-Service-Token` header value is a literal
string typed into both HTTP Request nodes, not an n8n Credential reference. Whether
the repo JSON also encodes this as a literal (vs. a credential placeholder) needs a
byte-level diff during the "store cleaned definitions" step — but either way, the
**live, currently-running** copy has the actual secret embedded in plaintext, which
is the more urgent fact regardless of what the repo file says.
- **Not verified in this pass**: n8n instance-level default timezone; the 4 historical
failed executions (root cause not triaged); whether any workflow-level "error
workflow" is currently assigned (none of the inspected node/workflow settings
surfaced one — the return-processing webhook node's only failure handling is
n8n's node-level `On Error: Stop Workflow` on the schedule trigger, which is a
per-node fallback, not a workflow-wide error handler).
## Stale or duplicate workflows
None found. Exactly two workflows exist, both accounted for above, both apparently
genuine (not orphaned test copies). No `ARCHIVED —`-prefixed or otherwise stale
workflow exists yet.
## Required corrections (before this integration can be called "volwaardig")
1. **Move the shared `X-Service-Token` secret into an n8n Credential** (e.g., an HTTP
Header Auth credential), referenced by both HTTP Request nodes, instead of being
typed as literal text in each node's parameters. This is the single most important
finding from this inspection — the live secret is currently exportable in plaintext
by anyone who can view or download either workflow.
2. **Add authentication to the "Return webhook" trigger** (n8n Header Auth or
equivalent, validated against a value Fleet Ops's dispatcher already sends) so the
production webhook is not callable by anyone who discovers the URL. Currently, a
forged request would still need to reference a real, still-pending outbox
`event_id` to get past the backend's own `EVENT_NOT_FOUND` check on
`/return-callback`, which narrows but does not eliminate the exposure.
3. Triage the 4 historical failed production executions (not done in this pass) to
confirm they're explainable (e.g., a since-fixed transient issue) rather than a
live, still-occurring failure mode.
4. Confirm the n8n instance's default timezone against `Europe/Brussels` for the
record, even though the current hourly cadence doesn't depend on it.
5. Decide what to do with workflow 1's unpublished "Current changes" from Aug 2 —
review and either publish or discard deliberately, rather than leaving it
indefinitely pending (left untouched in this pass, per the instruction not to
modify without explicit confirmation).
6. Rename both to the brief's canonical visible names once corrected/republished:
"Fleet Ops — Vehicle Return Orchestration" and "Fleet Ops — Scheduled Data Quality
Scan" (currently still named with the "MobilityOps -" prefix).
## Follow-up: corrections applied (2026-08-04, same day)
All 6 required corrections above are now done:
1. **Done.** Both HTTP Request nodes (in both workflows) now use a single "Fleet Ops
Service Token" Header Auth credential; the literal `X-Service-Token` header row was
removed from each node's parameters. Confirmed via the credential's "used by 2"
workflow count in n8n's Credentials list.
2. **Done.** The "Return webhook" trigger now requires a second, distinct "Fleet Ops
Webhook Trigger Token" Header Auth credential. Fleet Ops's outbox dispatcher
(`backend/app/services/dispatcher.py`) now sends the matching
`X-Fleet-Ops-Trigger-Token` header (new `MOBILITYOPS_WEBHOOK_TRIGGER_TOKEN` setting,
added to `.env.example`, `compose.yaml`, the local dev `.env`, and the Unraid
server's `.env`). Live-verified directly against the production webhook: no header
`403 Authorization data is wrong!`; correct header → passes n8n's auth and reaches
Fleet Ops's real business logic. Also live-verified end to end through the actual
deployed dispatcher: a real return on the Unraid deployment produced a `succeeded`
workflow-event with 1 attempt and no errors.
- This same live test 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 crashed the dispatcher's `response.json()` outside its own
error handling. Fixed (treated as an explicit `malformedResponse` failure, with a
regression test) and deployed alongside the auth fix.
3. **Done.** Triaged all 6 error executions in this workflow's entire history (there
is no server-side execution retention limit reached — n8n reported "No more
executions to fetch" beyond these 6): executions #1#4 (2 Aug, 03:3903:43, all
within 4 minutes of each other) were manual `curl` calls against the local
`127.0.0.7:5678` test webhook with a `curl/8.16.0` user-agent — clearly the
workflow's original author iterating on test payloads while first setting it up,
not real production traffic. Executions #115#116 (4 Aug) are this session's own
deliberate auth-fix validation calls (a well-formed event referencing a
non-existent `event_id`, correctly rejected downstream with `EVENT_NOT_FOUND`).
**Zero unexplained or currently-live failures.**
4. Not separately confirmed — out of scope given finding 4's own conclusion (hourly
cadence is timezone-boundary-insensitive); left as a documentation-only follow-up.
5. **Done, per explicit user confirmation.** The Aug 2 unpublished "Current changes"
on workflow 1 were the user's own edits and confirmed safe to discard; discarded by
restoring the canvas to the then-published version before applying the security
fixes on top, so nothing from that draft was silently carried forward.
6. **Done.** Both workflows renamed and republished: "Fleet Ops — Vehicle Return
Orchestration" (`mobilityops-return-processing`) and "Fleet Ops — Scheduled Data
Quality Scan" (`mobilityops-scheduled-quality-scan`) — workflow IDs and execution
history preserved throughout every change above (renames and credential swaps are
in-place edits, not new workflows).
+1
View File
@@ -1,6 +1,7 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json tsconfig.json vite.config.ts index.html ./
COPY public ./public
COPY src ./src
RUN npm ci && npm run build
@@ -18,7 +18,7 @@ test("capture demo-release evidence screenshots", async ({ page, request }) => {
await page.screenshot({ path: `${OUT}/02-demo-entry-mobile.png` });
await page.setViewportSize({ width: 1280, height: 900 });
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByText("Probeer een demonstratiescenario")).toBeVisible();
await page.screenshot({ path: `${OUT}/03-dashboard-with-scenarios.png`, fullPage: true });
+1 -1
View File
@@ -14,7 +14,7 @@ test("capture the seven main pages", async ({ page, request }) => {
await page.goto("/login");
await page.screenshot({ path: `${OUT}/1-login.png` });
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page.getByRole("heading", { name: "Operational metrics" })).toBeVisible();
await page.screenshot({ path: `${OUT}/2-dashboard.png`, fullPage: true });
+37 -2
View File
@@ -38,7 +38,7 @@ test("demo guide does not cover the return form's action buttons on desktop", as
test("demo badge and guide trigger are keyboard reachable and Escape closes them", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const guideTrigger = page.getByRole("button", { name: /Demo-gids/ });
@@ -74,7 +74,7 @@ test("key demo pages load without console errors", async ({ page }) => {
page.on("pageerror", (err) => errors.push(err.message));
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
await expect(page.getByRole("heading", { name: "Probeer een demonstratiescenario" })).toBeVisible();
@@ -85,3 +85,38 @@ test("key demo pages load without console errors", async ({ page }) => {
expect(errors, `Unexpected console errors: ${errors.join("\n")}`).toEqual([]);
});
test("status-recommendation panel is fully keyboard operable, respects reduced motion, and never signals status by colour alone", async ({
page,
request,
}) => {
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await request.post("/api/v1/demo/reset");
await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-STATUS");
const reviewButton = page.getByRole("button", { name: "Aanbeveling bekijken" });
await reviewButton.focus();
await expect(reviewButton).toBeFocused();
await page.keyboard.press("Enter");
const confirmButton = page.getByRole("button", { name: /^Status wijzigen naar/ });
await expect(confirmButton).toBeVisible();
// Status is never conveyed by colour alone: the badge always carries its own text.
const badge = page.locator(".status-decision .badge").first();
await expect(badge).not.toHaveText("");
// The confirm action itself is a real, focusable, keyboard-activatable button (the
// previous "Aanbeveling bekijken" button is unmounted once the decision panel
// replaces it, so focus is verified directly rather than via a Tab chain from it).
await confirmButton.focus();
await expect(confirmButton).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
});
+4 -4
View File
@@ -7,8 +7,8 @@ test("demo entry screen names the fictional org and never shows a password", asy
await expect(page.getByText(/Northstar Mobility/)).toBeVisible();
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await expect(page.getByRole("button", { name: "Start begeleide demo" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Operations Manager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Rental Employee" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Operationsmanager" })).toBeVisible();
await expect(page.getByRole("button", { name: "Verken als Verhuurmedewerker" })).toBeVisible();
await expect(page.locator('input[type="password"]')).toHaveCount(0);
});
@@ -26,7 +26,7 @@ test("start guided demo logs in as Operations Manager and opens the guide at ste
test("permanent demo badge shows a popover with last reset info and a working About link", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
@@ -45,7 +45,7 @@ test("permanent demo badge shows a popover with last reset info and a working Ab
test("badge popover closes on Escape and outside click", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
const trigger = page.getByRole("button", { name: /Synthetische demo/ });
+4 -4
View File
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
test("scenario overview lists all 5 scenarios, ready right after a reset", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
@@ -27,12 +27,12 @@ test("scenario overview lists all 5 scenarios, ready right after a reset", async
test("starting a scenario navigates to its fixed record", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/scenarios");
const duplicateCard = page.locator(".scenario-card", { hasText: "dubbele klant" });
await duplicateCard.getByRole("link", { name: "Start scenario" }).click();
await duplicateCard.getByRole("link", { name: "Scenario starten" }).click();
await expect(page).toHaveURL(/\/data-quality\/DQ-DEMO-DUPLICATE$/);
});
@@ -92,7 +92,7 @@ test("demo guide progress persists across navigation and the trigger shows it",
test("demo guide is not shown to a rental employee", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
await page.getByRole("button", { name: "Verken als Verhuurmedewerker" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("button", { name: /Demo-gids/ })).toHaveCount(0);
});
+5 -5
View File
@@ -12,7 +12,7 @@ test.describe.configure({ mode: "serial" });
test("return flow pre-fills the suspicious odometer reading and explains why", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/bookings/BK-DEMO-RETURN");
@@ -27,12 +27,12 @@ test("return flow pre-fills the suspicious odometer reading and explains why", a
await page.getByRole("button", { name: "Retour bevestigen" }).click();
await expect(page.getByRole("heading", { name: "Retour geregistreerd" })).toBeVisible();
await expect(page.getByRole("link", { name: "Automatiseringsstatus bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "Audit trail bekijken" })).toBeVisible();
await expect(page.getByRole("link", { name: "Auditgeschiedenis bekijken" })).toBeVisible();
});
test("data quality issue detail explains what's wrong and why it matters", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality/DQ-DEMO-DUPLICATE");
@@ -43,7 +43,7 @@ test("data quality issue detail explains what's wrong and why it matters", async
test("data quality list can filter to demo scenarios only", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/data-quality");
@@ -61,7 +61,7 @@ test("data quality list can filter to demo scenarios only", async ({ page }) =>
test("knowledge page suggested question returns a grounded, honestly-labelled answer", async ({ page }) => {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await page.goto("/knowledge");
+1 -1
View File
@@ -19,7 +19,7 @@ test("five-minute demo script end to end", async ({ page, request }) => {
await test.step("1. login as Operations Manager", async () => {
await page.goto("/login");
await expect(page.getByText(/Synthetische demo/)).toBeVisible();
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
});
+205
View File
@@ -0,0 +1,205 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { ApiError } from "../src/api/apiError";
import { describeApiError, KNOWN_CODES } from "../src/api/errorMessages";
// Pure Node-context checks for the central API-error-localization function (section 7 /
// 11 of the Fleet Ops final localization brief). No browser needed: describeApiError()
// only depends on a `t` function and a caught error, so it's tested here against the
// real locale JSON with a minimal i18next-shaped `t` stub -- proving the known-code and
// known-HTTP-status paths never leak raw backend English as the primary message, and
// that the raw text is always still available via `.technical` for "Technical details".
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const LOCALES_DIR = path.resolve(__dirname, "../src/i18n/locales");
const LANGUAGES = ["nl-BE", "en-GB", "fr-BE"] as const;
function loadNamespace(language: string, namespace: string): Record<string, unknown> {
const filePath = path.join(LOCALES_DIR, language, `${namespace}.json`);
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
}
// Mirrors the (namespace, options.defaultValue) contract react-i18next's `t` exposes,
// resolving "namespace:dotted.path" against the real locale files for the given language.
function makeT(language: string): (key: string, options?: Record<string, unknown>) => string {
return (key: string, options?: Record<string, unknown>) => {
const [ns, ...rest] = key.includes(":") ? key.split(":") : ["errors", key];
const dottedPath = key.includes(":") ? rest.join(":") : rest.join("");
const data = loadNamespace(language, ns);
const value = dottedPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
if (typeof value === "string") return value;
if (options && "defaultValue" in options) return String(options.defaultValue);
return key;
};
}
const KNOWN_HTTP_STATUSES = ["401", "403", "404", "409", "422", "500"];
test("every known AppError code has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const codes = loadNamespace(language, "errors").codes as Record<string, { title?: string; explanation?: string }>;
for (const code of KNOWN_CODES) {
expect(codes[code], `${language}/errors.json is missing codes.${code}`).toBeTruthy();
expect(codes[code]?.title?.trim().length ?? 0, `${language}/errors.json:codes.${code}.title is empty`).toBeGreaterThan(0);
expect(
codes[code]?.explanation?.trim().length ?? 0,
`${language}/errors.json:codes.${code}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("every known HTTP status fallback has a non-empty title+explanation in all 3 locales", () => {
for (const language of LANGUAGES) {
const http = loadNamespace(language, "errors").http as Record<string, { title?: string; explanation?: string }>;
for (const status of KNOWN_HTTP_STATUSES) {
expect(http[status], `${language}/errors.json is missing http.${status}`).toBeTruthy();
expect(http[status]?.title?.trim().length ?? 0, `${language}/errors.json:http.${status}.title is empty`).toBeGreaterThan(0);
expect(
http[status]?.explanation?.trim().length ?? 0,
`${language}/errors.json:http.${status}.explanation is empty`,
).toBeGreaterThan(0);
}
}
});
test("a known AppError code resolves to its localized codes.* entry, never the raw backend message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "IntegrityError: duplicate key value violates unique constraint";
const err = new ApiError(409, "VEHICLE_NOT_FOUND", raw, "corr-1");
const info = describeApiError(t, err);
const expected = loadNamespace(language, "errors").codes as Record<string, { title: string; explanation: string; nextStep?: string }>;
expect(info.title).toBe(expected.VEHICLE_NOT_FOUND.title);
expect(info.explanation).toBe(expected.VEHICLE_NOT_FOUND.explanation);
expect(info.title).not.toBe(raw);
expect(info.explanation).not.toBe(raw);
// The raw backend text must still be reachable, just demoted to `.technical`.
expect(info.technical).toBe(raw);
}
});
test("a code with nextStep populates it; a code without nextStep leaves it undefined", () => {
const t = makeT("nl-BE");
const withNextStep = describeApiError(t, new ApiError(422, "EMPTY_VALUE", "raw", "c1"));
expect(withNextStep.nextStep).toBeTruthy();
const withoutNextStep = describeApiError(t, new ApiError(404, "CUSTOMER_NOT_FOUND", "raw", "c2"));
expect(withoutNextStep.nextStep).toBeUndefined();
});
test("an unrecognized AppError code falls back to the matching known HTTP status, not raw text", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "Some brand-new backend code nobody localized yet";
const err = new ApiError(404, "SOME_FUTURE_CODE_NOT_YET_LOCALIZED", raw, "corr-2");
const info = describeApiError(t, err);
const expected404 = (loadNamespace(language, "errors").http as Record<string, { title: string; explanation: string }>)["404"];
expect(info.title).toBe(expected404.title);
expect(info.explanation).toBe(expected404.explanation);
expect(info.title).not.toBe(raw);
expect(info.technical).toBe(raw);
}
});
test("an unrecognized code and an unrecognized HTTP status fall back to the fully generic message", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const raw = "418 I'm a teapot (never mapped)";
const err = new ApiError(418, "418", raw, "corr-3");
const info = describeApiError(t, err);
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
expect(info.technical).toBe(raw);
}
});
test("a stringified HTTP status used as the AppError code (plain HTTPException path) resolves via the http map", () => {
// Mirrors app/main.py's plain-HTTPException handler, which sets code = str(status_code)
// (e.g. "401") rather than a semantic AppError code -- see backend/app/main.py.
const t = makeT("fr-BE");
const err = new ApiError(401, "401", "Not authenticated", "corr-4");
const info = describeApiError(t, err);
const expected401 = (loadNamespace("fr-BE", "errors").http as Record<string, { title: string }>)["401"];
expect(info.title).toBe(expected401.title);
expect(info.title).not.toBe("Not authenticated");
});
test("a non-ApiError (e.g. network failure before any response) uses the fallback key, never a raw JS error message as the primary text", () => {
const t = makeT("nl-BE");
const networkFailure = new TypeError("Failed to fetch");
// Real call sites (e.g. Automation.tsx) invoke t() with their own default namespace
// already scoped via useTranslation("integrations"); this stub's default namespace is
// "errors", so the fallback key is qualified explicitly here to match.
const info = describeApiError(t, networkFailure, "integrations:ledger.retryFailed");
const expectedFallback = loadNamespace("nl-BE", "integrations").ledger as Record<string, string>;
expect(info.explanation).toBe(expectedFallback.retryFailed);
expect(info.explanation).not.toBe("Failed to fetch");
expect(info.technical).toBe("Failed to fetch");
});
// --- Backend/frontend AppError code drift guard ---
// KNOWN_CODES is a hand-maintained mirror of every `raise AppError("CODE", ...)` in the
// backend (see app/core/errors.py::AppError and every raise site). If the backend adds a
// new code and nobody updates KNOWN_CODES, it silently falls back to the generic-but-
// still-localized HTTP/generic message rather than raw English -- not a broken build, but
// a missed opportunity for a more specific message. This test surfaces that drift instead
// of letting it go unnoticed indefinitely.
const BACKEND_APP_DIR = path.resolve(__dirname, "../../backend/app");
function collectPyFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectPyFiles(full);
return entry.name.endsWith(".py") ? [full] : [];
});
}
function collectBackendAppErrorCodes(): Set<string> {
const codes = new Set<string>();
for (const file of collectPyFiles(BACKEND_APP_DIR)) {
const source = fs.readFileSync(file, "utf-8");
const pattern = /AppError\(\s*"([A-Z_]+)"/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(source)) !== null) {
codes.add(match[1]);
}
}
return codes;
}
test("frontend KNOWN_CODES exactly matches every AppError code actually raised by the backend", () => {
const backendCodes = collectBackendAppErrorCodes();
const frontendCodes = KNOWN_CODES;
const missingFromFrontend = [...backendCodes].filter((c) => !frontendCodes.has(c)).sort();
const staleInFrontend = [...frontendCodes].filter((c) => !backendCodes.has(c)).sort();
expect(
missingFromFrontend,
`Backend raises AppError code(s) with no localized entry in errorMessages.ts KNOWN_CODES ` +
`(they'll fall back to a generic/HTTP-status message): ${missingFromFrontend.join(", ")}`,
).toEqual([]);
expect(
staleInFrontend,
`errorMessages.ts KNOWN_CODES lists code(s) the backend never raises -- likely renamed or ` +
`removed on the backend side: ${staleInFrontend.join(", ")}`,
).toEqual([]);
});
test("a non-ApiError with no fallbackKey uses the fully generic explanation", () => {
for (const language of LANGUAGES) {
const t = makeT(language);
const info = describeApiError(t, new TypeError("Failed to fetch"));
const generic = loadNamespace(language, "errors").generic as { title: string; explanation: string };
expect(info.title).toBe(generic.title);
expect(info.explanation).toBe(generic.explanation);
}
});
+389
View File
@@ -0,0 +1,389 @@
import { expect, test, type APIRequestContext, type Page } from "@playwright/test";
// Targeted end-to-end coverage for the Fleet Ops correction brief (docs/fleet-ops-
// correction/): branding, the redesigned status-recommendation flow (preview/apply/
// manual-review/stale-token), MO-016 order independence, trilingual knowledge
// grounding, and localized audit/automation content. See also i18n-coverage.spec.ts
// (key parity, brand invariant, translation-quality) and responsive-i18n.spec.ts
// (breakpoint matrix) for the complementary static-content checks.
async function resetDemoData(request: APIRequestContext) {
const login = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(login.ok()).toBeTruthy();
const reset = await request.post("/api/v1/demo/reset");
expect(reset.ok()).toBeTruthy();
// /api/v1/demo/reset deletes the session cookie (it recreates the users table), so any
// further authenticated call through this same request context needs a fresh login.
const relogin = await request.post("/api/v1/demo/login", { data: { role: "operations_manager" } });
expect(relogin.ok()).toBeTruthy();
}
const EXPLORE_OPS_MANAGER: Record<string, string> = {
"nl-BE": "Verken als Operationsmanager",
"en-GB": "Explore as Operations Manager",
"fr-BE": "Explorer en tant que Responsable des opérations",
};
const REVIEW_RECOMMENDATION: Record<string, string> = {
"nl-BE": "Aanbeveling bekijken",
"en-GB": "Review recommendation",
"fr-BE": "Voir la recommandation",
};
const CHANGE_STATUS_PREFIX: Record<string, RegExp> = {
"nl-BE": /^Status wijzigen naar/,
"en-GB": /^Change status to/,
"fr-BE": /^Changer le statut vers/,
};
const MANUAL_REVIEW_HEADING: Record<string, string> = {
"nl-BE": "Handmatige beoordeling vereist",
"en-GB": "Manual review required",
"fr-BE": "Évaluation manuelle requise",
};
async function loginAsOpsManager(page: Page, lang: string) {
await page.addInitScript((l) => localStorage.setItem("fleetops.language", l), lang);
await page.goto("/login");
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER[lang] }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
test.describe.configure({ mode: "serial" });
test.describe("branding", () => {
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
test(`Fleet Ops is the visible brand and no MobilityOps/PoC leaks through (${lang})`, async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await expect(page.locator(".brand-mark").first()).toBeVisible();
await expect(page.getByText("Fleet Ops", { exact: true }).first()).toBeVisible();
await expect(page.locator(".app-footer")).toContainText("Fleet Ops");
await expect(page.locator("html")).toHaveAttribute("lang", lang);
for (const path of ["/dashboard", "/vehicles", "/data-quality", "/audit", "/automation", "/knowledge"]) {
await page.goto(path);
const text = await page.locator("body").innerText();
expect(text, `${path} (${lang})`).not.toContain("MobilityOps");
expect(text, `${path} (${lang})`).not.toMatch(/\bPoC\b/);
}
});
}
});
test("the Fleet Ops favicon is linked and resolves (not the browser's blank-tab default)", async ({ page, request }) => {
await resetDemoData(request);
await page.goto("/login");
const href = await page.locator('link[rel="icon"]').getAttribute("href");
expect(href).toBe("/favicon.svg");
const response = await page.request.get(href as string);
expect(response.ok()).toBeTruthy();
expect(response.headers()["content-type"]).toContain("svg");
});
test("language switcher control changes the UI and persists across a reload", async ({ page, request }) => {
await resetDemoData(request);
// Deliberately not using loginAsOpsManager here: its addInitScript would re-force
// nl-BE on every reload, defeating exactly the persistence behaviour under test.
await page.goto("/login");
await page.getByRole("button", { name: EXPLORE_OPS_MANAGER["nl-BE"] }).click();
await expect(page).toHaveURL(/\/dashboard$/);
await expect(page.getByRole("heading", { name: "Aandachtspunten" })).toBeVisible();
await page.getByRole("combobox", { name: "Taal" }).selectOption("fr-BE");
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
await page.reload();
await expect(page.getByRole("heading", { name: "File d'attention" })).toBeVisible();
await expect(page.locator("html")).toHaveAttribute("lang", "fr-BE");
});
test.describe("status-recommendation flow", () => {
test("preview does not mutate anything, apply names the exact target status", async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await expect(page.getByText("Aanbevolen status")).toBeVisible();
await expect(page.getByRole("heading", { name: "Waarom" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Gevolg" })).toBeVisible();
// Previewing must not have resolved the issue -- still open, using the page's own
// authenticated session (page.request shares cookies with the browser context).
const issue = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
expect((await issue.json()).status).toBe("open");
const confirmButton = page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] });
await expect(confirmButton).toHaveText(/Geblokkeerd/);
await confirmButton.click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
const resolved = await page.request.get("/api/v1/data-quality/issues/DQ-DEMO-STATUS");
expect((await resolved.json()).status).toBe("resolved");
});
test("manual review state offers no generic apply button for a genuine fact contradiction", async ({
page,
request,
}) => {
await resetDemoData(request);
const issues = await (
await request.get("/api/v1/data-quality/issues", {
params: { rule_type: "vehicle_status_conflict", status: "open" },
})
).json();
const conflicted = issues.find((i: { entity_ref: string }) => i.entity_ref === "MO-024");
expect(conflicted, "expected MO-024's vehicle_status_conflict issue to exist after reset").toBeTruthy();
await loginAsOpsManager(page, "en-GB");
await page.goto(`/data-quality/${conflicted.public_ref}`);
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
await expect(page.getByRole("heading", { name: MANUAL_REVIEW_HEADING["en-GB"] })).toBeVisible();
await expect(page.getByRole("button", { name: /^Change status to/ })).toHaveCount(0);
});
test("a stale recommendation is rejected and the user must review again before applying", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "en-GB");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] }).click();
await expect(page.getByRole("button", { name: /^Change status to/ })).toBeVisible();
// Simulate the underlying facts changing after the preview was shown (the same
// session resolves the booking overlap in the meantime) -- the previously-fetched
// recommendation token must no longer be accepted.
await page.evaluate(async () => {
await fetch("/api/v1/data-quality/issues/DQ-DEMO-OVERLAP/resolve-overlap", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ booking_ref: "BK-DEMO-OVERLAP-B" }),
});
});
await page.getByRole("button", { name: /^Change status to/ }).click();
await expect(page.getByText(/situation has changed/i)).toBeVisible();
await expect(page.getByRole("button", { name: REVIEW_RECOMMENDATION["en-GB"] })).toBeVisible();
});
});
test.describe("MO-016 status conflict is order-independent", () => {
// Order independence does NOT mean "the same final vehicle status regardless of
// order" -- resolving the booking overlap first genuinely removes the conflict, so
// there is correctly nothing left to apply afterwards. What must hold in either
// order: the recommendation always reflects the real, current facts (never a stale
// "was some other issue open" proxy), and nothing unsafe is ever applied (never
// "rented").
test("resolving the booking overlap first correctly leaves nothing to apply", async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
await expect(page.getByText("Opgelost").first()).toBeVisible();
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await expect(page.getByRole("heading", { name: "Geen wijziging nodig" })).toBeVisible();
await expect(page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] })).toHaveCount(0);
const vehicle = await request.get("/api/v1/vehicles/MO-016");
expect((await vehicle.json()).operational_status).toBe("available");
});
test("resolving the status conflict first safely blocks the vehicle, unaffected by the later overlap fix", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
const vehicleMid = await request.get("/api/v1/vehicles/MO-016");
const statusAfterApply = (await vehicleMid.json()).operational_status;
expect(statusAfterApply).not.toBe("rented");
await page.goto("/data-quality/DQ-DEMO-OVERLAP");
await page.getByRole("radio", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).check();
await page.getByRole("button", { name: /BK-DEMO-OVERLAP-B blokkeren/ }).click();
await expect(page.getByText("Opgelost").first()).toBeVisible();
// Resolving the now-redundant overlap afterwards must not itself change the
// vehicle's status as a side effect.
const vehicleFinal = await request.get("/api/v1/vehicles/MO-016");
const statusFinal = (await vehicleFinal.json()).operational_status;
expect(statusFinal).toBe(statusAfterApply);
expect(statusFinal).not.toBe("rented");
await resetDemoData(request);
});
});
test.describe("knowledge base is grounded in the operator's own language", () => {
const cases: { lang: string; question: string; sourceHint: RegExp }[] = [
{
lang: "nl-BE",
question: "Wat moet ik doen wanneer een voertuig beschadigd terugkomt?",
sourceHint: /schadeafhandeling/i,
},
{
lang: "en-GB",
question: "What should I do when a vehicle returns with damage?",
sourceHint: /damage/i,
},
{
lang: "fr-BE",
question: "Que dois-je faire lorsqu'un véhicule revient endommagé ?",
sourceHint: /dommages/i,
},
];
for (const { lang, question, sourceHint } of cases) {
test(`grounded ${lang} answer cites a ${lang} source about damage`, async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await page.goto("/knowledge");
await page.locator("#knowledge-question").fill(question);
await page.getByRole("button", { name: /^(Vraag stellen|Ask|Demander)$/ }).click();
await expect(page.getByText(sourceHint).first()).toBeVisible({ timeout: 10_000 });
});
}
});
test("audit trail shows localized action and field labels with raw codes only in technical details", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/data-quality/DQ-DEMO-STATUS");
await page.getByRole("button", { name: REVIEW_RECOMMENDATION["nl-BE"] }).click();
await page.getByRole("button", { name: CHANGE_STATUS_PREFIX["nl-BE"] }).click();
await expect(page.getByText("Toegepast", { exact: false })).toBeVisible();
await page.goto("/audit");
// Applying resolves both the vehicle status and the issue in one correlated action --
// expand the group's "technical events" toggle so the other audit row's diff renders
// too, regardless of which one the grouping picked as primary.
const toggle = page.getByRole("button", { name: /technische gebeurtenis/ }).first();
await expect(toggle).toBeVisible();
await toggle.click();
await expect(page.getByRole("button", { name: "Technische gebeurtenissen verbergen" })).toBeVisible();
await expect(page.getByText("Aanbevolen status toegepast").first()).toBeVisible();
const diffs = page.locator(".change-diff");
await expect(diffs.first()).toBeVisible();
const combinedDiffText = (await diffs.allInnerTexts()).join(" ");
expect(combinedDiffText).toContain("Operationele status");
expect(combinedDiffText).not.toContain("operational_status");
});
test("automation shows a localized error explanation with the raw error only under technical details", async ({
page,
request,
}) => {
await resetDemoData(request);
await loginAsOpsManager(page, "nl-BE");
await page.goto("/automation");
await expect(page.getByText(/tijdelijk niet bereikbaar/).first()).toBeVisible();
await expect(page.getByText("Synthetic connection timeout to n8n")).not.toBeVisible();
await page.getByText("Technische details").first().click();
await expect(page.getByText("Synthetic connection timeout to n8n")).toBeVisible();
});
test.describe("route matrix (section 11F)", () => {
// Opens every main route in all 3 languages: no console errors, correct html[lang],
// and a real, non-empty page heading (proving the route actually rendered content
// instead of silently falling back to a raw i18next key or a blank screen). Key
// parity across locale files is already proven structurally by i18n-coverage.spec.ts
// (every key that exists in nl-BE also exists, non-empty, in en-GB/fr-BE), so this
// matrix focuses on what only a live render can catch.
const routes = [
"/dashboard",
"/vehicles",
"/vehicles/MO-001",
"/bookings",
"/bookings/BK-DEMO-RETURN",
"/data-quality",
"/data-quality/DQ-DEMO-STATUS",
"/automation",
"/knowledge",
"/audit",
"/scenarios",
"/about",
];
for (const lang of ["nl-BE", "en-GB", "fr-BE"]) {
test(`every main route renders correctly with no console errors (${lang})`, async ({ page, request }) => {
await resetDemoData(request);
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
if (msg.text().includes("401") && msg.text().includes("Unauthorized")) return;
errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
await loginAsOpsManager(page, lang);
for (const route of routes) {
await page.goto(route);
await expect(page.locator("html")).toHaveAttribute("lang", lang);
const heading = page.getByRole("heading", { level: 1 });
await expect(heading, `${route} (${lang})`).toBeVisible();
const headingText = (await heading.first().textContent())?.trim() ?? "";
expect(headingText, `${route} (${lang}) heading text`).not.toBe("");
// A raw, unresolved i18next key looks like "namespace:some.key.path" -- real
// page headings never contain a colon followed by a dotted identifier.
expect(headingText, `${route} (${lang}) heading looks like a raw i18n key`).not.toMatch(
/^[a-zA-Z]+:[\w.]+$/,
);
}
expect(errors, `Console errors across the route matrix (${lang}):\n${errors.join("\n")}`).toEqual([]);
});
}
});
test.describe("data-quality evidence summary is localized, not raw English (section 6)", () => {
// The primary evidence line at the top of every issue's detail page must render the
// structured `evidence.signals` in the operator's language; the legacy English
// `evidence.summary` string is a technical fallback only, visible solely inside
// "Technical details". Live-caught: this line was unconditionally showing raw
// English ("vehicle marked available while reserved bookings conflict") in every
// language until fixed.
const cases: { lang: string; expectedText: RegExp }[] = [
{ lang: "nl-BE", expectedText: /overlappende reserveringen/i },
{ lang: "en-GB", expectedText: /overlapping bookings/i },
{ lang: "fr-BE", expectedText: /chevauchent|chevauchement/i },
];
for (const { lang, expectedText } of cases) {
test(`vehicle_status_conflict evidence is localized (${lang})`, async ({ page, request }) => {
await resetDemoData(request);
await loginAsOpsManager(page, lang);
await page.goto("/data-quality/DQ-DEMO-STATUS");
const summarySection = page.locator(".record-surface-evidence");
await expect(summarySection).toBeVisible();
await expect(summarySection).toContainText(expectedText);
await expect(summarySection).not.toContainText("vehicle marked available while reserved bookings conflict");
});
}
});
+107
View File
@@ -0,0 +1,107 @@
import { expect, type Page, test } from "@playwright/test";
// Browser-context evidence for the time-dependent Europe/Brussels dashboard greeting
// (section 9 / 11 / 16 of the Fleet Ops final localization brief): the real rendered app,
// in all 3 languages, at every required boundary instant, using Playwright's clock API to
// control the browser's Date without waiting on real wall-clock time. Also proves the
// greeting updates live (no reload) when the period rolls over while the app stays open.
// 2026-01-15 is CET (UTC+1): Brussels hour = UTC hour + 1.
function cet(hour: number, minute = 0, second = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute, second));
}
const BOUNDARY_CASES: Array<{ time: Date; label: string; period: string }> = [
{ time: cet(4, 59), label: "04:59", period: "night" },
{ time: cet(5, 0), label: "05:00", period: "morning" },
{ time: cet(11, 59), label: "11:59", period: "morning" },
{ time: cet(12, 0), label: "12:00", period: "afternoon" },
{ time: cet(17, 59), label: "17:59", period: "afternoon" },
{ time: cet(18, 0), label: "18:00", period: "evening" },
{ time: cet(22, 59), label: "22:59", period: "evening" },
{ time: cet(23, 0), label: "23:00", period: "night" },
];
const EXPECTED_TITLE: Record<string, Record<string, string>> = {
"nl-BE": {
morning: "Goedemorgen. Hier is de status van je wagenpark voor vandaag.",
afternoon: "Goedemiddag. Hier is het actuele overzicht van je wagenpark.",
evening: "Goedenavond. Hier is het overzicht van je wagenpark voor vanavond.",
night: "Welkom terug. Hier is het laatste overzicht van je wagenpark.",
},
"en-GB": {
morning: "Good morning. Here's today's fleet status.",
afternoon: "Good afternoon. Here's the current overview of your fleet.",
evening: "Good evening. Here's this evening's fleet overview.",
night: "Welcome back. Here's the latest overview of your fleet.",
},
"fr-BE": {
morning: "Bonjour. Voici l'état de votre flotte pour aujourd'hui.",
afternoon: "Bonjour. Voici l'aperçu actuel de votre flotte.",
evening: "Bonsoir. Voici l'aperçu de votre flotte pour ce soir.",
night: "Bon retour. Voici le dernier aperçu de votre flotte.",
},
};
async function loginAsOperationsManager(page: Page) {
await page.goto("/login");
await page.getByRole("button", { name: "Verken als Operationsmanager" }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}
async function switchLanguage(page: Page, language: "nl-BE" | "en-GB" | "fr-BE") {
// At the default desktop viewport, only the topbar's compact switcher is visible --
// the sidebar's full switcher is `display: none` until the <960px breakpoint.
await page.locator(".language-switcher-compact select").selectOption(language);
}
const dashboardHeading = (page: Page) => page.locator(".page-header h1");
for (const language of ["nl-BE", "en-GB", "fr-BE"] as const) {
test(`dashboard greeting matches every required boundary time in ${language}`, async ({ page }) => {
await page.clock.install({ time: BOUNDARY_CASES[0].time });
await loginAsOperationsManager(page);
if (language !== "nl-BE") {
await switchLanguage(page, language);
}
for (const { time, label, period } of BOUNDARY_CASES) {
await page.clock.setFixedTime(time);
await page.reload();
await expect(dashboardHeading(page), `${language} @ ${label} Brussels time (expected period: ${period})`).toHaveText(
EXPECTED_TITLE[language][period],
);
}
});
}
test("dashboard greeting updates live across a period rollover without a page reload", async ({ page }) => {
// Start at 11:59:31 Brussels -- 29s before the 12:00 boundary.
await page.clock.install({ time: cet(11, 59, 31) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
// Advance 30s of fake time (crossing 12:00) so the hook's 30s poll interval fires and
// recomputes the period -- no page.reload() call anywhere in this test.
await page.clock.fastForward(30_000);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].afternoon);
});
test("dashboard greeting updates immediately on language switch without changing the time period", async ({ page }) => {
await page.clock.install({ time: cet(9, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["nl-BE"].morning);
await switchLanguage(page, "en-GB");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["en-GB"].morning);
await switchLanguage(page, "fr-BE");
await expect(dashboardHeading(page)).toHaveText(EXPECTED_TITLE["fr-BE"].morning);
});
test("dashboard never shows 'Goedenacht' as a greeting at any hour", async ({ page }) => {
await page.clock.install({ time: cet(2, 0) });
await loginAsOperationsManager(page);
await expect(dashboardHeading(page)).not.toContainText("Goedenacht");
await expect(dashboardHeading(page)).toContainText("Welkom terug");
});
+68
View File
@@ -0,0 +1,68 @@
import { expect, test } from "@playwright/test";
import { getBrusselsHour, getGreetingPeriod } from "../src/i18n/greeting";
// Pure Node-context boundary tests for the central, clock-injectable greeting function
// (section 9 of the Fleet Ops final localization brief). Every case below constructs an
// explicit UTC instant that corresponds to a specific Europe/Brussels wall-clock time --
// this is what "clock injection" buys: no real time needs to pass, and DST is exercised
// by picking instants either side of the CET/CEST transition.
// 2026-01-15 is CET (UTC+1): 04:59 Brussels = 03:59 UTC.
function cet(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 0, 15, hour - 1, minute));
}
// 2026-07-15 is CEST (UTC+2): 04:59 Brussels = 02:59 UTC.
function cest(hour: number, minute = 0): Date {
return new Date(Date.UTC(2026, 6, 15, hour - 2, minute));
}
test("period boundaries are correct in winter time (CET, UTC+1)", () => {
expect(getGreetingPeriod(cet(4, 59))).toBe("night");
expect(getGreetingPeriod(cet(5, 0))).toBe("morning");
expect(getGreetingPeriod(cet(11, 59))).toBe("morning");
expect(getGreetingPeriod(cet(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cet(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cet(18, 0))).toBe("evening");
expect(getGreetingPeriod(cet(22, 59))).toBe("evening");
expect(getGreetingPeriod(cet(23, 0))).toBe("night");
});
test("period boundaries are correct in summer time (CEST, UTC+2)", () => {
expect(getGreetingPeriod(cest(4, 59))).toBe("night");
expect(getGreetingPeriod(cest(5, 0))).toBe("morning");
expect(getGreetingPeriod(cest(11, 59))).toBe("morning");
expect(getGreetingPeriod(cest(12, 0))).toBe("afternoon");
expect(getGreetingPeriod(cest(17, 59))).toBe("afternoon");
expect(getGreetingPeriod(cest(18, 0))).toBe("evening");
expect(getGreetingPeriod(cest(22, 59))).toBe("evening");
expect(getGreetingPeriod(cest(23, 0))).toBe("night");
});
test("DST transition (2026-03-29, clocks spring forward 02:00 -> 03:00 CEST): the Brussels hour never regresses or skips a period incorrectly", () => {
// 00:30 UTC = 01:30 CET, still "night" (before the 05:00 boundary regardless).
const beforeTransition = new Date(Date.UTC(2026, 2, 29, 0, 30));
expect(getBrusselsHour(beforeTransition)).toBe(1);
expect(getGreetingPeriod(beforeTransition)).toBe("night");
// 09:00 UTC on transition day = 11:00 CEST (already sprung forward) -- still morning.
const afterTransition = new Date(Date.UTC(2026, 2, 29, 9, 0));
expect(getBrusselsHour(afterTransition)).toBe(11);
expect(getGreetingPeriod(afterTransition)).toBe("morning");
// Autumn transition, 2026-10-25: fall-back happens at 01:00 UTC (03:00 CEST -> 02:00
// CET), so 00:30 UTC is still CEST -> 02:30 Brussels.
const beforeFallBack = new Date(Date.UTC(2026, 9, 25, 0, 30));
expect(getBrusselsHour(beforeFallBack)).toBe(2);
expect(getGreetingPeriod(beforeFallBack)).toBe("night");
// 09:00 UTC on fall-back day = 10:00 CET (already fallen back) -- still morning.
const afterFallBack = new Date(Date.UTC(2026, 9, 25, 9, 0));
expect(getBrusselsHour(afterFallBack)).toBe(10);
expect(getGreetingPeriod(afterFallBack)).toBe("morning");
});
test("default argument uses the real current time when no clock is injected", () => {
const period = getGreetingPeriod();
expect(["morning", "afternoon", "evening", "night"]).toContain(period);
});
+250
View File
@@ -71,3 +71,253 @@ test("no locale file contains an empty string value", () => {
}
}
});
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
// A regression here means someone re-introduced a per-locale brand key/value instead of
// interpolating {{productName}} from the shared constant.
test("no locale file defines an 'appName' key or the literal brand string", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const data = loadNamespace(language, namespace);
const raw = JSON.stringify(data);
expect(
raw.includes("Fleet Ops"),
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
`use {{productName}} interpolation instead so the brand can never drift per locale`,
).toBe(false);
const keys = collectKeyPaths(data);
expect(
keys.some((k) => k === "appName" || k.endsWith(".appName")),
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
`from the PRODUCT_NAME constant, never a translatable key`,
).toBe(false);
}
}
});
test("no locale file contains the internal project name 'MobilityOps' or the word 'PoC'", () => {
for (const language of LANGUAGES) {
for (const namespace of namespaces) {
const raw = JSON.stringify(loadNamespace(language, namespace));
expect(
raw.includes("MobilityOps"),
`${language}/${namespace}.json contains "MobilityOps" -- the visible product name is ` +
`always "Fleet Ops" (via {{productName}}); "MobilityOps" is a technical/repo-only identifier`,
).toBe(false);
expect(
/\bPoC\b/.test(raw),
`${language}/${namespace}.json contains "PoC" -- Fleet Ops is never described as a PoC ` +
`in user-facing copy`,
).toBe(false);
}
}
});
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
// the literal English string under the right key and still pass). For every "real prose"
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
// "Dutch text copy-pasted into French" in one pass.
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
// hide an unrelated real mistranslation under the same key in a different namespace.
const IDENTICAL_VALUE_ALLOWLIST = new Set([
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
"common.language.nl-BE", // language-picker options show each language's own endonym
"common.language.fr-BE",
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
"knowledge.questionLabel", // "Question" -- identical spelling in French
"knowledge.retrievalFlow.question",
"knowledge.questionLabelExchange",
"returns.result.inspection", // "Inspection" -- identical spelling in French
]);
function isTranslatableProse(value: unknown): value is string {
if (typeof value !== "string") return false;
if (value.trim().length < 8) return false;
// Strip interpolation placeholders and non-letter characters; if nothing substantial
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
const stripped = value
.replace(/\{\{[^}]+\}\}/g, " ")
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
return stripped.trim().length >= 3;
}
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
for (const namespace of namespaces) {
const en = loadNamespace("en-GB", namespace);
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
const keys = collectKeyPaths(en);
for (const keyPath of keys) {
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
const at = (data: Record<string, unknown>) =>
keyPath.split(".").reduce<unknown>((acc, part) => {
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
return undefined;
}, data);
const enValue = at(en);
if (!isTranslatableProse(enValue)) continue;
const nlValue = at(nl);
const frValue = at(fr);
expect(
nlValue,
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
).not.toBe(enValue);
expect(
frValue,
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
).not.toBe(enValue);
expect(
frValue,
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
`looks like Dutch text was copy-pasted into the French locale`,
).not.toBe(nlValue);
}
}
});
// --- Embedded English/Dutch fragments inside otherwise-translated prose ---
// The whole-string identity check above only catches a value that is IDENTICAL to
// en-GB end-to-end. It cannot catch a real bug class found during the Fleet Ops final
// localization pass: a sentence gets 95% translated but a role/status noun phrase is
// left embedded mid-sentence, e.g. nl-BE "... moet door de Operations Manager worden
// goedgekeurd." This scan flags known English fragments appearing literally inside any
// nl-BE or fr-BE string value, and known Dutch fragments leaking into fr-BE (copy-paste
// mistakes). Deliberately limited to unambiguous multi-word phrases (not single common
// words like "Open" or "Field", which collide with genuine Dutch/French vocabulary).
const FORBIDDEN_ENGLISH_FRAGMENTS = [
"Operations Manager",
"Operations Managers",
"Rental Employee",
"Rental Employees",
"Audit trail",
"Start scenario",
];
const FORBIDDEN_DUTCH_FRAGMENTS_IN_FR = [
"Operationsmanager",
"Verhuurmedewerker",
"Auditgeschiedenis",
"Scenario starten",
];
function collectStringLeaves(value: unknown, prefix = ""): Array<{ path: string; value: string }> {
if (typeof value === "string") return [{ path: prefix, value }];
if (value === null || typeof value !== "object") return [];
return Object.entries(value as Record<string, unknown>).flatMap(([key, nested]) =>
collectStringLeaves(nested, prefix ? `${prefix}.${key}` : key),
);
}
test("no known English role/status fragments leak into nl-BE or fr-BE prose", () => {
const findings: string[] = [];
for (const namespace of namespaces) {
const nl = loadNamespace("nl-BE", namespace);
const fr = loadNamespace("fr-BE", namespace);
for (const { path: keyPath, value } of collectStringLeaves(nl)) {
for (const fragment of FORBIDDEN_ENGLISH_FRAGMENTS) {
if (value.includes(fragment)) {
findings.push(`nl-BE/${namespace}.json:${keyPath} contains English fragment "${fragment}": "${value}"`);
}
}
}
for (const { path: keyPath, value } of collectStringLeaves(fr)) {
for (const fragment of [...FORBIDDEN_ENGLISH_FRAGMENTS, ...FORBIDDEN_DUTCH_FRAGMENTS_IN_FR]) {
if (value.includes(fragment)) {
findings.push(`fr-BE/${namespace}.json:${keyPath} contains foreign-language fragment "${fragment}": "${value}"`);
}
}
}
}
expect(findings, findings.join("\n")).toEqual([]);
});
// --- Hardcoded JSX text (section 11D) ---
// A targeted, deliberately narrow static scan: JSX text nodes (`>literal text<`, not a
// `{...}` expression) containing two or more real words are almost always user-facing
// prose that should go through t(...). This is not a full parser, so a short, explicit
// allowlist covers technical tokens/proper nouns that are correctly never translated
// (MobilityOps.md is checked for absence elsewhere; this list is for things that ARE
// expected to appear literally in JSX).
const SRC_DIR = path.resolve(__dirname, "../src");
const SCAN_DIRS = ["pages", "components"];
const ALLOWED_LITERAL_TEXT = new Set([
"Fleet Ops", // the non-localizable brand name (frontend/src/product.ts)
"Northstar Mobility", // fictional demo org, a proper noun
"ITWorx MCP Hub", // proper noun
]);
function collectTsxFiles(dir: string): string[] {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap((entry) => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) return collectTsxFiles(full);
return entry.name.endsWith(".tsx") ? [full] : [];
});
}
function findHardcodedJsxText(filePath: string): string[] {
const source = fs.readFileSync(filePath, "utf-8");
const findings: string[] = [];
// Matches `<Tag ...>text</Tag>` where the closing tag name backreferences the
// opening one -- this specifically excludes TypeScript generics like
// `useState<string | null>(null)`, which have no matching `</string | null>` closer,
// unlike a naive `>...<` scan would. Deliberately spans newlines (Prettier commonly
// puts JSX text on its own line) and does not attempt to parse JSX properly -- it is
// a fast, approximate net for the common mistake, not a compiler.
const jsxTextPattern = /<([A-Za-z][\w.]*)(?:\s[^<>]*)?>([^<>{}]{3,200})<\/\1>/gs;
let match: RegExpExecArray | null;
while ((match = jsxTextPattern.exec(source)) !== null) {
const text = match[2].trim();
if (!text) continue;
if (ALLOWED_LITERAL_TEXT.has(text)) continue;
// Needs at least two alphabetic words to count as "prose" -- filters out numbers,
// single technical words, units (km, %), punctuation-only fragments, and JSX
// whitespace artifacts.
const words = text.match(/[A-Za-z]+/g) ?? [];
if (words.length < 2) continue;
// Skip anything that is itself an i18next interpolation artifact leaking through
// (shouldn't happen, but never flag `{{...}}`-shaped remnants) or looks like a URL
// or path.
if (/^https?:\/\//.test(text) || text.includes("/") || text.includes("{{")) continue;
findings.push(`${path.relative(SRC_DIR, filePath)}: "${text}"`);
}
return findings;
}
test("no hardcoded user-facing JSX text outside the approved technical-token allowlist", () => {
const allFindings: string[] = [];
for (const dir of SCAN_DIRS) {
const files = collectTsxFiles(path.join(SRC_DIR, dir));
for (const file of files) {
allFindings.push(...findHardcodedJsxText(file));
}
}
expect(
allFindings,
`Found ${allFindings.length} likely hardcoded JSX string(s) bypassing t(...). ` +
`Either route it through the translation system, or add the exact literal to ` +
`ALLOWED_LITERAL_TEXT in this test if it's a genuine proper noun/technical token:\n` +
allFindings.join("\n"),
).toEqual([]);
});
+7 -3
View File
@@ -130,9 +130,13 @@ test("return preview correctly reports blocked (not maintenance) for damage repo
// The preview is the server's authoritative evaluation: damage always routes to
// "blocked", never "maintenance" -- this used to be guessed client-side and wrong.
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
// The localized reason is the primary text; the raw code sits behind "Technical
// details" so it isn't visible until expanded.
await expect(page.getByText("Damage was reported on this return.")).toBeVisible();
const statusRegion = page.locator(".impact-preview");
await expect(statusRegion.getByText("blocked", { exact: true })).toBeVisible();
await page.getByText("Technical details").click();
await expect(page.getByText("Damage was reported on return.")).toBeVisible();
});
test("data quality page: status and rule-type filters work", async ({ page }) => {
@@ -201,8 +205,8 @@ test("data quality: applying the recommended status resolves a vehicle conflict"
await page.goto("/data-quality/DQ-DEMO-STATUS");
await expect(page.getByRole("heading", { name: "DQ-DEMO-STATUS" })).toBeVisible();
await page.getByRole("button", { name: "Calculate and apply recommended status" }).click();
await page.getByRole("button", { name: "Yes, apply" }).click();
await page.getByRole("button", { name: "Review recommendation" }).click();
await page.getByRole("button", { name: /^Change status to/ }).click();
await expect(page.getByText("Applied", { exact: false })).toBeVisible();
});
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Fleet Ops synthetic-data operations demo" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Fleet Ops</title>
</head>
<body>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="7" fill="#0f172a" />
<path d="M5 23V8l7.1 8L19 8h8v15h-5V14l-9.9 11L10 22.6V23H5Z" fill="white" />
<path d="M3 15.5h26" stroke="#2dd4bf" stroke-width="2.5" />
</svg>

After

Width:  |  Height:  |  Size: 266 B

+12
View File
@@ -0,0 +1,12 @@
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
+4 -13
View File
@@ -1,3 +1,7 @@
import { ApiError } from "./apiError";
export { ApiError } from "./apiError";
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "";
type UnauthorizedListener = () => void;
@@ -8,19 +12,6 @@ export function onUnauthorized(listener: UnauthorizedListener): () => void {
return () => unauthorizedListeners.delete(listener);
}
export class ApiError extends Error {
status: number;
code: string;
correlationId: string;
constructor(status: number, code: string, message: string, correlationId: string) {
super(message);
this.status = status;
this.code = code;
this.correlationId = correlationId;
}
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
...init,
+100
View File
@@ -0,0 +1,100 @@
import { ApiError } from "./apiError";
export interface ApiErrorInfo {
title: string;
explanation: string;
nextStep?: string;
technical: string;
}
type TFn = (key: string, options?: Record<string, unknown>) => string;
// Backend AppError codes this frontend knows how to present with a localized title,
// explanation and (where useful) a next step -- see
// backend/app/core/errors.py::AppError and every `raise AppError("CODE", ...)` site.
// Anything not in this list still gets a sensible HTTP-status-based fallback below, so
// a newly-introduced backend code never regresses to raw English -- it just falls back
// to a generic-but-localized message until this list is extended.
export const KNOWN_CODES = new Set([
"VEHICLE_NOT_FOUND",
"BOOKING_NOT_FOUND",
"CUSTOMER_NOT_FOUND",
"ENTITY_NOT_FOUND",
"EVENT_NOT_FOUND",
"ISSUE_NOT_FOUND",
"ISSUE_NOT_OPEN",
"BOOKING_NOT_ACTIVE",
"INVALID_BOOKING_STATE",
"NOT_RETRYABLE",
"CONFLICT_STILL_PRESENT",
"OVERLAP_STILL_PRESENT",
"EMPTY_VALUE",
"NO_FIELDS_PROVIDED",
"INVALID_FIELD",
"INVALID_FIELD_OVERRIDE",
"INVALID_SURVIVOR",
"INVALID_BOOKING_REFERENCE",
"INVALID_EVENT_ID",
"INVALID_IDEMPOTENCY_KEY",
"IDEMPOTENCY_KEY_REUSED",
"CORRECTED_VALUE_REQUIRED",
"CORRECTION_BELOW_CANONICAL",
"NOT_A_DUPLICATE_ISSUE",
"NOT_A_MISSING_FIELD_ISSUE",
"NOT_AN_ODOMETER_ISSUE",
"NOT_AN_OVERLAP_ISSUE",
"NOT_A_STATUS_CONFLICT_ISSUE",
"UNSUPPORTED_ENTITY",
"MANUAL_REVIEW_REQUIRED",
"NO_CONFLICT_DETECTED",
"RECOMMENDATION_STALE",
"UNAUTHORIZED_SERVICE",
]);
const KNOWN_HTTP_STATUSES = new Set(["401", "403", "404", "409", "422", "500"]);
/**
* Turns a caught error into a localized {title, explanation, nextStep?, technical}
* for display. The raw backend/network text is only ever exposed as `technical`
* (shown under "Technical details" by ApiErrorNotice) -- never as the primary message.
*
* `fallbackKey` is an existing, already-localized `t()` key used as the explanation
* when the error isn't an ApiError at all (e.g. the fetch failed before a response
* existed) and errors:generic doesn't fit the specific action being attempted.
*/
export function describeApiError(t: TFn, err: unknown, fallbackKey?: string): ApiErrorInfo {
if (!(err instanceof ApiError)) {
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err instanceof Error ? err.message : String(err),
};
}
if (KNOWN_CODES.has(err.code)) {
const nextStep = t(`errors:codes.${err.code}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:codes.${err.code}.title`),
explanation: t(`errors:codes.${err.code}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
const httpKey = KNOWN_HTTP_STATUSES.has(err.code) ? err.code : String(err.status);
if (KNOWN_HTTP_STATUSES.has(httpKey)) {
const nextStep = t(`errors:http.${httpKey}.nextStep`, { defaultValue: "" });
return {
title: t(`errors:http.${httpKey}.title`),
explanation: t(`errors:http.${httpKey}.explanation`),
nextStep: nextStep || undefined,
technical: err.message,
};
}
return {
title: t("errors:generic.title"),
explanation: fallbackKey ? t(fallbackKey) : t("errors:generic.explanation"),
technical: err.message,
};
}
+42 -2
View File
@@ -109,6 +109,7 @@ export interface AutomationRun {
status: string;
attempts: number;
last_error: string | null;
last_error_code: string | null;
occurred_at: string;
}
@@ -154,6 +155,8 @@ export interface ReturnPreviewResult {
resulting_odometer_km: number;
resulting_vehicle_status: string;
status_reason: string;
status_reason_code: string;
status_reason_params: Record<string, string | number>;
would_create_quality_issue: boolean;
attention_reasons: string[];
next_booking_risk: NextBookingRisk | null;
@@ -173,7 +176,27 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
export interface ApplyRecommendedStatusResult {
issue: DataQualityIssue;
applied_status: string;
reason: string;
reason_code: string;
}
export interface VehicleStatusFacts {
active_booking_refs: string[];
overlapping_booking_pairs: string[][];
service_threshold_reached: boolean;
odometer_km: number;
next_service_km: number;
open_booking_overlap_issue_ref: string | null;
}
export interface StatusRecommendation {
current_status: string;
recommended_status: string | null;
recommendation_code: string;
safe_to_apply: boolean;
manual_review_required: boolean;
facts: VehicleStatusFacts;
blocking_reasons: string[];
recommendation_token: string;
}
export interface ScanResult {
@@ -183,7 +206,8 @@ export interface ScanResult {
export interface SearchResultItem {
type: "vehicle" | "booking" | "data_quality_issue" | "section";
label: string;
detail: string;
detail_code: string;
detail_params: Record<string, string>;
link: string;
}
@@ -230,6 +254,18 @@ export interface KnowledgeHealth {
document_count: number;
}
export interface N8nWorkflowEvidence {
name: string;
built: boolean;
last_seen_at: string | null;
}
export interface N8nErrorHandlerStatus {
total_failures_registered: number;
latest_failure_at: string | null;
latest_failure_workflow: string | null;
}
export interface N8nIntegrationStatus {
configured: boolean;
dispatch_enabled: boolean;
@@ -240,6 +276,10 @@ export interface N8nIntegrationStatus {
succeeded: number;
latest_success_at: string | null;
latest_failure_at: string | null;
expected_workflow_count: number;
known_workflow_count: number;
workflows: N8nWorkflowEvidence[];
error_handler: N8nErrorHandlerStatus;
}
export interface McpHubIntegrationStatus {
+8 -5
View File
@@ -1,13 +1,16 @@
import { useNavigate, useLocation } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { useViewportTier } from "../hooks/useViewportTier";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { ApiErrorNotice } from "./PageChrome";
import { PRODUCT_NAME } from "../product";
export function DemoGuideTrigger() {
const { t } = useTranslation("demo");
@@ -67,7 +70,7 @@ export function DemoGuide() {
setCollapsedToChip,
} = useDemoGuide();
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const [mobileSheetState, setMobileSheetState] = useState<"collapsed" | "half" | "full">("half");
const pendingTarget = useRef<string | null>(null);
@@ -118,7 +121,7 @@ export function DemoGuide() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("guide.restartFailed"));
setResetError(describeApiError(t, err, "guide.restartFailed"));
} finally {
setResetting(false);
}
@@ -206,7 +209,7 @@ export function DemoGuide() {
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
</div>
)}
@@ -226,7 +229,7 @@ export function DemoGuide() {
</nav>
)}
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
<footer className="demo-guide-footer">
<button type="button" className="button button-secondary" onClick={goToStepRoute}>
+37 -9
View File
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import { useAuth } from "../context/AuthContext";
import type { Role, SearchResultItem } from "../api/types";
import { BrandMark, Icon, type IconName } from "./Icons";
@@ -10,6 +11,8 @@ import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
import { LanguageSwitcher } from "./LanguageSwitcher";
import { useDemoGuide } from "../context/DemoGuideContext";
import { useDemoManifest } from "../context/DemoManifestContext";
import { PRODUCT_NAME } from "../product";
import { ApiErrorNotice } from "./PageChrome";
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
vehicle: "fleet",
@@ -18,6 +21,31 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
section: "chevron",
};
// The backend only ever sends a stable code + raw data params (never English prose) --
// see app/api/routers/search.py. Localizing here means the label/detail always follow
// the operator's selected locale, in every namespace search results can point to.
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
return item.label;
}
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
switch (item.type) {
case "section":
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
case "vehicle":
return t("searchVehicleSummary", { ...item.detail_params });
case "booking":
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
case "data_quality_issue":
return t(`quality:ruleTypes.${item.detail_code}`, {
defaultValue: item.detail_code.replace(/_/g, " "),
});
default:
return item.detail_code;
}
}
interface NavItem {
to: string;
labelKey: string;
@@ -60,7 +88,7 @@ export function Layout() {
const [activeIndex, setActiveIndex] = useState(-1);
const [resetConfirming, setResetConfirming] = useState(false);
const [resetting, setResetting] = useState(false);
const [resetError, setResetError] = useState<string | null>(null);
const [resetError, setResetError] = useState<ApiErrorInfo | null>(null);
const searchInput = useRef<HTMLInputElement>(null);
const searchBox = useRef<HTMLDivElement>(null);
@@ -137,7 +165,7 @@ export function Layout() {
await logout();
navigate("/login");
} catch (err) {
setResetError(err instanceof ApiError ? err.message : t("resetFailed"));
setResetError(describeApiError(t, err, "resetFailed"));
setResetConfirming(false);
} finally {
setResetting(false);
@@ -177,7 +205,7 @@ export function Layout() {
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
<div className="brand-lockup">
<BrandMark className="brand-mark" />
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
</div>
<div className="sidebar-language">
<LanguageSwitcher />
@@ -205,7 +233,7 @@ export function Layout() {
</div>
{user?.role === "operations_manager" && manifest?.allow_reset !== false && (
<div className="sidebar-reset">
{resetError && <p className="error" role="alert">{resetError}</p>}
<ApiErrorNotice error={resetError} />
{!resetConfirming ? (
<button type="button" className="button button-secondary" onClick={() => setResetConfirming(true)}>
{t("resetDemoData")}
@@ -234,7 +262,7 @@ export function Layout() {
</button>
<div className="global-search" role="search" ref={searchBox}>
<Icon name="search" />
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
<input
id="global-search-input"
ref={searchInput}
@@ -276,8 +304,8 @@ export function Layout() {
>
<Icon name={SEARCH_ICON[item.type]} />
<span className="search-result-copy">
<strong>{item.label}</strong>
<small>{item.detail}</small>
<strong>{searchResultLabel(t, item)}</strong>
<small>{searchResultDetail(t, item)}</small>
</span>
</button>
))}
@@ -302,7 +330,7 @@ export function Layout() {
</header>
<main id="main-content" tabIndex={-1}><Outlet /></main>
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
</div>
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
+20
View File
@@ -67,6 +67,26 @@ export function ErrorState({ message }: { message: string }) {
);
}
// Shared rendering for describeApiError()'s output: a localized title + explanation +
// optional next step, with the raw backend/network text demoted to a "Technical
// details" disclosure -- never shown as the primary message. See
// frontend/src/api/errorMessages.ts and docs/fleet-ops-final-localization/audit.md.
export function ApiErrorNotice({ error }: { error: import("../api/errorMessages").ApiErrorInfo | null }) {
const { t } = useTranslation("common");
if (!error) return null;
return (
<div className="error api-error-notice" role="alert">
<strong>{error.title}</strong>
<p>{error.explanation}</p>
{error.nextStep && <p className="api-error-next-step">{error.nextStep}</p>}
<details className="evidence-disclosure">
<summary>{t("actions.technicalDetails")}</summary>
<pre className="evidence-block">{error.technical}</pre>
</details>
</div>
);
}
export function EmptyState({
icon = "check",
title,
+18 -6
View File
@@ -1,7 +1,8 @@
import { useState, type FormEvent } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { api, ApiError } from "../api/client";
import { api } from "../api/client";
import { describeApiError, type ApiErrorInfo } from "../api/errorMessages";
import type { RegisterReturnRequest, RegisterReturnResult, ReturnPreviewResult } from "../api/types";
import { useAuth } from "../context/AuthContext";
import { useDemoGuide } from "../context/DemoGuideContext";
@@ -10,6 +11,7 @@ import { useLocaleFormat } from "../i18n/format";
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
import { Icon } from "./Icons";
import { StatusBadge } from "./Badge";
import { ApiErrorNotice } from "./PageChrome";
function newIdempotencyKey(): string {
return typeof crypto.randomUUID === "function"
@@ -103,7 +105,7 @@ export function ReturnForm({
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState(false);
const [previewing, setPreviewing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [error, setError] = useState<ApiErrorInfo | null>(null);
const [idempotencyKey] = useState(newIdempotencyKey);
const [step, setStep] = useState<"capture" | "review">("capture");
const [preview, setPreview] = useState<ReturnPreviewResult | null>(null);
@@ -132,7 +134,7 @@ export function ReturnForm({
setPreview(evaluated);
setStep("review");
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setPreviewing(false);
}
@@ -148,7 +150,7 @@ export function ReturnForm({
);
onRegistered(registered);
} catch (err) {
setError(err instanceof ApiError ? err.message : t("errors:generic"));
setError(describeApiError(t, err));
} finally {
setSubmitting(false);
}
@@ -158,7 +160,7 @@ export function ReturnForm({
<form className="panel return-form" onSubmit={handleSubmit} aria-labelledby="return-form-heading">
<div className="return-progress" aria-label={t("progress.ariaLabel")}><span className="is-complete"><i>1</i> {t("progress.capture")}</span><b /><span className={step === "review" ? "is-active" : ""}><i>2</i> {t("progress.review")}</span><b /><span><i>3</i> {t("progress.result")}</span></div>
<div className="section-heading"><div><p className="page-eyebrow">{bookingRef}</p><h2 id="return-form-heading">{step === "capture" ? t("capture.heading") : t("review.heading")}</h2><p>{step === "capture" ? t("capture.description") : t("review.description")}</p></div></div>
{error && <p className="error" role="alert">{error}</p>}
<ApiErrorNotice error={error} />
{step === "capture" ? <div className="return-capture">
<div className="form-grid"><label>
@@ -220,7 +222,17 @@ export function ReturnForm({
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
<div>
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
<p>{preview.status_reason}</p>
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
defaultValue: preview.status_reason,
threshold_km:
typeof preview.status_reason_params.threshold_km === "number"
? formatNumber(preview.status_reason_params.threshold_km)
: undefined,
})}</p>
<details className="evidence-disclosure">
<summary>{t("common:actions.technicalDetails")}</summary>
<pre className="evidence-block">{preview.status_reason}</pre>
</details>
</div>
</div>
{preview.odometer_regression && (
+10
View File
@@ -17,3 +17,13 @@ export const MCP_STATE_META: Record<IntegrationStatus["mcp_hub"]["state"], { sta
not_configured: { statusClass: "not_configured", labelKey: "notConnected" },
configured: { statusClass: "no_events", labelKey: "prepared" },
};
// Maps the backend's canonical n8n workflow name (see n8n/workflows/MANIFEST.md) to a
// stable slug for localized display names in `integrations:workflows.names.*` — the
// backend name itself is a technical identifier, not something to show untranslated.
export const N8N_WORKFLOW_SLUGS: Record<string, string> = {
"Fleet Ops — Vehicle Return Orchestration": "vehicleReturn",
"Fleet Ops — Scheduled Data Quality Scan": "scheduledScan",
"Fleet Ops — RAGcore Procedure Sync": "ragcoreSync",
"Fleet Ops — Workflow Error Handler": "errorHandler",
};
+28
View File
@@ -0,0 +1,28 @@
export type GreetingPeriod = "morning" | "afternoon" | "evening" | "night";
const BRUSSELS_TIME_ZONE = "Europe/Brussels";
// DST-safe: Intl.DateTimeFormat resolves the correct Europe/Brussels wall-clock hour for
// any instant, automatically accounting for the CET/CEST transition -- no manual UTC
// offset math (which would silently break twice a year) is needed. `hourCycle: "h23"`
// pins the output to a plain 0-23 range (some engines otherwise render midnight as "24").
export function getBrusselsHour(date: Date): number {
const formatter = new Intl.DateTimeFormat("en-GB", {
timeZone: BRUSSELS_TIME_ZONE,
hour: "numeric",
hourCycle: "h23",
});
return Number(formatter.format(date));
}
// Boundaries per the Fleet Ops final localization brief (section 9):
// 05:00-11:59 morning, 12:00-17:59 afternoon, 18:00-22:59 evening, 23:00-04:59 night.
// `date` defaults to `new Date()` but accepts any Date so callers (and tests) can inject
// a fixed clock instead of depending on the real wall clock.
export function getGreetingPeriod(date: Date = new Date()): GreetingPeriod {
const hour = getBrusselsHour(date);
if (hour >= 5 && hour < 12) return "morning";
if (hour >= 12 && hour < 18) return "afternoon";
if (hour >= 18 && hour < 23) return "evening";
return "night";
}
@@ -40,6 +40,29 @@
"was": "{{field}}: was {{value}}",
"changed": "{{field}}: {{before}} → {{after}}"
},
"actorTypes": {
"user": "User",
"system": "System",
"service": "Service"
},
"fields": {
"status": "Status",
"operational_status": "Operational status",
"registration_number": "Registration number",
"make": "Make",
"model": "Model",
"location": "Location",
"first_name": "First name",
"last_name": "Last name",
"email": "Email",
"phone": "Phone",
"postal_code": "Postal code",
"city": "City",
"booking_end_odometer_km": "Booking end odometer",
"vehicle_odometer_km": "Vehicle odometer",
"survivor": "Retained profile",
"loser": "Merged profile"
},
"actions": {
"demo_login": "Logged in",
"demo_logout": "Logged out",
+1 -1
View File
@@ -3,7 +3,7 @@
"orgLine": "Demo organisation: {{orgName}} (fictional)",
"headline1": "Every hand-off.",
"headline2": "One clear view.",
"defaultDescription": "Fleet Ops brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
"defaultDescription": "{{productName}} brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
"accessEyebrow": "Demo access",
"accessHeading": "Choose how to start",
+1 -2
View File
@@ -1,5 +1,4 @@
{
"appName": "Fleet Ops",
"orgName": "Northstar Mobility",
"brandTagline": "Control Centre",
"actions": {
@@ -35,7 +34,7 @@
"fr-BE": "Français"
},
"footer": {
"productLine": "Fleet Ops Demo",
"productLine": "{{productName}} Demo",
"locale": "Europe/Brussels · Synthetic demo data"
},
"demo": {
+12 -1
View File
@@ -1,6 +1,17 @@
{
"eyebrow": "Operations / Live overview",
"title": "Good morning. Here's the fleet.",
"greeting": {
"morning": "Good morning",
"afternoon": "Good afternoon",
"evening": "Good evening",
"night": "Welcome back"
},
"greetingBody": {
"morning": "Here's today's fleet status.",
"afternoon": "Here's the current overview of your fleet.",
"evening": "Here's this evening's fleet overview.",
"night": "Here's the latest overview of your fleet."
},
"description": "Readiness, exceptions and hand-offs across today's operation.",
"viewFleet": "View fleet",
"demoStart": {
+4 -4
View File
@@ -83,7 +83,7 @@
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
"startAction": "Read the 'About this demo' page.",
"expectedOutcome": "You can explain yourself what Fleet Ops is and isn't, with no verbal explanation needed."
"expectedOutcome": "You can explain yourself what {{productName}} is and isn't, with no verbal explanation needed."
}
}
},
@@ -146,16 +146,16 @@
},
"about": {
"eyebrow": "About this demo",
"title": "What Fleet Ops is and isn't",
"title": "What {{productName}} is and isn't",
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
"loading": "Loading demo information…",
"ctaTitle": "Prefer to jump right in?",
"ctaBody": "The guided demo walks through all eight steps above in practice.",
"ctaButton": "Start guided demo",
"problemTitle": "The fictional problem",
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. Fleet Ops shows how one connected system flags these problems early and lets them be resolved under control.",
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
"scopeTitle": "Who it's for and its scope",
"scopeBody": "This demo is for anyone who wants to see how Fleet Ops tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
"scopeBody": "This demo is for anyone who wants to see how {{productName}} tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
"realTitle": "What really works",
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
"syntheticTitle": "What's synthetic",
+178 -6
View File
@@ -1,8 +1,180 @@
{
"generic": "Something went wrong. Please try again.",
"workspaceLoadFailed": "We couldn't load this workspace.",
"unauthorized": "Your session has expired. Please log in again.",
"forbidden": "You don't have access to this section.",
"notFound": "This record could not be found.",
"networkUnavailable": "The connection to the server is currently unavailable."
"generic": {
"title": "Something went wrong",
"explanation": "This action could not be completed. Please try again."
},
"codes": {
"VEHICLE_NOT_FOUND": {
"title": "Vehicle not found",
"explanation": "This vehicle could not be found. It may have been removed or the reference may be incorrect."
},
"BOOKING_NOT_FOUND": {
"title": "Booking not found",
"explanation": "This booking could not be found. It may have been removed or the reference may be incorrect."
},
"CUSTOMER_NOT_FOUND": {
"title": "Customer not found",
"explanation": "This customer record could not be found."
},
"ENTITY_NOT_FOUND": {
"title": "Record not found",
"explanation": "The underlying record for this action could not be found."
},
"EVENT_NOT_FOUND": {
"title": "Workflow event not found",
"explanation": "This automation event could not be found."
},
"ISSUE_NOT_FOUND": {
"title": "Data-quality issue not found",
"explanation": "This data-quality issue could not be found."
},
"ISSUE_NOT_OPEN": {
"title": "Issue is no longer open",
"explanation": "This issue has already been resolved, deferred or rejected.",
"nextStep": "Refresh the page to see its current state."
},
"BOOKING_NOT_ACTIVE": {
"title": "Booking is not active",
"explanation": "Only a reserved or active booking can be used for this action."
},
"INVALID_BOOKING_STATE": {
"title": "Booking is in the wrong state",
"explanation": "This booking's current state does not allow this action."
},
"NOT_RETRYABLE": {
"title": "This event cannot be retried",
"explanation": "Only a failed delivery can be retried."
},
"CONFLICT_STILL_PRESENT": {
"title": "Conflict was not resolved",
"explanation": "Applying this change did not resolve the underlying conflict.",
"nextStep": "Review the recommendation again before retrying."
},
"OVERLAP_STILL_PRESENT": {
"title": "Overlap was not resolved",
"explanation": "Blocking this booking did not remove the overlap; another commitment remains."
},
"EMPTY_VALUE": {
"title": "A required field is blank",
"explanation": "This field cannot be left blank.",
"nextStep": "Enter a value and try again."
},
"NO_FIELDS_PROVIDED": {
"title": "No changes provided",
"explanation": "At least one field must be filled in to continue."
},
"INVALID_FIELD": {
"title": "Field not allowed here",
"explanation": "One of the fields provided is not permitted for this action."
},
"INVALID_FIELD_OVERRIDE": {
"title": "Field cannot be merged",
"explanation": "One of the selected fields cannot be used in this merge."
},
"INVALID_SURVIVOR": {
"title": "Invalid selection",
"explanation": "The record you selected to keep must be one of the two records being compared."
},
"INVALID_BOOKING_REFERENCE": {
"title": "Booking reference not valid here",
"explanation": "The selected booking is not one of the bookings related to this issue."
},
"INVALID_EVENT_ID": {
"title": "Invalid event reference",
"explanation": "This automation event reference is not valid."
},
"INVALID_IDEMPOTENCY_KEY": {
"title": "Request could not be repeated safely",
"explanation": "This request's tracking key is not valid.",
"nextStep": "Reload the page and try again."
},
"IDEMPOTENCY_KEY_REUSED": {
"title": "This action was already submitted",
"explanation": "An identical request was already processed with a different outcome.",
"nextStep": "Reload the page to see the current state before retrying."
},
"CORRECTED_VALUE_REQUIRED": {
"title": "A corrected value is required",
"explanation": "Choose \"correct the reading\" requires entering the corrected value."
},
"CORRECTION_BELOW_CANONICAL": {
"title": "Correction is below the confirmed reading",
"explanation": "A corrected odometer reading can never be lower than the last confirmed reading."
},
"NOT_A_DUPLICATE_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to possible-duplicate-customer issues."
},
"NOT_A_MISSING_FIELD_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to missing-required-field issues."
},
"NOT_AN_ODOMETER_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to odometer-regression issues."
},
"NOT_AN_OVERLAP_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to booking-overlap issues."
},
"NOT_A_STATUS_CONFLICT_ISSUE": {
"title": "Wrong issue type",
"explanation": "This action only applies to vehicle-status-conflict issues."
},
"UNSUPPORTED_ENTITY": {
"title": "Not supported for this record type",
"explanation": "This action is not available for this kind of record."
},
"MANUAL_REVIEW_REQUIRED": {
"title": "Manual review required",
"explanation": "The facts for this vehicle contradict each other, so no automatic change is safe.",
"nextStep": "Defer or reject this issue, or investigate manually."
},
"NO_CONFLICT_DETECTED": {
"title": "Nothing to apply",
"explanation": "The current state no longer conflicts, so there is nothing left to apply."
},
"RECOMMENDATION_STALE": {
"title": "The situation has changed",
"explanation": "The underlying facts changed since this recommendation was shown.",
"nextStep": "Review the recommendation again before applying it."
},
"UNAUTHORIZED_SERVICE": {
"title": "Service authorisation failed",
"explanation": "This automated request could not be authorised."
}
},
"http": {
"401": {
"title": "Session expired",
"explanation": "Your session has expired.",
"nextStep": "Please log in again."
},
"403": {
"title": "Permission denied",
"explanation": "You don't have permission to perform this action."
},
"404": {
"title": "Not found",
"explanation": "This record could not be found."
},
"409": {
"title": "This action is no longer possible",
"explanation": "The underlying state has changed since this page was loaded.",
"nextStep": "Refresh the page and try again."
},
"422": {
"title": "Validation failed",
"explanation": "The information provided is not valid."
},
"500": {
"title": "Server error",
"explanation": "Something went wrong on our side."
},
"network": {
"title": "Connection unavailable",
"explanation": "The connection to the server is currently unavailable.",
"nextStep": "Check your connection and try again."
}
}
}
+9 -1
View File
@@ -63,6 +63,14 @@
"noQualityIssues": "No quality issues recorded.",
"fuel": "Fuel {{percent}}%",
"damage": "Damage",
"technicalWarning": "Technical warning"
"technicalWarning": "Technical warning",
"inspectionTypes": {
"departure": "Departure inspection",
"return": "Return inspection"
},
"maintenanceCategories": {
"periodic_service": "Periodic service",
"repair": "Repair"
}
}
}
@@ -26,6 +26,28 @@
"demoMode": "Demo mode",
"unavailable": "Unavailable"
},
"workflows": {
"title": "Automation workflows",
"description": "{{known}} of {{expected}} canonical n8n workflows have live evidence of running.",
"notBuilt": "Not built yet",
"noEvidence": "No evidence yet",
"columns": {
"name": "Workflow",
"status": "Status",
"lastSeen": "Last evidence",
"technicalId": "Details"
},
"names": {
"vehicleReturn": "Vehicle return orchestration",
"scheduledScan": "Scheduled data quality scan",
"ragcoreSync": "Knowledge procedure sync",
"errorHandler": "Workflow error handler"
},
"errorHandler": {
"summary": "{{count}} automation failure(s) registered — latest from {{workflow}} at {{when}}.",
"summaryEmpty": "No automation failures have been registered."
}
},
"ledger": {
"title": "Automation jobs",
"description": "Persisted automation attempts with the latest failure evidence.",
@@ -65,6 +87,13 @@
"noAction": "—",
"eventTypes": {
"vehicle.returned.v1": "Vehicle return processed"
},
"errorCodes": {
"connectionError": "The workflow service was temporarily unreachable. The return was safely saved and can be retried.",
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
"unknownError": "An unexpected error occurred while delivering this job."
}
}
}
@@ -24,7 +24,7 @@
"Which checks are required before checkout?"
],
"emptyTitle": "Evidence before answers",
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. Fleet Ops will not invent an answer when evidence is missing.",
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. {{productName}} will not invent an answer when evidence is missing.",
"retrievalFlow": {
"question": "Question",
"sources": "Sources",
@@ -27,12 +27,22 @@
"resetConfirmYes": "Yes, reset",
"resetCancel": "Cancel",
"resetFailed": "Could not reset demo data.",
"searchLabel": "Search Fleet Ops",
"searchLabel": "Search {{productName}}",
"searchPlaceholder": "Search fleet, booking or section…",
"searchShortcutHint": "Ctrl K",
"searchSearching": "Searching…",
"searchUnavailable": "Search is unavailable right now.",
"searchNoResults": "No matches for \"{{query}}\".",
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
"searchSections": {
"overview": "Operations dashboard",
"fleet": "Vehicle registry",
"bookings": "Rental bookings",
"quality": "Quality workbench",
"knowledge": "Procedure assistant",
"integrations": "Automation and integration status",
"audit": "Audit history"
},
"switchRole": "Switch role",
"switchRoleTitle": "Switch demo role",
"languageSwitcherLabel": "Change language"
+51 -5
View File
@@ -101,6 +101,17 @@
"whyItMatters": "An incorrect status can make an unavailable vehicle appear bookable, or keep an available vehicle hidden from the fleet."
}
},
"evidence": {
"duplicate.exact_email": "Identical email address",
"duplicate.exact_phone": "Identical phone number",
"duplicate.same_postal_code": "Same postal code",
"duplicateSimilarName": "Strongly similar name (score {{score}})",
"missingField": "Missing field: {{field}}",
"overlapReservedBookings": "Overlapping bookings: {{refs}}",
"odometerRegression": "Booking {{laterRef}} recorded {{laterKm}} km, below the {{earlierKm}} km recorded by earlier booking {{earlierRef}}.",
"upcomingBookingMissingInspection": "Booking {{bookingRef}} starts soon but the required operational inspection is still missing.",
"seedPlaceholder": "Synthetic seed data with no further detail."
},
"duplicateCustomer": {
"heading": "Compare and merge",
"description": "Choose the canonical customer and review each conflicting field.",
@@ -180,13 +191,48 @@
"heading": "Resolve the status conflict",
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
"currentStatus": "Current status",
"calculateAndApply": "Calculate and apply recommended status",
"confirmTitle": "Confirm status change",
"confirmBody": "Apply the authoritative recommended status for this vehicle?",
"reviewRecommendation": "Review recommendation",
"loadingRecommendation": "Loading recommendation…",
"recommendationFailed": "Could not load a recommendation.",
"recommendedStatus": "Recommended status",
"whyHeading": "Why",
"evidenceHeading": "Facts",
"consequenceHeading": "Consequence",
"consequence": {
"statusWillChange": "The vehicle status will change to {{status}}.",
"issueWillBeRechecked": "This quality issue will be re-checked.",
"changeWillBeAudited": "The change will be recorded in the audit trail.",
"bookingsNotDeleted": "The bookings themselves are not deleted."
},
"changeStatusTo": "Change status to {{status}}",
"applying": "Applying…",
"confirmYes": "Yes, apply",
"applied": "Applied {{status}} — {{reason}}",
"applyFailed": "Could not apply a recommended status."
"applyFailed": "Could not apply a recommended status.",
"staleRecommendation": "The situation has changed since this recommendation was shown. Review it again before applying.",
"reviewAgain": "Review recommendation again",
"manualReview": {
"heading": "Manual review required",
"body": "The facts for this vehicle contradict each other. An Operations Manager needs to review this in person rather than apply an automatic status change.",
"hint": "Use 'Defer' or 'Reject' below, or investigate the vehicle and the related bookings manually."
},
"noConflict": {
"heading": "No change needed",
"body": "This vehicle's current status already matches the facts."
},
"evidence": {
"activeBookings": "Active booking(s): {{refs}}",
"overlappingBookings": "Overlapping bookings: {{pairs}}",
"serviceThresholdReached": "Odometer reading ({{odometer}} km) has reached the service threshold ({{threshold}} km).",
"openOverlapIssue": "Open booking-overlap issue: {{ref}}"
},
"reasonCodes": {
"vehicle.active_rental": "This vehicle has an active rental but is not recorded as rented. The status is corrected to reflect the actual situation.",
"vehicle.service_threshold_reached": "This vehicle has reached its service threshold. It is set to maintenance so it is not deployed before servicing takes place.",
"vehicle.booking_conflict": "This vehicle has two overlapping bookings. Blocking it prevents it from being considered available again before the booking conflict is resolved.",
"vehicle.rental_ended": "The rental for this vehicle has ended and there is no active booking left. It is set back to available.",
"vehicle.manual_review_required": "The facts for this vehicle contradict each other (for example, an active booking while the vehicle is in maintenance, or a service threshold reached alongside an active rental). This requires a human review rather than an automatic status change.",
"vehicle.no_conflict": "This vehicle's current status already matches the facts. No change is needed."
}
}
}
}
+9 -1
View File
@@ -36,6 +36,13 @@
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
"reasonCodes": {
"returnBlockedDamageAndTechnical": "Both damage and a technical warning were reported on this return.",
"returnBlockedDamage": "Damage was reported on this return.",
"returnBlockedTechnicalWarning": "A technical warning was reported on this return.",
"returnServiceThresholdReached": "The odometer reading has reached the {{threshold_km}} km service threshold.",
"returnRoutedToCleaning": "No damage, technical warning or service threshold reached; the vehicle goes to cleaning."
},
"commitList": {
"inspection": "Create a return inspection",
"updateAtomic": "Update the booking and vehicle atomically",
@@ -66,8 +73,9 @@
"continueDemo": "Continue the demo"
},
"scenario": {
"ariaLabel": "Demo scenario",
"title": "Demo scenario: odometer anomaly",
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how Fleet Ops detects and handles this.",
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how {{productName}} detects and handles this.",
"preparing": "Preparing scenario…"
}
}

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