16
API Controllers
31
Frontend Pages
20+
Data Models
44+
DB Migrations
1

High-Level Architecture

🌐
Cloudflare
DNS + CDN
β†’
πŸ”’
Nginx
SSL + Proxy
β†’
βš›οΈ
React App
Static Files
↔
πŸš€
Express API
Port 4000
β†’
πŸ—„οΈ
PostgreSQL
Port 5432
↔
☁️
Linode S3
File Storage

Request Flow

staging.truxflow.app
HTTPS :443
↓
/api/*
β†’ Backend :4000
/webhook
β†’ Webhook :9000
/*
β†’ Static Files
2

Backend Structure

Tech Stack

πŸš€
Express.js v5
API Framework
πŸ’Ž
Prisma v6.17
ORM
🐘
PostgreSQL 16
Database
πŸ”„
PM2
Process Manager

Current Directory Structure

backend/ β”œβ”€β”€ prisma/ β”‚ β”œβ”€β”€ schema.prisma # Data models β”‚ β”œβ”€β”€ seed.js β”‚ └── migrations/ # 44+ migrations β”œβ”€β”€ controllers/ # 16 controllers (flat) β”œβ”€β”€ routes/ # API endpoints (flat) β”œβ”€β”€ middleware/ β”‚ β”œβ”€β”€ auth.js # JWT β”‚ β”œβ”€β”€ requirePermission.js # RBAC β”‚ └── carrierScope.js # Multi-tenant └── utils/ β”œβ”€β”€ s3.js, prismaFilters.js, logActivity.js β”œβ”€β”€ airtableSync.js # TruxFlow β†’ Airtable └── email.js # Postmark
3

Data Models (Prisma)

Core Entities

🏒
Organization
Multi-tenant root
πŸ‘€
User
System users
🎭
Role
RBAC roles
πŸ”‘
Permission
Granular access

Safety Module Entities

πŸš›
Carrier
DOT#, MC#, insurance
πŸ§‘β€βœˆοΈ
Driver
CDL, TWIC, endorsements
🚚
Truck
Units, specs, status
πŸ“¦
Trailer
Type, size, assignments
πŸ“±
Device
ELD, GPS, Tablet
πŸ”§
Contractor
Service providers
πŸ”
Credentials
Login vault
β›½
FuelCard
Fuel management
4

Frontend Modules & Status

πŸ›‘οΈ Safety Active

Core module - compliance, documents, assets

  • Dashboard/safety/dashboard
  • Carriers/safety/carriers
  • Drivers/safety/drivers
  • Trucks/safety/trucks
  • Trailers/safety/trailers
πŸ‘‘ Admin Partial

User management, roles, activity logs

  • Users/admin/users
  • Roles/admin/roles
  • Company Profile/admin/company-profile
  • Activity Logs/admin/logs
🚚 Dispatch Partial

Brokers, factoring, load board

  • Brokers/dispatch/brokers
  • Factoring/dispatch/factoring
  • Load Board/dispatch/board
πŸ’° Accounting Planned

QuickBooks, invoicing, settlements

  • All routes/accounting/*
βš™οΈ Operations Planned

Daily operations management

  • All routes/operations/*
πŸ“‹ Tasks Planned

Task management, calendar

  • All routes/tasks/*
5

Quick Reference

Local Paths

Backend: /root/truxflow-hosting/app/backend/ Frontend: /root/truxflow-hosting/app/frontend/ Scripts: /root/truxflow-hosting/scripts/

Deploy Commands

./scripts/deploy-all.sh ./scripts/deploy-all.sh backend ./scripts/deploy-all.sh frontend

PM2 Processes

pm2 logs truxflow-staging pm2 restart truxflow-staging --update-env pm2 restart truxflow-webhook

Database

cd backend && npx prisma studio npx prisma migrate deploy npx prisma migrate status

GitHub Repos

weLinkCargoEnterprise/TruxFlow weLinkCargoEnterprise/TruxFlow-Front

Auto-deploy on push to main branch

Current Data (Apr 2026)

Carriers: 18 | Drivers: 235 Trucks: 199 | Trailers: 74 Documents: 1,900+ total
πŸš€ Path A: WeLink-First Approach

Complete TruxFlow quickly for WeLink's internal use. Keep current flat structure, add modules incrementally, refactor later when scaling to external customers.

A

Philosophy

Core Principles

  • βœ“ Ship working software to WeLink ASAP
  • βœ“ Keep current flat folder structure
  • βœ“ Add controllers/routes as needed
  • βœ“ Single Organization = WeLink
  • βœ“ Refactor when external customers demand it
Advantages
  • Faster time to production
  • No refactoring overhead now
  • Familiar codebase structure
  • Lower complexity for solo/small team
  • All modules share same patterns
Tradeoffs
  • Refactor cost later when adding tenants
  • Harder to extract modules as services
  • Permission system stays basic
  • No subdomain/white-label support
  • Technical debt accumulates
A

Directory Structure (No Change)

backend/ β”œβ”€β”€ prisma/ β”‚ └── schema.prisma # Add new models here β”œβ”€β”€ controllers/ # Flat - add as needed β”‚ β”œβ”€β”€ authController.js β”‚ β”œβ”€β”€ carrierController.js β”‚ β”œβ”€β”€ driverController.js β”‚ β”œβ”€β”€ trucksController.js β”‚ β”œβ”€β”€ trailersController.js β”‚ β”œβ”€β”€ loadController.js # NEW for Dispatch β”‚ β”œβ”€β”€ invoiceController.js # NEW for Accounting β”‚ β”œβ”€β”€ settlementController.js # NEW for Accounting β”‚ └── ... (other existing) β”œβ”€β”€ routes/ # Match controllers β”‚ β”œβ”€β”€ loadRoutes.js β”‚ β”œβ”€β”€ invoiceRoutes.js β”‚ └── ... β”œβ”€β”€ middleware/ # Unchanged └── utils/ # Add helpers as needed
A

Implementation Timeline

Phase 1: Safety Module
Carriers, Drivers, Trucks, Trailers, Documents, Compliance β€” extracted into modules/safety/
βœ… Complete β€” Apr 21, 2026
Phase 1: Admin Module
Users, Roles (incl. Phase 1.5 role + carrier-restriction sub-routes) β€” extracted into modules/admin/
βœ… Complete β€” Apr 22, 2026
Phase 1: Dispatch Module β€” Broker + Factoring
Broker + Factoring (both tables empty at extraction, 0 rows) β€” extracted into modules/dispatch/. Load / load-board is build-new work still pending.
βœ… Complete β€” Apr 22, 2026
Dispatch: Load Module (build-new)
Loads, Load Board, Rate-Con, POD/BOL, dispatcher lifecycle. Replacing Airtable (no sync mapper). Design pending: lifecycle states, stops model, assignment ↔ Safety availability.
🚧 Not yet started β€” Load schema design is next real product work
Phase 3: Accounting Module
Invoices, Settlements, QuickBooks Integration
⏳ 4-6 weeks after Dispatch
Phase 4: Operations & Tasks
Daily ops, task management, scheduling
⏳ 3-4 weeks
Phase 5: WeLink Production
Full internal deployment, Airtable sunset
⏳ Target: Q3 2026
A

When to Switch to Path B

Migration Triggers

Consider refactoring to modular structure when ANY of these occur:

Trigger Why It Matters
First external customer signs up Multi-tenant isolation becomes mandatory
Team grows to 3+ developers Developers stepping on each other in flat structure
Controllers folder has 25+ files Navigation and mental overhead too high
Need different access per module Current RBAC can't scope by business domain
Subdomain/white-label request Requires account-level tenant model
🏒 Path B: SaaS-Ready Architecture

Build multi-tenant architecture from the start. Modular monolith structure with clean boundaries. Ready for external customers without major refactoring.

B

Philosophy

Core Principles

  • βœ“ Group code by business domain (modules)
  • βœ“ Each module is self-contained
  • βœ“ Modules communicate through defined interfaces
  • βœ“ Multi-tenant from day one
  • βœ“ Any module can become a microservice later
Advantages
  • External customers without refactoring
  • Clean separation of concerns
  • Module-level access grants possible
  • Easy microservice extraction
  • Onboard new devs to single module
Tradeoffs
  • More upfront work
  • Database schema changes needed
  • New patterns to learn
  • Slower initial velocity
  • May be overengineered if WeLink-only forever
B

Modular Directory Structure

backend/ β”œβ”€β”€ modules/ β”‚ β”œβ”€β”€ safety/ # Owns: compliance, docs, assets β”‚ β”‚ β”œβ”€β”€ controllers/ β”‚ β”‚ β”‚ β”œβ”€β”€ carrierController.js β”‚ β”‚ β”‚ β”œβ”€β”€ driverController.js β”‚ β”‚ β”‚ β”œβ”€β”€ truckController.js β”‚ β”‚ β”‚ └── trailerController.js β”‚ β”‚ β”œβ”€β”€ services/ β”‚ β”‚ β”‚ β”œβ”€β”€ complianceService.js β”‚ β”‚ β”‚ └── onboardingService.js β”‚ β”‚ β”œβ”€β”€ routes.js # All /safety/* routes β”‚ β”‚ └── index.js # Public API for other modules β”‚ β”‚ β”‚ β”œβ”€β”€ dispatch/ # Owns: loads, assignments β”‚ β”‚ β”œβ”€β”€ controllers/ β”‚ β”‚ β”‚ β”œβ”€β”€ loadController.js β”‚ β”‚ β”‚ β”œβ”€β”€ brokerController.js β”‚ β”‚ β”‚ └── statusController.js β”‚ β”‚ β”œβ”€β”€ services/ β”‚ β”‚ β”‚ └── loadBoardService.js β”‚ β”‚ β”œβ”€β”€ routes.js β”‚ β”‚ └── index.js β”‚ β”‚ β”‚ β”œβ”€β”€ accounting/ # Owns: money flow β”‚ β”‚ β”œβ”€β”€ controllers/ β”‚ β”‚ β”‚ β”œβ”€β”€ invoiceController.js β”‚ β”‚ β”‚ └── settlementController.js β”‚ β”‚ β”œβ”€β”€ routes.js β”‚ β”‚ └── index.js β”‚ β”‚ β”‚ └── admin/ # Owns: users, roles, audit β”‚ β”œβ”€β”€ controllers/ β”‚ β”‚ β”œβ”€β”€ userController.js β”‚ β”‚ └── roleController.js β”‚ β”œβ”€β”€ routes.js β”‚ └── index.js β”‚ β”œβ”€β”€ shared/ # Cross-cutting concerns β”‚ β”œβ”€β”€ auth/ β”‚ β”‚ β”œβ”€β”€ authController.js β”‚ β”‚ └── authMiddleware.js β”‚ β”œβ”€β”€ documents/ β”‚ β”‚ β”œβ”€β”€ documentController.js β”‚ β”‚ └── s3Service.js β”‚ β”œβ”€β”€ notifications/ β”‚ β”‚ └── emailService.js β”‚ β”œβ”€β”€ integrations/ β”‚ β”‚ └── airtable/ β”‚ β”‚ β”œβ”€β”€ syncService.js β”‚ β”‚ └── webhookController.js β”‚ └── middleware/ β”‚ β”œβ”€β”€ carrierScope.js β”‚ └── requirePermission.js β”‚ β”œβ”€β”€ prisma/ # Shared database β”œβ”€β”€ config/ β”‚ └── modules.js # Module registration └── app.js # Wires up all modules
B

Three Tenant Types

1️⃣ Single Carrier

  • One owner, one DOT#
  • Full access to all modules
  • Self-managed trucking company
  • Example: "ABC Trucking LLC"

2️⃣ Multi-Carrier Owner

  • One owner, multiple DOT#s
  • Common in trucking industry
  • Switch between carriers OR aggregate view
  • Example: "Smith Holdings" owns 3 carriers

3️⃣ Service Provider

  • Dispatch company (like WeLink)
  • Gets DELEGATED access from carriers
  • Access scoped by: module, permission, units
  • Carriers can also use TruxFlow directly
B

New Database Schema

Account & Access Models

// NEW: Account - The paying customer Account { id String name String // "WeLink Cargo" or "ABC Trucking" type AccountType // CARRIER | SERVICE_PROVIDER subdomain String? // "welink" β†’ welink.truxflow.app carriers Carrier[] // Carriers this account OWNS accessGrants CarrierAccess[] // Access granted TO this account } // NEW: CarrierAccess - Delegated access for service providers CarrierAccess { id String accountId String // Who gets access (e.g., WeLink) carrierId String // Which carrier grantedById String // Who granted it (carrier owner) modules Module[] // ["DISPATCH", "SAFETY"] permissionLevel Permission // VIEW | EDIT | FULL unitScope UnitScope // ALL | SELECTED allowedUnitIds String[] // If SELECTED } // MODIFIED: Carrier now has owner Carrier { ...existing fields... ownedByAccountId String // NEW: Which account owns this carrier } // MODIFIED: User belongs to Account User { ...existing fields... accountId String // NEW: Which account (not organization) }
B

Module Communication

How Modules Talk to Each Other

// modules/dispatch/services/loadBoardService.js const safety = require('../../safety'); // Import via index.js async function assignDriverToLoad(driverId, loadId) { // Ask Safety module if driver is compliant const isCompliant = await safety.checkDriverCompliance(driverId); if (!isCompliant) throw new Error('Driver not compliant'); // Continue with assignment... } // modules/safety/index.js β€” Public API const { checkDriverCompliance } = require('./services/complianceService'); const { getDriver } = require('./controllers/driverController'); module.exports = { checkDriverCompliance, getDriver, // Only expose what other modules need };
B

Migration Path

Step 1: Create folder structure
Create modules/ and shared/ directories, don't move code yet
1 day
Step 2: Build new modules in new structure
Dispatch & Accounting go directly into modules/
Ongoing with development
Step 3: Add Account schema
TenantAccount + CarrierAccess landed on staging. Backfill complete: 1 service-provider tenant (WeLink), 18 carrier tenants, 18 FULL-access grants. See MCP Readiness tab β†’ Phase 0.
βœ… Complete β€” Apr 21, 2026
Step 4: Move Safety module
Biggest move β€” drivers, trucks, trailers, devices, contractors, fuel cards. Landed with extraction guardrails + parity tooling.
βœ… Complete β€” Apr 21, 2026
Step 5: Move Admin module
Users + Roles (incl. Phase 1.5 sub-routes). Literal code-motion β€” (req, res) signatures preserved to avoid Safety's context-rewrite regressions.
βœ… Complete β€” Apr 22, 2026
Step 6: Move Dispatch module (broker + factoring)
Zero-data extraction. Load module build-new is a separate track, using Phase 2 signatures from the start.
βœ… Complete β€” Apr 22, 2026
Step 6: Move shared utilities
Auth, documents, notifications, integrations
1 day
πŸ”Œ MCP-Ready Amendments to Path B

Amendments on top of Path B that make the modular SaaS structure MCP-ready from day one β€” instead of bolting agent surfaces on later. Tracked as Phases 0–4. Baseline: v0.1.0-welink-mvp on both backend and frontend repos.

πŸ”Œ

10-Gap Analysis β€” Path B β†’ MCP-Ready

These are the gaps between the original Path B proposal and what MCP readiness requires. Each becomes a blueprint amendment plus an implementation task inside the phase it belongs to.

# Gap Lands in
1Module boundaries as MCP tool surfaces. Each module exposes one tool-entrypoint file enumerating MCP-callable actions.Phase 1
2Context object standardization. All service functions take Context ({ account, user, carrierAccess, correlationId }) as first arg β€” MCP can forge context identically to HTTP.Phase 2
3Zod schemas as contract. MCP tool schemas reuse the same Zod input/output schemas the REST handlers validate against. Single source of truth.Phase 2
4Service / transport split. Services know nothing about Express, HTTP, or MCP. Transports (REST + MCP) are thin adapters.Phase 1
5Account / CarrierAccess before MCP. Multi-tenant scoping must land first, or agents leak cross-tenant data.Phase 0 βœ…
6Idempotency keys on write tools. Agents retry more than humans β€” every mutating tool accepts an idempotency key.Phase 2
7Read vs. write tool separation. Readonly and mutating tools live in different namespaces so per-agent permissions are simple.Phase 3
8Audit log on every tool call. Who (human or agent), what, when, before/after state.Phase 2
9Rate limiting at transport boundary. REST has its own, MCP needs its own β€” agents burst differently.Phase 3
10Error taxonomy. Structured error codes (not free-text) so agents can recover or escalate sensibly.Phase 2
πŸ”Œ

Phase Sequencing (0 β†’ 4)

Phase 0 β€” Baseline & Tenancy Foundation
Lock v0.1.0-welink-mvp as reference. Introduce TenantAccount + CarrierAccess multi-tenant model so all later phases have a tenant to scope to. No user-visible API change yet β€” carrierScope.js remains authoritative.
βœ… Schema + backfill landed on staging β€” Apr 21, 2026
Phase 1 β€” Service Extraction (REST-only)
Carve modules out of the current flat backend into modules/<name>/{service,schema,transport-rest}. Still fully REST-only. Tests green throughout.
πŸ”„ In progress β€” Safety extracted Apr 21; Admin + Dispatch (broker + factoring) extracted Apr 22. Organization + Context threading + Zod + tools.js still owed. Load is build-new (Dispatch product work).
Phase 2 β€” Context + Audit + Idempotency
Plumb Context through every service. Zod as cross-transport contract. Error taxonomy. Audit log + idempotency-key support later in the phase.
πŸ”„ Slices A+B (scaffolding) shipped Apr 22, 2026 (88bfcc6 + ef8e9d8). Slice C (Safety) shipped Apr 23, 2026 late evening β€” all 8 Safety services on (context, input) + ServiceError throws + errorMiddleware mounted in server.js; modules/safety/tools.js populated with 15 read-only MCP tool entries. Commits 28ace31 (driver) + 492faf4 (remaining 7 + 2 device Rule 9 fixes) + a2390b2 (allowlist unblock). Slices D (Admin) + E (Dispatch) + Preferences migration queued.
Phase 3 β€” MCP Transport
Add modules/<name>/transport-mcp wrapping the same services. Read tools first, write tools after per-module review. Per-tenant auth tokens. MCP-boundary rate limiting.
⏳ After Phase 2
Phase 4 β€” Agent-Facing Surfaces
Voice agent, dispatch copilot, safety reviewer β€” each a consumer of the MCP surface, not a parallel implementation.
⏳ After Phase 3
0

Phase 0 β€” Sub-Steps

Step 1: Schema + migration
TenantAccount (+ TenantAccountType enum), CarrierAccess (+ AccessLevel enum), nullable tenancy columns on Carrier and User. Additive only β€” no drops, no renames. Migration 20260421182024_phase0_tenant_account.
βœ… Applied to staging β€” Apr 21, 2026
Step 2: Backfill
1 SERVICE_PROVIDER tenant (WeLink Cargo Enterprise, profile copied from Organization), 18 CARRIER tenant shells, 18 CarrierAccess rows (WeLink β†’ every carrier, FULL, all 5 modules), 12 users attached via tenantAccountId. Idempotent script kept for re-runs.
βœ… Applied to staging β€” Apr 21, 2026
Step 3: accountScope middleware
Draft accountScope.js in parallel with carrierScope.js. Plan controller cutover module-by-module. No user-visible API change until cutover. Also: update the create-carrier controller to populate ownedByTenantAccountId / createdByTenantAccountId on new carriers (currently NULL on new rows β€” acceptable pre-MCP, must fix before Phase 3).
⏳ Pending
Step 4: CI guardrail
Lint rule or integration test preventing un-scoped Prisma queries from landing. Must be in place before Step 3 controller rewrites ship.
⏳ Pending
Step 5: Deprecate organizationId
Only after Steps 3 + 4 prove the tenant layer is authoritative. Organization remains source of truth for scoping until then.
⏳ Pending
0

What Shipped β€” 2026-04-21 Snapshot

19
TenantAccounts (1 SP + 18 carrier)
18
CarrierAccess grants (WeLink β†’ all)
12
Users attached to WeLink tenant
0
Schema drops or renames

Files of Record

Path Purpose
prisma/schema.prismaTenantAccount, CarrierAccess, enums, Carrier/User deltas
prisma/migrations/20260421182024_phase0_tenant_account/migration.sqlApplied SQL (2 enums, 2 tables, 3 nullable columns, 5 FKs, 2 indexes)
prisma/_phase0_draft/README.mdRollback plan + apply sequence
prisma/_phase0_draft/backfill.cjsIdempotent backfill script (re-run safe)
/root/backups/pre-phase0-20260421181841..dumpPre-Phase-0 pg_dump (278 KB, custom format)
⚠️ Intentionally NOT Done in Phase 0

Phase 0 is foundation only β€” it intentionally does not change runtime behavior. carrierScope.js remains the active tenant filter; tenantAccountId on User is set but not read by any controller; organizationId is still the source of truth. Those are Phase 1+ work β€” no controller should consult the new tenancy fields without an explicit sequenced rollout (Steps 3–5).

πŸ”Œ

Open Decision Points

  • ? Which module carves first in Phase 1 β€” Safety (well-bounded, drives Airtable sync) vs. Admin (smallest, lowest-risk practice migration)?
  • ? MCP server hosting β€” same PM2 process as backend, or separate truxflow-mcp process?
  • ? Rename Accounts (49-row expense-category table) to ExpenseCategory in a separate PR β€” kept distinct from TenantAccount to avoid naming collision.
  • ? Airtable PAT rotation β€” deferred until later phases close; tracked in truxflow/secrets-rotation.md.
1

Phase 1 β€” Extraction Progress (2026-04-21 β†’ 2026-04-22)

πŸ“¦ Commits landed on staging

Safety module (Apr 21, 2026)

4299f98 β€” feat(safety): add extraction guardrails and restore endpoint parity (backend, 15 files)
662e7b1 β€” fix(safety): address Phase 1 edit/view regressions and user carrier display (frontend, 5 files)

Apr 22 hotfix sprint β€” 13 commits closing regressions from the Safety extraction: input sanitization, $transaction semantics, P2002 error translation, Airtable field-name quirks, tolerant singleSelect normalizer, per-entity sync lock, tolerant date parser. Full incident log captured.

Admin module (Apr 22, 2026)

7c77d74 β€” feat(admin): extract user + role controllers into modules/admin/ (backend, 12 files, +1778/-7)

Dispatch module β€” broker + factoring (Apr 22, 2026)

bfa71ce β€” feat(dispatch): extract broker + factoring into modules/dispatch/ (backend, 11 files, +1834/-5). Zero-data extraction β€” all four dispatch tables (Broker, BrokerAssignment, Factoring, FactoringAssignment) had 0 rows at extraction time, so this was a clean relocate with no data-integrity surface.

Dispatch Module β€” What Shipped (Apr 22, 2026)

Third module carved out the same day as Admin. Same discipline: literal code motion, (req, res) signatures preserved. Factoring (1026 LoC) assembled via cat+sed to guarantee a byte-identical body β€” only the import paths differ from the legacy controller.

What moved From To
Broker CRUD + bulk assignment + terminatecontrollers/brokerController.js (7 endpoints)modules/dispatch/service/broker.service.js + transport-rest/broker.routes.js
Factoring CRUD + count + expiring-contracts + single/bulk assignment + terminate + carrier historycontrollers/factoringController.js (13 endpoints)modules/dispatch/service/factoring.service.js + transport-rest/factoring.routes.js
Cross-cutting auth plumbing (auth.js, accountScope, computePermissions, revokeSession)middleware/ + utils/Unchanged β€” shared by every module
πŸ“Œ Dispatch ADR β€” TruxFlow is replacing Airtable

The upcoming Load / load-board work will NOT carry an Airtable sync mapper (unlike Safety's driver/truck/trailer sync). Dispatch becomes the single source of truth for loads once the Load schema lands. Broker + Factoring controllers never had Airtable hooks, so nothing to strip during this extraction. Safety's Recruiting β†’ Dispatch sync for driver/truck/trailer docs is orthogonal and stays.

Dispatch Module β€” Live Validation

Test Result
Row counts before extraction (Broker / BrokerAssignment / Factoring / FactoringAssignment)βœ… 0 / 0 / 0 / 0 β€” zero-data extraction
Route diff routes/brokerRoutes.js ↔ modules/dispatch/transport-rest/broker.routes.jsβœ… Empty
Route diff routes/factoringRoutes.js ↔ modules/dispatch/transport-rest/factoring.routes.jsβœ… Empty
20 dispatch endpoints β€” unauthenticated smokeβœ… All 401 (routed, auth-gated)
Neighboring routes (Safety, Admin, carriers, dashboard)βœ… Unaffected β€” all 401
npm run lint:scopeβœ… No new un-scoped queries
9-item regression checklist (both services)βœ… 9/9 preserved

Dead-code carry: legacy factoringController.js declared listCarrierFactoringAssignments but never wired a route for it. Preserved in the extracted service (Rule 4 β€” don't rewrite during extraction). Phase 2 cleanup can drop it.

Admin Module β€” What Shipped (Apr 22, 2026)

Second module carved out. Literal code motion β€” kept (req, res) signatures because Safety's (context, params) rewrite during extraction was the source of ~8 of the Apr 22 hotfix-sprint regressions. Phase 2 will do signature unification across Safety + Admin at once with Zod as the boundary.

What moved From To
Role CRUD + presets + permissions shimcontrollers/rolesController.js (7 endpoints)modules/admin/service/role.service.js + transport-rest/role.routes.js
User CRUD + Phase 1.5 sub-routes (roles + carrier-restrictions)controllers/userController.js (12 endpoints)modules/admin/service/user.service.js + transport-rest/user.routes.js
Inline requireAdmin gate (duplicated in both old route files)Inlinemiddleware/requireAdmin.js (new, shared)
Cross-cutting auth plumbing (auth.js, requireDepartment, requireAction, accountScope, computePermissions, tokenVersionCache, revokeSession)middleware/ + utils/Unchanged β€” infrastructure, not Admin domain; used by every module

Admin Module β€” Extraction Discipline Walk

Every regression the Safety extraction silently introduced (13 traced in the Apr 22 hotfix sprint) is represented in feedback/phase1-extraction-regressions.md. The Admin extraction was walked through the 9-item checklist for both services β€” 9/9 preserved.

# Checklist item Preserved how
1Input sanitizationExplicit destructure throughout (no ...body spread); phone: phone || null kept; no date fields on Role or User payloads.
2Immutable field strippingorganizationId, tenantAccountId read from req.user, never from body; id/createdAt/updatedAt never referenced.
3Transaction semanticscreateUser + replaceUserRoles + addUserRole + removeUserRole + replaceUserCarrierRestrictions + updateRole β€” every $tx boundary preserved byte-for-byte. handoffSuperAdmin runs inside tx. revokeUserSessions(target.id, tx) inside same tx as the mutation.
4Upsert idempotencyaddUserRole keeps userRoleAssignment.upsert on composite key.
5Error propagationNo return null; all errors logged + 5xx returned.
6Required-field validation400 on missing firstName/lastName/email/password preserved; validateRoleIds/validateCarrierIds return structured errors.
7Prisma error translationcreateUser: P2002 β†’ 409 with fields; createRole: P2002 β†’ 409; ROLE_LIMIT_REACHED / ROLE_IN_USE / SELF_DEMOTION_BLOCKED / SUPER_ADMIN_REQUIRED all preserved.
8Response envelope{ user } / { users } / { message } / { departments } / bare role array / { carrierIds, carriers } β€” all identical to legacy.
9Phantom flagsNo feature flags; permissionsChanged is computed-diff, not phantom.

Admin Module β€” Live Validation

User browser-walked the extracted surface on staging. Plus one end-to-end save on a real user (Moe Abraham) during testing.

Test Result
Route diff routes/userRoutes.js ↔ modules/admin/transport-rest/user.routes.jsβœ… Empty
Route diff routes/rolesRoutes.js ↔ modules/admin/transport-rest/role.routes.jsβœ… Empty
19 admin endpoints β€” unauthenticated smokeβœ… All 401 (routed, auth-gated as expected)
Neighboring routes (Safety, carriers, dashboard, docs, organization, factoring, broker)βœ… Unaffected β€” all 401
npm run lint:scopeβœ… No new un-scoped queries
/admin/roles loads with preset + custom + cap counterβœ…
Create custom role β†’ counter incrementsβœ…
Edit Dispatcher preset β€” edit icon absent (client gate)βœ…
Assign user to custom role β†’ counter reactsβœ… (via POST /users/:id/roles/:roleId)
Delete role while in use β†’ blockedβœ… 409 ROLE_IN_USE preserved
Delete unused roleβœ…
/admin/users renders with role chipsβœ…
Live save on Moe Abraham (profile + roles + carriers)βœ… PATCH /users/:id 200 (25ms), PUT /users/:id/roles 200 (27ms), PUT /users/:id/carrier-restrictions 200 (27ms) β€” tx committed, revoke fired
Re-login as Moe β†’ Safety scope + one-carrier restriction intactβœ… (JWT replay + listUserRoles + carrier restrictions all extracted)

Pre-existing Bug Surfaced During Testing

Not caused by the Admin extraction. In frontend/src/components/forms/UserForm.jsx, dirty-state detection misses several field changes β€” Save button stays disabled when the user edits firstName, toggles the Restricted/Unrestricted carrier radio, or removes a role chip. Typing digits into Phone does activate Save. Backend behavior is correct when Save eventually fires. Logged for follow-up; pre-dates the Admin extraction.

1

Phase 1 β€” Safety Module Progress (2026-04-21)

Guardrails Added

Four prevention measures to catch the kind of regressions that slipped through the initial Safety extraction (missing endpoints, shape mismatches, verb drift). All four run against modules/safety/* and are ready to apply to the next module extraction.

Tool Purpose Location
Request loggerSurface every 4xx/5xx in pm2 logs with method, path, status, latency β€” drops the guesswork when the frontend reports a broken call.server.js (inline middleware)
Route extractor + parity snapshotRegex-pulls every registered route from old & new routers, sorted, diff-friendly. Current state + regen instructions captured per entity.scripts/extract-routes.js, modules/safety/ENDPOINT_PARITY.md
Response-shape contract testsDeclarative list of endpoint β†’ expected envelope (list / { data } / { message, data } / bare entity). Runner logs in, walks the contracts, exits non-zero on drift.modules/safety/contracts.js, scripts/check-response-shapes.js
Extraction rules docFour codified rules: preserve endpoint surface, preserve HTTP verbs, preserve response envelopes, don't rewrite query logic during extraction.modules/safety/EXTRACTION_RULES.md

Parity Restorations (surfaced by the new tooling)

Entity What was missing / drifted Fix
TruckGET /assignments and GET /statuses/availability not registered in new routerRestored both routes + imports in transport-rest/truck.routes.js
TrailergetTrailer dropped the trailerAssignments include on nested truck, so the overview couldn't render sibling trailersAdded trailerAssignments include in service/trailer.service.js
Contractor / Device / DriverMinor include, permission, and response-shape driftAligned services + routes with pre-extraction behavior

Frontend Regressions Fixed

Page Issue Fix
TruckProfileV2.jsxClose nav targeted /safety/units (undefined route)Route changed to /safety/trucks
CarrierProfileV2.jsxEdit modal framed lg β€” cramped vs. other entitiesBumped to xl to match Driver/Trailer/Truck
DriverProfileV2.jsxEdit button rendered DriversFormV2 (still hardening)Swapped back to the stable DriversForm pending V2 completion
Drivers.jsxStatus dropdown offered INACTIVE and OUT_OF_SERVICE β€” backend enum rejects both with VALIDATION_ERRORList trimmed to real enum: ACTIVE, ONBOARDING, TERMINATED
Users.jsxUser cards didn't render assigned carriers even though the API returns themAdded Carriers display (truncates to 3 + "+N more")
1

Old vs New Structure β€” Safety Module

File Layout Diff

Same HTTP surface, reorganized into the Path B modules/<name>/{service,transport-rest} layout. Old files remain in place during Phase 1 (still wired via server.js), but every Safety endpoint is now served by the new module.

Old (flat) β†’ New (modular)
controllers/driverController.js→modules/safety/service/driver.service.js
routes/driversRoutes.js→modules/safety/transport-rest/driver.routes.js
controllers/trucksController.js→modules/safety/service/truck.service.js
routes/trucksRoutes.js→modules/safety/transport-rest/truck.routes.js
controllers/trailersController.js→modules/safety/service/trailer.service.js
routes/trailersRoutes.js→modules/safety/transport-rest/trailer.routes.js
controllers/devicesController.js→modules/safety/service/device.service.js + deviceCategory.service.js
routes/devicesRoutes.js→modules/safety/transport-rest/device.routes.js + deviceCategory.routes.js (sub-router mount /categories)
controllers/contractorsController.js→modules/safety/service/contractor.service.js + vendorType.service.js
routes/contractorsRoutes.js→modules/safety/transport-rest/contractor.routes.js + vendorType.routes.js (sub-router mount /subtypes)
(empty) routes/fuelRoutes.js→modules/safety/service/fuelCard.service.js + transport-rest/fuelCard.routes.js (net-new; no parity concern)

Per-Endpoint Comparison

Generated via scripts/extract-routes.js. Empty diff = parity. ● same in both, ● new in modular, ● removed (none β€” all restored). Sub-router mounts expanded inline below each parent.

πŸš› Drivers β€” 15 endpoints, full parity
MethodPathOldNew
GET/βœ“βœ“
GET/driversCountβœ“βœ“
GET/endorsementsβœ“βœ“
GET/statusβœ“βœ“
GET/:idβœ“βœ“
GET/:id/historyβœ“βœ“
GET/:id/assignmentHistoryβœ“βœ“
GET/:id/available-carriersβœ“βœ“
POST/βœ“βœ“
POST/:id/duplicateβœ“βœ“
POST/:id/assignTruckβœ“βœ“
POST/:id/unlinkTrucksβœ“βœ“
PATCH/:idβœ“βœ“
PATCH/:id/statusβœ“βœ“
DELETE/:idβœ“βœ“
🚚 Trucks β€” 24 endpoints, full parity (2 restored today)
MethodPathOldNewNote
GET/βœ“βœ“
GET/countβœ“βœ“
GET/accountsβœ“βœ“
GET/assignmentsβœ“βœ“restored Apr 21
GET/dispatchStatusesβœ“βœ“
GET/operationStatusesβœ“βœ“
GET/safetyStatusesβœ“βœ“
GET/statusesβœ“βœ“
GET/statuses/availabilityβœ“βœ“restored Apr 21
GET/:idβœ“βœ“
GET/:id/assignmentHistoryβœ“βœ“
GET/:id/deviceAssignmentHistoryβœ“βœ“
GET/:id/trailerAssignmentHistoryβœ“βœ“
POST/βœ“βœ“
POST/:id/devicesβœ“βœ“
POST/:id/driversβœ“βœ“
POST/:id/trailersβœ“βœ“
PATCH/:idβœ“βœ“
PATCH/:id/statusβœ“βœ“
PATCH/:id/safetyStatusβœ“βœ“
PATCH/:id/devices/:deviceIdβœ“βœ“
PATCH/:id/trailers/:trailerIdβœ“βœ“
PATCH/:id/unlinkDriversβœ“βœ“
DELETE/:idβœ“βœ“
🚚 Trailers β€” 12 endpoints, parity + 1 alias
MethodPathOldNewNote
GET/βœ“βœ“
GET/countβœ“βœ“
GET/:idβœ“βœ“nested trailerAssignments include restored Apr 21
GET/:id/assignmentHistoryβœ“βœ“
POST/βœ“βœ“
POST/:trailerId/assignβœ“βœ“
PUT/:idβœ“βœ“
PATCH/:idβ€”βœ“new alias (shared handler)
PATCH/:id/operationStatusβœ“βœ“
PATCH/:id/safetyStatusβœ“βœ“
PATCH/assignments/:assignmentId/unassignβœ“βœ“
DELETE/:idβœ“βœ“
πŸ“‘ Devices β€” 15 endpoints, parity + sub-router refactor + 1 alias
MethodPathOldNewNote
GET/βœ“βœ“
GET/countβœ“βœ“
GET/:idβœ“βœ“
GET/:id/assignmentsβœ“βœ“
POST/βœ“βœ“
POST/importβœ“βœ“
POST/:deviceId/assignβœ“βœ“
POST/:id/categoriesβœ“βœ“
DELETE/:idβœ“βœ“
DELETE/:id/categoriesβœ“βœ“
PUT/:idβœ“βœ“
PATCH/:idβ€”βœ“new alias
PUT/assignments/:assignmentId/unassignβœ“βœ“
Sub-router USE /categories β†’ deviceCategory.routes.js
GET/categories/allβœ“βœ“
POST/categoriesβœ“βœ“
PUT/categories/:categoryIdβœ“βœ“
DELETE/categories/:categoryIdβœ“βœ“
πŸ‘· Contractors β€” 10 endpoints, parity + sub-router refactor + 1 alias
MethodPathOldNewNote
GET/βœ“βœ“
GET/countβœ“βœ“
GET/:idβœ“βœ“
GET/carrier/:carrierIdβœ“βœ“
POST/βœ“βœ“
PUT/:idβœ“βœ“
PATCH/:idβ€”βœ“new alias
PATCH/:id/statusβœ“βœ“
DELETE/:idβœ“βœ“
Sub-router USE /subtypes β†’ vendorType.routes.js
GET/subtypesβœ“βœ“
GET/subtypes/:idβœ“βœ“
POST/subtypesβœ“βœ“
PUT/subtypes/:idβœ“βœ“
DELETE/subtypes/:idβœ“βœ“
β›½ Fuel Cards β€” 8 endpoints, net-new (old file was empty)
MethodPathOldNew
GET/β€”βœ“
GET/:idβ€”βœ“
GET/:id/historyβ€”βœ“
POST/β€”βœ“
POST/:fuelCardId/assignTruckβ€”βœ“
POST/:fuelCardId/unassignTruckβ€”βœ“
PATCH/:idβ€”βœ“
DELETE/:idβ€”βœ“
🧭 What Phase 1 Still Owes

Remaining extraction: Organization module (largest legacy surface β€” carriers, credentials, documents, org profile). Last sizable carve; likely lands after Phase 2 Slices C/D/E so it arrives on the new pattern.

Build-new Dispatch work: Load schema + lifecycle + Load Board + Rate-Con / POD / BOL + Safety availability integration. Design pending; no Airtable sync (per ADR). Will use Phase 2 signatures (Context + Zod) from the start.

Cleanup debt: unmounted-but-on-disk legacy files. As of Apr 22: controllers/{user,roles,broker,factoring}Controller.js + matching routes/*.js; Safety's legacy files since Apr 21. Safe to delete once Organization lands.

Phase 2 prep has its own section below β€” Slices A+B (scaffolding) landed, Slices C/D/E (service migration) queued.

2

Phase 2 Prep β€” Slices A+B Landed (2026-04-22 evening)

πŸ“¦ Commits landed on staging

88bfcc6 β€” feat(shared): Phase 2 prep scaffolding β€” Context, ServiceError, validate (5 files, +328 LoC, pure additive)
ef8e9d8 β€” feat(phase2): install zod + exemplar schemas per module (5 files, +150 LoC)

Neither commit changed any module's runtime behavior β€” scaffolding is loaded but unconsumed. Services + routes still use their pre-Phase-2 signatures until Slice C starts.

shared/ β€” Cross-Module Building Blocks

File Purpose Gap Closed
shared/errors.jsServiceError class + ErrorCodes taxonomy (VALIDATION_ERROR, UNAUTHORIZED, FORBIDDEN, NOT_FOUND, CONFLICT, UNIQUE_VIOLATION, FK_VIOLATION, ROLE_LIMIT_REACHED, ROLE_IN_USE, SUPER_ADMIN_REQUIRED, SELF_DEMOTION_BLOCKED, SESSION_REVOKED, INTERNAL) with default HTTP status mapping. translatePrismaError() for P2002/P2003/P2025.#10
shared/context.jsContext shape { user, tenant, carrierAccess, correlationId, now } + buildContextFromRequest(req) + test helper.#2
shared/handleError.jsExpress errorMiddleware + toErrorBody. Response envelope { message, error, code, details? } on non-2xx.#10
shared/validate.jsparseInput(schema, data) Zod helper throwing ServiceError(VALIDATION_ERROR) with structured issue details.#3
shared/index.jsPublic re-exports so domain code has one import path.β€”

Zod 4.3.6 + Exemplar Schemas

One exemplar schema per module establishes the pattern. Full per-endpoint schemas grow inline with each module's Slice C/D/E migration β€” lazy expansion rather than big-bang schema write.

Module Exemplar Covers
Safetymodules/safety/schema/driver.schema.jsdriverIdParams, listDriversInput (coerce + defaults), createDriverInput (passthrough), updateDriverInput (partial)
Adminmodules/admin/schema/role.schema.jsroleIdParams, rolePayload (department + action enum validation), create/update inputs
Dispatchmodules/dispatch/schema/broker.schema.jsbrokerIdParams, listBrokersInput, create/update inputs, bulkAssignBrokerInput

npm audit note: 35 pre-existing vulnerabilities (5 moderate, 29 high, 1 critical) surfaced during npm install zod. Unrelated to zod (4.3.6 is clean); project-wide dependency debt logged for a dedicated triage pass.

Slices C / D / E β€” Queued for Next Session

Slice Scope Risk Est.
βœ… Slice C β€” SafetyMigrated 8 services + endpoints to (context, input) + ServiceError. modules/safety/tools.js populated (15 read tools). errorMiddleware mounted. Friendly per-entity P2002 β†’ 409 preserved. Two Apr 21 device-extraction Rule 9 gaps fixed alongside (createDevice + updateDevice categoryIds/carrierId).Shipped 2026-04-2328ace31, 492faf4
Slice D β€” AdminBigger rewrite: (req, res) β†’ (context, input). Preserve every Phase 1.5 behavior (createUser $tx, handoffSuperAdmin, revokeUserSessions, self-demotion guard, response envelope shape).Medium2-3 hr
Slice E β€” DispatchSame rewrite for broker + factoring services.Low (zero-data tables)1-2 hr

Browser-test checkpoint after each slice. Slice C can be narrowed to driver-only first if the full-Safety migration feels too broad at restart. See session-2026-04-22-phase2-prep.md for the restart pre-flight.

βœ… Slice C Shipped (2026-04-23 late evening)

Three-commit progression: 28ace31 (driver narrow start, browser-tested) β†’ a2390b2 (deploy-gate unblock for pre-existing getExpiringDocuments un-scoped query) β†’ 492faf4 (remaining 7 services + 2 device Rule 9 fixes + 15-tool tools.js). Browser smoke green across drivers / trucks / trailers / devices / device categories / contractors / vendor types / carriers. Phase 2 scaffolding now consumed by every Safety service. Slice D (Admin) is the next move β€” see session-2026-04-23-phase2-slice-c.md for the full log + Slice D pre-flight.

πŸ” Phase 1.5 β€” Permission Model v1 Β· βœ… SHIPPED + 4 POLISH PASSES (2026-04-21)

Merged feat/permission-model-v1 β†’ main on both backend + frontend; tagged v0.2.0-permission-model; auto-deployed to staging. Lands between Phase 1 (Safety extraction) and Phase 2 (Context/Audit).

Validated end-to-end in production: admin Adam sees all modules + /admin/roles UI with preset + custom CRUD. Safety Officer Moe (with a custom "Operation - Compliance" role, one-carrier restriction) sees only the Safety + Operations modules, scoped to that one carrier across every list, form, and dropdown.

Scope delivered: 11 commits for the core launch + 4 polish passes on top β€” Users page brand rewrite, list/card toggle w/ filters + sortable columns, UserForm v2 (single-pass create), Single-Super-Admin invariant w/ handoff. Total 24 commits across both repos.

🎯

Why Now

Phase 0 tenant scoping answers "which carriers does this tenant have?". It cannot answer "of those carriers, which has this specific user been allowed to see?". April 21 smoke test exposed the gap: revoking one carrier from a user still showed all carriers in the UI. The legacy User.carriers relation exists but is not read by any middleware.

Phase 1.5 collapses three pending concerns into one pass:

  • Per-user carrier restriction (smoke-test gap)
  • Department + action roles (Safety Officer, Accountant, Dispatcher, Admin, Super Admin)
  • Tenant-type-aware defaults (Single-Carrier / Multi-Carrier / Service Provider)
🧭

Design Principles

PrincipleWhat it means
Stateless at request timePermissions computed once at login, embedded in JWT. Every protected request reads from the token β€” no DB round-trip for authz checks.
JWT-driven frontendSidebar, dropdowns, action buttons all read from decoded JWT. Zero per-button API calls. Server stays cool, UX stays fast.
Immediate revocationPermission change bumps user's tokenVersion + evicts cached value. Old JWT fails on next request, forces re-login.
Preset + custom, same tableSystem presets live in the same RoleDefinition table as tenant-custom roles, distinguished by tenantAccountId IS NULL. One query covers both.
Ship simple, leave hooksEntity-level fine-grained perms (e.g. "view drivers but not trucks within Safety") deferred. Schema field entities: string[] reserved β€” no later migration churn.
πŸ”‘

JWT Payload β€” Single Source of Truth at Request Time

{
  "sub": "user-abc",
  "tenantAccountId": "xyz",
  "tenantType": "SERVICE_PROVIDER",
  "isSuperAdmin": false,
  "isAdmin": false,
  "allowedCarrierIds": ["c1"],                        // one-carrier restriction
  "departmentsMap": {                                 // Phase 1.5 object
    "SAFETY": { "role": "Safety Officer", "actions": ["view","edit","delete"] },
    "TASKS":  { "role": "Safety Officer", "actions": ["view","edit","delete"] },
    "SETUP":  { "role": "Safety Officer", "actions": ["view","edit","delete"] }
  },
  "departments": ["SAFETY"],                          // legacy array (back-compat)
  "tokenVersion": 11
}

allowedCarrierIds is already the intersection of tenant.CarrierAccess.carrierIds ∩ user.carrierRestrictions.ids β€” computed at login, no request-time math. Frontend reads user.departmentsMap (preferred) or falls back to user.departments when it's the object shape (org-type sessions).

πŸ—„οΈ

Schema Changes

ChangeDetails
Rename User.carriers β†’ carrierRestrictionsZero data migration β€” relation reused. Name now reflects semantics ("allow-list subset of tenant carriers").
NEW RoleDefinitionid, tenantAccountId?, name, isSystemPreset, isSuperAdmin, isAdmin, departmentScope[], actions[], entities[] (reserved), createdAt. tenantAccountId IS NULL β‡’ system preset.
NEW UserRoleAssignmentMany-to-many join. A user can hold multiple roles (e.g. Safety Officer + Accountant).
Reused User.tokenVersionAlready exists. Bumped on any permission change to invalidate outstanding JWTs.
Deprecated (not removed) Role, Permission, RolePermissionLegacy org-scoped tables. Left in place for Phase 2 cleanup, no new reads.
πŸ‘₯

Preset Role Seeds

Preset isSuperAdmin isAdmin Department Scope Actions
Super Adminβœ“β€”all 7 departmentsview, edit, delete
Adminβ€”βœ“all 7 departmentsview, edit, delete
Safety Officerβ€”β€”SAFETY, TASKS, SETUPview, edit, delete
Accountantβ€”β€”ACCOUNTING, TASKS, SETUPview, edit
Dispatcherβ€”β€”DISPATCH, TASKS, SETUPview, edit, delete

All non-admin presets include TASKS and SETUP so every role can see those universal tabs (per-department reflection of Tasks/Setup content handled in UI). DepartmentType enum: ACCOUNTING, DISPATCH, SAFETY, OPERATIONS, RECRUITING, TASKS, SETUP. Dispatcher's former OPERATIONS scope was dropped before Day 6 per tenant feedback.

πŸ›‘οΈ

Middleware Chain

verifyAccessToken        // decode + tokenVersion cache check
  ↓
restrictToTenantCarriers // JWT-read: req.carrierScope = allowedCarrierIds
  ↓
requireDepartment('SAFETY')   // NEW β€” department gate
  ↓
requireAction('edit')         // NEW β€” action gate
  ↓
handler

tokenVersion cache is in-memory Map with 60s TTL. Single PM2 process on staging makes Redis unnecessary. Cache interface abstracted so a Redis swap is later a one-file change.

πŸ”„

Revocation Flow

Admin revokes carrier X from user U
  ↓
Transaction:
  - UPDATE user: tokenVersion += 1, carrierRestrictions disconnect X
  - Evict U from tokenVersion cache
  ↓
U's next request (old JWT)
  ↓
JWT tokenVersion (7) != DB tokenVersion (8) β†’ 401
  ↓
Frontend: redirect to /login
  ↓
Re-login β†’ new JWT without carrier X

Propagation: instant on cache eviction, max 60s via TTL expiry if eviction missed.

βš›οΈ

Frontend Impact β€” No Extra API Calls

const { user } = useAuth();  // decoded JWT already in context

// No API calls below:
{user.departments.SAFETY && <SafetyNav />}
{user.departments.SAFETY?.actions.includes('edit') && <EditButton />}
<CarrierDropdown
  options={carriers.filter(c => user.allowedCarrierIds.includes(c.id))}
/>
πŸ“…

Sequencing β€” 10 Working Days

Day 1 β€” Schema migration + preset seed + backfill βœ…
  • Prisma migration 20260421195527_permission_model_v1 adds RoleDefinition + UserRoleAssignment tables (applied to staging DB)
  • Seeded 5 system presets (Super Admin, Admin, Safety Officer, Accountant, Dispatcher) as tenantAccountId=null rows
  • Idempotent backfill script scripts/seed-permission-model-v1.js created 35 UserRoleAssignment rows for 13 existing staging users
  • User.carriers field rename deferred to Day 2 (11 call sites overlap with login rewrite)
βœ“ Shipped 2026-04-21 β€” backend commit 623b3e6
Day 2 β€” Login permission computation β†’ JWT embed βœ…
  • New utils/computePermissions.js β€” intersects tenant.CarrierAccess.carrierIds ∩ user.carrierRestrictions, resolves departmentβ†’actions map from role assignments
  • Login + refresh-token paths both embed {isSuperAdmin, isAdmin, allowedCarrierIds, departments, tokenVersion} in access token
  • User.carriers β†’ carrierRestrictions rename completed (11 call sites migrated, relation name UserCarriers unchanged so zero DDL)
  • Smoke-verified on Adam (Super Admin) + Mariam (Safety Officer)
βœ“ Shipped 2026-04-21 β€” backend commit 1e4a35b
Day 3 β€” Middleware (JWT-first authz chain) βœ…
  • utils/tokenVersionCache.js β€” in-memory Map, 60s TTL, explicit evictUser() + evictOrganization() for instant revocation
  • middleware/auth.js β€” JWT-first validation; stale tokenVersion returns 401 "Session revoked"
  • middleware/accountScope.js rewritten β€” reads from req.user, zero DB queries; Super Admin/Admin bypass; missing tenantAccountId β†’ allowedCarrierIds:[]
  • New middleware/requireDepartment.js + middleware/requireAction.js factories with Super Admin/Admin bypass
  • Smoke-verified on Mariam: 3 protected endpoints 200, stale tokenVersion=999 β†’ 401
βœ“ Shipped 2026-04-21 β€” backend commit 651751c
Day 4 β€” Retrofit Safety routes βœ…
  • 8 sub-routers gated: driver, truck, trailer, device, deviceCategory, contractor, fuelCard, vendorType
  • router.use(requireDepartment("SAFETY")) after verifyAccessToken + restrictToTenantCarriers
  • Replaced 39 requirePermission() calls with requireAction("SAFETY", view|edit|delete) β€” create folded into edit
  • Zero blast radius β€” all 13 staging users have SAFETY department; smoke-verified 5 cases (full access, no-SAFETY, view-only)
βœ“ Shipped 2026-04-21 β€” backend commit 7e96460
Day 5 β€” Permission-change APIs bump tokenVersion + evict cache βœ…
  • New utils/revokeSession.js β€” composable revokeUserSessions(userId, tx) / revokeOrganizationSessions(orgId, tx) helpers (tx-aware)
  • userController.updateUser detects role/departments/carrierIds/isActive changes; bumps tokenVersion only on real change (no-op PATCH stays silent)
  • userController.deleteUser evicts cache on delete
  • carrierController.createCarrier auto-assign path revokes creator's sessions inside the transaction
  • middleware/requirePermission.js now bypasses on isSuperAdmin || isAdmin from JWT β€” unblocks legacy admin flows
  • Smoke-verified: identical PATCH = no tv bump; permission PATCH β†’ old JWT returns 401 "Session revoked"
βœ“ Shipped 2026-04-21 β€” backend commit bca4d1b
Day 6 β€” RoleDefinition CRUD + preset tweaks βœ…
  • controllers/rolesController.js rewritten for RoleDefinition β€” list / presets / get / create / update / delete; 5-per-tenant custom-role cap (409 ROLE_LIMIT_REACHED); system presets read-only on PUT/DELETE (403)
  • routes/rolesRoutes.js β€” inline requireAdmin gate (isSuperAdmin || isAdmin from JWT); static /presets before dynamic /:id
  • utils/revokeSession.js β€” new revokeUsersByRoleId(roleId, tx) helper; updateRole wraps mutation + all session revocations in a single $transaction
  • Migration 20260421210000_department_tasks_setup β€” additive TASKS + SETUP enum values so every preset can see those universal tabs
  • Preset tweaks before seed re-run: Dispatcher loses OPERATIONS, all non-admin presets gain TASKS+SETUP
  • Smoke: 8 endpoint cases green incl. 5-cap enforcement (5Γ—201 then 6th = 409)
βœ“ Shipped 2026-04-21 β€” backend commit f4be470
Day 7 β€” User role + carrier-restriction endpoints βœ…
  • Six admin-gated endpoints on /api/users/:userId/*: list roles, replace roles ({roleIds}), add single role, remove single role, get carrier-restrictions, replace carrier-restrictions ({carrierIds})
  • Every mutation wraps writes + revokeUserSessions in a $transaction β€” change and revocation commit atomically
  • Cross-tenant role assignment blocked (presets-only or same-tenant). Cross-tenant carrier assignment blocked via CarrierAccess membership check; Super Admin bypasses
  • Self-demotion guard: admin editing their own roles cannot remove the last isAdmin/isSuperAdmin-granting role; returns 409 SELF_DEMOTION_BLOCKED
  • computePermissions.ALL_DEPARTMENTS extended with TASKS + SETUP so admin-fill matches the seed
  • Smoke: 12 cases green (list, replace, add idempotent, remove, invalid roleId, self-demotion, carrier replace/empty/invalid, non-admin 403)
βœ“ Shipped 2026-04-21 β€” backend commit fbe4e6e
Hotfix β€” Org access tokens must embed org.tokenVersion βœ…
  • Latent since Day 3. Login + refresh for ORG-type sessions were embedding perms.tokenVersion (the admin USER's tokenVersion from computeUserPermissions) in the access token, while middleware checks Organization.tokenVersion
  • Surface: the moment any Phase 1.5 endpoint bumped the admin user's tokenVersion (e.g. our Day 7 smoke), every subsequent ORG-session request returned 401 "Session revoked"
  • Fix: set tokenVersion: org.tokenVersion on both login (line 190) and refresh (line 416) paths
βœ“ Shipped 2026-04-21 β€” backend commit 43296b7
Day 8 β€” Admin UI for RoleDefinition + user assignments βœ…
  • pages/Roles.jsx rewritten: Presets section (read-only, cyan accent) + Custom section (editable, teal accent) with live 5-cap counter
  • components/forms/RoleForm.jsx β€” name input + department multi-select (7 values incl. TASKS+SETUP) + actions checkbox grid; revoke warning when role has assignments
  • components/forms/UserForm.jsx gains Roles multi-select + Carrier Restrictions multi-select; empty restrictions = "see all tenant carriers"
  • New API clients + React Query hooks: api/roles.js, useRoles.js (splitRoles, customCount, atCap); api/users.js +6 fns, useUsers.js +4 mutations + useUserRoles / useUserCarrierRestrictions queries
  • Self-edit path triggered interim force-logout (replaced by Day 9 global 401 handler)
βœ“ Shipped 2026-04-21 β€” frontend commit 603d31b
Day 9 β€” usePermissions() hook + nav gates + 401 handler βœ…
  • New hooks/usePermissions.js reads JWT claims from AuthContext: isSuperAdmin, isAdmin, hasDepartment(d), canDo(d,a), scopeCarriers(list), allowedCarrierIds
  • Sidebar + TopBar menu items carry dept or adminOnly meta; non-matching items hide for non-admins
  • Global "Session revoked" handler in axios.js β€” redirects to /login?reason=session-revoked with single-shot guard against concurrent 401s
  • Login page reads the query param and surfaces an amber notice explaining why the user is back
  • Removed the Day 8 interim alert() hack from UserForm self-edit path β€” global handler covers it cleanly
βœ“ Shipped 2026-04-21 β€” frontend commit b7325ec
Day 10 β€” IfCan helper + Create-button sweep + E2E smoke + merge βœ…
  • components/IfCan.jsx β€” wraps children behind canDo(dept, action) with optional fallback; Super/Admin bypass handled in the hook
  • "+ Create" buttons on Drivers, Units, Trailers, Carriers, Contractors, FuelCard wrapped with <IfCan dept="SAFETY" action="edit">
  • E2E smoke (all green): (1) revoke-carrier β€” stale JWT 401, fresh JWT 200 (2) custom-role β€” view-only user GETs 200 but POSTs 403 (3) cross-tenant β€” plain-Admin blocked 400 on foreign carrier
  • Merged feat/permission-model-v1 β†’ main on both repos; tagged v0.2.0-permission-model; auto-deployed to staging
  • Scope-guard hotfix f8aeef5 β€” getUserCarrierRestrictions:carrier.findMany added to .scope-allowlist.json (safe by chain of custody β€” ids derived from tenant-scoped parent load)
βœ“ Shipped 2026-04-21 β€” frontend commit a5358bf
Post-merge hotfix β€” departmentsMap shape for user-type sessions βœ…
  • Browser smoke with Safety Officer Moe surfaced: user-type login/refresh returns user.departments as legacy array ["SAFETY"] and the new object at user.departmentsMap. Org-type sessions return the object at user.departments.
  • usePermissions was reading only user.departments β†’ for Moe, departmentsMap["SAFETY"] was undefined β†’ every nav item hid β†’ blank TopBar + empty Sidebar, just dashboard
  • Fix: hook now prefers user.departmentsMap, falls back to user.departments only when it's a non-array object
  • Validated live: Moe sees Safety tab + sidebar items, scoped to his one assigned carrier across the entire app
βœ“ Shipped 2026-04-21 β€” frontend commit d286da4
✨

Post-Ship Polish Passes (same day)

Pass 1 β€” Users page brand rewrite + row-action sweep βœ…
  • Backend: getUsers / getUser now return the authoritative roles[] shape + isAdmin / isSuperAdmin. Legacy user.role kept for back-compat.
  • Frontend: full pages/Users.jsx rewrite using the TruxFlow brand palette (navy #01377D / cyan #009DD1 / teal #2EDAC4). Gradient avatars, accent strip on admin cards, StatPill row, live search, carrier restriction summary, hover-revealed action icons with self-edit carve-out.
  • New components/RoleChip.jsx β€” 4 variants (Super Admin navy+teal-dot, Admin solid-navy, preset cyan-tinted, custom teal-tinted) reused on Users + UserForm + Roles pages.
  • Row-action sweep on 5 Safety entity pages β€” hasPermission("x.edit") replaced with canDo("SAFETY", "edit"); Contractors gained missing canDelete binding.
  • Hotfix a5ded47: AuthContext.hasDepartment was blanking forms for users whose role assignments came from the new admin UI (legacy array empty). Now consults departmentsMap first, falls back to legacy array.
βœ“ 2026-04-21 β€” backend e824a02, frontend e6295e1 + a5ded47
Pass 2 β€” List/card toggle + 4-facet filter + sortable columns βœ…
  • List view is now the Users page default (localStorage-persisted preference).
  • Four multi-select filters via new components/FilterPopover.jsx (cyan/navy gradient header, optional in-popover search, checkbox items): Role Type (Super Admin / Admin / Preset / Custom), Department, Role, Carriers (incl. "Unrestricted (all tenant)" pseudo-value). OR within a filter, AND across filters. Active selections render as removable chips with color-coded facets. Stale persisted ids silently reconcile against the current option set.
  • Stats row recomputes against the filtered set β€” "Total users" becomes "Showing N / total" when filtered.
  • Sortable column headers in list view β€” User (firstName), Roles (first role name), Departments (count), Carriers (count; Unrestricted = largest), Modified (timestamp). Click asc, click again desc; idle columns show a neutral unfold icon.
  • Admin-priority carve-out β€” the default sort (column=name, direction=asc) floats Super Admin holders first, then Admin-only holders, then alphabetical within each tier. Any other sort or descending name treats admins as ordinary rows.
βœ“ 2026-04-21 β€” frontend 0841f63 + aeb3cc9
Pass 3 β€” UserForm v2 with single-pass create βœ…
  • Backend createUser accepts roleIds[] + tenant-validated carrierIds[]; user creation + UserRoleAssignment.createMany wrap in one $transaction. Sets tenantAccountId on the new user (fixes a latent legacy bug where new users had no tenant). Response mirrors getUser shape.
  • Frontend UserForm full visual + flow rewrite: avatar header on Edit (gradient navyβ†’cyan for admins, cyanβ†’teal for non-admins), 3 numbered sections (Profile / Roles & Permissions / Carrier Access), sticky footer with gradient CTA.
  • Password section: segmented strength meter (cyanβ†’teal gradient fills as score grows) + 5-rule inline checklist.
  • Roles section: clickable rows with RoleChip previews + department/action description + "Selected" chip summary on top.
  • Carrier Access: two radio cards (Unrestricted default / Restricted) β€” when Restricted, searchable multi-select scoped to ACTIVE + ONBOARDING carriers with live "N selected" counter and Clear.
  • "Session will be revoked" amber banner fires only when dirty role/carrier changes are present on Edit. Self-demotion client guard retained; Day 8's interim alert() hack removed β€” Day 9 global 401 handler covers self-edit token-version bumps.
  • Single-pass create retires the old "create profile then edit to assign" two-step flow.
βœ“ 2026-04-21 β€” backend e046451, frontend 18f712e
Pass 4 β€” Single Super Admin per tenant with handoff βœ…
  • Policy change β€” supersedes Decision #1 ("Multiple Super Admins allowed") below. Exactly one SA per tenant; break-glass for stranded tenants is a DB-level op (WeLink support only).
  • Backend gate: validateRoleIds returns {grantsSuperAdmin} and returns 403 SUPER_ADMIN_REQUIRED when a non-SA caller includes the SA preset. Enforced at createUser, replaceUserRoles, addUserRole.
  • Atomic handoff: new helper handoffSuperAdmin(tx, tenantAccountId, targetUserId) strips SA from every OTHER tenant user and revokes their sessions β€” runs inside the same $transaction as the role write + target revocation.
  • Admin/SA mutual exclusivity in UserForm: selecting Admin or Super Admin auto-clears all other roles (they already union every department + action). Selecting a non-admin role while Admin/SA is locked replaces the admin role. Non-admin rows render muted while Admin/SA is locked.
  • Role picker visibility: Super Admin is hidden entirely for non-SA callers. Visible at the top of the picker for SA callers.
  • Handoff confirmation modal fires at chip-selection time (not on save). Shows Caller (you) Β· Super Admin β†’ Admin + Target Β· β†’ Super Admin visualization + amber "both sessions revoked" notice. Cancel reverts the click; Confirm commits only SA as selected.
βœ“ 2026-04-21 β€” backend b2f465b, frontend 519eca0
πŸ§ͺ

E2E Validation (Live Staging)

ScenarioSetupResult
revoke-carrier Admin restricts target user's carriers; target's old JWT in-flight Stale JWT β†’ 401 "Session revoked" + redirect to /login?reason=session-revoked. Re-login mints fresh JWT that includes only the allowed carriers. βœ“
custom-role Admin creates "View Only" custom role (SAFETY + view only), assigns to target Target's GET /drivers β†’ 200, POST /drivers β†’ 403. Action gate fires correctly for view-only custom role. βœ“
cross-tenant Plain-Admin (no Super Admin) attempts to restrict a user to a carrier outside their tenant's CarrierAccess 400 with "One or more carriers are not in your tenant's access list". Super Admin bypasses this guard by design. βœ“
Live Safety Officer Moe, Safety Officer, one-carrier restriction, real browser session TopBar shows Safety only, Sidebar shows full Safety nav, all carrier dropdowns + list pages scoped to the single assigned carrier. Zero leakage. βœ“
βœ…

Decisions Locked In (Apr 21, 2026)

#DecisionRationale
1Single Super Admin per tenant, with handoffOne account owner per tenant. Granting Super Admin to another user auto-demotes the acting Super Admin to Admin in one transaction. Confirmation modal fires at chip-selection time, not on save. Break-glass for a stranded tenant is a DB-level operation (WeLink support only).
2Custom roles allowed, cap 5 per tenantDB-driven so custom roles compose cleanly. Cap keeps UI + audit manageable.
3Entity-level permissions deferred, entities: string[] reservedShip simple now, no schema churn later.
4Revocation immediate via tokenVersion bump + cache evictionMax 60s propagation; instant on eviction.
5Reuse User.carriers β†’ rename carrierRestrictionsZero data migration.
6In-memory Node Map for tokenVersion cache (not Redis)Single PM2 process on staging. Interface abstracted for Redis swap later.
7Presets as system RoleDefinition rows with tenantAccountId = nullSame table for presets + custom β€” one query, one UI.
8Frontend reads permissions from JWT, no per-button backend checksUser's explicit preference: "server not stressed, UX smooth".
🚧 Deferred Polish + Out of Scope

Deferred polish (candidate follow-up PR):

  • Row-level edit/delete icon guards on entity pages (backend already enforces 403; marginal visibility win)
  • Users.jsx list card showing RoleDefinition chips instead of legacy user.role.name
  • Remove the legacy user.role?.name !== "Admin" guard on Users page edit/delete
  • Update Users.jsx Create flow to use new RoleAssignments pattern consistently

Out of scope (original decisions hold):

  • Entity-level fine-grained permissions β€” schema field entities: string[] reserved, not wired
  • Custom role cap elevation beyond 5 β€” requires user request + justification
  • Redis-backed tokenVersion cache β€” swap in-memory Map when we scale to multi-process
  • Audit log of permission changes β€” Phase 2 concern (gap #8)
  • Time-limited role grants (e.g. "Dispatcher for 24h") β€” not in v1
  • Retire legacy Role / Permission / Organization tables β€” Phase 0.5 cleanup debt
βš–οΈ

Path Comparison

Decision Point Path A: WeLink-First Path B: SaaS-Ready
Folder Structure Keep flat controllers/routes Modular (modules/safety/, etc.)
Multi-Tenancy Single org (WeLink), add later Account model from start
External Customers Requires refactoring Ready immediately
Time to WeLink Production Faster (~3-4 months) Slower (~5-6 months)
Refactor Cost Later Higher (2-4 weeks when needed) Lower (already structured)
Module-Level Access Not supported Built-in (CarrierAccess.modules)
Subdomain Support Not supported Account.subdomain ready
Team Scaling Harder (everyone in same folders) Easier (assign devs to modules)
Microservice Extraction Major surgery Module already isolated
Complexity Now Lower Higher
Best For Solo dev, WeLink-only focus Growth plans, selling to others
🎯

Recommendation Matrix

Choose Path A If...

  • β†’ WeLink is the only user for 12+ months
  • β†’ Solo developer or very small team
  • β†’ Need to ship to production ASAP
  • β†’ Comfortable refactoring later
  • β†’ No immediate plans to sell TruxFlow

Choose Path B If...

  • β†’ Plan to sell to carriers within 6-12 months
  • β†’ Team will grow to 3+ developers
  • β†’ Need module-level access control
  • β†’ Want white-label/subdomain support
  • β†’ Prefer investing upfront over refactoring
πŸ’‘ Hybrid Approach Possible

You can start with Path A and adopt Path B patterns incrementally. Build new modules (Dispatch, Accounting) in the modular structure while keeping existing Safety code flat. Migrate Safety when convenient.

πŸ“‹ Field Registry β€” Complete Mapping

Every synced field between TruxFlow and Airtable. Check this BEFORE changing any field to avoid breaking sync. Source: airtableSync.js

1

Driver Fields (27 fields)

Core Driver Information

TruxFlow Field Airtable Field Dir Notes
firstNameFirst Name ( CDL Driver )β†’Required
lastNameLast Name ( CDL Driver )β†’Required
workEmailEmail Address→
workPhonePhone→
typeType→Maps: Driver→Company Driver, OWNER_OPERATOR→Owner Operator, FLEET_OWNER→Fleet Owner
statusSafety Status→Maps: ACTIVE→Active, ONBOARDING→Onboarding, DEACTIVATED→InActive, TERMINATED→Terminated
licenseNumberCDL #β†’
licenseExpireCDL EXPIRATION DATE→Date format: YYYY-MM-DD
operationStatusstatus - Operation Driver←INCOMING ONLY - Set by dispatchers
dispatchStatusDispatch Status←INCOMING ONLY - Cascades from truck

TWIC & Company Information

TruxFlow FieldAirtable FieldDirNotes
twicCardTWIC Card Required?β†’Boolean
twicCardNumberTWIC Card Number→
twicExpirationDateTWIC Card Expiration Date→Date
ownCompanyDo you have LLC / Corp ?→Maps: true→Yes, false→No
companyNameCompany name ( If Applicable )β†’
companyAddressCorp Address→
companyPhoneCorp Phone→
companyEmailCorp Email→
einNumberEIN Number→

Additional Driver Fields

TruxFlow FieldAirtable FieldDirNotes
SafetyNotesNotes→Text
endorsementExplanationCDL Endorcment→Note: Airtable field has typo
cleanBackgroundClean Background?β†’Boolean
cdlRestrictionCDL Restriction→
showLoadRateShow Load Rate→Boolean
orientationDateOrientation Date→Date
hiredDateHired Date→Date
telegramLinkTelegram Link→URL
carrier.nameCarrier Base Link→Linked record (carrier Airtable ID)

Driver Documents (11 types)

TruxFlow Doc TypeAirtable AttachmentAirtable ExpirationAirtable Required
CDL_FRONTCDL Front - Imageβ€”β€”
CDL_BACKCDL Back - Imageβ€”β€”
MEDICAL_CARDMedical Card - ImageMED EXPIRATION DATEMedical Registry
MVRMVRMVR EXPIRATION DATEβ€”
PSPPSPPSP EXPIRATION DATEPSP Required?
CLEARINGHOUSEClearinghouse - ImageClearinghouse Expiration DateClearinghouse Required?
DRUG_TESTDrug Testβ€”Drug Test Required?
TWIC_CARDTWIC Cardβ€”β€”
APPLICATIONAPPLICATIONβ€”β€”
SSNSocial Security - Imageβ€”β€”
W9W9β€”β€”
2

Truck Fields (18 fields)

Core Truck Information

TruxFlow FieldAirtable FieldDirNotes
unitNumberUNIT ID→Required, unique identifier
vinTruck Vin→
plateNumberPlate Number→
yearTruck Year→Converted to String
makeTruck Make→
modelTruck Model→
transmissionTransmission Type→Maps: MANUAL→Manual, AUTOMATIC→Automatic
relationTruck Relation→Maps: COMPANY_OWNED→Company Owned, OWNER_OPERATOR→Leased Owner Operator, LEASED_FLEET_OWNER→Leased Fleet Owner, LEASED_RENTAL→Leased - Rental

Truck Status & Operations

TruxFlow FieldAirtable FieldDirNotes
eldStatusELD Status→Maps: ACTIVE→Active, INACTIVE→InActive, EXEMPTED→exampted (lowercase)
fuelCardStatusFuel Card Statues→Maps: ACTIVE→Active Fuel Card, INACTIVE→InActive Fuel Card, NONE→No Fuel Card
safetyStatusSafety Status→Maps: ACTIVE→Active, ONBOARDING→Onboarding, PENDING→Pending, OUT_OF_SERVICE→Out Of Service, DEACTIVATED→DeActivated
statusStatus-Unit Operation←INCOMING ONLY - Maps: Activeβ†’OPERATING, InActiveβ†’NOT_OPERATING, Broken Downβ†’BROKE_DOWN
dispatchStatusDispatch Status←INCOMING ONLY - Set by dispatchers in Airtable
telegramLinkTelegram Group→URL
dispatcherNotesDispatcher Notes→Text
onboardingNotesOnboarding Notes→Text
titleOwnershipIssueDateTitle Ownership Issue Date→Date
carrier.nameCarrier Base Link→Linked record
⚠️ Critical: Operation Status is INCOMING ONLY

status (Status-Unit Operation) and dispatchStatus flow FROM Airtable TO TruxFlow only. Dispatchers control these fields. Do NOT sync them outgoing or you'll overwrite dispatcher changes.

Truck Documents (10 types)

TruxFlow Doc TypeAirtable AttachmentAirtable ExpirationAirtable Required
OWNERSHIPTitle Imageβ€”β€”
TITLE_RECEIPTRegistration ImageRegistration Expiration DateRegistration Required?
STATE_INSPECTIONState Inspection ImageState Inspection Due DateState Inspection Required?
DOT_INSPECTIONAnnual DOT Inspection IMAGEAnnual DOT Inspection Due Dateβ€”
CAB_CARDCab Card ImageCab Card Expiration DateCab Card Required?
FORM_22902290 Image2290 Expiration Date2290 Required?
LEASE_AGREEMENTLease Agreementβ€”β€”
PLATEPlate Imageβ€”β€”
W9W9 Formβ€”β€”
PICTURESFull picture of the vehicleβ€”β€”
3

Trailer Fields (16 fields)

Core Trailer Information

TruxFlow FieldAirtable FieldDirNotes
trailerNumberTrailer Number→Required, unique identifier
vinTrailer Vin→
plateNumberPlate Number→
trailerTypeTrailer Type→Maps: DRY_VAN→Dry Van, REEFER→Reefer, FLATBED→Flatbed, STEPDECK→Stepdeck, LOWBOY→Lowboy, DOUBLE_DROP→Double Drop, HOTSHOT→Hotshot
trailerDoorTypeDoor Type→
trailerSizeTrailer Size→Maps: 48/48_FEET→48 Feet, 53/53_FEET→53 Feet
numberOfAxlesNumber of Axles→Integer
trailerMakeMake - Trailer→
trailerModelTrailer Model→
trailerYearTrailer Year→Converted to String

Trailer Status & Operations

TruxFlow FieldAirtable FieldDirNotes
relationOwnership - Trailer→Maps: OWNED→OWNED - Hyper Lane, RENTAL→RENTAL, OWNER_OPERATOR→Owner Operator
suspensionSuspension→Maps: AIR_RIDE→Air Ride, SPRING→Spring
hubmeterReadingHubmeter Reading - Trailer→
noteNotes→Text
safetyStatusSafety Status→Maps: ACTIVE/ONBOARDING/PENDING→Active, OUT_OF_SERVICE/DEACTIVATED/INACTIVE→InActive
operationStatusOperating Status→Maps: OPERATING→Operating, NOT_OPERATING/BROKE_DOWN/TIME_OFF/SITUATION→Not Operating
ℹ️ Note: Carrier Base Link

Trailer's Carrier Base Link is a computed lookup field in Airtable β€” it's inherited through Driver/Unit linkages. Do NOT write to it directly.

Trailer Documents (6 types)

TruxFlow Doc TypeAirtable AttachmentAirtable ExpirationAirtable Required
TITLETitleβ€”β€”
REGISTRATIONRegistrationRegistration Expiration Dateβ€”
DOT_INSPECTIONDOT InspectionDOT Inspection Expiration Dateβ€”
STATE_INSPECTIONState InspectionState Inspection Expiration DateState Inspection Required?
PLATEPlate copyβ€”β€”
TRAILER_PICTURESTrailer Picturesβ€”β€”
4

Contractors

ℹ️ Contractors are TruxFlow-Only

Contractors are not synced to Airtable. They are created in TruxFlow during onboarding (auto-generated from Owner Operator driver data) and exist only in TruxFlow database.

5

Webhook Endpoints

Endpoint Dir Purpose
POST /api/webhooks/airtable/onboard ← UNIFIED - Create driver/contractor + truck + trailer + documents
POST /api/webhooks/airtable/truck/operation ← Sync Status-Unit Operation β†’ TruxFlow truck.status + driver.operationStatus
POST /api/webhooks/airtable/truck/dispatch ← Sync Dispatch Status β†’ TruxFlow truck.dispatchStatus + driver.dispatchStatus
POST /api/webhooks/airtable/driver ← Legacy - Create driver only (use /onboard instead)
POST /api/webhooks/airtable/truck ← Legacy - Create truck only (use /onboard instead)
6

Airtable Base & Table IDs

Bases

Dispatch: appgkNoBxps3vytbs Recruiting: appU3d2aJS4tk4A4d

Dispatch Tables

Drivers (2.1.): tbldwlXQlfx1qwRDP Trucks (2.2.): tblav6peuPlIwP8wg Trailers (2.5.): tblwGQQ9EomlBiM25 Carriers (4.1.): tblzMTF3LeUAIOBUL Driver Apps (7.4.): tblGb9dSQkk7r5NAz

Automation Scripts

Recruiting: /root/airtable-onboard-script.js Safety: /root/airtable-safety-onboard-script.js

Sync Code: /backend/utils/airtableSync.js

7

Enum Value Mappings

Driver Type Mapping

TruxFlowAirtable
DriverCompany Driver
OWNER_OPERATOROwner Operator
Owner OperatorOwner Operator
FLEET_OWNERFleet Owner ( Not driving )

Safety Status Mapping (Drivers)

TruxFlowAirtable
ACTIVEActive
ONBOARDINGOnboarding
PENDINGOnboarding
OUT_OF_SERVICEOut of Service
DEACTIVATED / INACTIVEInActive
TERMINATEDTerminated

Truck Relation Mapping

TruxFlowAirtable
COMPANY_OWNEDCompany Owned
OWNER_OPERATORLeased Owner Operator
LEASED_OWNER_OPERATORLeased Owner Operator
LEASED_FLEET_OWNERLeased Fleet Owner
LEASED_RENTALLeased - Rental

Operation Status (Incoming ←)

AirtableTruxFlow TruckTruxFlow Driver
ActiveOPERATINGOn Duty
InActiveNOT_OPERATINGNot Available
Approved OffTIME_OFFApproved Off
Broken DownBROKE_DOWNBroken Down
Home TimeTIME_OFFHome Time
SituationSITUATIONβ€”
1

Data Flow Architecture

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ DATA FLOW DIAGRAM β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ β”‚ β”‚ RECRUITING TRUXFLOW DISPATCH β”‚ β”‚ ────────── ─────── ──────── β”‚ β”‚ β”‚ β”‚ Driver Apps ──────────────► Driver ──────────────► Drivers Base β”‚ β”‚ (7.4.) /onboard + Docs (2.1.) β”‚ β”‚ β”‚ β”‚ β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β–Ί Truck ──────────────► Units Base β”‚ β”‚ β”‚ + Docs (2.2.) β”‚ β”‚ β”‚ β”‚ β”‚ └─────────► Trailer ──────────────► Trailers Base β”‚ β”‚ + Docs (2.5.) β”‚ β”‚ β”‚ β”‚ REVERSE SYNC (Dispatch β†’ TruxFlow): β”‚ β”‚ β”‚ β”‚ Units Base ──────────────► Truck.status β”‚ β”‚ (Status-Unit /truck/ Driver.operationStatus β”‚ β”‚ Operation) operation β”‚ β”‚ β”‚ β”‚ Units Base ──────────────► Truck.dispatchStatus β”‚ β”‚ (Dispatch /truck/ Driver.dispatchStatus β”‚ β”‚ Status) dispatch β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
2

Email Notifications

Postmark Integration

Event Subject Recipient
Onboarding Success βœ… Driver Onboarded: {name} Safety@welinkcargo.com
Onboarding Failure ❌ Onboarding Failed: {name} Safety@welinkcargo.com

Environment Variables

POSTMARK_API_TOKEN=3500a88a-... POSTMARK_FROM_EMAIL=noreply@welinkcargo.com SAFETY_NOTIFY_EMAIL=Safety@welinkcargo.com

Code Location

/backend/utils/email.js

Fire-and-forget, non-blocking

3

Unified Onboard Logic

POST /api/webhooks/airtable/onboard

Type Creates Links
Owner Operator Driver + Contractor + Truck + Trailer Driver→Truck, Trailer→Truck, Truck.owner
Company Driver Driver only β€”
Fleet Owner Contractor + Truck + Trailer Trailer→Truck, Truck.owner
4

Status Mappings (Reverse Sync)

Airtable β†’ TruxFlow Truck Status

Airtable ValueTruxFlow Enum
ActiveOPERATING
InActiveNOT_OPERATING
Approved OffTIME_OFF
Broken DownBROKE_DOWN
SituationSITUATION
Home TimeTIME_OFF

Airtable β†’ TruxFlow Driver Status

Airtable ValueoperationStatus
ActiveOn Duty
InActiveNot Available
Approved OffApproved Off
Broken DownBroken Down
Home TimeHome Time
5

File Storage (S3)

Linode Object Storage

Bucket: trux-flow Region: us-ord-1 Endpoint: us-ord-1.linodeobjects.com

Document Flow

Airtable β†’ Download β†’ S3 β†’ TruxFlow DB

Files transferred during onboarding, URLs stored in Document table

πŸ‘₯ Multi-User & Multi-Agent Collaboration

How two humans (and their Claude Code agents) work safely on this VPS β€” across multiple projects, sometimes the same project on different modules β€” without corrupting shared state, leaking credentials, or stepping on each other's git working trees.

Status (2026-05-04): Part A (Anthropic Console) complete. Part B (VPS Linux setup) drafted, partially pre-staged. Execution session 2026-05-05.

1

Architecture Overview β€” The Stack

The shape of two humans on one VPS

Hostinger VPS (single machine, single billing) β”‚ β”œβ”€β”€ /opt/claude-shared/ # Read-only shared rules β€” both users symlink in β”‚ β”œβ”€β”€ CLAUDE.md # Imports orchestrator β”‚ β”œβ”€β”€ businesses.md # Portfolio context β”‚ β”œβ”€β”€ infrastructure.md # VPS, domains, services β”‚ β”œβ”€β”€ coding-standards.md # Code conventions β”‚ β”œβ”€β”€ project-structure.md # Required folder layout β”‚ β”œβ”€β”€ workflow-rules.md # Discipline + protocols β”‚ β”œβ”€β”€ design-skills.md # Frontend design loop β”‚ └── feedback/ # Cross-team lessons learned β”‚ β”œβ”€β”€ /root/ # Moe (mode 700, isolated) β”‚ β”œβ”€β”€ .claude/ # Symlinks β†’ /opt/claude-shared/ + own state β”‚ β”‚ β”œβ”€β”€ CLAUDE.md β†’ /opt/claude-shared/CLAUDE.md β”‚ β”‚ β”œβ”€β”€ personal-info.md # Moe's own β”‚ β”‚ β”œβ”€β”€ communication-style.md # Moe's own β”‚ β”‚ β”œβ”€β”€ settings.json # Moe's permissions/hooks β”‚ β”‚ β”œβ”€β”€ .credentials.json # Subscription OAuth β”‚ β”‚ └── projects/-root/memory/ # Moe's auto-memory (private) β”‚ β”œβ”€β”€ truxflow-hosting/ # Working tree β”‚ β”œβ”€β”€ welink-ops/ β”‚ └── ... β”‚ └── /home/shady/ # Shady (mode 700, isolated) β”œβ”€β”€ .claude/ # Same symlinks β†’ /opt/claude-shared/ β”‚ β”œβ”€β”€ CLAUDE.md β†’ /opt/claude-shared/CLAUDE.md β”‚ β”œβ”€β”€ personal-info.md # Shady's own β”‚ β”œβ”€β”€ communication-style.md # Shady's own β”‚ β”œβ”€β”€ settings.json # Shady's permissions/hooks β”‚ └── projects/-home-shady/memory/ # Shady's auto-memory (private) β”œβ”€β”€ truxflow-hosting/ # Shady's clone β”œβ”€β”€ welink-ops/ └── ...

Each human is a separate Linux user. Auto-memory, settings, and OAuth credentials are per-user by design β€” no shared MEMORY.md to corrupt. Global rules live in /opt/claude-shared/ and are read-only for both: when Moe edits a global rule, Shady's next session sees the update instantly.

2

Anthropic Console Architecture

Org β†’ Workspaces β†’ Members β†’ Keys

Anthropic Organization (Moe, billing owner) β”‚ β”œβ”€β”€ Workspace: Default β”‚ β”œβ”€β”€ WeLink-Sale-Bot # Moe's general API key (misnamed; sits in ~/.bashrc) β”‚ └── Momo1claude # Trading bot key (hardcoded in claude_bot.py) β”‚ β”œβ”€β”€ Workspace: Shady's β”‚ └── shady-claude-code # New, scoped to this workspace only β”‚ └── Members β”œβ”€β”€ Moe β€” Owner (full access) └── Shady β€” Developer # Scoped to his workspace; cannot see Default; cannot touch billing

What this gets us

  • βœ“ Per-workspace usage dashboards β€” see exactly who/what spent the tokens
  • βœ“ Per-workspace spend caps β€” limit Shady's burn-rate without affecting bots
  • βœ“ Member scoping β€” Shady cannot see Default workspace
  • βœ“ One bill (Moe is owner), zero credential sharing

Billing model verified

  • πŸ“Œ Claude Code uses subscription OAuth (Moe's Pro/Max), not the API key in ~/.bashrc
  • πŸ“Œ Bots must use API keys (SDK doesn't accept subscription)
  • πŸ“Œ Two billing rails β€” they don't overlap. No double-billing.
  • πŸ“Œ Shady starts on API key. Switch to his own subscription if usage > ~$20/month.
Console gotchas (recorded for future)
  • ⚠ Individual accounts cannot invite members. Must upgrade to Organization tier first (free, no extra cost).
  • ⚠ Workspace member screen requires org-level user first. Two-stage: org-invite β†’ user accepts β†’ then add to workspace.
  • ⚠ Cannot rename keys or workspaces. Only disable/delete. Plan names carefully at creation.
3

VPS Linux User Architecture

Why /opt/claude-shared/ instead of /root/.claude/

/root/ is mode 700 β€” only root can read or traverse it. If shared rules stayed there, Shady couldn't follow a symlink into them. Moving them to /opt/claude-shared/ (mode 755, world-readable) lets both users symlink to the same source. One source of truth, two consumers.

/opt/claude-shared/ drwxr-xr-x (755 β€” world readable) β”œβ”€β”€ CLAUDE.md -rw-r--r-- (644 β€” read-only for users) β”œβ”€β”€ businesses.md β”œβ”€β”€ coding-standards.md β”œβ”€β”€ ... (7 files total + feedback/) β”‚ # Both users symlink in: /root/.claude/CLAUDE.md β†’ /opt/claude-shared/CLAUDE.md /home/shady/.claude/CLAUDE.md β†’ /opt/claude-shared/CLAUDE.md

What's shared vs per-user

Layer Location Shared / Per-User Why
Global rules (CLAUDE.md, businesses, coding-standards, etc.) /opt/claude-shared/ Shared (read-only via symlink) Same standards apply to everyone
Feedback corpus (cross-team lessons) /opt/claude-shared/feedback/ Shared (read-only) Lessons graduate from per-project to here
personal-info.md ~/.claude/ Per-user Different person, different identity
communication-style.md ~/.claude/ Per-user Different preferences possible
settings.json / settings.local.json ~/.claude/ Per-user Permissions, hooks should not bleed across
.credentials.json (subscription OAuth) ~/.claude/ Per-user (never share) Personal authentication tokens
Auto-memory (MEMORY.md + memory tree) ~/.claude/projects/<cwd>/memory/ Per-user (never share) Concurrent writes corrupt; each builds own context
Runtime: sessions, file-history, paste-cache, plugins, telemetry ~/.claude/ Per-user Per-session state, not shareable
4

Memory & State Isolation Model

The momentum question β€” answered

Per-user auto-memory does NOT mean Shady starts blind every session. The momentum on big projects lives in docs/SESSION-LOG.md, docs/decisions/, and docs/FIELD-REGISTRY.md β€” files that live inside the project's git repo, not in auto-memory. Both Claudes pull the same git, both load the same project docs at session start, both have full context.

Auto-memory is for per-human shorthand: "Moe prefers blunt pushback", "Moe is non-technical", "Moe's API key was rotated 2026-04-28". Shady's auto-memory holds his shorthand β€” and that's the whole point. Sharing it would actually be wrong.

Where project momentum lives

<project>/docs/ # In git, both Claudes read β”œβ”€β”€ SESSION-LOG.md # Every session, newest top β”œβ”€β”€ decisions/ β”‚ └── YYYY-MM-DD-*.md # Architectural decisions β”œβ”€β”€ FIELD-REGISTRY.md # Every field + dependencies β”œβ”€β”€ 03-DATA-MODEL.md # Schema source of truth └── modules/ └── <module>.md # Module spec

Where per-user shorthand lives

~/.claude/projects/<cwd>/memory/ β”œβ”€β”€ MEMORY.md # Per-user index β”œβ”€β”€ user_role.md # Who I am to this Claude β”œβ”€β”€ feedback_*.md # What I want repeated/avoided └── project_*/ # Per-project snapshots └── snapshot.md
The discipline this requires

The whole system depends on SESSION-LOG.md being updated at end of every session β€” a global rule already. With two humans, this rule becomes more load-bearing: skip a session log β†’ next person's Claude starts blind on what just changed.

5

Project Access β€” Git Strategy

Audit findings (2026-05-04) β€” what's actually wired up

Repo State GitHub Remote Auth
truxflow-hosting/app/backend git, has remote weLinkCargoEnterprise/TruxFlow SSH alias github-staging + deploy key
truxflow-hosting/app/frontend git, has remote weLinkCargoEnterprise/TruxFlow-Front SSH alias github-frontend + deploy key
security/reports-repo git, has remote weLinkCargoEnterprise/security-reports SSH alias github-security + deploy key
welink-ticket git, no remote β€” n/a
welink-website git, no remote β€” n/a
welink-airtable git, no remote β€” n/a
truxflow-preview git, no remote β€” n/a
SleepScape git, no remote β€” n/a
welink-ops not a git repo β€” n/a
10-4Hire not a git repo β€” n/a

Hybrid clone strategy for tomorrow

For the 3 GitHub-tracked repos

  • 1 Add Shady as collaborator on each GitHub repo
  • 2 Shady generates his own SSH key on the VPS
  • 3 Adds his pubkey to GitHub (not a deploy key)
  • 4 Clones via SSH from GitHub directly

Don't share Moe's deploy keys β€” those are single-purpose private keys per repo.

For repos without GitHub remotes

  • 1 Create local bare repo at /srv/git/<project>.git
  • 2 Group devs owns it; both users in the group
  • 3 Moe pushes his working tree β†’ bare repo
  • 4 Shady clones from bare repo
  • 5 Both push/pull against bare repo

GitHub remote restoration deferred to follow-up session. Code lives only on VPS until then β€” flag for backup.

6

Concurrency Rules β€” How Two Humans Don't Step on Each Other

The default rule

One human per project at a time, unless using git worktrees on different modules with non-overlapping files.

  • πŸ“’ Announce before starting (Telegram/Slack/etc.) β€” "I'm on welink-ops for the next 2 hours"
  • ⬇ Always git pull at session start
  • ⬆ Always git push at session end β€” never commit and disappear
  • ⚠ Shared files (prisma/schema.prisma, docs/03-DATA-MODEL.md, root config) β€” coordinate before touching mid-session

For TruxFlow specifically β€” concurrent module work

Both can work TruxFlow at the same time only if (a) different modules, and (b) docs are split per-module. Today the docs are still unified β€” that's a blocker we'll resolve in a follow-up session before any real concurrent work happens.

truxflow-hosting/docs/ # Future shape (per-module split) β”œβ”€β”€ SESSION-LOG.md # Project-wide events only β”œβ”€β”€ FIELD-REGISTRY.md # Index β†’ per-module registries β”œβ”€β”€ decisions/ # Cross-module decisions └── modules/ β”œβ”€β”€ safety/ β”‚ β”œβ”€β”€ SESSION-LOG.md # Safety dev writes here only β”‚ β”œβ”€β”€ FIELD-REGISTRY.md β”‚ └── CLAUDE.md # Module-specific Claude rules └── dispatch/ β”œβ”€β”€ SESSION-LOG.md # Dispatch dev writes here only β”œβ”€β”€ FIELD-REGISTRY.md └── CLAUDE.md

Different files = no merge conflicts. Each Claude session loads project-wide log + its module's log + its module's decisions.

7

Multi-Agent Setup β€” Workspace Approaches Compared

For same-project, different-module concurrent work

Approach Isolation Sync Disk Space Conflict Risk
Same folder, same branch None N/A Low High
Same folder, different branches Partial Git checkout Low Medium
Git worktrees (Recommended for same-project) Full Same repo Medium Low
Separate clones (Default β€” different humans) Full Git pull/push High Low
Different servers Full Git + deploy Highest None

Default: separate clones (per-user home). Same-project parallel work: add a worktree on a feature branch.

Worktree commands (for same-project parallel work)

# From within the main checkout cd ~/truxflow-hosting # Create a worktree on a feature branch git worktree add ~/truxflow-dispatch feature/dispatch-load-board # List current worktrees git worktree list # Remove when done git worktree remove ~/truxflow-dispatch
8

Rollout Plan β€” Where We Are

Part A β€” Anthropic Console (βœ“ Complete 2026-05-04)

Step Action Status
A1Audit existing keys (hash-based, no values exposed)βœ“ Done
A2Confirm billing model (subscription vs API)βœ“ Done
A3Upgrade Anthropic account from Individual β†’ Organizationβœ“ Done
A4Create Shady's workspaceβœ“ Done
A5Create Shady's API key, store in Moe's password managerβœ“ Done
A6Send Shady org-level Member invite (work email)βœ“ Done
A7Shady accepts invite + added to workspace as Developerβœ“ Done

Part B β€” VPS Linux Setup (drafted; partial pre-stage done; execute 2026-05-05)

Phase Action Estimate Status
1Migrate shared rules to /opt/claude-shared/ + symlink back2 minβœ“ Done
2Create shady Linux user with sudo30 secβœ“ Done
3Install Shady's SSH public key + lock down auth1 minβœ“ Done
4Set up /home/shady/.claude/ + symlinks + per-user templates2 minβœ“ Done
5Install Claude Code (per-user binary) for Shady3 minβœ“ Done
5bInstall claude-mem plugin for Shady1 min⏳ With Shady
6Wire Shady's API key into ~/.anthropic_env1 minβœ“ Done
7Project clones β€” hybrid (GitHub for 3 repos, bare repos for rest, git-init for welink-ops & 10-4Hire)10 min⏳ With Shady
8Shady's first-session protocol verification5 min⏳ With Shady
9Verification checklist + sign-off2 min⏳ With Shady

~5 of 9 phases pre-executed 2026-05-04 evening. Remaining (with Shady): ~10 min total β€” claude-mem plugin install + project clones + first-session test.

9

Findings From the 2026-05-04 Audit

What we discovered while planning this

  • πŸ”‘ Two API keys live, both in same Anthropic account. WeLink-Sale-Bot is misnamed β€” it's actually Moe's general-purpose key. Momo1claude is the trading bot key, hardcoded in claude_bot.py + 6 backup copies.
  • πŸ’³ Claude Code uses subscription OAuth, not the API key. The ANTHROPIC_API_KEY in ~/.bashrc sits idle for Claude Code. Confirms zero double-billing.
  • πŸ™ 3 GitHub remotes exist with deploy-key SSH aliases (TruxFlow backend, frontend, security-reports). 7 other projects have no GitHub remotes; welink-ops and 10-4Hire aren't even git repos.
  • πŸ”’ API key plaintext leakage β€” same key appears in ~/.bashrc, PM2 dump, claude-mem logs, file-history snapshots, .bash_history. Rotation + scrub deferred to a future session.
  • πŸ“¦ Claude Code and claude-mem are per-user installs. Shady will need both reinstalled in his account.
  • πŸ“Š Sales bot stopped logging April 7, trading bot zero usage in May. Either crashed or paused β€” diagnostic deferred.
10

Deferred to Future Sessions (Not Blocking)

Item Why deferred Approx. effort
Key rotation + plaintext scrub (bashrc, PM2 dump, logs, file-history, claude_bot.py.save.*) Touches live bot configs; needs careful sequencing ~30 min standalone session
Trading bot key migration (hardcoded β†’ env var) Bundle with rotation ~10 min
Sales bot diagnostic β€” why zero usage post April 7 Not blocking; investigate during next bot work ~15 min
TruxFlow per-module SESSION-LOG / FIELD-REGISTRY split Required before truly-concurrent same-project work ~1 hour
GitHub remote restoration for 7 repos without remotes Bare repos at /srv/git/ are interim ~30 min β€” varies by repo
welink-ops + 10-4Hire git initialization Bundle with GitHub restoration ~10 min
Two-human coordination rule formalized in /opt/claude-shared/workflow-rules.md Tight, just need the section drafted ~10 min
Disable SSH password auth globally after Shady's key works Safer for both users; mid-Phase-3 if desired ~2 min
11

Quick Reference

Key paths

/opt/claude-shared/ /root/.claude/ /home/shady/.claude/ /srv/git/<project>.git

Authoritative docs

/root/multi-user-setup/SESSION-LOG.md /root/multi-user-setup/part-a-anthropic-console.md /root/multi-user-setup/part-b-vps-linux-setup.md

Memory pointer

~/.claude/projects/-root/memory/ multi-user-setup-2026-05-04.md (indexed in MEMORY.md)

Shared rules (read-only)

CLAUDE.md, businesses.md infrastructure.md, coding-standards.md workflow-rules.md, design-skills.md project-structure.md, feedback/

Per-user (never share)

personal-info.md communication-style.md settings.json, .credentials.json projects/<cwd>/memory/

Prerequisites β€” all collected βœ“

SSH key: SHA256:7d2Af3xj... (saved) GitHub: shady.3zzam@gmail.com (collab added) Anthropic Member: shady.azzam@welinkcargo.com No blockers for tomorrow.
πŸ“Œ Architecture in one sentence

Two Linux users on one VPS, sharing read-only global rules from /opt/claude-shared/, isolating per-user state and auto-memory, coordinating through git (GitHub for 3 repos, local bare repos for the rest), with one-human-per-project as the default and worktrees as the escape hatch for parallel module work β€” all while one Anthropic Org bills both users with per-workspace attribution.