# Synchron carriers, clients and drivers → HeavyHaul Agent

Written 2026-09-25. Preloads the Synchron Permits ecosystem (carrier companies, the
people who dispatched and drove for them, and their historical trips) into the
existing tables so those people can later register, verify their email, claim their
identity and see the historical trips they were on.

**Nothing here creates a login.** An imported person is a `profiles` row with
`claim_status = 'unclaimed'`: no `auth_accounts` row, no password, no session.

## The model (no parallel identity system)

| Object | Table | Import writes | After the claim |
|---|---|---|---|
| Carrier company | `companies` | `company_type = 'carrier'`, `source_system = 'synchron'`, `source_record_id = <Synchron carrier id>` | unchanged |
| Person | `profiles` | `claim_status = 'unclaimed'`, `historical_import = true`, `source_record_id = <Synchron user id>`, `email_normalized`, `source_roles`, `import_flags` | `claim_status = 'claimed'`, `claimed_at`, `claim_method = 'email_verified'` |
| Person ↔ company | `company_memberships` | `status = 'historical_pending_confirmation'`, `permission_level = 'member'`, `source_record_id = carrier:<id>:user:<id>` | `status = 'approved'`, `confirmed_at`, `verification_method = 'historical_email_claim'` |
| What they do there | `membership_roles` | `role_type = carrier_dispatcher` and/or `carrier_driver`, `status = 'pending'`, `source_role = synchron_client` / `synchron_driver` | `status = 'active'` (+ `role_audit_log` `role_verified`) |
| Person on a trip | `trip_participants` | already written by the trip import: `status = 'imported'`, `user_id = null`, `source_record_id = user:<id>` | `status = 'active'`, `user_id`, `claimed_at` |

Role mapping: a Synchron **Client** row becomes `carrier_dispatcher`, a **Driver** row
becomes `carrier_driver`; a person in both files (an owner-operator) gets both roles on
the same membership. Company Admin is never granted by the import.

The profile id of an unclaimed person is derived from the normalised email exactly the
way a self-signup id is (`identifierToUserId('email:<email>')`), so when the person signs
up with that email the account lands on the imported profile and every membership, role
and participant row already points at the right id.

## Cross-reference by token, not by id

Synchron gives every record its own token and their API references records by token
rather than by database id. Two different things are called token over there, and
confusing them breaks a cross-reference silently:

| | What it is | Where we keep it |
|---|---|---|
| **Order token** | 8 digits, the number people quote; `HH-83224251` is built from it | `trips.synchron_order_token` (migration 0035) |
| **Entity token** | 16 characters, e.g. carrier `4d6kAEJnSHv9YBOR`, user `SKYAWTDUOR8KBTVF` | `source_token` on companies, profiles, trip_participants, fleet_units and leads (migration 0040) |

**The token is the key to join on when talking to their API.** `source_record_id` keeps the
numeric id as the secondary reference and as the idempotency key for re-importing the CSV
exports. A `company_memberships` row has no Synchron entity of its own, so it has no token; it
is resolved through the tokens on its two ends.

Tokens are identifiers, not credentials — `remember_token` stays on the forbidden list in
`scripts/synchron/lib/sanitize.mjs`, while the plain `token` now comes through on people and
carriers so the mappers can store it.

Rows imported before 0040 are filled in by:

```
node scripts/synchron-people/backfill-tokens.mjs imports/synchron-people-2026-09-25            # dry run
node scripts/synchron-people/backfill-tokens.mjs imports/synchron-people-2026-09-25 --apply
```

It matches on the numeric id we did store and writes only the token. Idempotent.

## Migrations

- `0037_freight_broker_rename.sql` — retires the role value `broker_agent`; the role is
  `freight_broker` in `membership_roles.role_type`, the legacy `company_memberships.role`,
  `company_claims.requested_role`, `role_audit_log.role_type` and the mode keys in
  `auth_accounts`. Legacy carrier relationships stored under the broker value become
  `carrier_dispatcher`. The legacy role column also accepts `carrier_driver`.
- `0038_historical_profiles.sql` — the claim fields on `profiles`, provenance on
  `company_memberships` and `membership_roles`.
- `0040_synchron_tokens.sql` — `source_token` wherever a Synchron record is referenced, so the
  cross-reference matches what their API actually sends.

Verify with `node scripts/verify-migration-0037.mjs`, `…-0038.mjs` and `…-0040.mjs`.

## Running the import

The exports go in a folder under `imports/` (gitignored — personal data) as
`carriers.csv`, `clients.csv`, `drivers.csv`.

```
node scripts/synchron-people/analyze.mjs imports/synchron-people-2026-09-25
```

Phase A. Reads the three files and the live tables, writes nothing, produces
`phase-a-report.md` (the §38 checklist), `manual-review.csv` and `plan.json`.
**Do not run the phases below before the report is reviewed.**

```
node scripts/synchron-people/import.mjs <dir> --phase carriers                 # dry run
node scripts/synchron-people/import.mjs <dir> --phase carriers --apply
node scripts/synchron-people/import.mjs <dir> --phase people --apply
node scripts/synchron-people/import.mjs <dir> --phase memberships --apply
node scripts/synchron-people/import.mjs <dir> --phase roles --apply
node scripts/synchron-people/import.mjs <dir> --phase participants             # reconciliation only
```

Every phase re-reads the live tables before writing, so a phase run twice changes
nothing. Flags:

- `--only-carriers 1,109,26` — limit every phase to those Synchron carrier ids (the §49 pilot).
- `--include-review` — also write held people, with `claim_status = 'manual_review'` (they cannot self-claim).
- `--allow-generic-mailboxes` — a role mailbox (dispatch@, info@…) used by exactly one Synchron user becomes an unclaimed profile instead of being held.
- `--batch <name>` — stored in `profiles.import_batch`.

Each run writes `import-result-<phase>-<applied|dry>-<time>.json` (the §39 report).
No email is ever sent by these scripts.

## What the analysis holds back

- A person with **no carrier link** in Synchron is *deferred*: nothing to attach.
- **Synchron staff** (synchrontms.com addresses, or staff roles with no carrier) and
  **test rows** are skipped. A staff role on a person who also has carriers is held.
- **No / invalid email**, **shared email across different names**, **generic mailbox**,
  **linked to 4+ carriers** → `manual_review`.
- A carrier whose **MC already belongs to an HHA brokerage**, matched **by name only**,
  or a **test carrier / no MC and no DOT** → held or skipped.

## Claiming (what happens at sign-up)

1. The person asks for a sign-in code (`/api/auth/email-code`, op `start`). If an
   unclaimed profile carries that email the reply adds: *We found previous activity
   associated with this email. Verify your email to connect your historical trips and
   company relationships.* Nothing about the trips is shown.
2. The code is verified. `ensureSelfAccount` (or the env-login branch) calls
   `claimHistoricalProfile` with `emailVerified = true`.
3. `decideHistoricalClaim` (pure, `src/lib/domain/historical-claim.ts`): same id →
   claim in place; an existing account with another id → the memberships and roles are
   re-pointed to it; held / conflicting profiles are never claimed automatically.
4. Memberships `historical_pending_confirmation → approved`, roles `pending → active`
   (audited), participants `imported → active` with `user_id` (by verified email and by
   `user:<source id>`), profile `claimed`.
5. The sign-in screen shows *Your historical HeavyHaul/Synchron activity has been
   connected.* with the connected modes, e.g. `Carrier Dispatcher — ABC Transport`,
   `Carrier Driver — ABC Transport`; Switch Mode then offers both.

## The security rule (§43)

Imported trips are never returned to an unverified or unclaimed person, and this does
not depend on the UI: a `trip_participants` row with `status = 'imported'` grants no
access anywhere (`ACCESS_STATUSES` in `src/lib/domain/participants.ts`), and only the
claim — which runs only after the email code is accepted — turns a row `active`. A
self-service account cannot exist unverified, and an admin-provisioned login claims
only when it signs in with an emailed code.

## Pilot before the full run (§49)

1. Back up `companies`, `profiles`, `company_memberships`, `membership_roles`, `trip_participants`.
2. Dry-run every phase.
3. Run with `--only-carriers` for 5–10 carriers that together cover: a dispatcher-only
   person, a driver-only person, a dual-role owner-operator, a driver without an email
   (`--include-review`), an existing HHA user, a shared-mailbox case.
4. Sign up with one of the emails, confirm the *previous activity* notice, verify the code,
   confirm the *connected* screen, the modes in Switch Mode and that only that person's
   historical trips open.
5. Confirm a different verified account sees none of those trips.
6. Then run the full import.

## Run log

**2026-09-25 — exports of 2026-09-25 (164 carriers, 898 clients, 1,646 drivers).**
Reviewed by Nash record by record (decisions in the gitignored `overrides.json`), pilot on
7 carriers with a sign-up test that passed (previous-activity notice, code, connected screen,
both modes in Switch Mode, one historical trip visible, hidden from other accounts), then the
full run. Every phase re-run afterwards: nothing written, so the import is idempotent.

| Result | Count |
|---|---|
| Carrier companies created | 147 |
| Carrier companies reused (5 corrected: 4 brokerage rows converted to carrier, 1 with MC/DOT fixed, 1 seeded duplicate merged) | 7 |
| Unclaimed profiles | 1,357 |
| Memberships (`historical_pending_confirmation`, `member`) | 1,413 |
| Dispatcher roles / driver roles (all `pending`) | 324 / 1,279 |
| Dual-role people | 181 |
| Imported trip participants matched by Synchron id, still unclaimed | 92 |
| Skipped (tests, Synchron staff, dropped by review) | 62 |
| Deferred, no carrier link (future carrier leads) | 580 |
| Login accounts created | 0 |
| Emails sent | 0 |
