Atrax tachograph driving-time compliance — operations

Daily compliance alert on EU driving/rest-time regulation breaches (tachograph), for the Ecotrans fleet. Implemented in #7045.

This is not statistical analytics. Unlike the sibling Atrax jobs (atrax_route_anomaly_daily, atrax_ecodriving_anomaly_daily — median/MAD, modified-Z, per-route norms), this job reads the driving/rest counters Atrax has already computed from the tachograph (used-allowance counts and driving/rest minutes) and applies simple EU 561/2006 regulatory threshold rules — a breach is only an exceeded allowance or limit, never mere usage (#7458). There is no model, no norm, no distribution to study. It IS registered in dev_r_ai_systems (tachograph-compliance, #7153) because it is inherently per-driver — see §EU AI Act.

Delivery changed in #7153 (plan-7107 PR-1). The old bespoke delivery — one aggregate Discord embed + one GitHub compliance issue — is REMOVED. NEW violations now escalate into the shared p24_fleet_anomaly_escalations table (with a shared Discord digest); fleet managers triage in the et-operational-platform escalation panel. Every escalated tachograph row carries requires_human_review=true. Sections below describing the Discord-embed/GitHub-issue delivery are historical. See docs/atrax-fleet-anomaly-escalation-operations.md.

Why it matters

Driving/rest-time breaches are a real legal risk for the fleet: administrative fines at a roadside or ITD inspection. Atrax already surfaces the breach counters per driver; nothing consumed them until this alert.

Data flow

Atrax /api/rtdrivers
  → n8n atrax-drivers-daily-snapshot (existing, live)
  → p24_atrax_drivers_daily            (one row per driver per stat_date, Europe/Berlin)
  → atrax_tachograph_compliance_daily  (nightly, this job)
  → p24_tachograph_violations          (persist all current violations, is_new flag — audit history)
  → p24_fleet_anomaly_escalations      (upsert NEW violations, #7153) + shared Discord digest

Known limitation (shared, #7153): a rerun that recomputes a day as no longer in violation does not delete an existing open escalation row — it must be dismissed by a human in the panel.

Source columns consumed (p24_atrax_drivers_daily)

driver_id, stat_date, first_name, last_name, this_week_driving_min, this_week_remaining_min, this_week_limit_min (default 3360 = 56 h), last_2w_driving_min, last_2w_remaining_min, weekly_rest_min, extended_driving_periods (default 0), shortened_rest_periods (default 0). Idempotency key of the source table is (driver_id, stat_date).

vehicle_id / occurred_at on the escalation row (#7168, PR-2)

p24_atrax_drivers_daily is keyed only by (driver_id, stat_date), so it carries neither the vehicle nor the exact time of a violation. Etap 1 (verified at code level, no live call needed): the Atrax /api/rtdrivers payload has no per-violation vehicle or timestamp field either — the n8n map_drivers node (atrax-drivers-daily-snapshot) maps every field the payload exposes and none is a vehicle; /api/rtdrivers is a one-row-per-driver realtime snapshot of daily/weekly counters, not a per-violation event log. So vehicle_id/occurred_at cannot come directly from the tachograph feed.

Fallback — inferred from p24_ride_events (name join, low confidence): for each NEW violation the job pulls the driver’s DRIVING segments for the same Berlin day from p24_ride_events, matches the driver by name (first_name + last_name ↔ driver_name, case- and whitespace-folded — not transliterated), and takes the representative segment (longest duration, earliest event_start as tie-break) as the vehicle_id (atrax_car_id) and occurred_at (event_start).

Because there is no stable driver id shared between the two feeds, and driver_name in p24_ride_events may differ by Polish-character encoding (unverified), the name join is uncertain and every result is flagged in the escalation row’s detail jsonb:

detail->>'vehicle_id_confidence'vehicle_id / occurred_atmeaning
inferredfilled from the representative segmentname join matched — treat as a hint, not ground truth
nullNULLno matching DRIVING segment that day — left NULL rather than guessed
direct(reserved)reserved for a future real per-violation Atrax field; not emitted today

detail->>'vehicle_inference_note' carries a human-readable explanation of which case applied. The run summary reports escalations_vehicle_inferred (count of rows that got an inferred vehicle). The ride-events fetch runs inside the escalation step’s best-effort try, so a p24_ride_events failure never blocks the p24_tachograph_violations audit write.

Note on dedup_key: the escalation identity includes vehicle_id, so an inferred vehicle becomes part of the dedup key for a tachograph row. Ride events for a past day are stable, so reruns are deterministic; a genuine change in the underlying p24_ride_events data could produce a new escalation row rather than updating the old one (acceptable — the freeze trigger still protects any row a human has begun triaging).

Rules

violation_typeconditionseveritymeaning
extended_drivingextended_driving_periods > EXTENDED_DRIVING_MAX_ALLOWED (2)violationused more than the 2 extended daily-driving periods/week EU 561/2006 allows
shortened_restshortened_rest_periods > SHORTENED_REST_MAX_ALLOWED (3)violationused more than the 3 reduced daily-rest periods allowed between weekly rests
weekly_rest_shortweekly_rest_min < WEEKLY_REST_MIN_REQUIRED_MIN (1440)violationweekly rest below the 24 h reduced-weekly-rest minimum
weekly_rest_reducedWEEKLY_REST_MIN_REQUIRED_MIN <= weekly_rest_min < WEEKLY_REST_REGULAR_MIN (1440–2699)warningreduced weekly rest (legal, but requires later compensation — below the 45 h regular rest). Not escalated (#7727) — persisted as a warning counter only, see the cutover note below
weekly_limit_exceededthis_week_remaining_min <= 0violationweekly driving limit exceeded (56 h)
weekly_limit_near0 < this_week_remaining_min <= WEEKLY_REMAINING_WARN_MIN (60)warningapproaching the weekly limit
fortnight_limit_exceededlast_2w_remaining_min <= 0violationtwo-week driving limit exceeded (90 h)
fortnight_limit_near0 < last_2w_remaining_min <= FORTNIGHT_REMAINING_WARN_MIN (120)warningapproaching the two-week limit
  • *_exceeded / *_short takes precedence over *_near / *_reduced for the same counter — a driver over the weekly limit produces only the weekly_limit_exceeded row, and a driver below the 24 h rest minimum produces only the weekly_rest_short row, never both.
  • Allowances are flagged only when EXCEEDED, not merely used (#7458). extended_driving_periods (0–2/week) and shortened_rest_periods (0–3 between weekly rests) are counts of EU 561/2006 allowances a driver has used; using them is legal, so a violation fires only above the allowance (> 2 / > 3). Likewise a weekly rest of 24 h–<45 h is a legal reduced rest (warning, needs compensation); only a rest below 24 h is a breach.
  • The allowance limits + warning bands are tunable module constants in atrax_tachograph_compliance_daily.py: EXTENDED_DRIVING_MAX_ALLOWED=2, SHORTENED_REST_MAX_ALLOWED=3, WEEKLY_REST_MIN_REQUIRED_MIN=1440, WEEKLY_REST_REGULAR_MIN=2700, WEEKLY_REMAINING_WARN_MIN=60, FORTNIGHT_REMAINING_WARN_MIN=120 (minutes). The allowance counts and the 24 h / 45 h / 56 h / 90 h figures are the EU legal limits; the *_near driving bands are round, human-tunable starting values (same convention as the #6194 mad_floor / severity bands). Recalibrate the *_near bands against the observed *_remaining_min distribution once there is enough live data (a sys-admin live-data pass).

Rollup — alert only on NEW violations

Mirrors weekly-supabase-advisor-audit / nightly-devops-triage: diff the target day against the previous day’s snapshot and alert only on new violations, as ONE aggregate report grouped by driver — never one alert per driver.

  • Counter violations (extended_driving, shortened_rest): new when the counter increased vs the previous day (absent the previous day ⇒ baseline 0). An ongoing over-allowance count is not re-alerted; every newly-added period beyond the allowance is.
  • Limit violations (weekly / fortnight): escalation-aware. exceeded is new unless the driver was already exceeded yesterday; near is new unless the driver was already near or exceeded yesterday (crossing into a worse band re-alerts).
  • Weekly-rest violations (weekly_rest_short, weekly_rest_reduced): escalation-aware the same way, with inverted bands. weekly_rest_short is new unless the driver was already short yesterday; weekly_rest_reduced is new only when the driver was ok (full regular rest) yesterday.
  • All current-day violations (new and ongoing) are persisted to p24_tachograph_violations — the table is both the diff source and the audit history.
  • Zero new violations ⇒ no Discord message, no GitHub issue, exit 0.

Tables

p24_tachograph_violations

One row per (driver_id, stat_date, violation_type) (unique index ux_tachograph_violations_driver_date_type). Columns: the three key columns plus driver_name, severity, is_new, detail, the copied source counters (this_week_*, last_2w_*, weekly_rest_min, extended_driving_periods, shortened_rest_periods), and detected_at. RLS: service_role FOR ALL + grafana_readonly SELECT. Idempotent: the job does DELETE stat_date=D → INSERT, so a same-day re-run is safe.

2026-10-05 rule/mapping cutover + invalidation of pre-cutover rows (#7727)

Before 2026-10-05 the detector + the n8n map_drivers mapping were wrong: extended_driving was flagged with a > 0 rule on the wrong source field (used raw counts instead of …Left/limit). Every p24_tachograph_violations row dated 2026-09-17 … 2026-10-04 (489 rows — all extended_driving, the entire pre-cutover content) and the 50 open extended_driving escalations were therefore false. The 2026-10-05 cutover (n8n map_drivers fix, PR #7428 / #7460, plus the exceeded-allowance rules EXTENDED_DRIVING_MAX_ALLOWED=2 etc.) corrected the detector; from that date no extended_driving rows are produced.

Migration 20261010094733_purge_false_tachograph_violations_7727.sql cleaned this up reversibly:

  • The 489 false extended_driving rows were copied into p24_tachograph_violations_archive (full row shape + original_id, invalidated_at, invalidation_reason; same RLS as the live table) and then deleted from p24_tachograph_violations. The pop-up / /api/reports/ driver-compliance-violations read the live table, so they no longer show the false history; the archive preserves it for audit and makes the move undoable.
  • The 50 open pre-cutover extended_driving escalations and the 54 open weekly_rest_reduced escalations were soft-dismissed (status='dismissed', resolution_note referencing #7727) — reversible by setting them back to open.
  • weekly_rest_reduced no longer escalates (ESCALATION_SUPPRESSED_TYPES in the ETL): a reduced weekly rest (24–45 h) is legal under EU 561/2006, a warning band not a breach, so it is kept as a warning counter in p24_tachograph_violations but never raised into the human-review escalation panel. weekly_rest_short (<24 h, a real breach) and all *_exceeded breaches still escalate.
  • Backup of the invalidated ids/counts: scripts/nightly-etl/backups/7727-tachograph-purge-backup.json.

The nightly job (scripts/nightly-etl/atrax_tachograph_compliance_daily.py)

# default = yesterday (Europe/Berlin); --date to backfill a specific day; --dry-run writes/alerts nothing
python3 scripts/nightly-etl/atrax_tachograph_compliance_daily.py --env-file /opt/p24-infra/bms-4/.env
python3 scripts/nightly-etl/atrax_tachograph_compliance_daily.py --date 2026-09-14 --dry-run --env-file /opt/p24-infra/bms-4/.env
  • Sync Python, reuses atrax_norm_common (env loading, PostgREST read/write, Europe/Berlin day helpers, and the Error Notification Standard — Discord webhook + GH issue on failure).
  • Credentials from env only (SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); never logged.
  • Alert Discord webhook: P24_DISCORD_COMPLIANCE_WEBHOOK_URL if set, otherwise falls back to P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL (the only Discord webhook currently deployed to bms-4). A dedicated compliance channel is an optional future secret-manager task.
  • Aggregate GitHub issue: labelled compliance, title [Compliance] Tachograph driving-time violations — <date> (…). Idempotent per day — it will not re-file if an open issue with that date already exists.
  • Exit code: 0 on success, 1 on failure (failure also fires the Discord + GH bug error notification).

Cron wiring (live on bms-4)

Scheduled and live on bms-4 since #7045 (infra-task). The essential constraint is ordering: the job reads p24_atrax_drivers_daily for yesterday (Berlin), and those rows are written by the n8n atrax-drivers-daily-snapshot workflow at 04:00 UTC (0 6 Berlin). So the job must run after 04:00 UTC — the existing norm-control ETL wrapper (run-atrax-norm-nightly.sh, 02:15 UTC, under the claude-runner crontab) runs before the snapshot and therefore cannot host this job.

It runs from the claude-runner-2 user crontab at 04:45 UTC (06:45 Berlin) — 45 min after the snapshot, verified to have complete rows by then:

# claude-runner-2 crontab (crontab -l -u claude-runner-2) — 04:45 UTC, after the 04:00 UTC
# atrax-drivers-daily-snapshot. python3.12 pinned (3.10 mis-parses Atrax timestamptz).
45 4 * * * /usr/bin/python3.12 /opt/p24-infra/scripts/nightly-etl/atrax_tachograph_compliance_daily.py --env-file /opt/p24-infra/bms-4/.env >> /var/log/p24-infra-workers/atrax-tachograph-compliance.log 2>&1
  • Why claude-runner-2, not claude-runner: the infra-task worker that wired this runs under claude-runner-2 and cannot edit another user’s crontab without root; claude-runner-2 already hosts infra crons (check-sub-usage.py) on this host, so this is consistent. Consolidating it under claude-runner alongside the norm-control wrapper is an optional future cleanup — it needs a root/claude-runner crontab edit and a later run slot (the wrapper’s own 02:15 UTC is too early for this job); functionally the current wiring is complete.
  • Log: /var/log/p24-infra-workers/atrax-tachograph-compliance.log.
  • Manual / backfill: run any time with --env-file as shown above (add --date YYYY-MM-DD to target a specific Berlin day, --dry-run to compute without writing/alerting).
  • Empty-day safety: if the snapshot did not write the target day’s rows (upstream failure), the job reads 0 driver rows → 0 violations → no alert, exit 0. It never emits false “resolved” alerts.

EU AI Act

Registered in dev_r_ai_systems as tachograph-compliance (#7153, plan-7107 §4). The #7045 scope had deliberately deferred this (“no ML, no statistical scoring, no performance ranking”). The plan-7107 audit revised that call: although the logic is rule-based threshold checks over counters Atrax already computed — no ML, no median/MAD — the job is inherently per-driver (it evaluates a named driver’s regulatory compliance), which is adjacent to Annex III.4(a). It is registered at limited risk, not high-risk, because it is advisory / investigatory only: a human verifies every finding against the tachograph record and no output is wired into any automated disciplinary / ranking / allocation system. Every escalation row it creates carries requires_human_review=true, and the panel blocks bulk / automatic resolution on those rows. Wiring this output into any automated employment decision would require a fresh conformity assessment first — the same guardrail the ecodriving and route-norm jobs carry.

Failure handling

Any unhandled exception → atrax_norm_common.notify_failure: a RED ERROR Discord embed (P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL) + a GitHub issue labelled bug. Per-day idempotency means a failed run can simply be re-run once the cause is fixed.