Overview

Sentinel_Cab

In-cab driver identity and shift verification system for coal hauling operations. Third pillar under Argus_IIOP, alongside Sentinel_Fleet (truck telemetry / fuel / comparative dataset) and Sentinel_Edge (industrial cloud DC).

Pillar logic

  • Sentinel_Fleet answers: what is the truck doing? (fuel, idle, cycle, grade)
  • Sentinel_Cab answers: who is driving the truck, and is that valid? (RFID, selfie, shift state, audit trail)
  • Sentinel_Edge answers: where does the data live? (regional industrial cloud)

Together they form the operator-truck-data trio. Sentinel_Cab is the human/identity layer of the platform.

Review site (live)

https://sentinel-cab-design.pages.dev — all design docs rendered in a single navigable HTML page for review. Re-deployed by running python3 build.py && wrangler pages deploy dist --project-name=sentinel-cab-design --branch=main --commit-dirty=true inside review-site/.

Folder contents

File Purpose
README.md This file.
context-brief.md Source handoff received 2026-05-11. Verbatim. Do not edit.
decisions.md Running log of locked design decisions (D-001 → D-017) + open queue.
product-design.md v1.0-DRAFT — full system design: architecture, schema, API, state machine, UX, sync, hardware, audit, multi-tenancy.
pilot-adong-aug2026.md v1.0-DRAFT — Aug 2026 Adong pilot: sprint plan, hardware procurement timeline, per-truck install procedure, risk register (20 items), success metrics.
implementation-plan-sprint1.md v1.0-DRAFT — Sprint 1 (May 12–25) detailed task breakdown: 30 tasks across infra setup, backend schema, auth, mobile app foundation, hardware bench validation. Outline of Sprints 2–8 included.
bench-procurement.md Buying list for the bench test kit (~Rp 6.2–8.9 M post-D-026, Tokopedia/Shopee Jakarta delivery).
review-site/ Build script + dist for the Cloudflare Pages review site.

Status (as of 2026-05-11)

  • Source brief received and preserved
  • Brainstorming complete; D-001 → D-017 locked
  • Bench procurement list ready to order today
  • product-design.md v1.0-DRAFT written, ready for user review
  • pilot-adong-aug2026.md v1.0-DRAFT written, ready for user review
  • Next: user review → spec adjustments (if any) → writing-plans skill → sprint-level implementation plan

Key locked decisions (full detail in decisions.md)

  • D-001 Project named Sentinel_Cab
  • D-003 Pilot site: Adong, co-deployed with Sentinel_Fleet
  • D-004 External USB-OTG HF RFID reader (not on-device NFC)
  • D-005 HID keyboard-wedge interface mode
  • D-006 Capacitor wrapping React+Vite for tablet runtime
  • D-007 Pilot timeline: August 2026, 10 P500 trucks
  • D-010 MIFARE DESFire EV3 cards
  • D-012 Standalone Nest service + Neon Postgres, multi-tenant
  • D-013 Multi-tenant from day one (Adong = tenant_0)
  • D-014 Selfie storage: Cloudflare R2 Singapore
  • D-015 Driver-truck assignment: primary/backup/unscheduled, soft-enforced v1
  • D-016 v1 scope: 7 trust states, 9 exceptions, 3 risk triggers, 4 geofence zone types
  • D-017 Auth: CF Access + own session (dashboard); device-bound JWT (tablet)
  • D-022 1 shift / driver / 24h; truck-breakdown reassignment workflow (60-min window)
  • D-023 Geofence zone types: base camp / loading area / dumping area; out-of-zone activity → flag
  • D-024 Driver-handover workflow: truck-keyed 90-min continuation window for mid-shift driver swap
  • D-025 haul_route as fourth v1 zone type; mapped via satellite-imagery trace in Sprint 4
  • D-026 In-cab device: Samsung Galaxy XCover7 rugged phone (not tablet) — 40–50% CAPEX reduction per truck
Decisions

Sentinel_Cab — Decision Log

Running log of locked design and operational decisions, in chronological order. Each entry: date, decision, rationale, what it forecloses or unblocks.


2026-05-11 (Day 0 — brainstorming session)

D-001 — Project name

Decision: Sentinel_Cab Rationale: Names by physical location (in the cab), mirrors Sentinel_Fleet/Sentinel_Edge naming. Room to grow beyond drivers (cab telemetry, in-cab CCTV) without renaming. Forecloses: Sentinel_Driver (too narrow), Sentinel_Shift (less marketable), Sentinel_Crew (less crisp).

D-002 — Pillar positioning

Decision: Third pillar under Argus_IIOP, alongside Sentinel_Fleet (truck telemetry) and Sentinel_Edge (industrial DC). Rationale: Fleet answers what is the truck doing; Cab answers who is driving and is that valid; Edge answers where does the data live. Together they form the operator-truck-data trio. Folder: Argus_IIOP/Sentinel_Cab/

D-003 — Pilot site

Decision: Adong (Runa), co-deployed on the same 70 active haul Scanias as Sentinel_Fleet's Phase 0 trial. Rationale: One combined hardware install per truck (shared cradle, power tap, GPS, cellular). Faster installer trips, shared CAPEX line, integrated-platform demo. Friendly site for iteration. Forecloses: External-customer-first pilot, separate-site pilot. Couples to: Sentinel_Fleet Phase 0 install schedule (Aug P500 batch → Sep–Nov main install → Dec TRUST batch).

D-004 — RFID hardware: external reader, not on-device NFC

Decision: External USB-OTG RFID reader, on-puck LED + buzzer, mounted near steering column, separate from tablet placement. Rationale: Cab reality (gloved hands, dim, vibration) makes a dedicated reader puck at hand-height meaningfully easier than fumbling the back of a swivelling tablet. Tap success/failure feedback at the reader cuts re-tap loops. Decouples tablet placement (optimized for screen visibility) from reader placement (optimized for hand reach). Sabotage = truck_moving_no_active_shift exception in audit trail. Trade-off accepted: Higher CAPEX (~Rp 30–80M extra across 70 trucks for reader + cabling), more install time per truck. Forecloses for v1: On-tablet NFC, integrated cradle-with-NFC.

D-005 — RFID interface mode: HID keyboard-wedge

Decision: Reader emulates a USB keyboard, types the UID into the focused field. Rationale: Runtime-agnostic (works in PWA, Capacitor, native identically). Zero driver maintenance. UID-only is sufficient for v1 anti-sharing because the actual anti-sharing enforcement is selfie + supervisor review, not card cryptography. Future option preserved: Same physical reader can later be re-flashed or driven via PC/SC mode (Capacitor plugin) to enable MIFARE DESFire mutual auth if v2+ needs cryptographic anti-clone.

D-006 — Tablet runtime: Capacitor wrapping React+Vite

Decision: Single React+Vite codebase (shared with supervisor dashboard), wrapped in Capacitor for native Android APIs (camera, USB, background sync, kiosk, attestation). Rationale: Putro/Leon already know React+Vite (existing internal-app stack). Native APIs available when needed without a separate native codebase. Update path: signed APK over Sentinel_Fleet cellular when truck near gate Wi-Fi, fallback to MDM push. Forecloses: Pure PWA (storage eviction risk, no kiosk, no background sync reliability), Native Android (overkill for v1, requires Android dev hire).

D-007 — Pilot timeline

Decision: Aug 2026 pilot, riding the Sentinel_Fleet P500 install batch (10 P500-A6x4 trucks). Rationale: Small cohort = manageable bug blast radius. Fresh install = no retrofit complexity. Forces a healthy thin v1 (RFID start + selfie + basic dashboard + basic exceptions) and de-risks the fuller Sep–Nov main install. Drives integrated-platform narrative when Fleet and Cab arrive together. Implications: - Design freeze: mid-June 2026 (~5 weeks from D-001) - Hardware procurement: bench kit this week, pilot kit by mid-June - Capacitor app installable on bench: end of July - 10-truck install: August (alongside P500 commissioning) - Supervisor dashboard live: end of August - Pilot signal collection: Sep–Oct - Decision on Sep–Nov main-fleet expansion: end of October

D-008 — Bench procurement

Decision: 1 bench setup, sourced via Tokopedia/Shopee Jakarta delivery, ~Rp 9.7–12.7 M. Rationale: Fast (1–3 day delivery), let one developer prototype against real hardware before committing to pilot procurement. Single setup = lowest risk if any component disappoints. Components: Samsung Galaxy Tab Active5 WiFi (SM-X300), ACS ACR122U HF reader, MIFARE DESFire EV3 cards ×5 (production chip), MIFARE Classic 1K cards ×10 (dev/scratch), Ugreen USB-C OTG with PD passthrough, desktop tablet stand. Detailed BOM: see bench-procurement.md

D-009 — Card issuance model

Decision: Issue fresh Sentinel_Cab-branded driver ID cards to all Adong drivers. Do NOT piggyback on existing Adong site cards. Rationale: Full control over card format, branding, UID range, lifecycle (active / lost / revoked / expired), replacement policy, and anti-clone properties. Removes dependency on whatever access infrastructure Adong already runs. Cleaner audit trail — every card has known issuance date, driver assignment, and revocation history under our system from day one. Implications: - Card-printing lead time: 2–4 weeks for personalized cards via Jakarta print bureau - Per-card cost: Rp 25–50k (DESFire EV3 — see D-010) - Issuance workflow needed in admin panel: driver registration → card print → distribution → activation - Lost-card replacement policy needed: revoke old UID, issue new card with new UID, hold ~20% spare stock at site

D-010 — RFID chip type

Decision: MIFARE DESFire EV3 (HF 13.56 MHz, 4KB encrypted memory, AES-128 mutual auth capable). Rationale: - v1 uses UID-only via HID keyboard-wedge — identical UX to using a cheaper card - v2 can flip to cryptographic mutual auth (Capacitor PC/SC plugin) without re-issuing cards - ~Rp 1.7–3 M total premium over MIFARE Classic across 85 cards — rounding error on Rp 150–220M pilot CAPEX - Industrial standard for modern access control (HID, ASSA Abloy, Gallagher, Oyster, EZ-Link all use DESFire) - 7-byte UID = no collision risk - Multi-application capable (same card could later carry Runa gate-access or canteen-credit applications in separate cryptographic domains) Forecloses: MIFARE Classic 1K (broken crypto, would force re-issue if anti-clone ever needed), EM4100 LF (UID-only forever, no upgrade path). Reader implication: HF readers only. ACS ACR122U on bench (already in kit); pilot install reader is Sycreader R30D-HF / HID Omnikey 5022 CL — same Rp 1.5–3 M price range as LF equivalents, no procurement penalty. Card sourcing: Indonesian distributors (HID, ACS partners) or international (NXP authorized resellers). Lead time 2–4 weeks for blank cards; +1–2 weeks for personalized printing.

D-012 — Backend service split

Decision: Standalone Sentinel_Cab Nest service + dedicated Neon Postgres. Multi-tenant schema from day one. "Argus core" extraction (shared drivers/trucks/sites/geofences) deferred until Sentinel_Fleet is built. Rationale: YAGNI-correct for Aug 2026 deadline. Doesn't block Cab on Argus core being designed first. Schema designed with tenant_id on every relevant row + RLS-style guard rails so extraction later is well-scoped refactor, not rewrite. Forecloses: Single-monolith pattern, build-core-first pattern. Repo: new repo(s) under josepheffendy/sentinel-cab-api (Nest, SwiftRise boilerplate fork) + sentinel-cab-app (React+Vite + Capacitor) + sentinel-cab-dashboard (React+Vite). Mono-repo vs poly-repo locked during writing-plans phase.

D-013 — Tenancy posture

Decision: Multi-tenant from day one. Adong is tenant_0 (or whatever first ID is allocated). Every domain table has tenant_id column with NOT NULL constraint. Tenant-scoped queries enforced at the service layer (and later at Postgres RLS when we harden). Rationale: Argus_IIOP is explicitly a product, not an internal tool. Adong is "first customer," not "the customer." Cost of retrofitting tenancy onto a single-tenant schema later is much higher than designing it in. Same pattern as Tally (Nautilus) SG finops. Implications: All seed data, RFID cards, drivers, trucks, geofences, shifts, exceptions scoped per tenant. Supervisor dashboard auth includes tenant context. Future Sentinel_Fleet integration plays on the same tenant model.

D-014 — Selfie storage

Decision: Cloudflare R2 (Singapore region), with documented driver consent at hire and standard DPA. PDP-Law-compliant via consent + DPA route. Migrate to Sentinel_Edge or Indonesian domestic provider when SEdge Phase 0 colocation goes live in Balikpapan. Rationale: Cheapest + fastest + S3-compatible + already in our stack (asset.jofnd.ai, procure.jofnd.ai). Migration path is clean (S3 API portable). Consent collection happens once at driver onboarding, captured by the same admin panel that issues their card. Forecloses for v1: Indonesian domestic object storage (Biznet/Lintasarta/Telkom — too expensive + slower iteration for v1), self-hosted MinIO on Cab server (storage scaling becomes our problem), hybrid setup (adds architectural complexity v1 doesn't need). v2/v3 trigger: When Sentinel_Edge Phase 0 lands in Balikpapan OR when an IPO/large-customer compliance review demands provable domestic residency, migrate selfie blobs to domestic storage. Migration cost: ~1 sprint (one-time S3 sync + DNS swap + config). Retention: 12 months default, configurable per tenant. Auto-purge after retention. Audit log of selfie-delete events kept indefinitely.

D-015 — Driver-truck assignment model

Decision: Schema supports full primary/backup/unscheduled model from day one. v1 enforces softly (records + flags), v2 enforces hard (blocks or requires supervisor pre-approval). Rationale: Direct quote: "currently its any active driver can drive any truck, but long term we want it so there's restricted named pairing. but some drivers can drive any truck if the main driver isn't available or with approval. we need structured for responsibility, but flexibility for business."

Schema sketch:

truck_driver_assignments
  tenant_id, truck_id, driver_id, role ENUM('primary','backup'),
  valid_from, valid_to (nullable for open-ended),
  assigned_by, assigned_at, revoked_by, revoked_at, notes
  UNIQUE(tenant_id, truck_id, role='primary') WHERE valid_to IS NULL
  (i.e. at most one active primary per truck)

Shift-start eligibility decision (logged on every shift_start): 1. Driver active? → no = block + inactive_driver_attempt exception 2. Driver matches primary for this truck? → yes = eligibility=primary, activate normally 3. Driver matches backup for this truck? → yes = eligibility=backup, activate normally 4. Driver is neither? → v1: eligibility=unscheduled, activate as active_low_trust + unscheduled_driver_shift exception logged for supervisor review; v2: require supervisor pre-approval, otherwise block

Backfill behavior: if truck_driver_assignments has zero rows for a given truck, treat the truck as unassigned and behavior gracefully defaults to "any active driver" (= eligibility=unassigned_truck, activate normally, no exception). This lets Adong populate assignments at their own pace.

Forward path: dashboard surface to manage assignments + supervisor pre-approval flow lands in v2 once Adong's data is mature.

D-016 — v1 scope (Aug 2026 freeze)

Decision: Thin v1 covering all 7 trust states, 5 of 9 exception types, 3 of ~8 risk-trigger rules, 3 of 8 geofences, full admin CRUD, full supervisor dashboard at "live operations" level.

Included v1: - Tablet app: RFID-tap shift start (HID-wedge), eligibility check, optional selfie, status screen, shift end with reason capture, local event queue with idempotency, best-effort sync with backoff, Knox kiosk lock - Backend: multi-tenant Nest+Neon, CRUD for drivers/cards/trucks/geofences/assignments, shift lifecycle including breakdown-reassignment workflow (D-022), idempotent event ingestion, R2 selfie upload, supervisor read/write endpoints - Dashboard: CF Access + own session, live "now" view (including breakdown-reassignment status badges), selfie review queue, exception inbox, shift detail with timeline + continuation-shift links, basic admin CRUD - Trust states: all 7 — idle, rfid_verified, pending_selfie, active_verified, active_low_trust, ended, flagged - Exceptions (9): truck_moving_no_active_shift, unscheduled_driver_shift, selfie_timeout, missing_shift_end, suspicious_rfid_pattern, multiple_active_shifts_per_driver (promoted by D-022), shift_activity_outside_zones (promoted by D-023), shift_start_outside_base_camp_no_handover (promoted by D-024), multiple_active_drivers_per_truck (promoted by D-024) - Risk triggers (3): random ~15%, reassignment shift (covers both breakdown AND handover continuations per D-022 + D-024), previous shift was active_low_trust - Geofence zone types (4): base camp, loading area, dumping area, haul route (per D-023 + D-025 — multiple polygons allowed per type; activity outside the union of all four triggers shift_activity_outside_zones) - Continuation reasons (2 in v1): breakdown (D-022, driver-keyed window 60 min), driver_handover (D-024, truck-keyed window 90 min)

Deferred to v2: - Exceptions: gps_unavailable_at_start (flag only in v1) - supervisor_directed continuation reason - Risk triggers: repeated failed RFID reads, unusual shift time, suspicious route pattern, prior camera failure, device integrity - Geofences: workshop, pit loading area, ROM stockpile, weighbridge, crusher/dump point - Features: supervisor pre-approval flow (D-015 hard enforcement), DESFire mutual-auth mode (D-010 upgrade), face matching, route selection at shift start, telematics correlation with Sentinel_Fleet, multi-site map view, exports/analytics

Rationale for the cuts: deferred items are either (a) collision-rare in a 10-truck cohort, (b) require baseline data we won't have until ~30 days of v1 operation, (c) cosmetic dashboard enhancements that don't change pilot signal, or (d) operationally premature.

D-017 — Internal app auth pattern

Decision: Supervisor dashboard at cab.jofnd.ai (or similar sub) behind Cloudflare Access (edge identity gate) + own session-based login (authz, RBAC), mirroring the existing standard for asset.jofnd.ai and procure.jofnd.ai. Rationale: Already standardized per memory feedback_internal_app_auth_standard. Mining-customer end-users (Adong supervisors) get CF Access invite + receive their own per-tenant login. Tablet app uses device-bound API tokens (per-tablet, rotated), not CF Access (tablets can't do interactive auth).

2026-05-12

D-022 — Driver shift uniqueness + breakdown reassignment workflow

Decision: A driver is permitted at most one active shift per 24-hour rolling window, except in the case of truck breakdown mid-shift, in which case the driver may be reassigned to a different truck through a structured reassignment workflow. The reassignment creates a new shift record linked to the original via continuation_of_shift_id. Rationale: Direct quote: "driver should only be in 1 shift every 24hrs. unless their unit breaksdown mid shift, and they get allocated to a new unit." This is a domain rule for coal hauling at Adong — drivers operate 1 shift then rest. The breakdown carve-out exists because operational reality occasionally requires mid-shift truck swaps.

Schema additions to shifts:

end_reason ENUM('driver_tap','truck_breakdown','auto_close','supervisor_override','other')
end_reason_notes TEXT NULLABLE
continuation_of_shift_id UUID NULLABLE REFERENCES shifts(id)
continuation_reason ENUM('breakdown','supervisor_directed') NULLABLE

Breakdown reassignment flow: 1. Driver taps End Shift → tablet asks "Why are you ending the shift?" with options: Normal end / Truck breakdown / Other (free text) 2. If "Truck breakdown" → shift closes with end_reason='truck_breakdown'. Backend opens a reassignment window of 60 min (configurable per tenant) for that driver. 3. Within the window: driver can start a new shift on a different truck. The new shift is created with continuation_of_shift_id = <original> and continuation_reason = 'breakdown'. Does not trip the multiple_active_shifts_per_driver exception. 4. The continuation shift gets a selfie challenge by default (it's an unusual operational context — see updated D-016 risk triggers). 5. After window expires, any further shift attempt by the same driver in the 24h period triggers the multiple_active_shifts_per_driver exception (unless legitimately a new 24h window).

Dashboard surfacing: - Active shift with end_reason='truck_breakdown' shows a "Breakdown — awaiting reassignment (X min remaining)" badge - Continuation shifts visually linked to the original on shift-detail view

Impact on D-016 v1 scope: - Risk trigger "first shift of day for driver" → REPLACED with "reassignment shift" (since "first shift of day" is now redundant — every shift is the first of the day under the 1-shift-per-24h rule) - Exception multiple_active_shifts_per_driver → PROMOTED from v2 to v1 as a critical invariant under D-022

Impact on D-015 (eligibility): - Eligibility check now includes: "Is this driver within a breakdown-reassignment window?" If yes, skip multi-shift exception. If no and driver has any other active shift in 24h, raise multi-shift exception.

D-023 — Geofence zone semantics for Adong v1

Decision: v1 geofences at Adong define the operational cycle, not a security perimeter. Three zone types: - base_camp — workshop / parking / staging / where shifts start and end - loading_area — where coal is loaded onto the truck (may be one or several polygons depending on active pit faces) - dumping_area — where coal is unloaded (ROM stockpile / crusher / dump point — may be one or several polygons)

Any truck position outside the union of all three zone types during an active shift is treated as out-of-cycle activity and flagged for supervisor review (new exception shift_activity_outside_zones — promoted into v1, replacing the deferred shift_start_outside_approved_area_without_reason).

Rationale: Direct quote: "the geofences need to be defined again but its likely... base camp, loading area, dumping area, outside of that should be flagged." This is the operational reality of a coal haul cycle: leave base camp → drive to loading area → load → drive to dumping area → dump → return → repeat. Anything outside that triangle is unusual and worth supervisor visibility.

Schema change to geofences:

zone_type ENUM('base_camp', 'loading_area', 'dumping_area', 'restricted')
-- v1 uses the first three. 'restricted' reserved for v2 (e.g. blast zones, off-limits roads).
-- Multiple polygons per zone_type allowed (one row per polygon).

Out-of-cycle detection logic (backend): - Tablet emits gps_ping events every 60s during active shifts - Backend (or async job) computes: is the latest GPS point inside ANY active geofence polygon for the tenant? - If outside for >5 min continuously → emit truck_outside_operational_area event + raise shift_activity_outside_zones exception (severity=warn) - Geofence-exit + geofence-enter events continue to be emitted on every polygon boundary cross (as before)

v1 exception count update: 6 → 7. New addition: shift_activity_outside_zones.

Tablet UX: subtle indicator (a small zone-name badge) shows which zone the truck is currently inside during an active shift. Driver doesn't see a warning when outside — supervisor handles via dashboard. Avoids alarming drivers over GPS glitches or short out-of-zone roads.

Admin panel implication: geofence editor must support drawing multiple polygons per zone type (e.g. 3 loading areas, 2 dumping areas) and labelling them within the type.

D-024 — Mid-shift driver handover workflow

Decision: Add driver_handover as a peer of truck_breakdown in the end-reason enum. When a driver ends their shift mid-haul (going off-shift, sickness, fatigue, scheduled rotation), they select Driver handover as the reason. This opens a truck-keyed handover window (90 min default, configurable per tenant — longer than breakdown's 60 min because handover scenarios often involve transport time for the relief driver to reach the truck).

Within the handover window, a different authorized driver may tap into the same truck at the truck's current GPS location (which may be outside base_camp) without raising the new shift_start_outside_base_camp_no_handover exception. Outside the window, the same tap raises the exception (severity=warn) and activates the shift as active_low_trust.

Rationale: Direct quote: "sometimes a drivers shift ends midhaul, so the driver should already have communicated this before a new driver would be assigned and start outside of base camp area." Adong's pre-existing operational discipline is that mid-haul shift ends are pre-communicated and a relief driver is assigned. Sentinel_Cab must (a) not false-flag this legitimate flow, (b) catch the case where the discipline isn't followed.

Continuation key contrast (D-022 vs D-024):

End reason Window keys to Continuation =
truck_breakdown (D-022) Driver Same driver, different truck (truck is out of service)
driver_handover (D-024) Truck Different driver, same truck (driver is going off-shift)
driver_tap (normal) None expected; next shift is treated as fresh

Schema additions:

-- shifts.end_reason enum extended:
end_reason ENUM('driver_tap','truck_breakdown','driver_handover','auto_close','supervisor_override','other')

-- shifts.continuation_reason enum extended:
continuation_reason ENUM('breakdown','driver_handover','supervisor_directed') NULLABLE

(D-022's existing continuation_of_shift_id, reassignment_window_until columns are reused.)

New v1 exceptions (promoted by D-024): - shift_start_outside_base_camp_no_handover — shift started at a GPS point outside any base_camp polygon AND no handover window open on this truck AND no breakdown window open for this driver. Severity=warn. Trust state activates as active_low_trust. Supervisor visible. - multiple_active_drivers_per_truck — promoted from v2 (was deferred). New driver tap on a truck that already has an active shift, no handover window open. v1: blocks activation, flagged exception, asks driver to confirm with supervisor.

Tablet UX additions: - End-reason picker now has 4 options: Normal end / Truck breakdown / Driver handover / Other (notes) - "Ended — handover" exit screen variant: yellow icon + "Shift ended (driver handover). Relief driver has 90 minutes to take over this truck — your supervisor has been notified."

Dashboard additions: - "Trucks awaiting relief driver" counter alongside the breakdown counter - Truck cards in handover state show "Handover — relief due (X min remaining)" badge with the original driver named

Risk trigger update: - Continuation shifts (both reasons — breakdown AND handover) get a selfie challenge by default. Already covered by the "reassignment shift" trigger from D-022; just generalized to include both continuation_reason values.

Impact on v1 exception count: 7 → 9 (added shift_start_outside_base_camp_no_handover + multiple_active_drivers_per_truck).

D-025 — Haul route mapping as fourth v1 zone type

Decision: Add haul_route as a fourth zone type to D-023's enum. Haul route polygons are corridors traced along the actual haul road centerlines with ~50–100 m corridor width each side, modeled as GEOMETRY(Polygon,4326) rows like other zones (one polygon per logical road segment is fine; multiple polygons per zone_type='haul_route' allowed).

Rationale: Direct quote: "re truck_outside_operational_area, this means the haul route needs to be mapped ya?" Correct catch. Without haul_route polygons, the truck is "outside the operational area" every time it's in transit between base camp / loading / dumping — i.e. most of every haul cycle. This would produce constant false positives on the shift_activity_outside_zones exception, drowning real signal.

Updated zone enum:

zone_type ENUM('base_camp','loading_area','dumping_area','haul_route','restricted')
-- v1 uses the first four. 'restricted' reserved for v2.

shift_activity_outside_zones detection logic (refined): - Truck GPS not inside ANY of the four v1 zone types' active polygons for the tenant - Continuous for >5 min (configurable per tenant) - Triggers the exception

Sourcing the haul route polygons: v1 uses manual draw on satellite imagery (Google Earth Pro or equivalent against Sentinel-2 / Mapbox imagery in admin panel). Half-day dev task in Sprint 4 alongside other geofence work. Allows pilot to launch with accurate route geometry. Two alternative sources documented for v2: - Import from Adong's mining GIS shapefile if they share it (most accurate but creates dependency) - Auto-derive from Sentinel_Fleet GPS density after 2–4 weeks of pings (self-bootstrapping but means v1 launches with no haul_route polygons → false positives during initial weeks)

Corridor width recommendation: 50 m each side for engineered haul roads with hard shoulders; 100 m where the route runs near pit faces, switchbacks, or unstable terrain (gives GPS noise headroom). Joseph confirms specific widths with Adong workshop / planning team during haul route survey.

Implications: - Geofence count revised: 3 zone types4 zone types (base_camp, loading_area, dumping_area, haul_route); polygon count expected 6–15 across all types at Adong - Admin panel must support polyline-to-polygon corridor tooling (trace centerline, set corridor width, auto-buffer to polygon) - Pilot plan: haul route survey added as a Sprint 4 task; recommend co-locating with Adong's drone footage or Sentinel_Fleet grade survey if possible

D-026 — Rugged phone instead of rugged tablet

Decision: Use Samsung Galaxy XCover7 (rugged Android phone, 6.6" screen) as the in-cab device instead of Samsung Galaxy Tab Active5 (rugged tablet, 8" screen). Supersedes the tablet choice in D-006 / D-008 hardware sections (D-006 runtime decision — Capacitor + React+Vite — stands; only the physical device class changes).

Rationale: Direct quote: "what if i use a Galaxy XCover7 phone size instead of tablet? can save cost by 30-50% on each device." Cost analysis confirms ~40–50% per-device savings (Rp 5–6.5M vs Rp 10–12M for Tab Active5) plus ~Rp 1M cradle savings — per-truck CAPEX drops from Rp 15–22M to Rp 8–11M, a ~Rp 70–110M pilot saving and ~Rp 500–770M full-fleet saving.

Why the v1 design absorbs this cleanly: - Driver UX is single-purpose (tap → optional selfie → status → end). 6.6" handles this fine with our full-width-button layout - Supervisor dashboard is on a laptop, not the in-cab device — screen size doesn't constrain operations - External RFID reader (D-004) handles tapping — independent of device form factor - Same Samsung Knox MDM, same Capacitor APK, same Knox kiosk lock - Same 5MP front camera class for selfies (actually marginally newer imaging stack on XCover7)

What gets better: - Programmable XCover key (physical side button) can be OS-mapped to "End Shift" or "Help" — glove-friendly tactile fallback - Smaller mount footprint = more flexible cradle placement in cluttered truck cabs - Drivers more familiar with phone form factor than tablet

Trade-offs accepted: - Smaller screen (6.6" vs 8") — fine for our single-purpose flow but less visible from outside the cab (rarely an operational concern) - Phone is pocket-able if a driver removes it from cradle — mitigated by Knox kiosk lock (stolen device = brick) + positively-locking cradle + cable-secured + remote wipe - Fewer cradle vendor options than Tab Active5 — RAM Mounts X-Grip universal works; Brodit XCover-specific exists for European markets

Bench-validation items added to Sprint 1 exit criteria: 1. Glove-tap reliability on 6.6" with the v1 button layout under simulated dim cab lighting 2. XCover7-specific vehicle cradle quality + Indonesian availability — confirm RAM Mounts (or equivalent) ships locked cradle suitable for industrial vibration 3. Thermal behavior in a closed cab simulation (4 hours in a parked car midday) — confirm no thermal throttling during continuous-use bench test

Bench kit revision (supersedes D-008 line items): - Samsung Galaxy XCover7 (SM-G556B) WiFi or LTE — Rp 5–6.5 M (saves ~Rp 3–5 M vs Tab Active5) - All other bench-kit components unchanged (ACR122U reader, DESFire EV3 + MIFARE Classic test cards, USB-C OTG with PD passthrough, desktop stand) - Revised bench total: ~Rp 6.5–9 M (was Rp 9.7–12.7 M)

Pilot kit revision: - Per-truck CAPEX: Rp 15–22 M → Rp 8–11 M (40–50% reduction) - 10-truck pilot total: Rp 150–220 M → Rp 80–110 M - Spares (×2 phones, ×2 readers, ×1 cradle): Rp 12–18 M

Forecloses: Tab Active5 path for v1. If thermal or ergonomic issues emerge in bench testing, fallback is to either (a) upsize to Tab Active5 (procurement reset), or (b) try Honeywell ScanPal CT45 (still phone-class but more enterprise).

Forward path: if v2 introduces in-cab CCTV display, telematics dashboard for the driver, or other screen-heavy features that justify the tablet, the device class can be revisited — but v1 ships on XCover7.


Open queue (next brainstorming pass — operational decisions, not architectural blockers)

ID Topic When needed by
D-011 Card face design (branding, photo personalization, lanyard hole, foil) Before card-print order (~5 weeks before pilot install = early July)
D-018 Hardware install partner: GTrack (Fleet partner) vs separate vendor vs Runa in-house Before pilot kit procurement (~mid June)
D-019 Geofence source: manual map draw in admin panel vs import from existing Adong GIS shapefile Before pilot install (mid-July if drawing manually; earlier if GIS import)
D-020 Tablet APK distribution channel: Knox MDM, sideload via cable at install, or self-hosted F-Droid-style update server Before first APK build (~early July)
D-021 Driver consent collection process (PDP Law) — paper form vs in-app vs HR onboarding integration Before first card issued to a real driver (~early August)

Append new decisions here as they're made. Renumber if a decision is reversed (mark old entry REVERSED 2026-MM-DD and add new entry below).

Product Design

Sentinel_Cab — Product Design (v1)

Version: v1.0-DRAFT Date: 2026-05-11 Status: Brainstorming complete (D-001 → D-017 locked); design ready for review before writing-plans phase. Pilot target: Aug 2026, 10 P500 trucks at Adong (PT Runa Persada). Owner: Joseph Companion docs: context-brief.md (source handoff), decisions.md (locked decisions), bench-procurement.md (hardware buying list), pilot-adong-aug2026.md (sprint plan + risk register + install plan).


0. What this document is

A pillar-level product design for Sentinel_Cab v1 — covering architecture, data model, state machine, APIs, offline sync, hardware integration, tablet UX, and supervisor dashboard. It is not an implementation plan. Sprint-by-sprint breakdown lives in pilot-adong-aug2026.md and the formal implementation plan output of the writing-plans skill.

If you are picking this up cold, read in this order: 1. Section 1 (intent + design priorities) 2. Section 2 (system architecture diagram) 3. Section 5 (state machine — the conceptual core) 4. Whatever subsystem you need to touch


1. Intent and design priorities

1.1 What Sentinel_Cab is

The human / identity layer of Argus_IIOP. Answers four questions with high confidence, in real time, with a durable audit trail:

  1. Who is driving the truck right now?
  2. Did they validly start the shift?
  3. Which truck and (later) route is the shift tied to?
  4. Was identity verified strongly enough for audit and exception review?

Sentinel_Fleet answers what is the truck doing; Sentinel_Cab answers who is driving and is that valid; Sentinel_Edge answers where does the data live.

1.2 What v1 is NOT

Not a payroll system. Not a fatigue-management system. Not a telematics integration. Not a contractor-billing system. Not a weighbridge-reconciliation system. Not a production-optimization system. v2+ can layer those on; v1 does not.

1.3 Design priority order (source brief — non-negotiable)

  1. Operational practicality
  2. Identity assurance
  3. Auditability
  4. Speed of rollout
  5. Extensibility later

1.4 Locked architectural inputs (from decisions.md)

Decision Locked value
Pilot site Adong, co-deployed with Sentinel_Fleet (D-003)
Hardware External USB-OTG HF RFID reader, dedicated puck mount (D-004)
RFID protocol HID keyboard-wedge, UID-only in v1 (D-005)
Card chip MIFARE DESFire EV3 (HF 13.56 MHz, 7-byte UID) (D-010)
In-cab device Samsung Galaxy XCover7 (rugged phone, 6.6"), Capacitor + React+Vite (D-006 runtime; D-026 device-class swap from Tab Active5)
Backend Standalone Nest service + Neon Postgres, multi-tenant (D-012, D-013)
Selfie storage Cloudflare R2 Singapore + driver consent + DPA (D-014)
Driver-truck assignment primary/backup/unscheduled, soft-enforced v1 (D-015)
Driver shift uniqueness 1 shift / driver / 24h; truck-breakdown reassignment (D-022); driver-handover workflow (D-024)
Geofence zone types base camp / loading area / dumping area / haul route; out-of-zone activity → flag (D-023 + D-025)
Auth CF Access edge + own session for dashboard; device-bound tokens for tablet (D-017)

2. System architecture

2.1 Component map

                    ┌─────────────────────────────────────────────┐
                    │                ADONG SITE                    │
                    │                                              │
   ┌──────────────┐ │   ┌───────────────────────────────────────┐ │
   │  RFID Card   │─┼──►│  RFID Reader (HF, USB-OTG, HID-wedge) │ │
   │ (DESFire EV3)│ │   │  Mounted near steering column         │ │
   └──────────────┘ │   └───────────────┬───────────────────────┘ │
                    │                   │ USB-C OTG                │
                    │                   ▼                          │
                    │   ┌───────────────────────────────────────┐ │
                    │   │  Galaxy Tab Active5 LTE               │ │
                    │   │  Capacitor + React+Vite               │ │
                    │   │  • SQLite local store                 │ │
                    │   │  • Capacitor secure file storage      │ │
                    │   │  • Background sync service            │ │
                    │   │  • Camera (selfie)                    │ │
                    │   │  • GPS                                │ │
                    │   │  • Knox kiosk lock                    │ │
                    │   └─────────────────┬─────────────────────┘ │
                    │                     │ LTE (or wifi at gate)  │
                    └─────────────────────┼──────────────────────────┘
                                          │
                                          │ HTTPS, device-bound JWT
                                          ▼
                              ┌────────────────────────┐
                              │  Cloudflare Tunnel     │
                              │  cab.jofnd.ai          │
                              └───────────┬────────────┘
                                          │
                       ┌──────────────────┴──────────────────┐
                       ▼                                       ▼
       ┌───────────────────────────────┐         ┌──────────────────────┐
       │   sentinel-cab-api (Nest)     │         │   sentinel-cab-      │
       │   • Multi-tenant              │         │   dashboard          │
       │   • Idempotent event ingest   │         │   (React+Vite)       │
       │   • Selfie upload to R2       │         │   Supervisor + admin │
       │   • Eligibility engine        │         │   CF Access + login  │
       │   • Risk-trigger engine       │         └──────────┬───────────┘
       │   • Audit log writer          │                    │
       └───────┬───────────────┬───────┘                    │
               │               │                            │
               ▼               ▼                            │
     ┌──────────────────┐  ┌──────────────────┐             │
     │ Neon Postgres    │  │ Cloudflare R2    │             │
     │ (Singapore)      │  │ (Singapore)      │             │
     │ • domain tables  │  │ • selfie blobs   │             │
     │ • event stream   │  │ • 12mo retention │             │
     │ • audit log      │  │                  │             │
     │ • tenant-scoped  │  │                  │             │
     └──────────────────┘  └──────────────────┘             │
                                                            │
                                                  ┌─────────▼──────────┐
                                                  │ Supervisor browser │
                                                  │ (Adong office or   │
                                                  │  remote)           │
                                                  └────────────────────┘

2.2 Deploy topology

  • Tablet app: signed APK installed on each Galaxy Tab Active5; runs in Knox kiosk mode locked to the Sentinel_Cab app. Updates pushed via Knox MDM or pulled when truck is within gate Wi-Fi range. Tablet hardware lives in truck cab.
  • Backend (sentinel-cab-api): Nest service deployed on the same SwiftRise hosting pattern as procure-api / assetmgmt-api-nest — likely agent-mini Mac mini host with PM2 + LaunchAgent + Cloudflare Tunnel, OR (if pilot scale exceeds local-host capacity) Railway/Fly.io. Decision deferred to writing-plans.
  • Database: Neon Postgres, Singapore region. Single project, single database, multi-tenant schema. Branch per environment (production, staging, dev).
  • Selfie storage: Cloudflare R2 bucket in Singapore region (sentinel-cab-selfies), addressed by tenant_id/shift_id/selfie_id.jpg. Lifecycle rule auto-purges after 12 months.
  • Supervisor dashboard (sentinel-cab-dashboard): React+Vite SPA served behind Cloudflare Access at cab.jofnd.ai. Same codebase as tablet app where possible (shared component library).

2.3 Data flow on a typical shift start

  1. Driver enters cab. Tablet is already powered (cradle power). App is open in Knox kiosk mode showing idle/welcome screen.
  2. Driver taps DESFire card on reader puck. Reader buzzes + LED green. Reader emulates keyboard, types 7-byte UID into focused field.
  3. Tablet app receives UID via input handler. Online path: POST /api/v1/shifts/start-attempt with { uid, truck_id, tablet_id, timestamp, gps_at_attempt }. Offline path: local SQLite lookup against cached driver+card+assignment data; create local pending shift record.
  4. Backend (or local cache) runs eligibility check (D-015): - Card → driver lookup; driver active check - truck_driver_assignments lookup → eligibility = primary | backup | unscheduled | unassigned_truck - Conflict check: any active shift on this truck currently?
  5. Backend (or local) runs risk-trigger engine: shift count today for this driver, last shift trust state, random sample. Decides challenge = none | selfie.
  6. No challenge path: state machine transitions to active_verified (or active_low_trust if eligibility=unscheduled). Emit shift_activated_verified (or shift_activated_low_trust) event.
  7. Challenge path: state machine moves to pending_selfie. Tablet captures front-camera selfie under stationary check (no GPS movement >2m in last 60s). Driver submits within 3-min grace. On submit: state moves to active_verified (or active_low_trust). On timeout: state moves to flagged + selfie_timeout exception.
  8. Whatever path, every state transition is an event row in shift_events and an audit row in audit_logs. All events have idempotency keys ({tablet_id}:{local_seq}:{utc_ts}) so replay-on-sync is safe.
  9. Online events post immediately; offline events queue locally. Sync service flushes the queue on next network availability (best-effort, backoff, retry).

3. Hardware integration

3.1 Per-truck hardware stack (pilot — 10 trucks, post-D-026 device swap)

Component Spec Per-truck cost (IDR) Vendor / model Lead time
In-cab device Samsung Galaxy XCover7 LTE (rugged phone, 6.6") 5–6.5 M Samsung Enterprise (TAM warranty) 2–4 weeks
RFID reader Industrial HF 13.56 MHz, USB, HID-wedge, IP54+, on-puck LED+buzzer 1.5–3 M Sycreader R30D-HF or HID Omnikey 5022 CL 4–6 weeks
Vehicle cradle XCover-compatible powered cradle w/ PD passthrough 0.8–1.5 M RAM Mounts X-Grip / Brodit XCover holder 3–6 weeks
DC converter 24V truck power → USB-C PD, ignition-switched, capacitor for graceful shutdown 0.5–1 M Indonesian electronics retailer 1–2 weeks
Cabling, connectors, mounts Industrial M12 connectors at cradle, cable clips, fuse holder 0.5–1 M Workshop spec 1–2 weeks
Install labor Per-truck install — likely bundled with Sentinel_Fleet sensor install trip 1–2 M GTrack / Runa workshop (D-018 pending) scheduled
Per-truck subtotal ~8–11 M (was 15–22 M with tablet)
10-truck pilot total ~80–110 M (was 150–220 M)
Spares (phones ×2, readers ×2, cradles ×1) ~12–18 M

D-026 savings: ~Rp 70–110 M on the 10-truck pilot; ~Rp 500–770 M projected on the full 70-truck Adong fleet.

3.2 Reader mounting

  • Location: dashboard or center console, hand-reachable from driver seat without leaning forward, hand-height. Not on the door (vibration), not near ignition (D-004 — false reads from key card), not on the steering wheel (regulation + safety).
  • Orientation: reader face up or angled 30° toward driver. Tap surface clearly marked.
  • Mounting: through-bolted or industrial adhesive (3M VHB). Not magnetic (interferes with HF antenna).
  • Cable run: through cradle base, secured with cable clips along dash trim. Industrial M12 quick-disconnect at cradle base allows tablet swap without re-cabling. Standard-spec for service.

3.3 In-cab device mounting (D-026)

  • Location: optimized for screen visibility — typically right-of-center on dash, angled to reduce glare. Different optimal location from reader (intentional — D-004). Smaller XCover7 footprint allows more cradle position flexibility than a tablet.
  • Cradle: XCover-compatible (RAM Mounts X-Grip or Brodit XCover holder), positively-locking with secondary tether (phones are pocket-able if loose). USB-C PD charging in cradle. Powered by truck 24V → USB-C PD (ignition-switched).
  • Power behavior:
  • Ignition on → device wakes, app launches into idle/welcome screen
  • Ignition off → device stays on for 5 min (cradle capacitor) to flush pending sync, then sleeps
  • 5-min sleep timer cancellable by user activity (so driver can complete a shift-end after engine off)
  • XCover side key: OS-mapped to either "End Shift" (single press, with 2-sec long-press confirm) or "Help / Flag for supervisor" (TBD UX test in Sprint 5). Glove-friendly tactile fallback to touchscreen.

3.4 Device provisioning (D-026)

  • Knox kiosk mode lock → only the Sentinel_Cab app is launchable
  • Knox MDM enrollment → fleet management of OS updates + APK updates
  • Device-unique API token baked at provisioning (paired to tablet_devices.id in DB — table name kept generic for the future device-class agnostic case)
  • USB debugging disabled; bootloader locked
  • Factory-reset protection enabled with our admin account
  • 4G/5G SIM provisioned (managed by Sentinel_Fleet cellular plan)
  • Find My Mobile + remote wipe enabled (extra deterrent against pocketed device loss)

3.5 Card issuance (per driver)

Workflow lives in admin panel: 1. Driver onboarded (name, photo, employee ID, license class, consent signed) 2. Card printed: Sentinel_Cab branding + driver photo + driver name + employee ID + lanyard hole + anti-tamper foil overlay (D-011 still open) 3. Card chip encoded: pairs DESFire UID to driver record in DB 4. Card distributed; driver acknowledgment captured 5. Card → driver mapping syncs to all tablets within tenant 6. Lost-card workflow: revoke UID (recorded as card_revoked audit event), issue new card with new UID


4. Data model

All tables have tenant_id UUID NOT NULL (D-013) plus standard id UUID PK, created_at, updated_at, created_by, updated_by. Soft-delete columns where appropriate (deleted_at, deleted_by).

4.1 Tenant & identity tables

-- A tenant is a customer org (Adong is tenant_0; future contractors are tenant_1..N)
tenants(id, name, code, site_country, pdp_consent_template_version, created_at, ...)

-- Supervisor / admin users — log into dashboard
users(id, tenant_id, email, name, password_hash, role ENUM('admin','supervisor','viewer'),
      mfa_enabled, last_login_at, ...)

-- Drivers — never log into the dashboard; identified by RFID card
drivers(id, tenant_id, employee_id, name, name_phonetic, dob, license_class,
        license_number, license_expires_at, hire_date, photo_url, status ENUM('active','inactive','terminated'),
        consent_signed_at, consent_version, notes, ...)

4.2 Fleet & site tables

trucks(id, tenant_id, asset_code, plate_number, model, model_class,
       year, vin, status ENUM('active','workshop','retired'),
       primary_tablet_id, primary_reader_id, ...)

tablet_devices(id, tenant_id, hardware_serial, model, os_version, app_version,
               api_token_hash, last_check_in_at, status, ...)

rfid_readers(id, tenant_id, hardware_serial, model, mounting_location, status, ...)

-- D-023 + D-025: v1 zone types model the full operational coal-haul cycle.
-- base_camp / loading_area / dumping_area = work zones (the triangle vertices).
-- haul_route = the corridors connecting them (without these, every haul phase would
-- trip the out-of-zone exception during transit).
-- Multiple polygons per zone_type allowed (e.g. several loading faces, several route
-- segments). 'restricted' reserved for v2 (blast zones, off-limits roads).
geofences(id, tenant_id, name, zone_type ENUM('base_camp','loading_area','dumping_area','haul_route','restricted'),
          polygon GEOMETRY(Polygon,4326), is_active, ...)

4.3 Card tables

rfid_cards(id, tenant_id, uid VARCHAR(16) UNIQUE_PER_TENANT, chip_type, issued_to_driver_id,
           issued_at, issued_by, status ENUM('active','lost','revoked','expired'),
           revoked_at, revoked_by, revoke_reason, notes, ...)

card_audit_log(id, tenant_id, card_id, event ENUM('issued','assigned','revoked','reissued','found'),
               actor_user_id, occurred_at, details JSONB, ...)

4.4 Assignment table (D-015)

truck_driver_assignments(id, tenant_id, truck_id, driver_id,
                         role ENUM('primary','backup'),
                         valid_from TIMESTAMPTZ, valid_to TIMESTAMPTZ,
                         assigned_by_user_id, assigned_at,
                         revoked_by_user_id, revoked_at, notes,
                         -- partial unique index:
                         UNIQUE(tenant_id, truck_id) WHERE role='primary' AND valid_to IS NULL)

4.5 Shift & event tables

shifts(id, tenant_id, truck_id, driver_id, card_id_used,
       tablet_id, started_at TIMESTAMPTZ, ended_at TIMESTAMPTZ,
       trust_state ENUM('idle','rfid_verified','pending_selfie','active_verified',
                        'active_low_trust','ended','flagged'),
       eligibility_at_start ENUM('primary','backup','unscheduled','unassigned_truck','inactive_driver'),
       challenge_required BOOL, challenge_trigger_reason TEXT,
       gps_at_start GEOMETRY(Point,4326), gps_at_end GEOMETRY(Point,4326),
       -- D-022 + D-024: expanded end-reason capture + continuation-shift linking.
       -- 'truck_breakdown' = truck out of service, driver moves to a different truck (driver-keyed window 60 min)
       -- 'driver_handover' = driver going off-shift, truck stays in service with new driver (truck-keyed window 90 min)
       end_reason ENUM('driver_tap','truck_breakdown','driver_handover','auto_close','supervisor_override','other'),
       end_reason_notes TEXT NULLABLE,
       continuation_of_shift_id UUID NULLABLE REFERENCES shifts(id),
       continuation_reason ENUM('breakdown','driver_handover','supervisor_directed') NULLABLE,
       reassignment_window_until TIMESTAMPTZ NULLABLE,
       created_offline BOOL, synced_at TIMESTAMPTZ,
       ...)

shift_events(id, tenant_id, shift_id, event_type, payload JSONB,
             occurred_at TIMESTAMPTZ, source ENUM('tablet','backend','dashboard'),
             tablet_id, idempotency_key VARCHAR(128) UNIQUE,
             received_at TIMESTAMPTZ,
             gps_at_event GEOMETRY(Point,4326),
             ...)

photo_verifications(id, tenant_id, shift_id, requested_at, submitted_at,
                    timeout_at, status ENUM('pending','submitted','timeout','rejected'),
                    storage_key TEXT, thumbnail_storage_key TEXT,
                    exif_metadata JSONB, device_attestation JSONB,
                    reviewer_user_id, review_decision, review_notes,
                    ...)

4.6 Exception & audit tables

exceptions(id, tenant_id, shift_id NULLABLE, truck_id NULLABLE, driver_id NULLABLE,
           exception_type, severity ENUM('info','warn','critical'),
           detected_at, payload JSONB,
           status ENUM('open','acknowledged','resolved','dismissed'),
           assigned_to_user_id, resolved_by_user_id, resolved_at, resolution_notes,
           ...)

audit_logs(id, tenant_id, actor_type ENUM('user','tablet','system'), actor_id,
           action, entity_type, entity_id, before_state JSONB, after_state JSONB,
           occurred_at, ip_address, user_agent, source,
           ...)

4.7 Tablet-local cache (SQLite)

The tablet keeps a lean read-only-ish mirror of: - All drivers for the tenant (active only) - All rfid_cards for the tenant (active only) - All trucks for the tenant + this tablet's primary_tablet_id truck - All truck_driver_assignments for this tablet's truck - All geofences for the tenant - The currently-open shift for this tablet (if any) - Local shift_events queue (unsynced) - Local audit_logs queue (unsynced)

Sync direction: - Down-sync (server → tablet): on app launch, on Wi-Fi/LTE recover, on push notification (later), or every 15 min when online. Delta-sync using updated_at cursors per table. - Up-sync (tablet → server): immediate when online; queued + retried when offline. Idempotent (server is the durable store; tablet local writes are tentative until ACK).


5. State machine

5.1 Trust states (source brief, all 7 in v1)

digraph trust_states {
    rankdir=LR;
    node [shape=box, style=rounded];

    idle [shape=ellipse];
    rfid_verified [shape=box];
    pending_selfie [shape=box];
    active_verified [shape=box, style="rounded,filled", fillcolor="lightgreen"];
    active_low_trust [shape=box, style="rounded,filled", fillcolor="khaki"];
    ended [shape=ellipse];
    flagged [shape=octagon, style=filled, fillcolor="salmon"];

    idle -> rfid_verified [label="rfid_read_success +\neligibility OK"];
    idle -> flagged [label="rfid_read_success +\ninactive_driver"];

    rfid_verified -> pending_selfie [label="challenge_required"];
    rfid_verified -> active_verified [label="no challenge +\neligibility=primary|backup"];
    rfid_verified -> active_low_trust [label="no challenge +\neligibility=unscheduled"];

    pending_selfie -> active_verified [label="selfie_submitted +\neligibility OK"];
    pending_selfie -> active_low_trust [label="selfie_submitted +\neligibility=unscheduled"];
    pending_selfie -> flagged [label="selfie_timeout (3 min)"];

    active_verified -> ended [label="shift_end_requested"];
    active_low_trust -> ended [label="shift_end_requested"];
    active_verified -> flagged [label="exception + escalation"];
    active_low_trust -> flagged [label="exception + escalation"];

    active_verified -> ended [label="auto_close (12h timeout)"];
    active_low_trust -> ended [label="auto_close (12h timeout)"];
}

5.2 State transition rules

From To Trigger Notes
idle rfid_verified RFID UID validated against active driver + card Single-row state per tablet
idle flagged RFID UID matches but driver is inactive/terminated Logs inactive_driver_attempt exception
rfid_verified pending_selfie Risk-trigger engine returns challenge=selfie Stationary check must pass before camera UI shows
rfid_verified active_verified No challenge + eligibility ∈ {primary, backup, unassigned_truck} + no multi-shift conflict Normal happy path
rfid_verified active_low_trust No challenge + eligibility = unscheduled Logs unscheduled_driver_shift exception (D-015 soft)
rfid_verified flagged Driver already has an active shift in 24h window AND no breakdown-reassignment window is open for this driver Logs multiple_active_shifts_per_driver exception (D-022); shift NOT activated
pending_selfie active_verified Selfie submitted within grace + eligibility OK EXIF validated, stored in R2
pending_selfie active_low_trust Selfie submitted + eligibility = unscheduled Selfie + unscheduled_driver_shift both logged
pending_selfie flagged 3-min grace expires without submit Logs selfie_timeout exception
active_verified ended Driver taps End Shift, selects Normal end end_reason = 'driver_tap'
active_verified ended Driver taps End Shift, selects Truck breakdown (D-022) end_reason = 'truck_breakdown'; backend opens DRIVER-keyed reassignment window (60 min default); shift_ended_breakdown event emitted
active_verified ended Driver taps End Shift, selects Driver handover (D-024) end_reason = 'driver_handover'; backend opens TRUCK-keyed handover window (90 min default); shift_ended_handover event emitted
active_low_trust ended Same end paths as above Supervisor review still queued
active_* flagged Backend exception detector escalates E.g. truck_moving_no_active_shift raised after shift transition
active_* ended Auto-close: 12h since shift_start without end-tap Logs missing_shift_end exception

Continuation shift flows (D-022 + D-024):

Two continuation patterns. They differ in what the window keys to:

Pattern End reason Window keys to Window default New shift =
Breakdown truck_breakdown Driver 60 min Same driver, different truck
Handover driver_handover Truck 90 min Different driver, same truck

Breakdown continuation (D-022): after a shift ends with end_reason='truck_breakdown', the same driver may tap into a different truck's tablet within the 60-min reassignment window. On that tap: - Eligibility engine sees an open breakdown window for this driver → skips multiple_active_shifts_per_driver - New shift created with continuation_of_shift_id = <original> + continuation_reason='breakdown'

Handover continuation (D-024): after a shift ends with end_reason='driver_handover', a different authorized driver may tap into the same truck's tablet within the 90-min handover window — even if the truck is currently outside base_camp. On that tap: - Eligibility engine sees an open handover window on this truck → skips multiple_active_drivers_per_truck AND shift_start_outside_base_camp_no_handover - New shift created with continuation_of_shift_id = <original> + continuation_reason='driver_handover'

Both patterns: - Risk trigger: continuation shifts get a selfie challenge by default (reassignment shift trigger covers both continuation_reason values) - Trust state proceeds normally (rfid_verifiedpending_selfieactive_verified / active_low_trust) - After window expires without continuation: backend emits reassignment_window_expired; subsequent attempts raise the appropriate exception (multiple_active_shifts_per_driver for breakdown path; shift_start_outside_base_camp_no_handover for handover path)

5.3 State persistence

  • Authoritative state: shifts.trust_state in Neon Postgres
  • Tablet-local mirror: local_shifts.trust_state in SQLite (last-known-state, can lag during offline)
  • On reconnect: backend confirms trust_state (server wins on conflict; tablet shows authoritative value)
  • State changes always emit shift_events rows, idempotent on idempotency_key

6. Event catalog

All events have: id, tenant_id, shift_id (nullable for some), event_type, payload JSONB, occurred_at, received_at, source, tablet_id, idempotency_key.

Event type When emitted Payload extras Source
rfid_read_success Reader returns valid UID card_uid, read_dwell_ms tablet
rfid_read_failure Reader returns unrecognized UID attempted_uid, failure_reason tablet
shift_start_requested After RFID success, before eligibility check tablet_state tablet
shift_start_blocked Eligibility check fails (inactive driver, etc.) reason, eligibility_result backend
selfie_challenge_issued Risk-trigger engine demands selfie trigger_reason, grace_until backend/tablet
selfie_submitted Driver submits photo storage_key, capture_quality tablet
selfie_timeout 3-min grace expires requested_at, expired_at tablet (or backend if tablet offline)
selfie_rejected Supervisor reviews and rejects reviewer_user_id, reason backend
shift_activated_verified State → active_verified eligibility, challenge_outcome, continuation_of_shift_id (if any) backend/tablet
shift_activated_low_trust State → active_low_trust eligibility, challenge_outcome, continuation_of_shift_id (if any) backend/tablet
gps_ping Periodic GPS update lat, lon, accuracy_m, speed_kmh, heading, battery_pct, current_zone_id (nullable) tablet (queued, batched on sync)
geofence_enter Tablet GPS crosses into a geofence polygon geofence_id, zone_type, entry_point tablet
geofence_exit Tablet GPS leaves a geofence polygon geofence_id, zone_type, exit_point, dwell_seconds tablet
truck_outside_operational_area GPS outside all active geofences for >5 min during active shift (D-023) gps_at_detection, duration_outside_seconds, nearest_zone_name backend
shift_end_requested Driver taps End Shift tablet_state tablet
shift_ended State → ended end_reason, end_reason_notes, duration_minutes backend/tablet
shift_ended_breakdown Driver ended shift citing truck breakdown (D-022) end_reason_notes, reassignment_window_until backend/tablet
shift_ended_handover Driver ended shift citing handover (D-024) end_reason_notes, handover_window_until backend/tablet
reassignment_window_opened Backend opens grace window after breakdown OR handover (D-022, D-024) original_shift_id, window_kind ('breakdown_driver' 'handover_truck'), keyed_to_id, window_seconds
shift_continued_from_breakdown New shift linked to broken-down original (D-022) original_shift_id, new_truck_id, delta_seconds_from_breakdown backend
shift_continued_from_handover New shift linked to original via handover (D-024) original_shift_id, new_driver_id, delta_seconds_from_handover backend
reassignment_window_expired Reassignment window closed without continuation original_shift_id, window_kind backend
supervisor_override Supervisor acts on a shift/exception user_id, target_entity, action, reason dashboard
exception_created Detector raises an exception exception_id, exception_type, severity backend
exception_resolved Supervisor resolves an exception exception_id, resolution_notes dashboard

GPS pings are high-volume (one per 60s when moving = 480/shift). They are batched on sync, stored at lower resolution (rounded to 5-decimal lat/lon ≈ 1m), and rolled up to per-minute summaries after 30 days.


7. API contract

REST + JSON. Versioned /api/v1/. All requests carry Authorization: Bearer <token> and X-Tenant-Id: <uuid>. Idempotency via Idempotency-Key header on writes from tablets.

7.1 Tablet endpoints

Method Path Purpose Idempotent
POST /api/v1/tablets/handshake Tablet boot: confirm token, get tenant context, get current truck+geofences Yes
GET /api/v1/sync/down?cursors[drivers]=...&cursors[cards]=...&... Delta-sync all reference data Yes
POST /api/v1/shifts/start-attempt Begin a shift attempt with RFID UID Yes (idempotency key)
POST /api/v1/shifts/{id}/selfie Upload selfie blob + EXIF Yes
POST /api/v1/shifts/{id}/end End shift Yes
POST /api/v1/events/batch Push queued events from offline period Yes (each event has idempotency_key)

7.2 Supervisor + admin endpoints

Method Path Purpose
GET /api/v1/now/active-shifts Live "now" view
GET /api/v1/now/selfie-review-queue Pending selfie reviews
GET /api/v1/now/exceptions?status=open&severity=... Exception inbox
GET /api/v1/shifts/{id} Shift detail with event timeline + selfie
POST /api/v1/shifts/{id}/override Supervisor override on a shift
POST /api/v1/exceptions/{id}/acknowledge Acknowledge exception
POST /api/v1/exceptions/{id}/resolve Resolve exception with notes
POST /api/v1/selfies/{id}/decision Approve / reject submitted selfie
(CRUD) /api/v1/admin/drivers/... Driver management
(CRUD) /api/v1/admin/rfid-cards/... Card management
(CRUD) /api/v1/admin/trucks/... Truck management
(CRUD) /api/v1/admin/truck-driver-assignments/... Assignment management
(CRUD) /api/v1/admin/geofences/... Geofence management

7.3 Authentication

  • Tablet: device-bound JWT issued at provisioning, paired to tablet_devices.id. Rotated quarterly. Tablet sends Authorization: Bearer <jwt>. Backend verifies token + checks tablet_status=active + binds requests to the tablet's tenant_id and primary_truck_id.
  • Supervisor/admin dashboard: Cloudflare Access (edge identity gate, email-OTP or Google SSO) + own session-based login (password + MFA-optional, RBAC). Tenant context derived from user → tenant membership.

7.4 Errors

Consistent error envelope { code, message, details, retry_after_ms? }. HTTP 4xx for client errors, 5xx for server. Tablet retries 5xx + 429 with exponential backoff (1s, 2s, 4s, 8s, max 60s). Never retries 4xx.


8. Offline sync design

8.1 Principles

  1. Server is authoritative. Tablet writes are tentative until ACK'd.
  2. Idempotent writes. Every write carries an idempotency key; server dedupes.
  3. Forward-only event log. Never edit a past event; correct via new event.
  4. Conflict-free per truck. A tablet only ever writes events for its own truck's shifts → no cross-tablet write conflicts on shift state. Server-side detector handles cross-shift conflicts (multiple active shifts, etc.) as exceptions, not as merge conflicts.
  5. Bounded local queue. Tablet local SQLite caps at 30 days of events; older events archived to Capacitor secure file storage; 90 days max before forced sync (alarm to supervisor).

8.2 Idempotency key format

{tablet_id}:{local_sequence_int}:{event_type}

Tablet maintains a monotonic local sequence per event_type × shift_id. Server stores idempotency_key with UNIQUE index. Replays return the original response.

8.3 Down-sync (server → tablet)

Tablet calls GET /api/v1/sync/down with last-seen updated_at cursors per table. Server returns rows changed since cursor + new cursor. Client applies via SQLite upsert.

Tables synced down: - drivers (active only, current tenant) - rfid_cards (active only, current tenant) - trucks (this tablet's primary_truck) - truck_driver_assignments (this tablet's truck) - geofences (current tenant) - Reference data: challenge_rules, exception_definitions

Tables NOT synced down (server-only): - All users, audit_logs, historic shifts (not this tablet's), exceptions for other trucks

8.4 Up-sync (tablet → server)

Two paths: - Live path (online): each event POSTed individually, awaits ACK before next event - Batch path (offline → online recovery): POST /api/v1/events/batch with up to 100 events at a time, server processes idempotently and returns per-event ACK

On 5xx or network error, tablet keeps event in queue, retries with backoff. On 4xx (e.g. validation error), tablet marks event as failed_local, surfaces to driver via subtle UI badge, allows manual retry — but never silently drops.

8.5 Conflict scenarios

Scenario Handling
Tablet offline starts shift; driver's card was revoked while offline On sync, server detects card revoked at time X < shift_started_at, emits card_revoked_before_shift exception, shift flagged for supervisor review. Shift is NOT auto-reversed (driver was operating — that's an operational reality to investigate, not a transaction to undo).
Tablet offline; same driver starts another shift on different tablet (only possible with cloning) Server detects two shift_activated_* events for same driver, both emit multiple_active_shifts_per_driver exception (DEFERRED to v2). v1 logs but doesn't block.
Driver ends shift offline; tablet syncs hours later Server accepts the historical shift_end_requested event; ended_at = event's occurred_at; if (received_at - occurred_at) > 1h, flag for sync-delay review.
Backend assigns selfie challenge while tablet offline Backend can't push to offline tablet. On reconnect, if pending_selfie was decided server-side during the offline window, backend may either accept the tablet's resolved state (if it skipped the challenge offline based on stale cached rules) OR re-issue the challenge depending on configuration. v1: accept tablet's state with stale_challenge annotation logged.

8.6 Sync health monitoring

  • Tablet emits tablet_heartbeat every 5 min when online; backend detects silent tablets and surfaces tablet_offline_long warning on dashboard after 24h
  • Each event records received_at - occurred_at lag; dashboard graphs avg sync-lag per tablet

9. Tablet UX flow (v1 screens)

Designed for: gloved hands, dusty fingers, dim cab, 8" screen, single-handed operation while seated. Minimum touch target 64dp. No drag gestures. No long-press. Confirmation via dedicated big-button "Confirm" / "Cancel", never modals over the main flow.

9.1 Screen catalog (v1)

Screen Trigger Key actions Time-on-screen target
Idle / Welcome App boot, after shift end Wait for RFID tap. Show: truck plate, "Tap your card to start", current local time, network indicator, sync queue badge if any Indefinite
Validating Immediately after RFID tap Animated spinner: "Checking card...". Auto-progress in <1s if online, <100ms if cached 0.1–1s
Eligibility blocked Card invalid / driver inactive / multi-shift conflict Red icon + reason ("Card not recognized" / "You already have an active shift" / "See your supervisor") + truck plate + UID-masked-last-4 for supervisor ref Until driver removes card / 30s timeout
Selfie challenge — stationary check If challenge issued and truck still moving "Please park before continuing.\n\nWaiting for truck to stop..." + GPS speed indicator. Auto-progress when stationary >60s Driver-paced
Selfie capture Stationary check passed Front-camera live view, large circular face overlay, "Hold still — capturing in 3" countdown + auto-capture. Single retake option 5–10s
Activating Selfie submitted (or no challenge) Brief spinner: "Activating shift..." 0.5–1s
Active — verified Shift active, trust = active_verified Big green checkmark + driver name + truck plate + shift duration timer + current zone badge (e.g. "Loading area" / "Base camp") + big "End Shift" button (bottom of screen, requires 2s long-press to reduce mis-tap) Indefinite
Active — low trust Shift active, trust = active_low_trust Big yellow icon + driver name + truck plate + shift duration + zone badge + "Supervisor will review" badge + "End Shift" button Indefinite
Active — continuation Shift active, continuation_of_shift_id IS NOT NULL Same as Active-verified/low-trust + "Continuation of breakdown shift" badge linking back to original shift_id Indefinite
End reason "End Shift" pressed (D-022 + D-024) "Why are you ending this shift?" 4 large buttons: Normal end / Truck breakdown / Driver handover / Other (last opens a brief notes field). Always reversible with Cancel button 0–10s
Ended — normal Shift ended with end_reason='driver_tap' Brief "Shift ended. Have a safe drive home." + auto-return to Idle in 5s 5s
Ended — breakdown Shift ended with end_reason='truck_breakdown' (D-022) Yellow icon + "Shift ended (truck breakdown). You can start a new shift on a different truck within 60 minutes — your supervisor has been notified." + auto-return to Idle in 8s 8s
Ended — handover Shift ended with end_reason='driver_handover' (D-024) Yellow icon + "Shift ended (driver handover). The relief driver has 90 minutes to take over this truck — your supervisor has been notified." + auto-return to Idle in 8s 8s
Flagged Trust → flagged (selfie timeout, multi-shift, etc.) Red icon + reason + "Shift flagged for review. End shift normally; supervisor will contact you." + End Shift button Indefinite
Sync queue (manager view) Long-press of network indicator (hidden gesture for service techs) List of queued events + last sync time + manual sync button Indefinite

9.2 Design guidelines (tablet)

  • Single-tap commits. Every state-changing action is one tap (with 2s long-press only for End Shift to prevent accidental ends).
  • Typography: sans-serif (Roboto), minimum 18pt body, 24pt for actionable labels, 36pt+ for status (driver name, truck plate).
  • Color codes: green = active_verified, yellow = active_low_trust, red = flagged / blocked, gray = idle, blue = informational.
  • Glove-friendly: no thin tap targets (≥64dp). Buttons span full screen width where possible.
  • Dust/light tolerance: high-contrast colors (no light gray on white). Backlight set high by default.
  • No back button. Knox kiosk mode disables Android system buttons. Navigation is linear — every flow has exactly one path forward and one cancel/back path.
  • Audio feedback: chimes on RFID success/failure and shift activation/end. Honored even when truck radio is playing (volume locked at 80%).
  • Offline indicator: subtle persistent badge (top-right) shows sync state. Never blocks driver from continuing.

10. Supervisor dashboard spec

cab.jofnd.ai — React+Vite SPA behind Cloudflare Access. Shared component library with tablet (different layout, different routes, different role).

10.1 Information architecture

Top nav: [Now] [Review] [Exceptions] [Shifts] [Admin] [Reports*]
         (*Reports deferred to v2)

[Tenant selector] (only shown for Argus internal users with multi-tenant access)

10.2 Views

Now

Live operational view. Default landing page for supervisors.

Layout: - Top: "X of Y trucks active" counter, "Z pending selfie reviews" counter, "W open exceptions" counter, "N drivers awaiting reassignment" counter (D-022; breakdown queue), "M trucks awaiting relief driver" counter (D-024; handover queue) - Main grid: card per active truck — truck plate, current driver name + photo, trust state (color), shift duration, current operational zone (base camp / loading / dumping / haul route / outside — D-023 + D-025), last sync time - Reassignment banners (when any): - Drivers awaiting truck reassignment after breakdown: driver name, original truck, time remaining, "Cancel reassignment" / "Acknowledge" actions - Trucks awaiting relief driver after handover: truck plate, ending driver, time remaining, last known GPS / zone, "Cancel handover" / "Acknowledge" actions - Cards sorted by: flagged first (red), breakdown-pending (yellow with countdown), handover-pending (yellow with countdown), low-trust (yellow), then by shift_started_at desc - Click card → Shift detail view (10.4) - Auto-refresh every 30s

Review

Selfie review queue. Photos submitted but not yet judged.

Layout: - List or grid of pending selfies, sorted oldest-first - Per selfie: thumbnail, driver name, truck plate, shift_started_at, eligibility, reason challenge was issued - Click to expand: full-size photo, driver's reference photo side-by-side, action buttons: Approve / Reject + reason / Defer - Bulk action: approve multiple if obvious matches

Exceptions

Exception inbox.

Layout: - Filter by: status (open / acknowledged / resolved / dismissed), severity (info / warn / critical), type, truck, driver, date range - List view with columns: type, severity, truck, driver, detected_at, status, assigned to - Click row → exception detail with related shift, events, override actions

Shifts

Historical shift browser.

Layout: - Filter by: date range, driver, truck, trust state, eligibility, exception present - List with columns: shift_id, started_at, ended_at, duration, driver, truck, trust_state, eligibility - Click row → Shift detail (10.4)

Admin (RBAC: admin only)

CRUD surfaces for: drivers, rfid_cards, trucks, geofences, truck_driver_assignments, tablet_devices, rfid_readers, users.

Each CRUD page: - Searchable + sortable list - "Add new" button → modal form - Row click → edit - Soft-delete with confirmation - Audit log link from each entity detail page

Shift detail (10.4)

Single shift's complete record.

Layout: - Header: truck plate, driver name + photo, trust state badge, eligibility, started_at, ended_at (or "Active"), duration, end_reason (if ended) - Continuation chain panel (D-022): if this shift has continuation_of_shift_id, show "← Continued from shift #X (truck breakdown at HH:MM)". If this shift has children (someone continued from it), show "→ Continued by shift #Y on truck Z". - Tabs: Timeline | Selfie | Exceptions | Events | Audit - Timeline: chronological event list, color-coded; breakdown events visually distinct - Selfie: if any, photo + EXIF + reviewer decision history - Exceptions: linked exceptions with quick-resolve - Events: raw event log - Audit: all audit_log entries touching this shift - Action menu: Override trust state (with reason) | Force end | Cancel reassignment window (if open) | Add note | Export PDF

10.3 Roles (v1)

  • admin — full CRUD + all read + tenant-config
  • supervisor — read all + override/resolve actions
  • viewer — read-only (for owners, auditors, ops managers)

11. Admin panel — issuance workflows

11.1 Driver onboarding

  1. Admin opens "Drivers" → "Add driver"
  2. Form: name, employee_id, license class + number + expiry, hire_date, optional notes
  3. Photo upload (or capture from webcam): face-only crop, square aspect, ~512×512px
  4. Consent form: PDP Law-compliant consent text in Bahasa Indonesia and English shown side-by-side; admin confirms physical signed copy on file (uploads scan as evidence)
  5. Driver created, status=active
  6. Admin lands on driver page → "Issue card" CTA

11.2 Card issuance

  1. Admin clicks "Issue card" for the driver
  2. Form: card serial (manual entry from card box), chip type confirmed
  3. Tap card on bench reader → UID captured into form
  4. Card record created, issued_to_driver_id set
  5. Card-print job queued (sends to print bureau or marked manual-print)
  6. After physical card delivered, admin marks "activated" → card status=active, syncs to all tablets

11.3 Lost card

  1. Driver or supervisor reports lost card via dashboard
  2. Admin marks card status=lost (or revoked), records reason
  3. New card issued via 11.2 (new UID, new physical card)
  4. card_audit_log records reissue chain (original card → replacement)

12. Security, audit, multi-tenancy

12.1 Tenant isolation

  • Every query in Nest service includes WHERE tenant_id = $tenantId via a custom TypeORM subscriber / middleware
  • Postgres RLS policies enabled in v1.1 (post-pilot hardening) as belt-and-suspenders
  • Selfie storage keys prefixed with tenant_id and signed-URL access scoped to tenant
  • Dashboard auth derives tenant from user; no cross-tenant data joins permitted

12.2 Audit log

Every meaningful action writes an audit_logs row: - Admin CRUDs (before/after JSON) - Card issuance / revocation - Supervisor overrides - Exception status changes - Selfie decisions - Tablet handshakes - Login attempts (success and failure)

Audit log is never deleted (separate retention from operational data).

12.3 Threat model (v1 scope)

Threat Mitigation in v1 Deferred to v2
Driver shares card with another driver Random ~15% selfie challenge + reassignment-shift selfie + supervisor review; multiple_active_shifts_per_driver exception (D-022) catches the obvious "I'm on truck A but also on truck B" case Face matching, behavioral anomaly detection
Driver fakes a breakdown to start a fresh shift Breakdown reason is captured + logged; supervisor sees breakdown badge + can cancel reassignment window from dashboard; continuation shift gets mandatory selfie challenge (D-022) Tie to Sentinel_Fleet ECU fault codes for objective breakdown verification
Driver fakes a handover to give a different driver access to "their" shift hours Handover reason captured; relief driver tap gets mandatory selfie + supervisor sees handover badge; multiple_active_drivers_per_truck blocks if no handover window is open (D-024) Photo-verification of both outgoing and incoming driver at handover
Relief driver takes over without prior handover communication If outgoing driver didn't tap End-with-handover, the new driver's tap raises shift_start_outside_base_camp_no_handover + activates as active_low_trust (D-024) Real-time supervisor-approval flow for non-handover mid-cycle starts
Truck operates outside the loading-dumping-base + haul-route cycle truck_outside_operational_area event + shift_activity_outside_zones exception after >5 min outside any of the 4 zone types (D-023 + D-025) Real-time block; route prediction; geofence-based speed limit enforcement
Driver clones a card UID can be cloned but original is also still valid → multiple-active-shifts exception DESFire mutual auth, card-uniqueness attestation
Driver pulls reader cable to bypass identity Truck moves with no active shift → exception with timestamp Active tamper detection on cable
Driver tampers with tablet Knox kiosk lock; reboot detection; device attestation check on each sync Mandatory periodic attestation; remote wipe
Supervisor abuse of override power All overrides audited with reason; admin can review override patterns Override approval flow (2-person rule for high-stakes shifts)
Card lost / stolen Revocation propagates to tablets on next sync (max 15-min latency online; immediate on push notification when implemented) Push notification for instant revocation
Backend breach Standard hardening: short-lived tokens, MFA, audit, encrypted at rest SOC2-style controls
PII exposure (selfies, names) R2 signed URLs (15-min TTL), no public bucket, encryption at rest Per-driver consent dashboard

13. Observability

13.1 Logs

  • Backend: structured JSON via Pino, log level by env (debug in dev, info in prod), shipped to dashboard via existing internal-app pattern (Loki later)
  • Tablet: Capacitor logger writes to local file; uploaded on sync as tablet_diagnostics events (one batch per 24h)

13.2 Metrics

Metric Threshold Action
Tablet sync lag (median) >5 min Warn
Tablet sync lag (p99) >1h Alert supervisor
RFID read failure rate >5% over 1h Warn
Selfie timeout rate >10% over 24h Investigate
Backend p99 latency on /shifts/start-attempt >1s Alert dev
Selfie upload failures >2% Alert dev
Active shifts without GPS in last 5 min >0 Alert supervisor
Tablet offline (no heartbeat) >24h Alert supervisor

13.3 Alerts

  • v1: PostHog or simple email/Slack alerts (existing internal-app pattern)
  • v2: dedicated on-call rotation when scale demands

14. Internationalization

  • v1: dual-language (Bahasa Indonesia primary, English secondary for admin)
  • Driver-facing tablet UI: Bahasa Indonesia only
  • Supervisor + admin dashboard: language toggle (default Bahasa Indonesia)
  • All consent forms, error messages, audit-event descriptions: Bahasa Indonesia

15. Open items (carried to writing-plans + operational phases)

From decisions.md open queue:

ID Topic Owner Needed by
D-011 Card face design (branding, photo personalization, anti-tamper) Joseph + Runa brand Early July (before card-print order)
D-018 Hardware install partner: GTrack vs separate vs Runa in-house Joseph + Runa ops Mid June
D-019 Geofence source: manual map draw vs Adong GIS shapefile import Joseph + Adong ops Mid July
D-020 APK distribution: Knox MDM vs sideload at install vs self-hosted Joseph + Putro Early July
D-021 Driver consent collection process Joseph + Runa HR Early August

Additional items surfaced during writing-plans:

  • Repo structure (mono vs poly) — locks during writing-plans
  • Backend host (agent-mini vs Railway/Fly.io) — locks during writing-plans
  • Specific industrial reader model selection (Sycreader R30D-HF vs HID Omnikey 5022 CL) — bench test informs
  • Cradle vendor selection (RAM Mounts vs Brodit) — install partner informs

16. Document status & next steps

Status: v1.0-DRAFT, ready for user review.

Next steps: 1. User review of this document + decisions.md + bench-procurement.md 2. Order bench kit from Tokopedia/Shopee (parallel to review) 3. Resolve any user feedback into v1.1 of this doc 4. Invoke writing-plans skill to convert this design into a sprint-by-sprint implementation plan 5. Companion doc pilot-adong-aug2026.md covers items 8 (sprint plan) and 10 (risk register) from source brief

Pilot Plan

Sentinel_Cab — Adong Pilot Plan (Aug 2026)

Version: v1.0-DRAFT Date: 2026-05-11 Status: Pilot plan, ready for review. Scope: Aug 2026 install of Sentinel_Cab v1 on 10 P500 trucks at PT Runa Persada (Adong site), co-deployed with Sentinel_Fleet's Phase 0 P500 batch. Owner: Joseph Companion docs: product-design.md (system design), decisions.md (locked decisions), bench-procurement.md.


1. Pilot mission

Prove the Sentinel_Cab v1 design end-to-end on 10 real Adong trucks for 30+ days of live mining operations, gather operational signal, and earn the right to expand to the full 70-truck Adong cohort in Sep–Nov.

Success = three conditions: 1. Tablet uptime ≥ 95% per truck per shift over 30 days 2. Identity-event reliability ≥ 99% (every shift has correctly captured driver_id, eligibility, trust state — no silent corruption) 3. Supervisor adopts the dashboard — at least 5 supervisor sessions per week, plus exception-resolution latency p50 ≤ 4h

Failure modes worth distinguishing: - Design failure → kill or rebuild - Operational failure → adjust process, keep building - Hardware failure → swap vendor, redo install - People failure → training, support, refine UX


2. Calendar — Day 0 to pilot go-live

Week   Dates                Phase                                    Major outputs
─────  ──────────────────   ──────────────────────────────────────   ──────────────────────────────────────────
W1     2026-05-12 → 05-18   Bench arrival + foundation                Bench kit unboxed; schema + auth scaffold
W2     2026-05-19 → 05-25   Sprint 1 wrap                             ▸ Sprint-1 demo
W3     2026-05-26 → 06-01   Sprint 2 start: CRUD + shift lifecycle    Drivers/cards/trucks/assignments API
W4     2026-06-02 → 06-08   Sprint 2 wrap                             ▸ Sprint-2 demo
W5     2026-06-09 → 06-15   Sprint 3 start: selfie + risk + exceptions   ⛳ DESIGN FREEZE 06-15
W6     2026-06-16 → 06-22   Sprint 3 wrap                             ▸ Sprint-3 demo; ⛳ pilot kit order placed
W7     2026-06-23 → 06-29   Sprint 4 start: dashboard + offline sync   Dashboard "Now" view + sync queue
W8     2026-06-30 → 07-06   Sprint 4 wrap                             ▸ Sprint-4 demo; ⛳ card design freeze (D-011)
W9     2026-07-07 → 07-13   Sprint 5 start: hardening + Capacitor      Capacitor APK build, end-to-end on bench
W10    2026-07-14 → 07-20   Sprint 5 wrap                             ▸ Sprint-5 demo; ⛳ card-print order placed
W11    2026-07-21 → 07-27   Sprint 6 start: UAT + install rehearsal    Bench install rehearsal w/ 1 truck mockup
W12    2026-07-28 → 08-03   Sprint 6 wrap                             ▸ UAT sign-off; ⛳ pilot hardware delivered to Adong
W13    2026-08-04 → 08-10   Sprint 7: install begins                  Install on trucks 1–5
W14    2026-08-11 → 08-17   Sprint 7 wrap: install completes          Install on trucks 6–10; ⛳ GO-LIVE
W15    2026-08-18 → 08-24   Sprint 8: stabilization                   Support + weekly review meetings
W16    2026-08-25 → 08-31   Sprint 8 wrap                             ▸ Week-2 retrospective; first signals
W17–20 Sep                                                            Pilot signal collection, monthly review
W21–24 Oct                                                            Mid-pilot retrospective; ⛳ expand-or-fix decision (end Oct)

3. Sprint-by-sprint scope

The sprint plan below is a directional layout — the formal task breakdown comes out of the writing-plans skill. Items here are the major outputs each sprint must deliver to keep the Aug deadline.

Sprint 1 (May 12–25) — Bench arrival + foundation

  • Bench kit ordered, received, unboxing checklist run (per bench-procurement.md — note D-026 device swap to XCover7)
  • Repo(s) created (locked from D-012); CI/CD pipeline; Neon project + branches set up
  • SwiftRise Nest boilerplate forked, Postgres-specific fixes applied (per feedback_swiftrise_boilerplate_postgres_bugs)
  • Multi-tenant schema migrations: tenants, users, drivers, rfid_cards, trucks, tablet_devices, truck_driver_assignments, geofences
  • Auth scaffold: JWT for device (device-bound), session login for dashboard, CF Access on subdomain
  • React+Vite app + Capacitor scaffold; basic idle screen renders on XCover7; RFID-tap-types-into-input proven on bench

D-026 exit-criteria additions (must pass before Sprint 1 closes): - Glove-tap reliability test on XCover7's 6.6" screen with the v1 button layout (≥95% first-tap success with industrial gloves under simulated dim cab light) - XCover7 vehicle cradle quality test (RAM Mounts X-Grip or Brodit XCover holder) — vibration resistance, positive locking, USB-C PD passthrough confirmed - Thermal behavior test (4 hours in a closed parked car midday, ~40°C cabin) — no thermal throttling during continuous app use; confirms cab tolerance for Indonesian midday operations

Sprint 2 (May 26 – Jun 8) — CRUD + shift lifecycle

  • Admin CRUD APIs + minimal admin UI for drivers, cards, trucks, assignments, geofences
  • Shift lifecycle: start-attempt, eligibility engine (D-015), shift_activated_*, shift_end
  • shifts and shift_events tables + idempotent event ingestion
  • Audit log writer + audit endpoints
  • Tablet calls happy-path shift start/end against real backend

Sprint 3 (Jun 9–22) — Selfie + risk engine + exception detector — DESIGN FREEZE Jun 15

  • Selfie capture flow on tablet (camera, stationary check, retake, EXIF)
  • Selfie upload to R2; photo_verifications table
  • Risk-trigger engine (3 rules per D-016): random, first-shift-of-day, prev-low-trust
  • Exception detector for 5 v1 exception types (D-016): truck-moving-no-shift, unscheduled-driver, selfie-timeout, missing-shift-end, suspicious-rfid-pattern
  • State machine fully implemented; all 7 trust states reachable and tested

Sprint 4 (Jun 23 – Jul 6) — Dashboard + offline sync + geofence onboarding

  • Dashboard "Now" view (live active trucks, trust-state cards, breakdown + handover counters)
  • Selfie review queue
  • Exception inbox
  • Shift detail view with timeline + continuation-chain panel
  • Down-sync delta endpoint + tablet local cache
  • Up-sync batch endpoint + tablet event queue + idempotency
  • Conflict scenarios from §8.5 of product-design.md tested
  • Haul route mapping (D-025): half-day satellite-imagery trace of Adong haul roads in admin panel — polylines + corridor width → polygons. Validate corridor width by overlaying on a few hours of test GPS pings.
  • Geofence seed data: load Adong base camp + loading + dumping polygons into admin panel
  • Card face design locked (D-011)

Sprint 5 (Jul 7–20) — Hardening + Capacitor build

  • Capacitor production APK build + Knox kiosk lock testing
  • End-to-end test on bench: full shift cycle online + offline + reconnect
  • Performance pass: cold start <3s, RFID-tap-to-active <2s, dashboard "Now" view loads <1s
  • Security review: secrets handling, R2 signed URLs, RBAC enforced on all admin endpoints
  • Audit pass: every CRUD + override + decision writes audit_log
  • Card-print order placed (lead time 1–2 weeks for printing → arrive ~Jul 28)

Sprint 6 (Jul 21 – Aug 3) — UAT + install rehearsal

  • UAT script: 20+ scenarios (happy path, edge cases, offline scenarios, supervisor flows)
  • Joseph + 1 internal tester runs full UAT on bench
  • Bug fixes from UAT
  • Install rehearsal: 1 truck mock-up (parked truck, full hardware install dry-run with installer team), time per install measured + tuned
  • Training material: 1-page driver flyer (Bahasa Indonesia, big visuals), 5-minute supervisor video walkthrough, 30-minute admin training deck
  • Pilot hardware delivered to Adong site
  • UAT sign-off from Joseph

Sprint 7 (Aug 4–17) — Install + go-live

  • Trucks 1–5 install (Week 13): 2 trucks per day, 1.5–3 hours each
  • Per-truck install procedure (see §5)
  • Trucks 6–10 install (Week 14)
  • Cards distributed to ~20 P500-trained drivers (with backup spares)
  • Driver briefing sessions (~15 min per driver group)
  • Supervisor onboarding session (1 hour) + dashboard walkthrough
  • GO-LIVE: first real shift starts on real production trucks (end of Week 14)

Sprint 8 (Aug 18–31) — Stabilization

  • Daily support — Joseph or designated dev on-call for 14 days
  • Daily flush of exception inbox with Adong supervisor
  • Daily metric review: tablet uptime, sync lag, RFID failure rate, selfie timeout rate, exception volumes
  • Week-1 retro (Aug 24): identify any P0 issues, decide go/no-go on continuing
  • Week-2 retro (Aug 31): broader patterns, supervisor adoption check, first qualitative signal write-up

Sep–Oct — Pilot signal collection

  • Weekly metric review automated → posted to Joseph + supervisor
  • Monthly retros (end Sep, end Oct)
  • Mid-Oct decision review: do we expand to Sep–Nov main fleet install, fix what's broken first, or kill?

4. Hardware procurement timeline

Milestone Date Lead time Owner Notes
Bench kit ordered 2026-05-11 1–3 days Joseph Per bench-procurement.md (D-026 XCover7 swap), ~Rp 6.2–8.9 M
Bench kit received 2026-05-13 to 14 Joseph Run unboxing checklist immediately
Pilot kit specification finalized 2026-06-15 (Sprint 3 design freeze) Joseph Lock industrial reader model after bench test; confirm XCover7 cradle viability
Pilot kit purchase order placed 2026-06-22 (Sprint 3 wrap) 4–6 weeks Joseph + procurement ~Rp 80–110 M for 10 trucks + spares (was Rp 150–220 M with tablet, D-026 savings)
Phones delivered (10 + 2 spares) 2026-07-15 to 07-22 2–4 weeks Samsung Enterprise XCover7 — pre-provision Knox + APK before shipping to Adong
RFID readers delivered (10 + 2 spares) 2026-07-15 to 07-22 4–6 weeks Vendor (TBD) Sycreader R30D-HF or HID 5022 CL
Vehicle cradles delivered (10 + 1 spare) 2026-07-15 to 07-29 3–6 weeks RAM Mounts / Brodit XCover-compatible (X-Grip universal or XCover-specific)
DC converters + cabling 2026-07-15 1–2 weeks Indonesian retailer Workshop-spec
Card blanks delivered (100, ~85 issued + spares) 2026-07-20 2–4 weeks NXP authorized reseller DESFire EV3 blanks
Cards personalized + printed 2026-07-28 1–2 weeks Jakarta print bureau After D-011 design lock
All hardware staged at Adong 2026-08-03 Logistics Container or air freight depending on volume
Install begins 2026-08-04 Install team Trucks 1–5
Install complete 2026-08-17 Install team Trucks 6–10; go-live

5. Per-truck install procedure

Estimated time per truck: 1.5–3 hours (rehearsed time, varies with truck familiarity) Crew per truck: 2 people — 1 lead installer + 1 helper Tools: standard automotive electrical kit, M8/M10 wrench set, drill, panel-trim removal tools, multimeter, test card

Steps

  1. Receive truck at workshop (5 min) - Confirm truck VIN + plate matches install ticket - Confirm Sentinel_Fleet sensors already installed (or coordinate dual install if same trip)

  2. Power tap (15–25 min) - Identify 24V ignition-switched circuit (cab fuse box, typically map light or accessory rail) - Install DC converter + inline fuse - Run 5V/USB-C output to cradle position - Verify ignition-on wakes cradle output; ignition-off cuts after 5-min delay - Verify continuity + voltage with multimeter

  3. Tablet cradle mount (15–25 min) - Mount cradle on dashboard right-of-center, angled to avoid glare - Through-bolt or industrial adhesive (NOT magnetic) - Route power cable through cradle base, secure with clips - Test fit Tab Active5 in cradle; confirm POGO contact, charging indicator lights

  4. RFID reader mount (15–20 min) - Mount reader puck near steering column at hand-height - Through-bolt or industrial adhesive - Route USB cable through dash trim to cradle base - Terminate USB at industrial M12 quick-disconnect inside cradle base (so tablet can be swapped without re-cabling) - Confirm reader LED powers on - Test card tap with bench test card; confirm buzzer + green LED + tap event in app

  5. Tablet provisioning + handshake (10–15 min) - Power on tablet (cradled) - Confirm tablet boots into Knox kiosk to Sentinel_Cab app - Confirm tablet handshake with cab.jofnd.ai succeeds (or queues for sync if offline) - Confirm GPS lock obtained - Confirm tablet recognizes RFID reader via OTG - Tap test card; confirm event flows through to dashboard "Now" view (if online) or queued (if offline)

  6. Cab UX test with simulated driver (10–15 min) - Install lead acts as driver: full shift_start with test card - Verify selfie challenge can be issued and captured - Verify shift_end completes cleanly - Verify dashboard shows the test shift correctly

  7. Sign-off + handover (5 min) - Install lead checks off install checklist - Sign-off in install log (photo of truck cab + tablet + reader for record) - Truck released to ops; ready for first real driver shift

Install kit per truck (carried by install team)

  • Samsung Galaxy XCover7 (pre-provisioned, Knox-locked, APK installed) — D-026
  • 1× RFID reader puck
  • 1× XCover-compatible vehicle cradle + mounting hardware (with positive-lock + secondary tether)
  • 1× DC converter + fuse + inline harness
  • ~5 m USB-C + USB-A cabling
  • M12 quick-disconnect connector + housing
  • Adhesive (3M VHB), through-bolts, cable clips
  • 1× known-good test card (revoked from production use, but valid for bench reads)
  • Install checklist + sign-off form

6. Risk register

ID Risk Likelihood Impact Mitigation Owner
R-01 Hardware vendor lead-time slip (readers >6 weeks) Med High Order at Sprint 3 wrap with buffer; identify 2 vendor candidates from bench-test phase Joseph
R-02 Sentinel_Fleet P500 install delays push pilot to Sep Med High Decouple Cab install from Fleet install if needed; Cab can install standalone on P500 trucks if Fleet slips Joseph + Fleet PM
R-03 Adong supervisor doesn't engage with dashboard Med Critical Identify named supervisor user during Sprint 6; do 1:1 dashboard walkthrough; offer Bahasa Indonesia language Joseph + Adong ops
R-04 PDP consent collection blocked by HR process Low High Joseph + Runa HR confirm consent form template by mid-July; collect at hire-time integration with existing onboarding Joseph + Runa HR
R-05 LTE signal at Adong site too weak for live sync Med Med Offline-first design handles this by definition; gate Wi-Fi at workshop for nightly sync; satellite as escalation Joseph + Runa IT
R-06 RFID reader fails in dusty cab environment Med Med Spec IP54+ readers; spare units on site; quick-disconnect M12 allows swap in <5 min Joseph + install team
R-07 Tablet glare in cab unreadable in sunlight Low Med Tab Active5 has high-brightness screen (600 nits); anti-glare film if needed; UX uses high-contrast colors Joseph + UX iteration
R-08 Driver-truck assignment data never populated by Adong Med Low (v1) Soft enforcement (D-015) means system works without it; just every shift reads as eligibility=unassigned_truck; supervisor can populate over time Adong ops
R-09 Card printing delays from Jakarta bureau Med Med Identify 2 print bureaus during Sprint 5; place order with buffer; have un-printed blanks as fallback (just write UID on tape) Joseph
R-10 Cradle vendor (RAM Mounts) Tab Active5 fit issues Low Med Bench-test cradle fit before bulk order; alternative Brodit kit available Joseph
R-11 Knox MDM enrollment account issues for Argus/SwiftRise Med Low Joseph procures Knox enterprise license in W3–4; fallback: sideload signed APK during install Joseph
R-12 Driver pushback on selfie capture (privacy concerns) Med Med Frame as standard for industrial safety; consent at hire; low-rate random challenge (~15%); supervisor explains in driver briefing Joseph + Adong ops
R-13 Drivers find ways to spoof identity (gloved swap, etc.) High Med This is the point of the pilot — uncover and address. v1 captures via exceptions; v2 hardens. Don't try to solve all attacks in v1 Joseph
R-14 Solo-developer dependency: Joseph unavailable for stretches Med High Putro + Leon trained on stack from Sprint 2; documentation kept up-to-date; rotating bench tester from Sprint 5 Joseph
R-15 Bench test reveals on-cab reader doesn't work with chosen reader Low High Bench test in Week 1 explicitly tests this; second reader model available as fallback (ACR122U for further bench testing) Joseph
R-16 Capacitor + Knox kiosk mode incompatibility surprise Low High Sprint 1 immediately validates this on bench; if blocked, fallback to PWA + custom launcher app Joseph
R-17 R2 Singapore latency from Adong (East Kalimantan) too high for selfie upload Low Low Selfie upload is async (sync queue handles it); R2 is fast even from Indonesia (<500ms typical); fallback: tier to nearer R2 region or Indonesian provider Joseph
R-18 Adong shift end actually happens off-site (driver parks then walks away with tablet) Med Med Auto-close at 12h after start (D-016 exception). Tablet stays in cradle (locked). Plan B: shift-end-via-supervisor flow if pattern emerges Joseph + Adong ops
R-19 Pilot data reveals v1 design fundamentally wrong Low Critical This is the value of the pilot. End-of-Oct decision gate has explicit "kill or rebuild" option Joseph
R-20 Cost overrun: pilot CAPEX exceeds Rp 150 M Low (after D-026) Med Monitor procurement weekly; D-026 already cut baseline 40–50%; further fallback to Cilico C8 ~Rp 4–6M if XCover7 stockout Joseph
R-21 XCover7 fails Sprint-1 bench validation (glove-tap, cradle, thermal) Low High Sprint 1 exit criteria explicitly tests all 3 concerns; if fail, revert to Tab Active5 (forfeits Rp 70–110 M saving but no schedule slip) Joseph

7. People + roles

Role Person Responsibilities Time commitment
Product owner Joseph All decisions, design freezes, vendor selection, Adong relationship ~50% during Sprints 1–6, ~80% during install + stabilization
Backend dev TBD (Leon or contractor) Nest API, schema, business logic Full-time Sprints 2–5
Frontend / mobile dev TBD (Putro or contractor) React+Vite + Capacitor + dashboard Full-time Sprints 2–6
QA / UAT TBD (rotating internal) Sprint demos, UAT runs, bench testing Half-time Sprints 5–6
Hardware install lead TBD (GTrack or Runa workshop) Per-truck install, cradle mount, power tap Full-time during Sprint 7
Adong supervisor (pilot user) TBD (Adong ops lead) Daily dashboard use, exception resolution, feedback 30 min/day from go-live
Adong HR liaison TBD (Runa HR) Driver consent collection, employee data Ad-hoc through Sprint 6–7

Empty TBDs to be filled by Joseph before Sprint 2 starts. Hiring or contractor placement is a critical-path item.


8. Success metrics — Aug 31 read

After 2 weeks of live operation (Sprint 8 wrap):

Metric Target Threshold for concern
Tablet uptime per truck per shift ≥ 95% < 90% → investigate hardware
RFID read success rate (1st-tap) ≥ 95% < 85% → investigate reader or training
Selfie capture success rate (when challenged) ≥ 90% < 80% → investigate camera or UX
Selfie timeout rate ≤ 5% > 10% → investigate stationary check or grace period
Median sync lag (event-to-dashboard) ≤ 2 min when online > 10 min consistently → investigate sync
Supervisor sessions per week ≥ 5 < 2 → re-engage with supervisor
Exception resolution latency (p50) ≤ 4 hours > 24 h → process problem
Unscheduled-driver-shift rate learn (no target — first measurement)
Active shifts without valid driver_id 0 > 0 → P0 bug

9. After the pilot — branches

9.1 Expand to full 70-truck Adong fleet

Trigger: all success metrics met + supervisor wants more Window: Sep–Nov 2026 (rides Sentinel_Fleet main install) Scope: retrofit existing 60 trucks + add to TRUST batch in Dec Owner shift: procurement scales to ~Rp 1–1.5 B; ops scales; v1.1 hardening

9.2 Fix-and-retry

Trigger: some success metrics missed but not catastrophic Window: Sep–Oct fix sprints; expand decision deferred Scope: address P0/P1 issues from pilot; re-evaluate at end Oct

9.3 Kill or rebuild

Trigger: fundamental design failure or supervisor non-adoption Window: end-Oct decision gate Scope: unblock by either redesigning the broken assumption or pausing the pillar

9.4 v2 design kickoff

Trigger: v1 ships, signals collected, ready to harden Scope: items deferred per D-016 (DESFire mutual auth, supervisor pre-approval, expanded risk triggers, expanded geofences, face matching, route selection, Fleet telemetry correlation) Window: Nov–Dec 2026 design, Q1 2027 build

Sprint 1 Plan

Sentinel_Cab — Sprint 1 Implementation Plan (Foundation)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Establish the foundation that unblocks all subsequent Sentinel_Cab v1 sprints — three repos under SCM, multi-tenant Postgres schema for all v1 entities, working device-bound auth, a Capacitor APK that boots into an idle screen on Galaxy XCover7, and bench-validated D-026 exit criteria.

Architecture: Three repos (poly-repo): sentinel-cab-api (Nest + TypeORM + Postgres on Neon, multi-tenant from day 1), sentinel-cab-app (React+Vite + Capacitor wrapped for Android), sentinel-cab-dashboard (React+Vite SPA behind Cloudflare Access). API exposes JSON over HTTPS. App and dashboard share a @sentinel-cab/shared workspace package for types and API client (introduced in Sprint 2; Sprint 1 just keeps repos parallel).

Tech Stack: Nest 10 + TypeORM + class-validator + Pino, Postgres 16 + PostGIS on Neon (Singapore region), Cloudflare R2 (Singapore region) for selfie storage, Cloudflare Access + Tunnel at cab.jofnd.ai, React 18 + Vite 5 + TypeScript 5, Capacitor 6 + Android 14 SDK target, Samsung Galaxy XCover7 (SM-G556B) as in-cab device, ACS ACR122U HF reader for bench, MIFARE DESFire EV3 cards for production / MIFARE Classic 1K for dev.

Audience: Skilled engineers with no prior context on this project. Each task has exact file paths, complete code, exact commands, and expected output.


Pre-flight: locked decisions snapshot

Read decisions.md D-001 → D-026 and product-design.md before starting. Critical inputs for Sprint 1:

Decision Effect on Sprint 1
D-005 HID keyboard-wedge RFID Tablet code reads UID from a focused text input — no native RFID API plumbing
D-006 Capacitor + React+Vite Single React codebase, Capacitor wraps for Android
D-010 MIFARE DESFire EV3 Bench tests use ACR122U; expect 7-byte UIDs from real DESFire cards
D-012 Standalone Nest service New repo sentinel-cab-api, dedicated Neon DB
D-013 Multi-tenant from day one Every entity has tenant_id NOT NULL — locked from migration #1
D-014 R2 Singapore for selfies R2 bucket created in Sprint 1 even though selfie code lands Sprint 3
D-015 Driver-truck assignment truck_driver_assignments entity in Sprint 1 schema
D-017 CF Access + own session Auth scaffold splits into device-JWT (tablet) + session+CF (dashboard)
D-022/D-024 Continuation columns shifts table needs continuation_of_shift_id, continuation_reason, reassignment_window_until from migration #1 — no later schema rework
D-023/D-025 Geofence zone types geofences.zone_type enum has base_camp, loading_area, dumping_area, haul_route, restricted from day one
D-026 XCover7 not Tab Active5 Sprint 1 bench kit and exit criteria target the phone form factor

Sprint 1 outcome (what "done" looks like)

By 2026-05-25 Sprint 1 demo, the following are demonstrable:

  1. Three repos exist under josepheffendy/, each with CI green on main
  2. Neon project sentinel-cab exists with production and dev branches
  3. Postgres schema has all v1 tables created via migrations, including PostGIS for geofences.polygon
  4. R2 bucket sentinel-cab-selfies exists with lifecycle policy
  5. cab.jofnd.ai subdomain routes via Cloudflare Tunnel + Cloudflare Access to the API stub
  6. Device-JWT auth issues a token to a registered tablet device and validates it on a /api/v1/health endpoint
  7. Session auth lets a seeded admin user log into a dashboard "hello" page through CF Access
  8. Capacitor APK built from sentinel-cab-app, installed on the bench XCover7, boots into an idle screen showing "Tap your card to start" + truck plate placeholder + LTE/WiFi indicator
  9. RFID tap → UID-in-input proven: ACR122U reads a DESFire EV3 card; XCover7 receives the 7-byte UID into the focused input field via HID keyboard-wedge
  10. D-026 exit criteria pass: glove-tap reliability ≥95%, cradle vibration test passed, thermal test in closed car passed (no throttling at 4h / 40°C)

Dependency graph

                  ┌──────────────────────────────────┐
                  │  TASK 1: Bench kit order + arrive │
                  │  TASK 2: Repos created            │
                  │  TASK 3: Neon project + branches  │
                  │  TASK 4: R2 bucket created        │
                  │  TASK 5: CF Tunnel + Access       │
                  └────┬─────────────────────────────┘
                       │ (parallel where possible)
            ┌──────────┴───────────┐
            │                      │
   ┌────────▼─────────┐    ┌───────▼────────┐
   │  BACKEND TRACK    │    │  MOBILE TRACK   │
   │  6: Nest scaffold │    │  20: Vite init  │
   │  7: Multi-tenant  │    │  21: Capacitor  │
   │  8–17: Entities   │    │  22: Idle screen│
   │  18: Audit log    │    │  23: HID input  │
   │  19: Auth scaffold│    │  24: Knox lock  │
   └────────┬──────────┘    └───────┬─────────┘
            │                       │
            └───────┬───────────────┘
                    │
        ┌───────────▼──────────────┐
        │  HARDWARE VALIDATION      │
        │  25: Glove-tap test       │
        │  26: Cradle test          │
        │  27: Thermal test         │
        └───────────┬──────────────┘
                    │
        ┌───────────▼──────────────┐
        │  TASK 28: Sprint 1 demo   │
        └──────────────────────────┘

Backend track and Mobile track can proceed in parallel from Task 6/20 onward. Hardware validation depends on Mobile track Task 22 (idle screen).


Phase 0 — Bench + Infrastructure (Tasks 1–5)

Task 1: Order bench kit and validate on arrival

Files: - Reference: bench-procurement.md (existing)

  • [ ] Step 1: Place Tokopedia/Shopee orders today

Per bench-procurement.md: - 1× Samsung Galaxy XCover7 (search: Samsung Galaxy XCover7, model SM-G556B, ~Rp 5–6.5M, garansi resmi SEIN/TAM) - 1× ACS ACR122U (search: ACR122U, ~Rp 700k–1M, genuine ACS) - 1 pack × 5 MIFARE DESFire EV3 cards (search: MIFARE DESFire EV3 card, ~Rp 125–250k) - 1 pack × 10 MIFARE Classic 1K cards (search: Kartu RFID MIFARE Classic 1K 10pcs, ~Rp 80–150k) - 1× Ugreen USB-C OTG with PD passthrough (search: Ugreen USB-C OTG adapter PD passthrough, ~Rp 100–250k) - 1× Aluminum desktop phone stand (~Rp 200–700k) - Optional: 1× RAM Mounts X-Grip Quick Release for cradle bench rehearsal (~Rp 700k–1M, can also wait until Task 26)

Total: ~Rp 6.2–8.9 M

  • [ ] Step 2: On delivery, run unboxing checklist

Per bench-procurement.md "Receiving / unboxing checklist" section: - [ ] XCover7: BNIB seal intact, TAM warranty card, powers on, runs Android 14 update, NFC works, USB-C OTG works, XCover side key remappable in Android settings, front camera autofocuses in low light - [ ] ACR122U: LED on insert, beep on card tap, recognized by Android via OTG - [ ] DESFire EV3 cards: all 5 read with ACR122U, UIDs are 7 bytes (confirms genuine DESFire), unique UIDs per card - [ ] MIFARE Classic 1K cards: all 10 read with ACR122U, unique UIDs - [ ] OTG adapter: PD passthrough confirmed (XCover7 charging while ACR122U plugged into the OTG port)

If any item fails: return within Tokopedia 7-day window, reorder. Do not proceed to Tasks 22+ without working hardware.

  • [ ] Step 3: Photograph the bench setup for the team handbook

Take 3–5 photos: full bench, OTG adapter detail, ACR122U + cards, XCover7 with NFC tag tap. Save to Argus_IIOP/Sentinel_Cab/bench-setup-photos/.

  • [ ] Step 4: Commit photos
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/bench-setup-photos/
git commit -m "docs(sentinel-cab): bench kit setup photos"

Task 2: Create the three GitHub repos

Files: - Create: josepheffendy/sentinel-cab-api - Create: josepheffendy/sentinel-cab-app - Create: josepheffendy/sentinel-cab-dashboard

  • [ ] Step 1: Create all three private repos via gh CLI
gh repo create josepheffendy/sentinel-cab-api --private --description "Sentinel_Cab API — Nest + Postgres on Neon" --add-readme
gh repo create josepheffendy/sentinel-cab-app --private --description "Sentinel_Cab in-cab app — React+Vite + Capacitor + Android" --add-readme
gh repo create josepheffendy/sentinel-cab-dashboard --private --description "Sentinel_Cab supervisor dashboard — React+Vite" --add-readme

Expected: each command prints https://github.com/josepheffendy/sentinel-cab-* URL.

  • [ ] Step 2: Clone all three locally
mkdir -p ~/Code/sentinel-cab && cd ~/Code/sentinel-cab
gh repo clone josepheffendy/sentinel-cab-api
gh repo clone josepheffendy/sentinel-cab-app
gh repo clone josepheffendy/sentinel-cab-dashboard
ls

Expected: three directories listed.

  • [ ] Step 3: Add .gitignore + LICENSE + initial README to each

Use the standard SwiftRise pattern. For each repo:

cd ~/Code/sentinel-cab/sentinel-cab-api
curl -sL https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore > .gitignore
echo "node_modules/" >> .gitignore
echo "dist/" >> .gitignore
echo ".env" >> .gitignore
echo ".env.local" >> .gitignore

Repeat for sentinel-cab-app and sentinel-cab-dashboard.

  • [ ] Step 4: Commit and push initial state

For each repo:

git add -A
git commit -m "chore: initial repo setup with .gitignore"
git push origin main

Task 3: Create Neon project with two branches

Files: - Reference: external (Neon dashboard at console.neon.tech)

  • [ ] Step 1: Create the Neon project

In the Neon console: 1. Create new project named sentinel-cab 2. Region: Singapore (ap-southeast-1) 3. Postgres version: 16 4. Default branch: production

Note the connection string (looks like postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require).

  • [ ] Step 2: Create a dev branch from production

In Neon console: Branches → New branch → name dev, parent production. Note the dev connection string.

  • [ ] Step 3: Enable PostGIS extension on both branches

Connect to each branch via psql:

psql "postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require" -c "CREATE EXTENSION IF NOT EXISTS postgis;"

Repeat for the dev branch connection string.

Expected: CREATE EXTENSION printed.

  • [ ] Step 4: Verify PostGIS works
psql "postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require" -c "SELECT PostGIS_Version();"

Expected: prints PostGIS version (e.g. 3.4 USE_GEOS=1 USE_PROJ=1 USE_STATS=1).

  • [ ] Step 5: Save connection strings to 1Password

Create two 1Password entries: Sentinel_Cab Neon production and Sentinel_Cab Neon dev. Do not commit connection strings to any repo.


Task 4: Create Cloudflare R2 bucket for selfie storage

Files: - Reference: external (Cloudflare dashboard)

  • [ ] Step 1: Create R2 bucket via wrangler
wrangler r2 bucket create sentinel-cab-selfies --location apac

Expected: Created bucket sentinel-cab-selfies.

  • [ ] Step 2: Set bucket lifecycle policy (12-month retention per D-014)

Create ~/Code/sentinel-cab/r2-lifecycle.json:

{
  "rules": [
    {
      "id": "auto-purge-after-12-months",
      "enabled": true,
      "conditions": {
        "prefix": ""
      },
      "deleteObjectsTransition": {
        "condition": { "type": "Age", "maxAge": 31536000 }
      }
    }
  ]
}

Apply:

wrangler r2 bucket lifecycle set sentinel-cab-selfies --file ~/Code/sentinel-cab/r2-lifecycle.json

Expected: success confirmation. (If the wrangler subcommand syntax differs in your version, set the lifecycle via the Cloudflare dashboard: R2 → sentinel-cab-selfies → Settings → Object lifecycle rules.)

  • [ ] Step 3: Create R2 API token scoped to this bucket

In Cloudflare dashboard: R2 → Manage R2 API Tokens → Create API token. Permissions: Object Read & Write. Scope: sentinel-cab-selfies. Save Access Key ID + Secret Access Key + Endpoint to 1Password as Sentinel_Cab R2 production credentials.


Task 5: Create Cloudflare Access policy + Tunnel for cab.jofnd.ai

Files: - Reference: external (Cloudflare Zero Trust dashboard)

  • [ ] Step 1: Create CF Access application

In Cloudflare Zero Trust dashboard: Access → Applications → Add an application: - Type: Self-hosted - Application name: Sentinel_Cab - Domain: cab.jofnd.ai (or pick another sub if cab is taken) - Identity providers: Google + One-time PIN (so Adong supervisors can use email-OTP later)

Add policy: - Name: Sentinel_Cab admins - Action: Allow - Include: emails ending jofnd.ai, plus josepheffendy@gmail.com

(More-permissive Adong-supervisor policies added later.)

  • [ ] Step 2: Create CF Tunnel from agent-mini host (or wherever the API will run)
cloudflared tunnel create sentinel-cab
cloudflared tunnel route dns sentinel-cab cab.jofnd.ai

Expected: tunnel UUID printed, DNS record created.

  • [ ] Step 3: Configure tunnel to forward to local Nest port

Create ~/.cloudflared/sentinel-cab.yml:

tunnel: <TUNNEL-UUID>
credentials-file: /Users/jofnd.ai/.cloudflared/<TUNNEL-UUID>.json
ingress:
  - hostname: cab.jofnd.ai
    service: http://localhost:3010
  - service: http_status:404

Note: port 3010 is reserved for Sentinel_Cab API (matches existing app port allocation pattern).

  • [ ] Step 4: Start the tunnel as a LaunchAgent

Create ~/Library/LaunchAgents/com.jofnd.sentinel-cab-tunnel.plist mirroring the existing asset / procure tunnel LaunchAgents (per project_assetmgmt_deploy and project_procure_system memory references):

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.jofnd.sentinel-cab-tunnel</string>
    <key>ProgramArguments</key>
    <array>
        <string>/opt/homebrew/bin/cloudflared</string>
        <string>tunnel</string>
        <string>--config</string>
        <string>/Users/jofnd.ai/.cloudflared/sentinel-cab.yml</string>
        <string>run</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <true/>
    <key>StandardOutPath</key>
    <string>/tmp/sentinel-cab-tunnel.log</string>
    <key>StandardErrorPath</key>
    <string>/tmp/sentinel-cab-tunnel.err.log</string>
</dict>
</plist>

Load it:

launchctl load ~/Library/LaunchAgents/com.jofnd.sentinel-cab-tunnel.plist
launchctl start com.jofnd.sentinel-cab-tunnel
  • [ ] Step 5: Verify tunnel is up (will return 502 until API runs — that's expected)
curl -I https://cab.jofnd.ai/

Expected: HTTP/2 502 (Bad Gateway — no upstream listening yet) OR HTTP/2 401 (CF Access challenge) depending on policy ordering. Both confirm tunnel routing works.


Phase 1 — Backend scaffold (Tasks 6–9)

Task 6: Fork SwiftRise Nest boilerplate, apply Postgres patches, deploy hello-world

Files: - Modify: ~/Code/sentinel-cab/sentinel-cab-api/ (entire repo)

  • [ ] Step 1: Copy SwiftRise Nest boilerplate
gh repo clone josepheffendy/swiftrise-boilerplate-api-nest /tmp/swiftrise-nest-tmp
rsync -av --exclude='.git' --exclude='node_modules' --exclude='dist' /tmp/swiftrise-nest-tmp/ ~/Code/sentinel-cab/sentinel-cab-api/
cd ~/Code/sentinel-cab/sentinel-cab-api
rm -rf /tmp/swiftrise-nest-tmp
  • [ ] Step 2: Apply Postgres-specific bug-fixes per feedback_swiftrise_boilerplate_postgres_bugs

Open ~/Code/sentinel-cab/sentinel-cab-api/src/database/data-source.ts and verify it uses relative imports, not from 'src/...'. If the boilerplate has the alias-import bug, change it to relative imports.

Open ~/Code/sentinel-cab/sentinel-cab-api/tsconfig.json and ensure strictPropertyInitialization: false is set.

Search for unsigned: true and longtext in src/:

cd ~/Code/sentinel-cab/sentinel-cab-api
grep -rn "unsigned: true\|longtext" src/

Replace any occurrences (they break Postgres). Per the boilerplate-bugs feedback memory.

  • [ ] Step 3: Update package.json identity

Edit package.json:

{
  "name": "sentinel-cab-api",
  "version": "0.1.0",
  "description": "Sentinel_Cab API — Nest + Postgres on Neon"
}
  • [ ] Step 4: Install Postgres deps (boilerplate ships MySQL defaults)
cd ~/Code/sentinel-cab/sentinel-cab-api
npm uninstall mysql2 || true
npm install pg @types/pg
  • [ ] Step 5: Configure .env.example with Postgres + Neon

Create .env.example:

NODE_ENV=development
PORT=3010

# Database (Neon Postgres — use the "dev" branch connection string from Task 3)
DB_TYPE=postgres
DATABASE_URL=postgresql://USER:PASS@HOST.neon.tech/sentinel-cab?sslmode=require

# JWT
JWT_SECRET=replace-me-in-env
JWT_EXPIRES_IN=15m

# R2 (selfie storage)
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=sentinel-cab-selfies
R2_ENDPOINT=

# Tenant defaults
DEFAULT_TENANT_CODE=adong

Also create .env locally (NOT committed) with your real Neon dev connection string.

  • [ ] Step 6: Add a /health endpoint that proves DB connectivity

Open src/app.controller.ts. Replace its body with:

import { Controller, Get } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';

@Controller()
export class AppController {
  constructor(@InjectDataSource() private readonly dataSource: DataSource) {}

  @Get('health')
  async health() {
    const result = await this.dataSource.query('SELECT 1 as ok');
    return {
      status: 'ok',
      service: 'sentinel-cab-api',
      version: process.env.npm_package_version || '0.1.0',
      db: result[0]?.ok === 1 ? 'connected' : 'unknown',
      timestamp: new Date().toISOString(),
    };
  }
}
  • [ ] Step 7: Run the app locally and hit /health

Terminal 1:

cd ~/Code/sentinel-cab/sentinel-cab-api
npm install
npm run start:dev

Expected: starts on port 3010 with no errors.

Terminal 2:

curl http://localhost:3010/health

Expected:

{"status":"ok","service":"sentinel-cab-api","version":"0.1.0","db":"connected","timestamp":"2026-05-..."}
  • [ ] Step 8: Hit /health through the CF Tunnel (CF Access will challenge)
curl -I https://cab.jofnd.ai/health

Expected: HTTP/2 302 with location: https://...cloudflareaccess.com/... (CF Access OAuth challenge). Confirms tunnel + Access wiring.

  • [ ] Step 9: Commit + push
git add -A
git commit -m "feat: initial Nest scaffold from SwiftRise boilerplate with Postgres patches and /health endpoint"
git push origin main

Task 7: Multi-tenant middleware + base entity

Files: - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/entities/tenant-scoped.entity.ts - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/middleware/tenant-context.middleware.ts - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/common/decorators/current-tenant.decorator.ts - Test: ~/Code/sentinel-cab/sentinel-cab-api/test/tenant-context.middleware.spec.ts

  • [ ] Step 1: Write the failing middleware test

Create test/tenant-context.middleware.spec.ts:

import { TenantContextMiddleware } from '../src/common/middleware/tenant-context.middleware';
import { Request, Response, NextFunction } from 'express';

describe('TenantContextMiddleware', () => {
  let middleware: TenantContextMiddleware;
  let next: NextFunction;

  beforeEach(() => {
    middleware = new TenantContextMiddleware();
    next = jest.fn();
  });

  it('attaches tenantId to request when X-Tenant-Id header present', () => {
    const req = { headers: { 'x-tenant-id': '00000000-0000-0000-0000-000000000001' } } as unknown as Request;
    const res = {} as Response;
    middleware.use(req, res, next);
    expect((req as any).tenantId).toBe('00000000-0000-0000-0000-000000000001');
    expect(next).toHaveBeenCalled();
  });

  it('rejects requests with missing X-Tenant-Id header', () => {
    const req = { headers: {}, path: '/api/v1/protected' } as unknown as Request;
    const res = { status: jest.fn().mockReturnThis(), json: jest.fn() } as unknown as Response;
    middleware.use(req, res, next);
    expect(res.status).toHaveBeenCalledWith(400);
    expect(next).not.toHaveBeenCalled();
  });

  it('skips tenant check on health endpoint', () => {
    const req = { headers: {}, path: '/health' } as unknown as Request;
    const res = {} as Response;
    middleware.use(req, res, next);
    expect(next).toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run the test, watch it fail
cd ~/Code/sentinel-cab/sentinel-cab-api
npm test -- tenant-context

Expected: FAIL with "Cannot find module '../src/common/middleware/tenant-context.middleware'".

  • [ ] Step 3: Create the middleware

Create src/common/middleware/tenant-context.middleware.ts:

import { Injectable, NestMiddleware } from '@nestjs/common';
import { Request, Response, NextFunction } from 'express';

const PUBLIC_PATHS = ['/health', '/'];

@Injectable()
export class TenantContextMiddleware implements NestMiddleware {
  use(req: Request, res: Response, next: NextFunction): void {
    if (PUBLIC_PATHS.includes(req.path)) {
      return next();
    }
    const tenantId = req.headers['x-tenant-id'] as string | undefined;
    if (!tenantId) {
      res.status(400).json({
        code: 'TENANT_REQUIRED',
        message: 'X-Tenant-Id header is required for this endpoint',
      });
      return;
    }
    (req as any).tenantId = tenantId;
    next();
  }
}
  • [ ] Step 4: Wire the middleware in app.module

Open src/app.module.ts. Add:

import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
import { TenantContextMiddleware } from './common/middleware/tenant-context.middleware';

@Module({ /* ...existing imports... */ })
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer) {
    consumer.apply(TenantContextMiddleware).forRoutes('*');
  }
}
  • [ ] Step 5: Run the test, watch it pass
npm test -- tenant-context

Expected: 3 tests PASS.

  • [ ] Step 6: Create the @CurrentTenant decorator

Create src/common/decorators/current-tenant.decorator.ts:

import { createParamDecorator, ExecutionContext } from '@nestjs/common';

export const CurrentTenant = createParamDecorator(
  (_data: unknown, ctx: ExecutionContext): string => {
    const request = ctx.switchToHttp().getRequest();
    return request.tenantId;
  },
);
  • [ ] Step 7: Create the TenantScoped base entity

Create src/common/entities/tenant-scoped.entity.ts:

import { Column, CreateDateColumn, DeleteDateColumn, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

export abstract class TenantScopedEntity {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Index()
  @Column('uuid', { name: 'tenant_id', nullable: false })
  tenantId!: string;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt!: Date;

  @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
  deletedAt?: Date | null;

  @Column({ name: 'created_by', type: 'uuid', nullable: true })
  createdBy?: string | null;

  @Column({ name: 'updated_by', type: 'uuid', nullable: true })
  updatedBy?: string | null;
}
  • [ ] Step 8: Commit
git add -A
git commit -m "feat: multi-tenant middleware + @CurrentTenant decorator + TenantScopedEntity base"
git push origin main

Task 8: GitHub Actions CI for sentinel-cab-api

Files: - Create: ~/Code/sentinel-cab/sentinel-cab-api/.github/workflows/ci.yml

  • [ ] Step 1: Add Postgres + PostGIS test service to CI

Create .github/workflows/ci.yml:

name: ci
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgis/postgis:16-3.4
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: sentinel_cab_test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
      - name: Run tests
        env:
          DB_TYPE: postgres
          DATABASE_URL: postgresql://testuser:testpass@localhost:5432/sentinel_cab_test
          JWT_SECRET: ci-test-secret
        run: npm test
  • [ ] Step 2: Push and verify CI passes
git add .github/workflows/ci.yml
git commit -m "ci: GitHub Actions with Postgres+PostGIS test service"
git push origin main
gh run watch

Expected: CI green within ~3 min.


Task 9: Migration tooling and naming convention

Files: - Modify: ~/Code/sentinel-cab/sentinel-cab-api/package.json - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/database/migrations/.gitkeep

  • [ ] Step 1: Add migration scripts to package.json

Edit package.json, add to "scripts":

{
  "scripts": {
    "migration:generate": "npm run typeorm -- migration:generate -d src/database/data-source.ts",
    "migration:run": "npm run typeorm -- migration:run -d src/database/data-source.ts",
    "migration:revert": "npm run typeorm -- migration:revert -d src/database/data-source.ts",
    "typeorm": "typeorm-ts-node-commonjs"
  }
}
  • [ ] Step 2: Confirm migration directory exists
mkdir -p src/database/migrations
touch src/database/migrations/.gitkeep
  • [ ] Step 3: Commit
git add -A
git commit -m "chore: migration scripts and directory"
git push origin main

Phase 2 — Schema (Tasks 10–18)

Pattern for entity tasks (10–17): each follows the same shape — write entity → write migration → write repository unit test → run + commit. After Task 10 walks the full TDD cycle, Tasks 11–17 are presented as compressed templates with the entity file, migration, and test code provided directly. Engineers proficient in TypeORM/Nest can apply the pattern without restating each step.

Task 10: tenants entity (full TDD walkthrough — template for 11–17)

Files: - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/modules/tenants/tenant.entity.ts - Create: ~/Code/sentinel-cab/sentinel-cab-api/src/modules/tenants/tenants.module.ts - Test: ~/Code/sentinel-cab/sentinel-cab-api/test/modules/tenants/tenant.entity.spec.ts - Migration: ~/Code/sentinel-cab/sentinel-cab-api/src/database/migrations/<TIMESTAMP>-CreateTenants.ts (auto-generated)

  • [ ] Step 1: Write the failing entity test

Create test/modules/tenants/tenant.entity.spec.ts:

import { Tenant } from '../../../src/modules/tenants/tenant.entity';

describe('Tenant entity', () => {
  it('exposes the expected columns', () => {
    const t = new Tenant();
    t.name = 'Adong';
    t.code = 'adong';
    t.siteCountry = 'ID';
    expect(t.name).toBe('Adong');
    expect(t.code).toBe('adong');
    expect(t.siteCountry).toBe('ID');
  });
});
  • [ ] Step 2: Run, watch fail
npm test -- tenant.entity

Expected: FAIL with module-not-found.

  • [ ] Step 3: Create the entity

Create src/modules/tenants/tenant.entity.ts:

import { Column, CreateDateColumn, DeleteDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';

@Entity({ name: 'tenants' })
export class Tenant {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Index({ unique: true })
  @Column({ length: 64 })
  code!: string;

  @Column({ length: 256 })
  name!: string;

  @Column({ name: 'site_country', length: 2, nullable: true })
  siteCountry?: string;

  @Column({ name: 'pdp_consent_template_version', length: 32, nullable: true })
  pdpConsentTemplateVersion?: string;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt!: Date;

  @DeleteDateColumn({ name: 'deleted_at', type: 'timestamptz', nullable: true })
  deletedAt?: Date | null;
}
  • [ ] Step 4: Create the module shell

Create src/modules/tenants/tenants.module.ts:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from './tenant.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Tenant])],
  exports: [TypeOrmModule],
})
export class TenantsModule {}

Register TenantsModule in app.module.ts imports array.

  • [ ] Step 5: Generate migration
npm run migration:generate -- src/database/migrations/CreateTenants

Expected: migration file created in src/database/migrations/<TIMESTAMP>-CreateTenants.ts with CREATE TABLE tenants (...).

Inspect the generated SQL to confirm: UUID PK, unique index on code, soft-delete column, timestamps.

  • [ ] Step 6: Run the migration against dev DB
DATABASE_URL=<dev-connection-string> npm run migration:run

Expected: Migration <name> has been executed successfully.

  • [ ] Step 7: Verify table exists in Neon
psql "<dev-connection-string>" -c "\d tenants"

Expected: prints columns matching the entity definition.

  • [ ] Step 8: Run tests, watch them pass
npm test -- tenant.entity

Expected: PASS.

  • [ ] Step 9: Commit
git add -A
git commit -m "feat(tenants): tenant entity + migration"
git push origin main

Task 11: users entity

Files: - Create: src/modules/users/user.entity.ts - Create: src/modules/users/users.module.ts - Test: test/modules/users/user.entity.spec.ts - Migration: src/database/migrations/<TIMESTAMP>-CreateUsers.ts

Apply the Task 10 TDD cycle (test → fail → entity → migration → run → pass → commit) with this entity:

// src/modules/users/user.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type UserRole = 'admin' | 'supervisor' | 'viewer';

@Entity({ name: 'users' })
@Index(['tenantId', 'email'], { unique: true, where: 'deleted_at IS NULL' })
export class User extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ length: 256 })
  email!: string;

  @Column({ length: 256 })
  name!: string;

  @Column({ name: 'password_hash', length: 256 })
  passwordHash!: string;

  @Column({ type: 'enum', enum: ['admin', 'supervisor', 'viewer'], default: 'viewer' })
  role!: UserRole;

  @Column({ name: 'mfa_enabled', default: false })
  mfaEnabled!: boolean;

  @Column({ name: 'last_login_at', type: 'timestamptz', nullable: true })
  lastLoginAt?: Date | null;
}

Test:

// test/modules/users/user.entity.spec.ts
import { User } from '../../../src/modules/users/user.entity';

describe('User entity', () => {
  it('defaults role to viewer', () => {
    const u = new User();
    u.email = 'test@example.com';
    u.name = 'Test';
    u.passwordHash = 'hash';
    expect(u.role || 'viewer').toBe('viewer');
  });
});

Commit message: feat(users): user entity + migration with tenant-scoped unique email


Task 12: drivers entity

Apply the pattern with:

// src/modules/drivers/driver.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type DriverStatus = 'active' | 'inactive' | 'terminated';

@Entity({ name: 'drivers' })
@Index(['tenantId', 'employeeId'], { unique: true, where: 'deleted_at IS NULL' })
export class Driver extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ name: 'employee_id', length: 64 })
  employeeId!: string;

  @Column({ length: 256 })
  name!: string;

  @Column({ name: 'name_phonetic', length: 256, nullable: true })
  namePhonetic?: string | null;

  @Column({ type: 'date', nullable: true })
  dob?: string | null;

  @Column({ name: 'license_class', length: 32, nullable: true })
  licenseClass?: string | null;

  @Column({ name: 'license_number', length: 64, nullable: true })
  licenseNumber?: string | null;

  @Column({ name: 'license_expires_at', type: 'date', nullable: true })
  licenseExpiresAt?: string | null;

  @Column({ name: 'hire_date', type: 'date', nullable: true })
  hireDate?: string | null;

  @Column({ name: 'photo_url', length: 1024, nullable: true })
  photoUrl?: string | null;

  @Column({ type: 'enum', enum: ['active', 'inactive', 'terminated'], default: 'active' })
  status!: DriverStatus;

  @Column({ name: 'consent_signed_at', type: 'timestamptz', nullable: true })
  consentSignedAt?: Date | null;

  @Column({ name: 'consent_version', length: 32, nullable: true })
  consentVersion?: string | null;

  @Column({ type: 'text', nullable: true })
  notes?: string | null;
}

Commit: feat(drivers): driver entity + migration with tenant-scoped unique employee_id


Task 13: rfid_cards entity

// src/modules/rfid-cards/rfid-card.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
import { Driver } from '../drivers/driver.entity';

export type RfidCardStatus = 'active' | 'lost' | 'revoked' | 'expired';

@Entity({ name: 'rfid_cards' })
@Index(['tenantId', 'uid'], { unique: true })
export class RfidCard extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ length: 32 })
  uid!: string; // 7-byte DESFire UID = 14 hex chars; padding allows for future card formats

  @Column({ name: 'chip_type', length: 32, default: 'desfire-ev3' })
  chipType!: string;

  @ManyToOne(() => Driver, { nullable: true }) @JoinColumn({ name: 'issued_to_driver_id' })
  issuedToDriver?: Driver | null;

  @Column({ name: 'issued_to_driver_id', type: 'uuid', nullable: true })
  issuedToDriverId?: string | null;

  @Column({ name: 'issued_at', type: 'timestamptz', nullable: true })
  issuedAt?: Date | null;

  @Column({ name: 'issued_by', type: 'uuid', nullable: true })
  issuedBy?: string | null;

  @Column({ type: 'enum', enum: ['active', 'lost', 'revoked', 'expired'], default: 'active' })
  status!: RfidCardStatus;

  @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
  revokedAt?: Date | null;

  @Column({ name: 'revoked_by', type: 'uuid', nullable: true })
  revokedBy?: string | null;

  @Column({ name: 'revoke_reason', length: 256, nullable: true })
  revokeReason?: string | null;

  @Column({ type: 'text', nullable: true })
  notes?: string | null;
}

Commit: feat(rfid-cards): card entity + migration with tenant-scoped unique UID


Task 14: trucks entity

// src/modules/trucks/truck.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type TruckStatus = 'active' | 'workshop' | 'retired';

@Entity({ name: 'trucks' })
@Index(['tenantId', 'assetCode'], { unique: true, where: 'deleted_at IS NULL' })
export class Truck extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ name: 'asset_code', length: 64 })
  assetCode!: string;

  @Column({ name: 'plate_number', length: 32, nullable: true })
  plateNumber?: string | null;

  @Column({ length: 64, nullable: true })
  model?: string | null;

  @Column({ name: 'model_class', length: 32, nullable: true })
  modelClass?: string | null;

  @Column({ type: 'int', nullable: true })
  year?: number | null;

  @Column({ length: 32, nullable: true })
  vin?: string | null;

  @Column({ type: 'enum', enum: ['active', 'workshop', 'retired'], default: 'active' })
  status!: TruckStatus;

  @Column({ name: 'primary_tablet_id', type: 'uuid', nullable: true })
  primaryTabletId?: string | null;

  @Column({ name: 'primary_reader_id', type: 'uuid', nullable: true })
  primaryReaderId?: string | null;
}

Commit: feat(trucks): truck entity + migration with tenant-scoped unique asset_code


Task 15: tablet_devices and rfid_readers entities

Two entities in one task — both small.

// src/modules/tablet-devices/tablet-device.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type TabletStatus = 'provisioned' | 'active' | 'retired' | 'lost';

@Entity({ name: 'tablet_devices' })
@Index(['tenantId', 'hardwareSerial'], { unique: true })
export class TabletDevice extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ name: 'hardware_serial', length: 64 })
  hardwareSerial!: string;

  @Column({ length: 64, default: 'Galaxy XCover7' })
  model!: string;

  @Column({ name: 'os_version', length: 32, nullable: true })
  osVersion?: string | null;

  @Column({ name: 'app_version', length: 32, nullable: true })
  appVersion?: string | null;

  @Column({ name: 'api_token_hash', length: 256, nullable: true })
  apiTokenHash?: string | null;

  @Column({ name: 'last_check_in_at', type: 'timestamptz', nullable: true })
  lastCheckInAt?: Date | null;

  @Column({ type: 'enum', enum: ['provisioned', 'active', 'retired', 'lost'], default: 'provisioned' })
  status!: TabletStatus;
}
// src/modules/rfid-readers/rfid-reader.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type ReaderStatus = 'provisioned' | 'active' | 'retired' | 'broken';

@Entity({ name: 'rfid_readers' })
@Index(['tenantId', 'hardwareSerial'], { unique: true })
export class RfidReader extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ name: 'hardware_serial', length: 64 })
  hardwareSerial!: string;

  @Column({ length: 64, nullable: true })
  model?: string | null;

  @Column({ name: 'mounting_location', length: 128, nullable: true })
  mountingLocation?: string | null;

  @Column({ type: 'enum', enum: ['provisioned', 'active', 'retired', 'broken'], default: 'provisioned' })
  status!: ReaderStatus;
}

Commit: feat(devices): tablet_devices + rfid_readers entities + migrations


Task 16: truck_driver_assignments entity (D-015)

// src/modules/truck-driver-assignments/truck-driver-assignment.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';
import { Truck } from '../trucks/truck.entity';
import { Driver } from '../drivers/driver.entity';

export type AssignmentRole = 'primary' | 'backup';

@Entity({ name: 'truck_driver_assignments' })
@Index(['tenantId', 'truckId'], {
  unique: true,
  where: "role = 'primary' AND valid_to IS NULL AND deleted_at IS NULL",
})
@Index(['tenantId', 'truckId', 'driverId', 'role'])
export class TruckDriverAssignment extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @ManyToOne(() => Truck) @JoinColumn({ name: 'truck_id' })
  truck!: Truck;

  @Column({ name: 'truck_id', type: 'uuid' })
  truckId!: string;

  @ManyToOne(() => Driver) @JoinColumn({ name: 'driver_id' })
  driver!: Driver;

  @Column({ name: 'driver_id', type: 'uuid' })
  driverId!: string;

  @Column({ type: 'enum', enum: ['primary', 'backup'] })
  role!: AssignmentRole;

  @Column({ name: 'valid_from', type: 'timestamptz' })
  validFrom!: Date;

  @Column({ name: 'valid_to', type: 'timestamptz', nullable: true })
  validTo?: Date | null;

  @Column({ name: 'assigned_by_user_id', type: 'uuid', nullable: true })
  assignedByUserId?: string | null;

  @Column({ name: 'assigned_at', type: 'timestamptz', nullable: true })
  assignedAt?: Date | null;

  @Column({ name: 'revoked_by_user_id', type: 'uuid', nullable: true })
  revokedByUserId?: string | null;

  @Column({ name: 'revoked_at', type: 'timestamptz', nullable: true })
  revokedAt?: Date | null;

  @Column({ type: 'text', nullable: true })
  notes?: string | null;
}

Test the partial unique constraint:

// test/modules/truck-driver-assignments/uniqueness.spec.ts
// (integration test against test DB — verifies that two open primary assignments for the same truck are rejected)
import { Test } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { TruckDriverAssignment } from '../../../src/modules/truck-driver-assignments/truck-driver-assignment.entity';

describe('truck_driver_assignments uniqueness', () => {
  let repo: Repository<TruckDriverAssignment>;

  beforeAll(async () => {
    // Test setup using process.env.DATABASE_URL (CI Postgres)
    // ...wire repo from a test module that loads only the entities under test...
  });

  it('rejects two open primary assignments on the same truck', async () => {
    const tenantId = '00000000-0000-0000-0000-000000000001';
    const truckId = '00000000-0000-0000-0000-0000000000aa';
    const driver1 = '00000000-0000-0000-0000-0000000000b1';
    const driver2 = '00000000-0000-0000-0000-0000000000b2';
    await repo.save({ tenantId, truckId, driverId: driver1, role: 'primary', validFrom: new Date(), validTo: null });
    await expect(
      repo.save({ tenantId, truckId, driverId: driver2, role: 'primary', validFrom: new Date(), validTo: null }),
    ).rejects.toThrow();
  });
});

Commit: feat(assignments): truck_driver_assignments with partial unique on active primary per truck


Task 17: geofences entity with PostGIS polygon (D-023, D-025)

// src/modules/geofences/geofence.entity.ts
import { Column, Entity, Index, ManyToOne, JoinColumn } from 'typeorm';
import { TenantScopedEntity } from '../../common/entities/tenant-scoped.entity';
import { Tenant } from '../tenants/tenant.entity';

export type ZoneType = 'base_camp' | 'loading_area' | 'dumping_area' | 'haul_route' | 'restricted';

@Entity({ name: 'geofences' })
@Index(['tenantId', 'zoneType', 'isActive'])
export class Geofence extends TenantScopedEntity {
  @ManyToOne(() => Tenant) @JoinColumn({ name: 'tenant_id' })
  tenant!: Tenant;

  @Column({ length: 128 })
  name!: string;

  @Column({
    name: 'zone_type',
    type: 'enum',
    enum: ['base_camp', 'loading_area', 'dumping_area', 'haul_route', 'restricted'],
  })
  zoneType!: ZoneType;

  @Column({
    type: 'geometry',
    spatialFeatureType: 'Polygon',
    srid: 4326,
  })
  polygon!: any; // GeoJSON Polygon

  @Column({ name: 'is_active', default: true })
  isActive!: boolean;

  @Column({ type: 'text', nullable: true })
  notes?: string | null;
}

Test:

// test/modules/geofences/spatial.spec.ts
// Integration test — insert a polygon, query "is point inside?"
import { Repository } from 'typeorm';
import { Geofence } from '../../../src/modules/geofences/geofence.entity';

describe('geofences spatial queries', () => {
  let repo: Repository<Geofence>;
  beforeAll(async () => { /* wire repo */ });

  it('returns geofence containing a given point', async () => {
    const tenantId = '00000000-0000-0000-0000-000000000001';
    // Polygon roughly bounding (lon 116.5, lat -1.5) at Adong
    const polygon = {
      type: 'Polygon',
      coordinates: [[
        [116.4, -1.6], [116.6, -1.6], [116.6, -1.4], [116.4, -1.4], [116.4, -1.6],
      ]],
    };
    await repo.save({ tenantId, name: 'test base camp', zoneType: 'base_camp', polygon, isActive: true });

    const point = 'SRID=4326;POINT(116.5 -1.5)';
    const found = await repo
      .createQueryBuilder('g')
      .where('g.tenant_id = :tenantId', { tenantId })
      .andWhere('ST_Contains(g.polygon, ST_GeomFromEWKT(:p))', { p: point })
      .getOne();
    expect(found?.name).toBe('test base camp');
  });
});

Commit: feat(geofences): geofence entity with PostGIS polygon + spatial containment query


Task 18: audit_logs entity (write-only, never deleted)

// src/modules/audit-logs/audit-log.entity.ts
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';

export type ActorType = 'user' | 'tablet' | 'system';

@Entity({ name: 'audit_logs' })
@Index(['tenantId', 'occurredAt'])
@Index(['tenantId', 'entityType', 'entityId'])
export class AuditLog {
  @PrimaryGeneratedColumn('uuid')
  id!: string;

  @Column({ name: 'tenant_id', type: 'uuid' })
  tenantId!: string;

  @Column({ name: 'actor_type', type: 'enum', enum: ['user', 'tablet', 'system'] })
  actorType!: ActorType;

  @Column({ name: 'actor_id', type: 'uuid', nullable: true })
  actorId?: string | null;

  @Column({ length: 64 })
  action!: string;

  @Column({ name: 'entity_type', length: 64, nullable: true })
  entityType?: string | null;

  @Column({ name: 'entity_id', type: 'uuid', nullable: true })
  entityId?: string | null;

  @Column({ name: 'before_state', type: 'jsonb', nullable: true })
  beforeState?: any;

  @Column({ name: 'after_state', type: 'jsonb', nullable: true })
  afterState?: any;

  @Column({ name: 'ip_address', length: 64, nullable: true })
  ipAddress?: string | null;

  @Column({ name: 'user_agent', length: 256, nullable: true })
  userAgent?: string | null;

  @Column({ length: 32, nullable: true })
  source?: string | null;

  @CreateDateColumn({ name: 'occurred_at', type: 'timestamptz' })
  occurredAt!: Date;
}

(Note: audit_logs does NOT extend TenantScopedEntity because it has no soft-delete and no updated_at — audit log entries are immutable once written.)

Commit: feat(audit-logs): immutable audit_logs entity


Phase 3 — Auth scaffold (Tasks 19–21)

Task 19: Session-based auth for dashboard users

Files: - Create: src/modules/auth/auth.module.ts - Create: src/modules/auth/auth.service.ts - Create: src/modules/auth/auth.controller.ts - Create: src/modules/auth/dto/login.dto.ts - Create: src/modules/auth/strategies/session.strategy.ts - Create: src/modules/auth/guards/session-auth.guard.ts - Test: test/modules/auth/auth.service.spec.ts

  • [ ] Step 1: Install deps
npm install @nestjs/passport passport passport-local express-session bcrypt
npm install -D @types/passport-local @types/express-session @types/bcrypt
  • [ ] Step 2: Write auth.service.spec.ts test for password verification

Create test/modules/auth/auth.service.spec.ts:

import { AuthService } from '../../../src/modules/auth/auth.service';
import * as bcrypt from 'bcrypt';

describe('AuthService', () => {
  let usersRepo: any;
  let svc: AuthService;
  beforeEach(() => {
    usersRepo = { findOne: jest.fn() };
    svc = new AuthService(usersRepo as any);
  });

  it('returns user on correct password', async () => {
    const hash = await bcrypt.hash('correct-password', 10);
    usersRepo.findOne.mockResolvedValue({ id: 'u1', email: 'a@b.c', passwordHash: hash, tenantId: 't1', role: 'admin' });
    const result = await svc.validate('a@b.c', 'correct-password');
    expect(result?.id).toBe('u1');
  });

  it('returns null on wrong password', async () => {
    const hash = await bcrypt.hash('correct-password', 10);
    usersRepo.findOne.mockResolvedValue({ id: 'u1', email: 'a@b.c', passwordHash: hash, tenantId: 't1', role: 'admin' });
    const result = await svc.validate('a@b.c', 'wrong-password');
    expect(result).toBeNull();
  });

  it('returns null on unknown user', async () => {
    usersRepo.findOne.mockResolvedValue(null);
    const result = await svc.validate('nope@b.c', 'whatever');
    expect(result).toBeNull();
  });
});
  • [ ] Step 3: Run, watch fail
npm test -- auth.service

Expected: FAIL.

  • [ ] Step 4: Implement AuthService

Create src/modules/auth/auth.service.ts:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as bcrypt from 'bcrypt';
import { User } from '../users/user.entity';

export interface AuthenticatedUser {
  id: string;
  email: string;
  tenantId: string;
  role: string;
}

@Injectable()
export class AuthService {
  constructor(
    @InjectRepository(User) private readonly users: Repository<User>,
  ) {}

  async validate(email: string, password: string): Promise<AuthenticatedUser | null> {
    const user = await this.users.findOne({ where: { email } });
    if (!user) return null;
    const ok = await bcrypt.compare(password, user.passwordHash);
    if (!ok) return null;
    return { id: user.id, email: user.email, tenantId: user.tenantId, role: user.role };
  }
}
  • [ ] Step 5: Run test, watch pass
npm test -- auth.service

Expected: 3 tests PASS.

  • [ ] Step 6: Wire express-session + passport-local + login/logout endpoints

Create src/modules/auth/strategies/session.strategy.ts:

import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy, 'local') {
  constructor(private readonly auth: AuthService) {
    super({ usernameField: 'email' });
  }

  async validate(email: string, password: string) {
    const user = await this.auth.validate(email, password);
    if (!user) throw new UnauthorizedException();
    return user;
  }
}

Create src/modules/auth/guards/session-auth.guard.ts:

import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
  async canActivate(context: ExecutionContext): Promise<boolean> {
    const result = (await super.canActivate(context)) as boolean;
    const req = context.switchToHttp().getRequest();
    await new Promise<void>((resolve, reject) => {
      req.logIn(req.user, (err: any) => (err ? reject(err) : resolve()));
    });
    return result;
  }
}

@Injectable()
export class SessionGuard {
  canActivate(context: ExecutionContext): boolean {
    const req = context.switchToHttp().getRequest();
    return req.isAuthenticated && req.isAuthenticated();
  }
}

Create src/modules/auth/auth.controller.ts:

import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { LocalAuthGuard, SessionGuard } from './guards/session-auth.guard';
import { Request } from 'express';

@Controller('api/v1/auth')
export class AuthController {
  @UseGuards(LocalAuthGuard)
  @Post('login')
  login(@Req() req: Request) {
    return { user: req.user };
  }

  @UseGuards(SessionGuard)
  @Get('me')
  me(@Req() req: Request) {
    return { user: req.user };
  }

  @Post('logout')
  logout(@Req() req: Request) {
    return new Promise((resolve) => req.logout(() => resolve({ ok: true })));
  }
}

Wire express-session in src/main.ts:

import * as session from 'express-session';
import * as passport from 'passport';

// in bootstrap():
app.use(session({
  secret: process.env.SESSION_SECRET || 'dev-only-change-in-prod',
  resave: false,
  saveUninitialized: false,
  cookie: { httpOnly: true, sameSite: 'lax', secure: process.env.NODE_ENV === 'production' },
}));
app.use(passport.initialize());
app.use(passport.session());

Per feedback_swiftrise_dashboard_proxy memory: dev dashboard must call /api via Vite proxy (relative URL), NOT absolute VITE_API_URL — otherwise SameSite=Lax cookie drops. Document this in sentinel-cab-dashboard/README.md for the dashboard dev.

  • [ ] Step 7: Seed an admin user for Adong tenant in dev DB

Create src/database/seeds/0001-seed-tenant-admin.ts:

import { DataSource } from 'typeorm';
import * as bcrypt from 'bcrypt';

export async function seedTenantAdmin(ds: DataSource) {
  const tenantId = '00000000-0000-0000-0000-000000000001';
  await ds.query(
    `INSERT INTO tenants (id, code, name, site_country) VALUES ($1, 'adong', 'Adong (Runa)', 'ID') ON CONFLICT (id) DO NOTHING`,
    [tenantId],
  );
  const passwordHash = await bcrypt.hash('SeedAdongAdmin!', 10);
  await ds.query(
    `INSERT INTO users (id, tenant_id, email, name, password_hash, role)
     VALUES (gen_random_uuid(), $1, 'admin@adong.local', 'Adong Admin', $2, 'admin')
     ON CONFLICT DO NOTHING`,
    [tenantId, passwordHash],
  );
}

Add a npm run seed:dev script that imports + invokes this against the dev DB.

  • [ ] Step 8: Run seed against dev DB
DATABASE_URL=<dev-conn> npm run seed:dev
  • [ ] Step 9: Manual login test

Start the API: npm run start:dev. Then:

curl -i -X POST http://localhost:3010/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -H 'X-Tenant-Id: 00000000-0000-0000-0000-000000000001' \
  -d '{"email":"admin@adong.local","password":"SeedAdongAdmin!"}'

Expected: HTTP 201 + Set-Cookie: connect.sid=... + {"user":{...}}.

  • [ ] Step 10: Commit
git add -A
git commit -m "feat(auth): session-based auth for dashboard users + Adong admin seed"
git push origin main

Task 20: Device-bound JWT auth for tablets

Files: - Create: src/modules/auth/strategies/device-jwt.strategy.ts - Create: src/modules/auth/guards/device-auth.guard.ts - Create: src/modules/auth/device-token.service.ts - Test: test/modules/auth/device-token.service.spec.ts

  • [ ] Step 1: Install deps
npm install @nestjs/jwt passport-jwt
npm install -D @types/passport-jwt
  • [ ] Step 2: Write the failing test for token issuance + verification

Create test/modules/auth/device-token.service.spec.ts:

import { JwtService } from '@nestjs/jwt';
import { DeviceTokenService } from '../../../src/modules/auth/device-token.service';

describe('DeviceTokenService', () => {
  let svc: DeviceTokenService;
  beforeEach(() => {
    const jwt = new JwtService({ secret: 'test-secret' });
    svc = new DeviceTokenService(jwt);
  });

  it('issues a token containing the device id and tenant id', () => {
    const token = svc.issueForDevice({ deviceId: 'd1', tenantId: 't1', truckId: 'tr1' });
    expect(typeof token).toBe('string');
    const decoded = svc.verify(token);
    expect(decoded.deviceId).toBe('d1');
    expect(decoded.tenantId).toBe('t1');
    expect(decoded.truckId).toBe('tr1');
  });

  it('rejects a tampered token', () => {
    const token = svc.issueForDevice({ deviceId: 'd1', tenantId: 't1' });
    const bad = token.replace(/.$/, '');
    expect(() => svc.verify(bad)).toThrow();
  });
});
  • [ ] Step 3: Run, watch fail
npm test -- device-token
  • [ ] Step 4: Implement

Create src/modules/auth/device-token.service.ts:

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';

export interface DeviceTokenPayload {
  deviceId: string;
  tenantId: string;
  truckId?: string;
  iat?: number;
  exp?: number;
}

@Injectable()
export class DeviceTokenService {
  constructor(private readonly jwt: JwtService) {}

  issueForDevice(payload: { deviceId: string; tenantId: string; truckId?: string }): string {
    return this.jwt.sign(payload, { expiresIn: '90d' });
  }

  verify(token: string): DeviceTokenPayload {
    return this.jwt.verify<DeviceTokenPayload>(token);
  }
}
  • [ ] Step 5: Add the JWT strategy + guard

Create src/modules/auth/strategies/device-jwt.strategy.ts:

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class DeviceJwtStrategy extends PassportStrategy(Strategy, 'device-jwt') {
  constructor() {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      secretOrKey: process.env.JWT_SECRET || 'dev-only-change-in-prod',
    });
  }

  async validate(payload: any) {
    return {
      deviceId: payload.deviceId,
      tenantId: payload.tenantId,
      truckId: payload.truckId,
    };
  }
}

Create src/modules/auth/guards/device-auth.guard.ts:

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class DeviceJwtAuthGuard extends AuthGuard('device-jwt') {}
  • [ ] Step 6: Wire JwtModule in AuthModule, register strategy

Update src/modules/auth/auth.module.ts:

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { LocalStrategy } from './strategies/session.strategy';
import { DeviceJwtStrategy } from './strategies/device-jwt.strategy';
import { DeviceTokenService } from './device-token.service';
import { User } from '../users/user.entity';

@Module({
  imports: [
    TypeOrmModule.forFeature([User]),
    JwtModule.register({
      secret: process.env.JWT_SECRET || 'dev-only-change-in-prod',
      signOptions: { expiresIn: '90d' },
    }),
  ],
  controllers: [AuthController],
  providers: [AuthService, LocalStrategy, DeviceJwtStrategy, DeviceTokenService],
  exports: [DeviceTokenService],
})
export class AuthModule {}
  • [ ] Step 7: Add admin endpoint to provision a device + receive its token

Add to auth.controller.ts:

import { Body, Post, UseGuards } from '@nestjs/common';
import { SessionGuard } from './guards/session-auth.guard';
import { DeviceTokenService } from './device-token.service';
import { CurrentTenant } from '../../common/decorators/current-tenant.decorator';

// Constructor:
constructor(private readonly tokens: DeviceTokenService) {}

@UseGuards(SessionGuard)
@Post('device-tokens')
provision(
  @CurrentTenant() tenantId: string,
  @Body() body: { deviceId: string; truckId?: string },
) {
  const token = this.tokens.issueForDevice({
    deviceId: body.deviceId,
    tenantId,
    truckId: body.truckId,
  });
  return { token };
}
  • [ ] Step 8: Add /api/v1/health-device — protected by device JWT

Add to app.controller.ts:

import { UseGuards } from '@nestjs/common';
import { DeviceJwtAuthGuard } from './modules/auth/guards/device-auth.guard';

@UseGuards(DeviceJwtAuthGuard)
@Get('api/v1/health-device')
deviceHealth() {
  return { status: 'ok', context: 'device-authenticated' };
}
  • [ ] Step 9: Smoke test the JWT path

Start API. Login as admin to get a session, then provision a device:

curl -X POST http://localhost:3010/api/v1/auth/device-tokens \
  -H 'Content-Type: application/json' \
  -H 'X-Tenant-Id: 00000000-0000-0000-0000-000000000001' \
  -b cookie.txt \
  -d '{"deviceId":"00000000-0000-0000-0000-0000000000aa"}'

Expected: {"token":"eyJ..."}. Save the token.

curl -i http://localhost:3010/api/v1/health-device \
  -H "Authorization: Bearer <TOKEN>"

Expected: HTTP 200 with {"status":"ok","context":"device-authenticated"}.

  • [ ] Step 10: Commit
git add -A
git commit -m "feat(auth): device-bound JWT for tablets + provisioning endpoint"
git push origin main

Task 21: Verify CF Access policy works end-to-end on dashboard subdomain

  • [ ] Step 1: Open https://cab.jofnd.ai/health in a browser

Expected: redirected to Cloudflare Access OAuth, login with josepheffendy@gmail.com (or jofnd.ai email), then sees the /health JSON.

  • [ ] Step 2: Confirm device JWT path bypasses CF Access (it shouldn't)
curl -i https://cab.jofnd.ai/api/v1/health-device \
  -H "Authorization: Bearer <TOKEN>"

Expected: HTTP 302 to CF Access (because tablets can't do interactive OAuth, this won't work). Decision: for the v1 pilot, the tablet API surface (/api/v1/shifts/*, /api/v1/events/*, /api/v1/sync/*, /api/v1/health-device) needs a separate subdomain (e.g. cab-api.jofnd.ai) WITHOUT CF Access — protected only by device JWT. Document this and create the second tunnel route in Sprint 2.

Add a TODO at the top of pilot-adong-aug2026.md Sprint 2 section: "Split CF Tunnel to two hostnames: cab.jofnd.ai (CF Access + dashboard + admin) vs cab-api.jofnd.ai (no CF Access, device-JWT only)."

  • [ ] Step 3: Commit auth-related docs

Update sentinel-cab-api/README.md with auth model summary (session for dashboard via cab.jofnd.ai, JWT for tablets via cab-api.jofnd.ai). Commit.


Phase 4 — Mobile app foundation (Tasks 22–26)

Task 22: Initialize React+Vite project for the in-cab app

Files: - Create: ~/Code/sentinel-cab/sentinel-cab-app/ (entire repo)

  • [ ] Step 1: Initialize Vite + React + TS
cd ~/Code/sentinel-cab/sentinel-cab-app
npm create vite@latest . -- --template react-ts
npm install

When prompted for "Current directory not empty," choose Ignore files and continue.

  • [ ] Step 2: Update package.json identity
{
  "name": "sentinel-cab-app",
  "version": "0.1.0",
  "description": "Sentinel_Cab in-cab app (Capacitor + React+Vite, Galaxy XCover7)"
}
  • [ ] Step 3: Smoke-test in browser
npm run dev

Visit http://localhost:5173/. Expected: Vite + React default page renders.

  • [ ] Step 4: Commit
git add -A
git commit -m "chore: initial Vite+React+TS scaffold"
git push origin main

Task 23: Add Capacitor + Android platform

Files: - Modify: ~/Code/sentinel-cab/sentinel-cab-app/ - Create: ~/Code/sentinel-cab/sentinel-cab-app/android/

  • [ ] Step 1: Install Capacitor core + CLI + Android platform
cd ~/Code/sentinel-cab/sentinel-cab-app
npm install @capacitor/core @capacitor/cli @capacitor/android
  • [ ] Step 2: Initialize Capacitor
npx cap init "Sentinel Cab" "ai.jofnd.sentinelcab" --web-dir=dist

Expected: creates capacitor.config.ts.

  • [ ] Step 3: Build the web bundle and add Android platform
npm run build
npx cap add android

Expected: android/ directory created with Android Studio project.

  • [ ] Step 4: Configure capacitor.config.ts for v1 needs

Replace capacitor.config.ts content:

import { CapacitorConfig } from '@capacitor/cli';

const config: CapacitorConfig = {
  appId: 'ai.jofnd.sentinelcab',
  appName: 'Sentinel Cab',
  webDir: 'dist',
  server: {
    androidScheme: 'https',
    cleartext: false,
  },
  android: {
    allowMixedContent: false,
    overrideUserAgent: undefined,
  },
};

export default config;
  • [ ] Step 5: Build the APK in debug mode
npx cap sync android
npx cap open android

In Android Studio: Build → Build Bundle(s)/APK(s) → Build APK(s). Note path of generated APK (typically android/app/build/outputs/apk/debug/app-debug.apk).

  • [ ] Step 6: Install on bench XCover7

Connect XCover7 via USB. Enable Developer Options + USB Debugging on the phone. Then:

adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ai.jofnd.sentinelcab/ai.jofnd.sentinelcab.MainActivity

Expected: default Vite + React page renders on the XCover7.

  • [ ] Step 7: Commit
git add -A
git commit -m "feat: Capacitor + Android platform; APK installs on XCover7 bench"
git push origin main

Task 24: Build the v1 idle screen

Files: - Create: src/screens/IdleScreen.tsx - Create: src/components/StatusBar.tsx - Create: src/styles/tokens.css - Modify: src/App.tsx - Test: src/screens/__tests__/IdleScreen.test.tsx

  • [ ] Step 1: Install testing deps
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom

Add to vite.config.ts:

/// <reference types="vitest" />
// in defineConfig: test: { environment: 'jsdom', globals: true, setupFiles: ['./src/setupTests.ts'] }

Create src/setupTests.ts:

import '@testing-library/jest-dom';

Add to package.json scripts: "test": "vitest".

  • [ ] Step 2: Write failing test

Create src/screens/__tests__/IdleScreen.test.tsx:

import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { IdleScreen } from '../IdleScreen';

describe('IdleScreen', () => {
  it('shows the prompt and the truck plate', () => {
    render(<IdleScreen truckPlate="DT-001" online={true} pendingSyncCount={0} />);
    expect(screen.getByText(/tap your card/i)).toBeInTheDocument();
    expect(screen.getByText('DT-001')).toBeInTheDocument();
  });

  it('shows offline indicator when not online', () => {
    render(<IdleScreen truckPlate="DT-001" online={false} pendingSyncCount={3} />);
    expect(screen.getByText(/offline/i)).toBeInTheDocument();
    expect(screen.getByText(/3 pending/i)).toBeInTheDocument();
  });
});
  • [ ] Step 3: Run, watch fail
npm test -- IdleScreen
  • [ ] Step 4: Add design tokens

Create src/styles/tokens.css:

:root {
  --color-bg: #0b1a18;
  --color-surface: #11241f;
  --color-text: #f3f7f5;
  --color-text-muted: #b6c8c2;
  --color-accent: #4dd8a7;
  --color-warn: #f4c75a;
  --color-danger: #ef6a6a;
  --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --tap-min: 64px;
  --tap-comfy: 80px;
}

* { box-sizing: border-box; }
html, body, #root { margin: 0; padding: 0; height: 100%; background: var(--color-bg); color: var(--color-text); font-family: var(--font-sans); }
  • [ ] Step 5: Implement IdleScreen

Create src/screens/IdleScreen.tsx:

import './IdleScreen.css';

export interface IdleScreenProps {
  truckPlate: string;
  online: boolean;
  pendingSyncCount: number;
}

export function IdleScreen({ truckPlate, online, pendingSyncCount }: IdleScreenProps) {
  return (
    <main className="idle">
      <header className="idle__header">
        <span className="idle__truck">{truckPlate}</span>
        <span className={`idle__net ${online ? 'is-online' : 'is-offline'}`}>
          {online ? 'Online' : 'Offline'}
        </span>
        {pendingSyncCount > 0 && (
          <span className="idle__sync">{pendingSyncCount} pending</span>
        )}
      </header>
      <section className="idle__prompt">
        <div className="idle__icon">🪪</div>
        <h1 className="idle__title">Tap your card to start</h1>
        <p className="idle__sub">Sentuh kartu Anda untuk memulai shift</p>
      </section>
      <footer className="idle__footer">
        <time className="idle__clock">{new Date().toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })}</time>
      </footer>
    </main>
  );
}

Create src/screens/IdleScreen.css:

.idle { display: flex; flex-direction: column; height: 100vh; padding: 24px; }
.idle__header { display: flex; gap: 12px; align-items: center; font-size: 16px; }
.idle__truck { background: var(--color-surface); padding: 8px 16px; border-radius: 8px; font-weight: 700; color: var(--color-accent); }
.idle__net.is-online { color: var(--color-accent); }
.idle__net.is-offline { color: var(--color-warn); }
.idle__sync { background: var(--color-warn); color: var(--color-bg); padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 700; }
.idle__prompt { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; }
.idle__icon { font-size: 96px; margin-bottom: 24px; }
.idle__title { font-size: 32px; margin: 0 0 12px; }
.idle__sub { font-size: 18px; color: var(--color-text-muted); margin: 0; }
.idle__footer { text-align: center; padding-top: 16px; }
.idle__clock { font-size: 20px; color: var(--color-text-muted); }

Update src/App.tsx:

import './styles/tokens.css';
import { IdleScreen } from './screens/IdleScreen';

export default function App() {
  return <IdleScreen truckPlate="DT-DEV" online={true} pendingSyncCount={0} />;
}
  • [ ] Step 6: Run test, watch pass
npm test -- IdleScreen

Expected: PASS.

  • [ ] Step 7: Build APK and install on XCover7
npm run build
npx cap sync android
npx cap open android
# Build APK in Studio, then:
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
adb shell am start -n ai.jofnd.sentinelcab/.MainActivity

Expected: idle screen with "DT-DEV" plate, "Tap your card to start" + Bahasa subline, current time, online indicator.

  • [ ] Step 8: Commit
git add -A
git commit -m "feat(idle-screen): v1 idle screen with truck plate, network state, sync queue indicator"
git push origin main

Task 25: Capture HID-wedge RFID tap into a focused input

Files: - Create: src/hooks/useRfidInput.ts - Create: src/components/HiddenRfidInput.tsx - Modify: src/screens/IdleScreen.tsx - Test: src/hooks/__tests__/useRfidInput.test.ts

  • [ ] Step 1: Write failing test for the hook

Create src/hooks/__tests__/useRfidInput.test.ts:

import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRfidInput } from '../useRfidInput';

describe('useRfidInput', () => {
  it('calls onUid when an enter-terminated UID is typed', () => {
    const onUid = vi.fn();
    const { result } = renderHook(() => useRfidInput({ onUid }));
    act(() => {
      result.current.feedKey({ key: '0', preventDefault: () => {} } as any);
      result.current.feedKey({ key: '4', preventDefault: () => {} } as any);
      result.current.feedKey({ key: 'A', preventDefault: () => {} } as any);
      result.current.feedKey({ key: 'Enter', preventDefault: () => {} } as any);
    });
    expect(onUid).toHaveBeenCalledWith('04A');
  });

  it('debounces partial reads', () => {
    const onUid = vi.fn();
    const { result } = renderHook(() => useRfidInput({ onUid, idleResetMs: 500 }));
    act(() => {
      result.current.feedKey({ key: '0', preventDefault: () => {} } as any);
    });
    expect(onUid).not.toHaveBeenCalled();
  });
});
  • [ ] Step 2: Run, watch fail
npm test -- useRfidInput
  • [ ] Step 3: Implement the hook

Create src/hooks/useRfidInput.ts:

import { useCallback, useEffect, useRef } from 'react';

export interface UseRfidInputOptions {
  onUid: (uid: string) => void;
  /**
   * Idle reset timeout — if no key typed for this many ms, the buffer is cleared.
   * Protects against stale keystrokes from supervisor poking the device.
   */
  idleResetMs?: number;
}

export function useRfidInput({ onUid, idleResetMs = 250 }: UseRfidInputOptions) {
  const bufferRef = useRef<string>('');
  const timerRef = useRef<number | null>(null);

  const reset = useCallback(() => {
    bufferRef.current = '';
    if (timerRef.current) {
      window.clearTimeout(timerRef.current);
      timerRef.current = null;
    }
  }, []);

  const feedKey = useCallback(
    (event: KeyboardEvent | React.KeyboardEvent<HTMLInputElement>) => {
      // Allow letters/digits (DESFire UIDs are hex); ignore modifier keys.
      const key = event.key;
      if (key === 'Enter') {
        if (bufferRef.current.length > 0) {
          onUid(bufferRef.current);
        }
        reset();
        return;
      }
      if (key.length !== 1) return; // ignore Tab, Shift, Backspace, etc.
      if (!/^[0-9A-Fa-f]$/.test(key)) return; // hex chars only

      bufferRef.current += key.toUpperCase();
      if (timerRef.current) window.clearTimeout(timerRef.current);
      timerRef.current = window.setTimeout(reset, idleResetMs);
    },
    [onUid, idleResetMs, reset],
  );

  useEffect(() => () => reset(), [reset]);

  return { feedKey };
}
  • [ ] Step 4: Build a hidden input that always-on focus and feeds the hook

Create src/components/HiddenRfidInput.tsx:

import { useEffect, useRef } from 'react';
import { useRfidInput } from '../hooks/useRfidInput';

export interface HiddenRfidInputProps {
  onUid: (uid: string) => void;
}

export function HiddenRfidInput({ onUid }: HiddenRfidInputProps) {
  const inputRef = useRef<HTMLInputElement | null>(null);
  const { feedKey } = useRfidInput({ onUid });

  // Always-on focus — reader emulates keyboard, types into the focused field.
  useEffect(() => {
    const focusInput = () => inputRef.current?.focus();
    focusInput();
    const interval = window.setInterval(focusInput, 1000); // re-focus every 1s in case something stole focus
    document.addEventListener('click', focusInput);
    return () => {
      window.clearInterval(interval);
      document.removeEventListener('click', focusInput);
    };
  }, []);

  return (
    <input
      ref={inputRef}
      onKeyDown={feedKey}
      // visually hidden but still focusable
      style={{
        position: 'fixed',
        left: '-9999px',
        opacity: 0,
        height: 0,
        width: 0,
      }}
      autoFocus
      aria-hidden="true"
      tabIndex={-1}
    />
  );
}
  • [ ] Step 5: Wire into IdleScreen

Update src/App.tsx:

import { useState } from 'react';
import './styles/tokens.css';
import { IdleScreen } from './screens/IdleScreen';
import { HiddenRfidInput } from './components/HiddenRfidInput';

export default function App() {
  const [lastUid, setLastUid] = useState<string | null>(null);
  return (
    <>
      <HiddenRfidInput onUid={setLastUid} />
      <IdleScreen truckPlate="DT-DEV" online={true} pendingSyncCount={0} />
      {lastUid && (
        <div style={{
          position: 'fixed', bottom: 16, left: 16, right: 16,
          background: 'rgba(77, 216, 167, 0.2)', border: '1px solid var(--color-accent)',
          padding: '12px 16px', borderRadius: 8, fontFamily: 'monospace', fontSize: 14,
        }}>
          Captured UID: <strong>{lastUid}</strong> (length {lastUid.length})
        </div>
      )}
    </>
  );
}
  • [ ] Step 6: Run tests, build, install
npm test
npm run build
npx cap sync android
# Build APK in Studio, install on XCover7
  • [ ] Step 7: Bench-test the RFID input

With XCover7 plugged into ACR122U via USB-OTG with PD passthrough: 1. Open the app — idle screen visible 2. Tap a DESFire EV3 card on the reader 3. Reader buzzes + LEDs green 4. App shows "Captured UID: 0419BA32A77380 (length 14)" — confirms 7-byte UID = 14 hex chars

Expected: UID appears within ~200ms of card tap. Try 5 different cards in succession to confirm reliable parsing + reset between taps.

If UID does not appear: check (a) is the reader showing visible LED + buzzer, (b) does Android recognize the OTG device (Settings → Connections → USB), (c) is the hidden input still focused (try clicking the screen), (d) is the reader actually in HID-wedge mode.

  • [ ] Step 8: Commit
git add -A
git commit -m "feat(rfid): HID keyboard-wedge input handler with always-on focus + visible UID readout for bench testing"
git push origin main

Task 26: Knox kiosk lock setup (configuration only — no code)

Files: - Create: ~/Code/sentinel-cab/sentinel-cab-app/docs/knox-kiosk-setup.md

  • [ ] Step 1: Sign up for Samsung Knox Manage trial

Visit https://www.knox.samsung.com/. Create a Knox account using a business email. Start a 90-day trial of Knox Manage.

  • [ ] Step 2: Enroll the bench XCover7 in Knox Manage

Per Knox Manage docs (which the sub-skill should fetch via the find-docs skill if needed): generate an enrollment QR, factory-reset the XCover7, scan QR during setup wizard.

  • [ ] Step 3: Configure kiosk policy

In Knox Manage console: - Create kiosk profile pinning ai.jofnd.sentinelcab as the only launchable app - Disable Settings access, recent apps button, status bar pull-down - Allow brightness control (drivers may need to adjust for sun/glare)

Apply to the bench XCover7.

  • [ ] Step 4: Verify kiosk lock works

Reboot the XCover7 — should boot directly into Sentinel_Cab app with no Android home screen reachable.

  • [ ] Step 5: Document the process

Create docs/knox-kiosk-setup.md capturing the enrollment QR generation steps, profile config, and rollback procedure (in case kiosk needs to be released for service).

  • [ ] Step 6: Commit
git add -A
git commit -m "docs(knox): kiosk lock setup procedure for XCover7 production fleet"
git push origin main

Phase 5 — D-026 hardware exit criteria (Tasks 27–29)

Task 27: Glove-tap reliability test

Files: - Create: ~/Documents/01_Local_Work/CoWork/Argus_IIOP/Sentinel_Cab/bench-test-results/2026-05-XX-glove-tap.md

  • [ ] Step 1: Procure test gloves

Use industrial work gloves typical of mining operations — leather/synthetic with conductive fingertips if available, plain cotton/nitrile otherwise. 2–3 different glove types if possible.

  • [ ] Step 2: Set up dim cab simulation

Indoor room with overhead lights off, single small lamp at 30° angle to simulate dim cab. XCover7 in desktop stand at typical dash angle.

  • [ ] Step 3: Run 100-tap reliability test

For each glove type (and bare hand control): - Open app, focus an "End Shift" mock button (1/3 width of screen at the bottom) - Tap 100 times - Record: first-tap success / second-tap success / fail - Target: ≥95% first-tap success in any glove type

  • [ ] Step 4: Document results

Write bench-test-results/2026-05-XX-glove-tap.md with: - Date, tester, glove types tested - Results table (gloves × first-tap success / second-tap / fail) - Photos of test setup - PASS/FAIL determination - Any UX implications for buttons (size, spacing, timing)

  • [ ] Step 5: Commit
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/bench-test-results/
git commit -m "test(sentinel-cab): D-026 glove-tap reliability bench test results"

Task 28: Cradle vibration test

Files: - Create: Argus_IIOP/Sentinel_Cab/bench-test-results/2026-05-XX-cradle.md

  • [ ] Step 1: Order RAM Mounts X-Grip Quick Release

If not already in bench kit. Search Tokopedia: RAM Mounts X-Grip Quick Release. Get the ball-mount + base + cradle bundle for ~Rp 700k–1M.

  • [ ] Step 2: Mount XCover7 in cradle

Confirm: positive lock (cradle clamps device, no slop), USB-C plug clears cradle frame for charging, secondary tether attachment point exists or can be added (e.g. coiled lanyard from cradle to XCover7 case).

  • [ ] Step 3: Vibration simulation

Either: take to a workshop and bolt the cradle to a vibrating panel for 30 min; OR mount in a personal car and drive over rough roads for 30 min; OR use a cheap 12V vibrating massager bolted to a board for desktop vibration test.

Observe: does the XCover7 stay in the cradle, does the USB-C connection stay made, does Knox kiosk persist.

  • [ ] Step 4: Document results

Write bench-test-results/2026-05-XX-cradle.md with: - Cradle model + price + Tokopedia source - Vibration test method - Pass/fail observations - Recommended cradle for pilot install (with brand + URL/SKU)

  • [ ] Step 5: Commit

Task 29: Thermal test in closed car

  • [ ] Step 1: Prepare test setup

XCover7 in cradle, cable to ACR122U (representing realistic load), ACR122U connected (no cards being read). Park car in midday sun, windows up. Outside temp recorded.

  • [ ] Step 2: Record start temp + state

Smartphone weather app for ambient. XCover7 battery temp via Settings → Battery → (or adb shell dumpsys battery). Record both.

  • [ ] Step 3: Run app continuously for 4 hours

Idle screen left on. Periodic wakeups every 5 min via screen-on broadcast (or just watch and prevent sleep). Record temp every 30 min.

  • [ ] Step 4: Watch for thermal throttling

Check if app remains responsive, battery temp stays below 50°C, no thermal-shutdown message. CPU throttling indicators via adb shell dumpsys thermalservice.

  • [ ] Step 5: Document results

Write bench-test-results/2026-05-XX-thermal.md with: ambient temp profile, device temp profile, any throttling observed, PASS/FAIL.

  • [ ] Step 6: Commit

Phase 6 — Sprint 1 demo (Task 30)

Task 30: Sprint 1 demo + retrospective + Sprint 2 kickoff

  • [ ] Step 1: Verify all Sprint 1 outcomes pass

Walk through the "Sprint 1 outcome" checklist (top of this doc). Each item must demonstrably work.

  • [ ] Step 2: Record a 5-minute demo video

Screen + voice: navigate the dashboard auth flow (CF Access → session login → /me), provision a tablet token, hit /api/v1/health-device with the token. Then film the XCover7: idle screen, RFID tap, UID appears.

  • [ ] Step 3: Write Sprint 1 retrospective

Create Argus_IIOP/Sentinel_Cab/sprint-retros/sprint-1-retro.md answering: - What shipped vs planned (gap analysis) - D-026 exit criteria results (all pass / any concerns) - New decisions surfaced during sprint (added to decisions.md) - Sprint 2 dependencies / risks - Team capacity reality check

  • [ ] Step 4: Update decisions.md with any new decisions

If any architectural fork was resolved during Sprint 1 (repo structure, backend host, specific cradle vendor), capture as new D-NNN entries.

  • [ ] Step 5: Kick off Sprint 2 plan writing

Trigger Sprint 2 detailed plan writing (the writing-plans skill again, scoped to Sprint 2: CRUD + shift lifecycle).

  • [ ] Step 6: Commit demo + retro
cd ~/Documents/01_Local_Work/CoWork
git add Argus_IIOP/Sentinel_Cab/sprint-retros/ Argus_IIOP/Sentinel_Cab/decisions.md
git commit -m "docs(sentinel-cab): Sprint 1 retrospective + decisions update"

Outline of Sprints 2–8

Each subsequent sprint will get its own detailed plan via the writing-plans skill at sprint kickoff. Outline below is task-level scope only.

Sprint 2 (May 26 – Jun 8) — CRUD + shift lifecycle

  • Admin CRUD modules: drivers, cards, trucks, geofences, assignments
  • DTOs + class-validator + repository pattern
  • Shift lifecycle endpoints: start-attempt, end, events
  • Eligibility engine (D-015 + D-022 + D-024) — primary/backup/unscheduled, multi-shift detection, breakdown/handover continuation lookups
  • shifts table migration with all D-022/D-024 columns
  • shift_events table + idempotent ingestion
  • Tablet calls happy-path against backend
  • Split CF Tunnel: cab.jofnd.ai (CF Access + dashboard) vs cab-api.jofnd.ai (no CF Access, JWT-only)

Sprint 3 (Jun 9–22) — Selfie + risk engine + exception detector — DESIGN FREEZE Jun 15

  • Selfie capture flow on tablet (camera, stationary check, retake, EXIF)
  • Selfie upload to R2; photo_verifications table
  • Risk-trigger engine (3 v1 rules): random ~15%, reassignment shift, prev-low-trust
  • Exception detector: 9 v1 exception types
  • State machine fully implemented; all 7 trust states reachable
  • Continuation flows tested (breakdown + handover)

Sprint 4 (Jun 23 – Jul 6) — Dashboard + offline sync + geofence onboarding

  • Dashboard "Now" view with breakdown + handover counters
  • Selfie review queue
  • Exception inbox
  • Shift detail with timeline + continuation chain panel
  • Down-sync delta endpoint + tablet local cache (SQLite via Capacitor)
  • Up-sync batch endpoint + tablet event queue
  • Haul route mapping (D-025) — half-day satellite-imagery trace
  • Geofence seed data: Adong base camp + loading + dumping polygons
  • Card face design locked (D-011)

Sprint 5 (Jul 7–20) — Hardening + Capacitor production build

  • Production APK build + Knox kiosk lock testing
  • End-to-end test: full shift cycle online + offline + reconnect
  • Performance pass: cold start <3s, RFID-tap-to-active <2s, dashboard "Now" view <1s
  • Security review
  • Audit log coverage check
  • Card-print order placed

Sprint 6 (Jul 21 – Aug 3) — UAT + install rehearsal

  • UAT script (20+ scenarios)
  • Joseph + 1 tester runs full UAT
  • Bug fixes
  • Install rehearsal on 1 mocked truck
  • Training material: driver flyer, supervisor walkthrough, admin training
  • Pilot hardware delivered to Adong

Sprint 7 (Aug 4–17) — Install + go-live

  • Install trucks 1–5 (Week 13, 2/day)
  • Install trucks 6–10 (Week 14)
  • Card distribution + driver briefings + supervisor onboarding
  • GO-LIVE

Sprint 8 (Aug 18–31) — Stabilization

  • Daily on-call support (14 days)
  • Daily exception inbox flush with supervisor
  • Week-1 retro (Aug 24)
  • Week-2 retro (Aug 31) — first qualitative signals

Self-review checklist (run before handing this plan to the dev team)

1. Spec coverage — every D-001 → D-026 decision is reflected somewhere in the plan: - D-001 → D-008: foundation tasks (1–8) - D-009 → D-011: card issuance (Sprint 2 admin) — outline note in Sprint 2 - D-010: DESFire EV3 in bench kit (Task 1) and entity defaults (Task 13) - D-012, D-013: standalone API + multi-tenant (Tasks 6, 7, 10–17) - D-014: R2 bucket creation (Task 4) — code lands Sprint 3 - D-015: assignments entity (Task 16) — eligibility engine Sprint 2 - D-017: dual auth (Tasks 19–21) + cab.jofnd.ai vs cab-api.jofnd.ai split (Sprint 2 outlined) - D-022 / D-024: schema columns prepared in Sprint 2 outline - D-023 / D-025: zone enum in Task 17; haul route mapping in Sprint 4 outline - D-026: XCover7 in Task 1 + 22 + 23 + 27–29 + Task 25 entity name

2. Placeholder scan — none. All TBDs are explicitly carried as Sprint 2+ outline items, not in-Sprint-1 work.

3. Type consistency — entity names match across tasks: Driver / RfidCard / Truck / TabletDevice / RfidReader / TruckDriverAssignment / Geofence / User / Tenant / AuditLog. tenantId lowercase camelCase in TS, tenant_id snake_case in SQL — consistent.

Bench Procurement

Sentinel_Cab — Bench Test Procurement (1 setup)

Date: 2026-05-11 Purpose: Single bench setup for prototyping the Capacitor app, validating reader ergonomics, and choosing between LF/HF card formats before committing to 10-truck pilot procurement. Source: Tokopedia / Shopee (Jakarta delivery, 1–3 days) Total budget: ~Rp 10–12 M Owner: Joseph (or assignee) Recipient: delivery to Joseph's working location for hands-on testing

All product links and exact seller picks intentionally omitted — verify the seller before paying. Use the search terms below in the Tokopedia / Shopee app, then sort by "Penjualan Terbanyak" (most sold) and prefer sellers with >500 transactions, >4.8 rating, Powerbadge or Mall.


Item 1 — Rugged Android phone (D-026: swapped from tablet)

Buy: Samsung Galaxy XCover7 - Model code: SM-G556B (international XCover7 — confirm Indonesian SKU matches) - Storage: 128 GB (offline event queue + photo cache + OTA buffer) - RAM: 6 GB (Capacitor + Chrome WebView is memory-hungry; 6 GB is the XCover7 standard) - Network: WiFi for bench; LTE/5G for pilot - Color: any

Search terms (Tokopedia/Shopee): - Samsung Galaxy XCover7 - Samsung XCover7 5G - SM-G556 6/128

Expected price (May 2026, Jakarta): - Rp 5 – 6.5 M new, official BNIB - Avoid: refurbished / second-hand / "garansi distributor" — for production we want official Samsung Indonesia warranty (TAM)

What to verify before paying: - Listed as garansi resmi SEIN/TAM (Samsung Electronics Indonesia / Trio Adi Mulya), not "garansi toko" - Box-sealed (BNIB), all accessories included - Seller is Samsung Official Store, Mall sellers, or established retailers (Bhinneka, Erafone, etc.) - Confirm device is XCover7 (NOT XCover6 Pro or XCover Pro — those are older generations)

Why this phone (per D-026): - IP68 dust/water + MIL-STD-810H — survives cab dust and vibration - Replaceable battery (driver shift change → hot-swap option later) - USB-C with OTG + PD passthrough — reader stays powered while phone charges - Knox kiosk mode built-in — lock to Sentinel_Cab app only - Programmable XCover side key — can be OS-mapped to "End Shift" tactile button - 5+ years of Android updates (Samsung enterprise commitment) - Strong enterprise channel for bulk pilot order - 40–50% cheaper than Tab Active5 with no operational compromise for the v1 use case

Bench-test focus areas (Sprint 1 exit criteria, per D-026): 1. Glove-tap reliability — confirm full-width buttons hit reliably with industrial gloves 2. Vehicle cradle quality — confirm RAM Mounts X-Grip (universal) or XCover-specific cradle is rugged enough for industrial cab vibration. Source links: Tokopedia search RAM Mounts X-Grip Quick Release or Brodit XCover holder 3. Thermal behavior — leave phone in cradle in a parked car midday (~40°C cabin temp) for 4 hours running our app; confirm no thermal throttling

Alternative (only if XCover7 stockout or fails bench): Honeywell ScanPal CT45 ~Rp 8–10M (more enterprise, more expensive but still phone-class). Last resort: revert to Tab Active5 tablet (forfeits the D-026 cost savings).


Item 2 — RFID Reader A (HF 13.56 MHz / NFC / MIFARE)

Buy: ACS ACR122U - Model code: ACR122U-A9 - Comes with USB-A cable (we'll add OTG adapter for Android — see Item 5)

Search terms: - ACR122U - ACS ACR122U NFC reader - Reader RFID NFC MIFARE 13.56

Expected price: Rp 700 k – 1 M

What to verify before paying: - Includes original USB cable - Genuine ACS branding (not unbranded clone — clones have flaky drivers on Android) - Seller mentions PC/SC support (means it's the real driver-stack model, not cosmetic clone) - Working on Android via USB-OTG (some sellers claim Windows-only — those are clones)

Why this reader for bench: - Industry-standard prototype reader, every NFC tutorial uses it - Supports MIFARE Classic, MIFARE DESFire, ISO 14443 A/B - HID keyboard mode available (firmware) AND PC/SC mode (richer Capacitor plugin path) - Cheap and replaceable

NOT for cab install — plastic enclosure, USB-A connector vulnerable to vibration. Pilot install will use industrial-grade Sycreader R30D / HID Omnikey 5022.


Item 3 — REMOVED

Updated 2026-05-11: Decision D-010 locked MIFARE DESFire EV3 (HF 13.56 MHz) as the production chip. LF 125 kHz reader is no longer needed for bench — Sentinel_Cab is HF-only. Saved ~Rp 200–500k. If we ever want to demo "why your existing LF site card won't work with Sentinel_Cab," buy one then.


Item 4 — Test cards (HF only — two grades)

We need two grades of test cards on bench: - 5× MIFARE DESFire EV3 — the actual production card type. Use these to confirm UID format (7-byte), read distance, tap reliability, and to develop the v2 mutual-auth Capacitor plugin against the real chip. - 10× MIFARE Classic 1K — cheap dev/test cards. Use these for repetitive UID-tap testing so the expensive DESFire cards don't get scratched up during prototyping. Both card types work identically with the ACR122U in UID-only / HID-wedge mode.

Buy 4a: MIFARE DESFire EV3 cards, 5-pack

Search terms: - MIFARE DESFire EV3 card - MIFARE DESFire EV3 4K - DESFire EV3 8K (8K version is OK too — only Rp 5–10k more per card)

Expected price: Rp 25 – 50 k per card × 5 = Rp 125 – 250 k

What to verify before paying: - Chip is DESFire EV3 (NOT EV1, NOT EV2 — EV3 is current generation, has the latest crypto) - Genuine NXP — ask the seller for NXP authentication - Blank cards (no pre-personalized printing — we'll print later via a Jakarta bureau) - 4K or 8K memory size (either is fine for our use)

Why this matters: the bench test for DESFire EV3 confirms read distance on the actual production chip. DESFire's RF signature is slightly different from Classic, and we want to know if the tap zone size shifts before we lock the cradle mounting position.

Buy 4b: MIFARE Classic 1K, 10-pack (dev cards)

Search terms: - Kartu RFID MIFARE Classic 1K 10pcs - MIFARE 1K card blank

Expected price: Rp 80 – 150 k

What to verify: - Confirms 1K (not 4K) - Plain white cards OK (no printed design needed for dev cards)

Also acceptable: a few MIFARE Classic keyfob units (round plastic with hole) for ~same per-unit price. Keep both form factors so we can test which is more cab-friendly during ergonomic prototyping.


Item 5 — USB-C OTG adapter with PD passthrough

Buy: Ugreen brand USB-C to USB-A OTG adapter with charging passthrough - Ugreen model 30205 or similar PD-passthrough hub

Search terms: - Ugreen USB-C OTG adapter PD passthrough - Ugreen USB-C hub OTG charging - OTG USB-C USB-A charging passthrough

Expected price: Rp 100 – 250 k

What to verify: - Explicitly states PD passthrough or "charging while OTG" — without this, reader plugged in = tablet not charging - Ugreen brand preferred (cheap clones sometimes don't actually pass PD through) - USB-C male side, USB-A female side

Why this matters: in-cab the tablet has to charge off truck power while the reader is plugged into its only USB port. Without PD passthrough, it's one or the other, and battery dies during a long shift.


Item 6 — Bench tablet stand

Buy: any sturdy desktop tablet stand (~Rp 200 – 500 k) - Adjustable angle preferred (so we can simulate dashboard mounting at different angles) - Aluminum > plastic for stability

Search terms: - Stand tablet desktop adjustable aluminum

Expected price: Rp 200 – 500 k

Don't buy a vehicle/cradle mount yet — wait until we test ergonomics on bench and decide on RAM Mounts vs Brodit for pilot install.


Total

Item Item description Price (IDR)
1 Samsung Galaxy XCover7 WiFi 6/128 (D-026 swap) 5,000,000 – 6,500,000
2 ACS ACR122U HF reader 700,000 – 1,000,000
3 ~~EM4100 LF reader~~ — removed (D-010 locked HF-only)
4a MIFARE DESFire EV3 cards ×5 (production chip) 125,000 – 250,000
4b MIFARE Classic 1K cards ×10 (dev/test cards) 80,000 – 150,000
5 Ugreen USB-C OTG with PD passthrough 100,000 – 250,000
6 Desktop phone stand (or universal RAM Mounts X-Grip for bench rehearsal) 200,000 – 700,000
TOTAL ~6.2 – 8.9 M

Savings vs original Tab Active5 bench kit: ~Rp 3.5–4 M.

Delivery: 1–3 days within Jakarta if you order today.


Receiving / unboxing checklist

When parcels arrive, validate before signing off:

Phone (XCover7): - [ ] Box sealed (BNIB), Samsung TAM warranty card present - [ ] Powers on, completes setup, runs latest Android update - [ ] NFC works (tap any NFC tag against back — confirms internal radio works as fallback) - [ ] USB-C OTG works (plug a USB stick in via the OTG adapter, see if file manager picks it up) - [ ] XCover side key registers in Android settings as remappable - [ ] Front camera 5MP, autofocus works in low light - [ ] Knox enterprise enrollment screen accessible (Settings → Knox)

ACR122U: - [ ] LED lights when plugged in (red/green flashes) - [ ] Beep on card tap (test with a MIFARE card) - [ ] Recognized by Android via OTG adapter (use any free NFC reader app to confirm)

DESFire EV3 cards: - [ ] All 5 cards scan with ACR122U - [ ] UIDs are 7 bytes long (vs 4 bytes for Classic — confirms genuine DESFire) - [ ] UIDs are unique per card - [ ] Cards register as "DESFire EV3" in any free NFC inspection app (e.g. NFC Tools Pro)

MIFARE Classic 1K dev cards: - [ ] All 10 cards scan with ACR122U - [ ] UIDs are unique per card

If any item fails the checklist, return immediately within Tokopedia 7-day return window — we don't want to discover problems mid-build.


What we'll learn from the bench setup (target: 1 week of testing)

  1. DESFire EV3 read characteristics on production chip — tap zone size, read distance (typical 1–3 cm for HF), LED/buzzer feedback audibility, time-to-read, glove-tap reliability
  2. ACR122U behavior in HID-wedge mode on Android via Capacitor — focus management, race conditions on rapid taps, what defensive UX we need (debounce, focus restoration, error timeout)
  3. PD passthrough power budget — can the Tab Active5 run 8 hours on cradle power while the reader is plugged in and actively reading?
  4. Camera capture quality — front-camera selfie under simulated dim cab lighting, exposure lock, file size, capture latency
  5. Offline storage capacity — how much event log + photo cache fits in Capacitor secure storage before sync; what's the eviction story
  6. Capacitor APK build & install on Tab Active5 — Knox kiosk mode setup, signed APK install, OTA update path

These six answers feed the design freeze (mid-June) and unblock the 10-truck pilot procurement order.

Source Brief

Sentinel_Cab — Context Brief

Source: AI agent handoff received from Joseph, 2026-05-11 Pillar: Argus_IIOP / Sentinel_Cab (third pillar — alongside Sentinel_Fleet and Sentinel_Edge) Status: Source brief, pre-design. Not yet shaped into product spec or implementation plan.

This document is preserved verbatim as the originating handoff. Subsequent design work (strategy.md, product-plan.md, pilot-plan.md, etc.) is layered on top — do not edit this file once design begins; treat it as the canonical "as-received" spec for traceability.


AI Agent Handoff: Coal Hauling Driver Shift Verification System

Mission

Build a production-ready v1 system for coal mining hauling operations that controls driver shift start using mandatory in-cab RFID authentication and performs random or risk-triggered photo verification for audit and anti-sharing control.

The system must be designed for harsh field conditions, inconsistent connectivity, and fast operational rollout.

Core outcome

The system must answer these questions with high confidence:

  • Who is driving the truck right now?
  • Did that driver validly start the shift?
  • Which truck and route is the shift tied to?
  • Was the identity verified strongly enough for audit?
  • Which sessions need supervisor review?

Operating context

This is for coal mining hauling.

Operational realities: - Trucks operate in remote mining environments with inconsistent signal. - Drivers start shifts in the truck cab, not at a central kiosk. - Shift control must not overly slow operations. - Auditability matters because RFID card sharing is possible. - The business wants a practical v1 first, not a bloated enterprise system.

Non-negotiable product rules

  1. RFID is mandatory to start shift.
  2. The RFID read happens in-cab using a truck-mounted reader.
  3. Each truck has an in-cab rugged Android tablet in a powered mount/cradle.
  4. The system may randomly or risk-trigger a photo/selfie challenge after RFID success.
  5. Photo challenge must only happen while the truck is stationary.
  6. The system must work offline-first and sync later.
  7. The system must maintain a durable event trail for audit.
  8. Supervisor dashboard must show active, low-trust, pending, and flagged shifts.

Hardware direction

Each truck should have:

  • 1 rugged Android tablet
  • 1 powered in-cab mount/cradle
  • 1 RFID reader connected to the tablet (integrated reader or external reader)
  • Front-facing camera on the tablet for photo verification
  • Hardwired truck power connection

Preferred physical flow: - Driver enters truck - Tablet wakes up / shift screen visible - Driver taps RFID card - System validates card + driver + truck context - System either activates shift or requests selfie

Reader placement rule: - Reader must be close enough for easy tap - Reader should not be mounted too close to ignition/key area to avoid accidental reads

Product intent

This is not just a time attendance app. It is a driver identity and shift validity system for mining hauling operations.

The v1 focus is: - identity control - shift start validity - shift end validity - basic route/zone awareness - operational exceptions - audit trail

The v1 does not need to fully solve: - payroll - full fatigue management - telematics/CAN integration - contractor billing - weighbridge reconciliation - production optimization

Primary user roles

1. Driver

Uses in-cab tablet to: - start shift - end shift - respond to selfie challenge - view basic shift status - optionally select route / reason code

2. Supervisor / dispatcher

Uses dashboard to: - view active shifts - review pending selfie or low-trust cases - override when necessary - investigate exceptions - see who is in which truck

3. Admin

Uses admin panel to: - manage drivers - manage RFID cards - manage trucks - manage routes - manage geofences - manage challenge rules - manage site configuration

Desired architecture

Use a stack aligned to the organization's preferred architecture:

  • Frontend: Next.js / mobile-first webapp or app shell
  • Backend: Postgres + API layer
  • Prefer Supabase/Postgres style data model
  • Event-driven design
  • Offline-first tablet workflow
  • Rules layer for challenge logic and exceptions
  • Supervisor dashboard as web interface

Important design principle: Every important action should emit an event.

Core workflow

Shift start

  1. Tablet is mounted in truck and linked to truck identity.
  2. Driver taps RFID card on reader.
  3. App reads RFID UID.
  4. Backend or local cache verifies: - card exists - card is active - card belongs to a valid driver - driver is active - driver is authorized for truck/site/class if such restrictions exist - truck has no conflicting active shift
  5. App asks for route/shift selection if required.
  6. Challenge engine decides: - no challenge -> activate shift as verified - challenge required -> move to pending selfie
  7. If selfie required, capture front camera photo while stationary.
  8. Shift becomes either: - active_verified - active_low_trust - flagged

Shift end

  1. Driver taps End Shift.
  2. App records timestamp, last GPS, truck, and shift state.
  3. If session had unresolved challenge or verification issue, route to supervisor review.
  4. If driver forgets to end shift, system creates exception and may auto-suggest closure later.

Shift trust model

Do not design this as only pass/fail. Use trust states.

Required states: - idle - rfid_verified - pending_selfie - active_verified - active_low_trust - ended - flagged

Reason: operations may need continuity even when verification is incomplete, but those sessions must remain reviewable.

Challenge logic

The photo challenge should support both random and risk-based prompting.

Random challenge

Initial default target: ~15% of shift starts.

Risk-triggered challenge examples

Trigger photo verification when: - first shift of day - unusual shift time - unusual truck assignment - repeated failed RFID reads before success - previous session was low-trust - suspicious prior route pattern - prior camera failure - device integrity issue

Photo challenge rules

  • must only trigger when truck is stationary
  • should have grace period (example: 3 minutes)
  • should store image reference and audit metadata
  • should never repeatedly spam the driver
  • should support manual supervisor review

Offline-first requirements

This is important.

The truck may have weak or no connectivity. So the tablet must be able to: - authenticate using local synced cache where appropriate - queue events locally - store pending uploads - continue workflow offline - sync later without creating duplicate records

Need idempotent event ingestion. Need conflict-safe syncing. Need local state persistence.

Geofence/location v1

Include only essential zones initially: - mine gate - parking/staging - workshop - fuel bay - pit loading area - ROM stockpile - weighbridge - crusher/dump point

Location use in v1: - timestamp context - start/end context - exception analysis - later analytics

Do not overbuild route optimization in v1.

Exceptions that must exist

Create explicit exception records for: - truck moving without active authenticated shift - one driver with multiple active shifts - one truck with multiple active drivers - missing shift end - selfie timeout - selfie failed / rejected - GPS unavailable at start - suspicious RFID usage pattern - shift start outside approved area without reason code

Minimum data model

The solution should include at least these entities:

  • drivers
  • driver_rfid_cards
  • trucks
  • tablet_devices
  • rfid_readers
  • routes
  • geofences
  • shifts
  • shift_events
  • photo_verifications
  • exceptions
  • audit_logs

Minimum event types

The system should emit events like:

  • rfid_read_success
  • rfid_read_failure
  • shift_start_requested
  • selfie_challenge_issued
  • selfie_submitted
  • selfie_timeout
  • shift_activated_verified
  • shift_activated_low_trust
  • gps_ping
  • geofence_enter
  • geofence_exit
  • shift_end_requested
  • shift_ended
  • supervisor_override
  • exception_created
  • exception_resolved

Supervisor dashboard requirements

The dashboard should clearly show:

  • all active trucks
  • current driver per truck
  • trust state per shift
  • pending selfie reviews
  • low-trust sessions
  • missing check-outs
  • flagged trucks/drivers
  • exception counts by site and shift

The dashboard must prioritize operational clarity over fancy UI.

UX principles

For drivers

  • minimal taps
  • large buttons
  • clear prompts
  • can be used with gloves/dusty environment in mind
  • no long forms
  • no confusing failure states

For supervisors

  • fast scanning of exceptions
  • color-coded trust states
  • easy override path with reason logging
  • simple review queue

For admins

  • easy mapping of RFID cards to drivers
  • easy device-to-truck mapping
  • editable challenge rate and rules

Security and audit

Must retain audit records for: - RFID tap events - card-to-driver mappings - shift state changes - photo challenge issuance - photo submission - overrides - admin edits

This system is an operational control system, so auditability matters almost as much as UX.

Suggested implementation phases

Phase 0 - design freeze

Define: - driver master data - truck master data - RFID card issuance model - truck-to-tablet mapping - truck-to-reader mapping - site geofences - trust rules - challenge rate - override policy

Phase 1 - pilot

Deploy to 5-10 trucks: - mandatory RFID start - offline queue - basic shift states - random selfie at low rate - basic dashboard - basic exceptions

Phase 2 - operational hardening

Add: - risk-triggered challenge rules - better review workflow - stronger analytics - admin tools - better offline recovery

Phase 3 - expansion

Add later if needed: - telematics - weighbridge integration - richer route analytics - contractor support - automated face matching

What the AI agent should produce next

The next AI agent should take this handoff and produce:

  1. system architecture
  2. database schema
  3. API contract
  4. state machine
  5. supervisor dashboard spec
  6. tablet UX flow
  7. offline sync design
  8. implementation plan by sprint
  9. hardware integration approach for RFID + camera
  10. risk register / edge cases

Constraints

  • Keep v1 small and shippable
  • Avoid enterprise bloat
  • Optimize for mine operations reality
  • Offline-first is mandatory
  • RFID start is mandatory
  • Photo verification is secondary but important
  • Trust-state model is required
  • Everything critical must be auditable

Final instruction to the next AI agent

Do not redesign this into a generic attendance app. This is a coal hauling in-cab driver verification and shift control system.

The design priority order is: 1. operational practicality 2. identity assurance 3. auditability 4. speed of rollout 5. extensibility later