# Multi-Company Management — Engineering Handoff

> **Read this first.** It is the single entry point for continuing the Multi-Company
> backend work. It maps the work back to the Jira spec, records the load-bearing
> decisions, lists exactly what is done vs remaining, and — most importantly —
> documents the repeatable patterns so you can keep going safely.
>
> Companion docs (all in `docs/multi-company/`):
> - [`tenant-registry.md`](tenant-registry.md) — the per-model registry/checklist + keyspaces + decisions (§0).
> - [`write-path-and-permissions.md`](write-path-and-permissions.md) — the P6 write/permission decision.
> - [`activation-runbook.md`](activation-runbook.md) — how to turn multi-company on for a pilot user.
> - [`frontend-company-management-contract.md`](frontend-company-management-contract.md) — **(2026-06-18)** FE API contract for the new *Gestion des entreprises* CRUD + the global header **scope selector** (single-company-at-a-time; `X-Company-Id` active-scope layer over the resolver). Unblocks the FE dev; also the backend roadmap for those two features.
> - [`AGENT-HANDOFF.md`](AGENT-HANDOFF.md) — **start here if you're the next AI agent.** Action-oriented: current state, prioritized next tasks, patterns to copy, gotchas. (Postman: `postman/MultiCompany-Fleet.postman_collection.json`.)

---

## 0. TL;DR — current state

- **Reads** work multi-company on **30 globally-scoped models + Vehicle's lists**. All **inert in prod** until a `company` scope is assigned to a user (nothing changes for single-company users).
- **Writes** stay home-only by design (the P6 default — see §3).
- **46 tests green** (`tests/Feature/Scopes`, `tests/Feature/Services/Tenancy`, `tests/Feature/Fleet/Company`), 281 assertions.
- **Header active scope + full company management LIVE (2026-06-18):** `AccessibleCompanyResolver` narrows every tenant-scoped query to the `X-Company-Id`-selected company (leaf) or parent+children (group), validated ⊆ accessible, inert when absent; `GET /api/v1/fleet/scope/options` feeds the dropdown; the **full `Gestion des entreprises` CRUD** (`/api/v1/fleet/companies` — tree/show/parent-options, create [headless child], update+reparent, disable/enable, restricted hard-delete, sites assign/unassign) + `sites.company_id` shipped. **Remaining (non-blocking):** `/scope/current` persistence, optional selector-validation middleware, auth grant gating — see [`frontend-company-management-contract.md`](frontend-company-management-contract.md).
- Two follow-up tasks already spawned (see §8).
- The hard remaining work is the **OWNER-keyspace core tables** and the **car-policy/commande/AO epic** — both are *more of the same pattern*, just bigger.

---

## 1. The spec, in one paragraph

The ticket wants: companies own all business data; users get **many-to-many access** to companies; a **strict one-level** company hierarchy; **access delegated to the auth system**; the backend **filters every query by the resolved accessible company IDs**. See §6 for the per-section conformance scorecard.

## 2. How it works (architecture)

Two id spaces exist in this codebase (this is the crux — see `tenant-registry.md §1`):
- **OWNER keyspace** — older tables key tenancy on the fleet-owner `users.id` (column is `user_id`, or a `company_id` that is a **FK to users**). e.g. `vehicles.user_id`, `expenses.company_id→users`.
- **COMPANY keyspace** — newer tables key on the real `companies.id` (column `company_id` FK to `companies`). e.g. `tickets`, `maintenance_plans`.

The flow:
```
AUTH SERVICE                          FLEET BACKEND
user_scopes(scope_type='company',  →  AccessibleCompanyResolver::forRequest()
            value=<companies.id>)        → {companyIds[], ownerIds[], fullAccess}
                                          (home company + granted scopes,
                                           +1 hierarchy level, company→owner via
                                           companies.user_id)
                                       →  App\Scopes\TenantScope (global, opt-in per model
                                          via config/tenancy.php) filters each query:
                                            COMPANY model → whereIn(company_id, companyIds)
                                            OWNER  model → whereIn(user_id,   ownerIds)
```

Key code:
- `app/Services/Tenancy/AccessibleCompanyResolver.php` (+ `AccessibleScope.php` DTO) — the resolver.
- `app/Scopes/TenantScope.php` — the global scope; `applyScope()` is pure & unit-testable.
- `app/Models/Traits/HasTenantScope.php` — opt-in trait.
- `config/tenancy.php` — **the registry**: `Model => {keyspace, strategy, column}`. A model that uses the trait but is missing here is **denied all rows (fail closed)**.
- Resolver bound `scoped()` in `app/Providers/AppServiceProvider.php`.

**Inert by construction:** with no `company` scope, the resolver returns home-only, so `whereIn(col,[home])` == today's `where(col, home)`. The scope **skips** entirely in console/jobs and for MCP tokens, and **internal admins (`users.internal_role` set) get full access** (so admin cross-tenant screens keep working).

## 3. Load-bearing decisions (do not silently reverse)

1. **§5 = OUTCOME, not literal** (accepted by product). Each record belongs to exactly one company (guaranteed by UNIQUE `companies.user_id`), so we do **not** re-point legacy OWNER tables to `companies.id`. Owner-user-id is the agreed proxy. → No mass FK migration. (`tenant-registry.md §0`.)
2. **P6 = read-only cross-company (default).** A `company` scope grants **visibility, not write authority**. Creates stamp the home company; update/delete keep a home-only guard. (`write-path-and-permissions.md`.) Phase 2 (cross-company writes) is sketched but NOT built — it needs product sign-off + a separate `company_write` grant.
3. **Vehicle is NOT globally scoped.** It has ~40 `Vehicle::find($id)`-by-id sites; a global scope would 500 them on cross-tenant input. Vehicle uses an **opt-in** `Vehicle::accessibleTenant()` scope on its LIST endpoints instead. (`tenant-registry.md §0`.)

## 4. What is DONE

**Foundation:** resolver (7 tests), inert chokepoint + trait + registry + `scoped()` binding (6 tests), registry-integrity test (validates every registered model each run), `nullable_shared` strategy validated on `custom_statuses`.

**28 models globally flipped** (read multi-company, write home-only) — in `config/tenancy.php`:
`AenRule` (OWNER), `Ticket`, `MaintenancePlan`, `TollBadge`, `FuelCard`, `VehicleOrder`, `GenericReminder`*, `SinistreAssuranceStatus`, `TollBadgeService`, `FuelCardProduct`, `TelematicsBoxOrder`, `Campaign`*, `FleetElectrificationReport`, `Fps`*, plus the §5.2 batch-1 OWNER ledgers `Expense`, `FuelHistory`, `ChargingHistory`, `MeterHistory`, `MobilityExpense`, batch-2 claims/inspection `Sinistre`, `Inspection`, `Issue`, batch-3 services/reminders `InhouseService`, `VehicleReminder`, `ServiceReminder`, batch-4 telematics/labels `Alert`, `Device`, `Label`, and deferred-pile `DeviceAlert` + `Infraction` (Infraction's two route-bound writes got an explicit home-owner guard) (all OWNER, `company_id`→users).
(*`GenericReminder`, `Campaign`, `Fps`: per-item **detail reads** are now multi-company too (§5.1) — Campaign `show`/`statistics`/`responses` and Fps `show`/`getAssignedDriver` dropped their home-only guards; `GenericReminder::show` was already flipped. Mutations stay home-only. **Only remaining:** `GenericReminder::listByVehicles` — a home-only list entangled with the vehicle id-space bug; flip it together with task_ac331afc.)

**Vehicle** — lists flipped via `Vehicle::accessibleTenant()` at the 4 fleet list reads (main grid, getAll, car-sharing availability, option filters). `find()`-by-id deliberately untouched (see §8 task).

**Tests:** `tests/Feature/Scopes/*` (TenantScope, TenancyRegistry, TicketMultiCompanyRead, AenRuleMultiCompanyRead, CompanyFlipBatch, OwnerFlipBatch, NullableSharedScope, VehicleAccessibleScope, DetailReadFlip) + `tests/Feature/Services/Tenancy/AccessibleCompanyResolverTest`. **28 green.**

**Auth side:** `company` scope_type added to `dadycar-auth-service` `ScopeTypeSeeder` (run `php artisan db:seed --class=ScopeTypeSeeder` there).

## 5. What REMAINS (the work list)

> The master per-model checklist with keyspaces + evidence is `tenant-registry.md §3`. Below is the prioritized plan. **Every item uses the patterns in §7 — copy them.**

### 5.1 Finish the 3 list-only flips — ✅ mostly DONE (2026-06-15)
Per-item **detail reads** are now multi-company (visibility); mutations keep their home-only guards (P6):
- **Campaign** — `show`/`statistics`/`responses` dropped the `company_id !== home → 403` guard; the route-model binding now governs via `TenantScope`.
- **Fps** — `show`/`getAssignedDriver` dropped `isUserAllowed`; the scoped find / route binding governs. (List + export already used `Fps::query()`.)
- **GenericReminder** — `show` was already flipped (route-model binding).

Covered by `tests/Feature/Scopes/DetailReadFlipTest` (Campaign via the real global scope; Fps via isolated `applyScope`).

**Remaining (do with task_ac331afc, not standalone):** `GenericReminder::listByVehicles` is still a home-only list **and** carries the vehicle id-space bug (it filters `vehicles.company_id` — which holds the owner-user-id — against a `companies.id`). Flip its reminder query to `GenericReminder::query()` **together with** the `vehicles.user_id` fix; half-flipping cross-wires the home company's vehicles onto a scoped company's `applies_to_all_vehicles` reminders.

### 5.2 OWNER-keyspace core tables (BIG — the bulk of remaining reads)
These older tables still scope home-only via explicit `where(user_id|company_id→users, $owner)`. They are the siblings of Vehicle. Approach per model: if it has **no risky find-by-id**, attach `HasTenantScope` with `keyspace=owner` + register + flip its list reads (like AenRule). If it has Vehicle-like `find($id)` exposure, use the `Vehicle::accessibleTenant()` opt-in pattern (§7.2) instead.

**✅ Batch 1 done (2026-06-15)** — the 5 vehicle-ledger models: `Expense`, `FuelHistory`, `ChargingHistory`, `MeterHistory`, `MobilityExpense` (all low find-by-id surface → global-scoped). Per model: trait + registry entry; **fleet** list helper `$user->companyXxx()` → `Model::query()`; `show` dropped its home-only `isUserAllowed` read-guard (non-accessible → 404). Kept home-only: update/delete (`isUserAllowed`), store (stamps home owner). **Left as-is (safe / later):** mobile controllers (employee-scoped, single-company by nature); admin company-delete cascades (internal-admin fullAccess skips the scope); the FuelHistory **export closure** (`FuelHistoryController:429` builds the query *inside* a possibly-queued export class — the relation is safe in job context where the scope skips, `Model::query()` would leak); report/dashboard relation reads (stay home-only → P7 / §5.6). Test: `tests/Feature/Scopes/OwnerFlipBatchTest` (MeterHistory representative). **Lesson for the next batch: only swap to `Model::query()` in guaranteed request context; deferred/queued closures must keep the owner relation.**

**✅ Batch 2 done (2026-06-15)** — claims/inspection cluster `Sinistre`, `Inspection`, `Issue` (OWNER, `company_id`→users; `isUserAllowed`-guarded writes). Same shape as batch 1: trait + registry entry; fleet list helper → `Model::query()` (`SinistreService::getSinistresWithFilters`, `IssueController::getIssuesWithFilters`, `InspectionController::getInspectionWithFilters`); `show` dropped its home-only read-guard — Issue/Sinistre gained a `null → 404` guard (their finds can return null), Inspection relies on route-model binding. Left home-only: mobile, the Issue export closure (relation), reports/dashboard, and `SinistreController::getConversations`/`restore`. Covered by TenancyRegistryTest (column + trait) + OwnerFlipBatchTest (mechanism).

**✅ Batch 3 done (2026-06-15)** — services/reminders cluster `InhouseService`, `VehicleReminder`, `ServiceReminder` (OWNER, `company_id`→users; `isUserAllowed`-guarded writes). Same shape: trait + registry entry; fleet list helper → `Model::query()` (`InhouseServiceController::getInhouseServicesWithFilters`, `VehicleReminderController::getVehicleRemindersWithFilters`, `ServiceReminderController::getServiceRemindersWithFilters`); `show` dropped its home-only read-guard — VehicleReminder + ServiceReminder gained a `null → 404` guard (which also fixed a latent null-deref: `show` called `->isUserAllowed()` / `->service` on a possibly-null find). Left home-only: mobile, export closures (relations), reports/calendar/dashboard, and the global console crons (`CheckPartnerServicesStatus`/`CreateServiceRemindersCommand`/`SendReminderEmailNotifications` — scope skips in console by design, so they keep processing all companies as intended).

**✅ Batch 4 done (2026-06-15)** — telematics/labels cluster `Alert`, `Device`, `Label` (OWNER, `company_id`→users — note Label's column was **renamed from `user_id`**, still FK to users; registry §3a corrected). Trait + registry entry; fleet list helper → `Model::query()` (`AlertsReportController` ×3 request-context sites, `DeviceController::getAllDevices`, `LabelController::index`+`getAllLabels`). Alert/Device have no per-item show/mutations to guard (Alert is job/observer-created; Device writes are vehicle-ownership-guarded; webhook/console paths scope-skip). Label `show` dropped its home-only read-guard (route-model binding); Label writes keep `isUserAllowed`. Left home-only: mobile, the Alert export closure (`$this->request->…` — relation, untouched), report generators (deferred, explicit `when($companyId)` filter).

**✅ Deferred-pile pass #1 done (2026-06-15)** — `Infraction` + `DeviceAlert`. **Infraction**: list → `Infraction::query()`; `show`/`checkFleet` are route-bound reads (now multi-company); the two previously-unguarded route-bound writes (`delete`, `storeDesignation`) got an explicit **home-owner guard** (`infraction.company_id !== homeOwnerId → 422`) to keep writes home-only (P6). **DeviceAlert**: the ~7 explicit `where('company_id', $companyId)` reads in `AlertsWebhookController` → `DeviceAlert::query()`; webhook `saveAlert` + telematics jobs scope-skip (unchanged).

**⚠ Deferred — need a write-guard pass / extra care before flipping** (route-bound/unguarded writes the read-flip would expose once a `company` scope is granted):
- **`InspectionForm`** — `update`/`delete`/several `show*` use unguarded `InspectionForm::findOrFail($id)`; the inspection-submission **FormRequest** also does `InspectionForm::findOrFail(form_id)` (will fail-closed if the trait is added without a registry entry — do both in one commit). Has form-builder + nestable sections; `selectAllForms` currently returns ALL forms (the scope fixes it). Add home guards to writes.
- **`PartnerService`** — has **two** fleet controllers (`Api\Fleet\PartnerServiceController` + `Api\V1\Fleet\PartnerService\PartnerServiceController`); confirm which are routed and flip both list helpers + `show`s. Writes are `isUserAllowed`-guarded (easy). Child `PartnerServiceMessage` has an FK to **companies** not users — leave to the message phase.
- **`VehicleAssignment`** — large read surface; the `User::driverVehicleAssignments` relation is cross-company **by design** (a driver may drive vehicles across companies), so global-scoping could over-restrict it; `update()` guards on `vehicle->user_id` not `company_id`; global `SendMileageCampaignEmailJob` queries all assignments (scope skips in jobs → unaffected, but verify). Flip reads, fix the `update` guard, decide on the driver-relation exemption.- **`ChargingLocation`** — ⚠ **pre-existing bug:** the model's `company()` relation is `belongsTo(Company::class)` but the FK `company_id` was **re-pointed to `users`** (migration `2025_03_11_134112`), so `->with('company')` in index/show loads wrong/null data. Fix the relation (→ `belongsTo(User::class, 'company_id')` or rename to `owner()`) in the same pass, then flip list + `show`. Writes are `isUserAllowed`-guarded; the export closure uses the relation (safe).

**Remaining OWNER models** (see `tenant-registry.md §3a`): `CarTrip` (also `CompanyPrivateModeScope`), `Vendor` (no FK), `Site`, `vehicle_security_actions`, `vehicle_site_changes`, `vehicle_status_changes`, plus deferred `InspectionForm`, `PartnerService`, `VehicleAssignment`, `ChargingLocation` (above). ⚠ **Resolve first:** `UploadedDocument` (FK→users but code writes companies.id) and `VehicleHistory` (has both) — pick the tenant column explicitly (`tenant-registry.md §2`).

### 5.3 Remaining COMPANY models
`tenant-registry.md §3b`. Straightforward (same flip as the 14): `UploadedContract`, `DirectMessage`/`SinistreMessage`/`PartnerServiceMessage` (⚠ messages may have **no standalone list** — they're read within a parent; flip the parent's scoping instead), `Geofence`, `report_schedules`, `reports`/`report_generations`, `rfid_card_mappings`, notification-settings tables, `company_sidebar_preferences`, billing/config (`invoices`, `subscriptions`, …).

### 5.4 The car-policy / commande / AO / approver epic (BIG — its own sub-project)
`car_policies`, `car_policy_versions`, `car_policy_assignments`, `car_policy_vehicles`, `policy_options`, `option_packs`, `commandes`, `commande_history`, `vehicle_requests`, `appels_offres`, `cotations`, `bons_de_commande`, `bc_history`, `restitution_disputes`, `approvers`, `approval_circuits`, `approval_digest_configs`, `approval_digest_logs`, `linkbycar_telematics_alerts`. COMPANY keyspace, **no-FK `company_id`** (so confirm the column holds `companies.id` — controllers use `userCompany->id`, so yes). Big controllers; audit each like §7.1.

### 5.5 nullable_shared, broadly-used models
`CustomStatus`, `CustomMotive`, `Department`, `Notification` (V2). Strategy is **`nullable_shared`** (proven). These are referenced app-wide → attach carefully and test that system-default rows (`company_id IS NULL`) stay visible (pattern §7.3).

### 5.6 Vehicle remainder
(a) the `Vehicle::find()` **security sweep** — spawned (§8); (b) secondary vehicle lists (dashboards/stats) for full parity, via `accessibleTenant()`.

### 5.7 Writes (P6) — BLOCKED on product
Only after product confirms cross-company writes are wanted: add `company_write` grant (auth) + `X-Company-Id` target resolution + validation; stamp the resolved target on create. See `write-path-and-permissions.md` Phase 2.

### 5.8 Auth-service UX, performance, data prereqs
- Assigning a `company` scope is currently a tinker one-liner (`activation-runbook.md`). Consider a small admin endpoint/UI.
- Performance: `EXPLAIN` the big `whereIn` on `vehicles`/`expenses` (tenant cols are FK-indexed, but the `vehicles` composite index leads with `type_id`).
- Data prereqs before going live broadly: reconcile the 3 SIRET-drift companies; confirm each child company has its own `companies.user_id` owner (the §5 proxy assumption). See `tenant-registry.md` PROD AUDIT / RESOLVED notes.

## 6. Spec conformance scorecard

| Spec section | Status | Note |
|---|---|---|
| §3 one-level hierarchy | ✅ exact | resolver expands granted parent → direct children only; tested |
| §4 M2M user↔company, delegated to auth | ✅ exact | `company` scope_type in auth; user→many companies |
| §6 filter by accessible company IDs | ✅ exact (per flipped model) | `TenantScope`; coverage grows as §5 proceeds |
| §9 performance | ⚠ on track | memo + 60s scope cache + indexed whereIn; load-test pending (§5.8) |
| §10 design principles | ✅ matches | user=access, auth=visibility, hierarchy=flat |
| §5 data owned by company | ⚠ **intent yes, letter no** | OUTCOME decision (§3.1): owner-id proxy, not physical company_id on legacy tables |
| §7 site belongs to one company | ⚠ via proxy | `sites` is OWNER-keyspace; no physical `sites.company_id` (optional, see §5.2) |
| §2 user = access actor, not owner | ⚠ at access layer | storage still uses user_id for OWNER tables (per §3.1) |

The one thing to keep visible to product: **§5 is satisfied as an OUTCOME, not literally.** If a record must ever move between companies *within a group while keeping the same owner-user*, the proxy breaks and those tables need a real `company_id` migration. Nothing requires that today.

## 7. The repeatable patterns (USE THESE)

### 7.1 The standard flip (per model)
1. **Audit** every query path: `grep "Model::"`, `->relation()`. Confirm admin/cross-tenant paths are internal-admin-only (covered by fullAccess) or console/MCP (scope skips). Leave those.
2. **Attach** `use HasTenantScope;` to the model + add a `config/tenancy.php` entry `{keyspace, strategy:'column', column}`. (Forgetting the config = fail-closed = model returns nothing.)
3. **Flip READ paths**: replace `Model::where('company_id', $userCompanyId)` or `$user->userCompany->{relation}()` with `Model::query()` (the scope filters it).
4. **show**: drop the home guard; rely on the now-scoped route-model binding (non-accessible → 404).
5. **Keep writes home-only**: `store` keeps stamping `userCompany->id`; update/delete keep their `isUserAllowed`/`company_id === userCompany->id` guard.
6. **Verify**: `php -l` + `php artisan config:clear` + run `tests/Feature/Scopes/TenancyRegistryTest.php` (auto-validates the new entry). Add a functional test if non-trivial.

### 7.2 The opt-in scope (for models too risky to global-scope, e.g. Vehicle)
Add a local scope (see `Vehicle::scopeAccessibleTenant`): resolve `app(AccessibleCompanyResolver::class)->forRequest(request())`; `fullAccess ? $query : $query->whereIn('<table>.<col>', $scope->ownerIds|companyIds)`. Apply it explicitly at LIST endpoints only. Nothing else changes.

### 7.3 `nullable_shared` (system-default rows stay visible)
Registry `strategy => 'nullable_shared'`. TenantScope does `whereIn(col, ids) OR col IS NULL`. Use for tables where `company_id` is nullable and NULL = a shared system default.

### 7.4 Child query inherits parent scope
A query on a non-scoped child can inherit the parent's tenant scope via `whereHas('parent')` (drop the explicit `company_id`). Example: `TelematicsBoxOrderService::kpis` uses `whereHas('line.order')`.

### 7.5 Shared find split (read vs mutation)
When one `findX()` serves both show (read) and a mutation: add a `findVisible($id)` (`Model::query()->find` — accessible read) for show, keep the home-only finder for the mutation. Examples: `AenRuleController::findVisible`, `TelematicsBoxOrderService::findVisible`.

### 7.6 Testing conventions (and the one gotcha)
`DatabaseTransactions`; create rows with `Model::create`/`Model::factory()`. To test the scope, **bind a mock resolver** returning a fixed `AccessibleScope` (`$this->app->instance(AccessibleCompanyResolver::class, $mock)`). **Gotcha:** the *real* resolver reads `request()->user()`, which the test harness does NOT populate the way prod `AuthServiceMiddleware` does (setUserResolver alone → empty result). For company→owner mapping use `AccessibleCompanyResolverTest`/`AenRuleMultiCompanyReadTest` (they call `forUser()` directly). Do **not** name a test helper `status()` (collides with `PHPUnit\TestCase::status()`).

## 8. Spawned follow-up tasks (already created)
- **task_ac331afc** — GenericReminder filters vehicles by `companies.id` against `vehicles.company_id` (which holds the owner user id) → id-space bug; `applies_to_all_vehicles` reminders resolve wrong/no vehicles. Fix: scope on `vehicles.user_id`.
- **task_fd8595ad** — Vehicle::find() cross-tenant leak security sweep (~20 sites / 10 Api/Fleet controllers). Fix with `Vehicle::accessibleTenant()->find()` + null-guards on the unguarded chained sites. Full site list + caveats in the task.

## 9. How to run / verify / activate
- Tests: `php artisan test tests/Feature/Scopes tests/Feature/Services/Tenancy` (run `php artisan config:clear` after editing `config/tenancy.php`). Local `.env` points at a dev DB; **never** run the suite against prod.
- Activate a pilot: `activation-runbook.md` (seed `company` scope_type in auth → assign a scope via tinker → hit a flipped endpoint).
- Conventions/constraints: API-only backend; tenant naming `$userId`/`$companyId`/`$companyUserId`; never use `vehicles.status`/`vehicles.odometer`; never read `App\Models\Driver`. (Project CLAUDE/memory conventions.)

## 10. Definition of done (for the whole initiative)
All tenant-scoped models in `tenant-registry.md §3a/§3b` either flipped (global or `accessibleTenant`) or consciously excluded (§3d reference); the two spawned security/bug tasks closed; product decision on P6 (read-only vs Phase-2 writes) recorded and implemented if writes are wanted; `sites.company_id` decision made (§5.2/§7); performance EXPLAIN done; data prereqs reconciled. Until then the feature ships **safely inert** on what's flipped.
