GPS fuel snapshots — Phase 1 (Lane B) import + kill switch

Operational runbook for the append-only p24_gps_fuel_snapshots branch added to the atrax-kravag-scheduled-fleet-updates n8n workflow (Lane B / Phase 1 of #6194). This closes the “no history” gap: p24_gps_current_state is UPSERT-only, so nothing downstream (trip-builder, norm-compute, anomaly-daily — Lane D, PR #6274) has a time series to compute from until this branch is live.

Re-targeted in #6548. The branch was originally designed against fleet-update-v2-batch (AJ1px9uHIfbsriof, PR #6275), but that workflow is orphaned (active=false, 0 executions — nothing calls it). The real, live GPS producer is atrax-kravag-scheduled-fleet-updates (workflow id CCx9UMdphmGficDX, 57 → 59 nodes, active, every 3 minutes), whose upsert_gps_current_state node is the actual writer of p24_gps_current_state. The snapshot branch now hangs off that workflow’s GPS path. fleet-update-v2-batch is left untouched (no activation, no deletion — that is a separate cleanup decision, out of scope for #6548).

The nightly jobs that consume this table live in a separate PR (#6274) — this doc covers only producing the raw snapshots. Once #6274 merges, its docs/atrax-route-norm-control-operations.md is the parent runbook; this file is the Phase-1/import half.

⚠️ Live is source of truth — never a repo-only edit (backup-clobber)

The daily n8n-backup.yml GH Action (cron 0 4 * * *) exports live n8n → repo, treating live as authoritative. A repo-only edit (JSON committed but never imported to live) is silently overwritten within 24 h — this already happened once to PR #6275’s nodes. Any change to this workflow must be imported to the live bms-4 n8n instance in the same work session as the merge — do not leave a merged-but-not-imported gap overnight. Always pull the current live definition via the n8n API before editing the repo copy, so you are not reconciling against a stale export.

What was added (in the workflow JSON)

Two nodes on n8n-workflows/atrax_kravag-scheduled-fleet-updates_CCx9UMdphmGficDX.json, wired as a parallel branch off aggregate_gps_upsert — alongside the existing upsert_gps_current_state, never in series with it. aggregate_gps_upsert (an Aggregate node, aggregateAllItemData) emits a single item whose $json.data is the array of GPS rows; both upsert_gps_current_state and the new kill switch read from it independently.

  1. snapshot_killswitch (IF node) — gates the branch on the env var P24_GPS_SNAPSHOTS_ENABLED. Writes only when it equals the string true. Any other value, or unset, routes to the (empty) false branch and writes nothing.
  2. insert_gps_fuel_snapshots (HTTP Request) — POST /rest/v1/p24_gps_fuel_snapshots (PostgREST, Prefer: return=minimal), reshaping aggregate_gps_upsert’s $json.data into snapshot rows. Uses this workflow’s own Supabase credential supabaseKapbaraAuthBearer (id cMpMbkDFAJzC3O4f) — the same one bound on upsert_gps_current_state and the other Supabase HTTP nodes here. (Note: this is a different credential id than PR #6275’s ZNuDXmNs3nT58L6p/supabase-service-role-key, which belonged to the fleet-update-v2-batch workflow — n8n credential ids are per-credential-object; always reuse whatever the sibling nodes in the same workflow already use.)

Design constraints baked into the node (do not “fix” these — they are deliberate, per #6194 §5a-ops):

  • No retryOnFail. The table is append-only with no unique constraint by design; an automatic retry after a partial-success/timeout would silently duplicate the whole batch and corrupt every downstream median/MAD computation. A failed run simply skips that 3-min snapshot.
  • onError: continueRegularOutput. The branch swallows its own errors so a snapshot hiccup can never fail the execution, block the existing (working) upsert_gps_current_state upsert, or fire the workflow’s shared errorWorkflow (gG3SsUPtcqgtsbje) alert path — a snapshot gap is a lower severity than a real GPS-sync outage and must not page on-call.

Handoff 1 (sys-admin / infra-task) — import to live bms-4

A dev-issue worker cannot import to live n8n; a sys-admin/infra-task worker on bms-4 performs the steps below. n8n runs locally on bms-4 (http://localhost:5678, queue mode — main + 3 workers); the API key is BMS4_N8N_API_KEY in /opt/p24-infra/bms-4/.env.

  1. Pull the live definition first and edit from it (backup-clobber guard, above): curl -H "X-N8N-API-KEY: $KEY" http://localhost:5678/api/v1/workflows/CCx9UMdphmGficDX.
  2. Import to live. The n8n public-API PUT /workflows/{id} is lossy on settings (it rejects maxConcurrency, binaryMode, callerPolicy, availableInMCP and would silently drop maxConcurrency: 1). Use the n8n CLI import instead, which preserves the full settings and the versioning layer:
    docker cp modified-full.json bms-4-n8n-1:/tmp/wf.json
    docker exec bms-4-n8n-1 n8n import:workflow --input=/tmp/wf.json --activeState=fromJson
    --activeState=fromJson respects the JSON active: true field (the default deactivates). Confirm the two new nodes appear, active is still true, and settings.maxConcurrency is still 1.
  3. Enable the kill switch. The flag is wired through bms-4/docker-compose.yml on all four n8n services (n8n, n8n-worker-1..3) as P24_GPS_SNAPSHOTS_ENABLED=${P24_GPS_SNAPSHOTS_ENABLED:-true} (#6548) — i.e. enabled by default, in git. It is deliberately not in bms-4/.env: that file is regenerated from SOPS by secrets-sync.yml on every run, so a manual non-secret line there would be clobbered. The compose default is the durable home. Recreate the n8n containers so the schedule trigger re-registers against the new definition and the workers pick up the env var (queue mode → $env is evaluated on the worker that runs the node, so every worker needs it):
    cd /opt/p24-infra/bms-4 && docker compose up -d n8n n8n-worker-1 n8n-worker-2 n8n-worker-3
    N8N_BLOCK_ENV_ACCESS_IN_NODE=false is already set on every n8n service, so $env access in the IF node is not blocked.
  4. Confirm the supabaseKapbaraAuthBearer credential (id cMpMbkDFAJzC3O4f) is bound on the new HTTP node after import.

Verification (Phase 1)

After import + enable, confirm rows accumulate at roughly one per active vehicle per 3-minute run:

SELECT count(*), min(captured_at), max(captured_at),
       count(DISTINCT atrax_car_id) AS vehicles
FROM public.p24_gps_fuel_snapshots
WHERE captured_at > now() - interval '1 hour';

Expect ~20 snapshots/hour per active vehicle (every 3 min). If vehicles is ~0 while the workflow is executing, the kill switch is still closed (env var not true, or the n8n containers were not recreated after setting it).

Kill switch — incident response (NOT a scheduled handoff)

Overriding P24_GPS_SNAPSHOTS_ENABLED to anything other than true — either by flipping the :-true default to :-false in bms-4/docker-compose.yml, or by exporting P24_GPS_SNAPSHOTS_ENABLED=false into the compose process env — and recreating the n8n containers stops all new snapshot writes without touching the rest of the workflow. This is an incident-response action owned by the same sys-admin on-call rotation that handles other live-production n8n issues — not a scheduled task. Reasons to flip it off: unbounded table growth ahead of the 35-day retention job landing (PR #6274 Lane E), or PostgREST write pressure on the shared Supabase project.

Retention (35-day prune + Parquet archival) is Lane E in PR #6274 — until that is scheduled, watch table size manually; at 3-min cadence ~263 vehicles × 20/hour × 24 h ≈ 126k rows/day.