# Risk Register

> Six risks that break the system in production if uncontrolled.
> Each risk has a phase reference where the mitigation is implemented.
> R6 is an extensibility risk — not a production crash risk, but a design trap that makes future domain additions expensive.
>
> **Architectural Gaps B and E** (from the principal architect review) are now wired in:
> - **Gap B (entity_relationships unused) — RESOLVED.** `EntityRegistry::convertsTo('lead')` is consulted by `LeadService::convert()`. `Deal` implements `ConvertibleTargetInterface::fromLead()`. Adding pharma's "Doctor Lead → Sample Visit" requires a new model + a pack-JSON `entity_relationships` row, no LeadService change. See `engines/entity-engine.md` and `modules/lead.md`.
> - **Gap E (no scheduled triggers) — RESOLVED.** `workflow_rules.trigger_event` accepts `'scheduled'`; `scheduled_cron` + `last_tick_at` columns added. `workflow:tick` Artisan command runs every 5 minutes via Laravel scheduler, evaluates cron match, runs conditions through `ConditionEvaluator` (now supports `now-7d`/`now+1h`/etc relative-time tokens), dispatches matching entities through the same `WorkflowDispatcher`. See `engines/workflow-engine.md` and `setup.md`.

---

## R1 — Custom Field Query Performance

**Problem:** Filtering leads by `preferred_country = UK AND score > 6.5` requires
joining `custom_field_values` twice — slow at scale.

**Mitigation:** Hybrid storage. Searchable fields are real columns in the model migration
(`leads.preferred_country`, `leads.score`, `deals.country`). `is_searchable: true` in
pack JSON is documentation only — never triggers schema changes.

**Rules:**
- `LeadFilter::preferredCountry()` filters `leads.preferred_country` (not a join)
- `HasCustomFields::setCustomField()` writes both `custom_field_values` AND the real column
- No joins on `custom_field_values` in any Filter class for searchable fields

**Implemented in:** `engines/field-engine.md` + `modules/lead.md` + `modules/deal.md`

---

## R2 — Tenant Job Consistency

**Problem:** `setup:crm` runs migrate → seed pack → apply profile. Queue failure midway
leaves tenant in partial state.

**Mitigation:**
- Every seeder step uses `upsert()` / `updateOrCreate()` — safe to re-run
- Command is a full idempotent reconcile, not an install script
- After full sequence: write `settings` row `crm_setup_completed_at = now()`
- On re-run: log "already set up, re-running" — do NOT skip
- If migrate fails for one tenant: log + `continue` to next — never abort all tenants

**Implemented in:** `setup.md`

---

## R3 — Lead Conversion Transaction Safety

**Problem:** `convert(Lead, opts)` creates Contact + Deal + updates Lead in sequence.
A crash between steps leaves orphaned records.

**Mitigation:**
```php
public function convert(Lead $lead, array $opts): array
{
    if ($lead->converted_at !== null) {
        throw new AlreadyConvertedException("Lead {$lead->id} is already converted.");
    }
    return DB::transaction(function () use ($lead, $opts) {
        $contact = $this->findOrCreateContact($lead);   // firstOrCreate on email
        $deal    = $this->createDeal($lead, $contact, $opts);
        $lead->update(['contact_id' => $contact->id, 'converted_at' => now()]);
        event(new LeadConverted($lead, $contact, $deal));  // AFTER commit
        return compact('contact', 'deal');
    });
}
```
- `findOrCreateContact` uses `firstOrCreate(['email' => $lead->email])` — idempotent
- `LeadConverted` event fires **after** commit, not inside transaction

**Implemented in:** `modules/lead.md`

---

## R4 — Feature Pack Conflicts (Additive-Only)

**Problem:** Re-applying a pack after user customized pipelines or fields overwrites work.

**Mitigation:**
- `upsert()` on `pipelines`/`pipeline_stages` — only updates system rows (by `slug`)
- `updateOrCreate()` on `custom_fields` — updates only `is_system = true` rows
- **Never deletes** any row
- `custom_field_values` (user data) is never touched under any condition
- `seeder_version` column distinguishes system rows from user-created rows

**Implemented in:** `packs/overview.md`

---

## R5 — WorkflowEngine Loop Guard

**Problem:** `UpdateField` action writes a field → triggers `updated` event →
`FiresWorkflowEvents` fires again → infinite loop.

**Mitigation:** `static array $running` in `WorkflowDispatcher`:
```php
private static array $running = [];

public function dispatch(string $event, Model $entity, array $context = []): void
{
    $key = $event . ':' . $entity->getMorphClass() . ':' . $entity->getKey();
    if (isset(self::$running[$key])) return;
    self::$running[$key] = true;
    try {
        // execute rules
    } finally {
        unset(self::$running[$key]);
    }
}
```

**Implemented in:** `engines/workflow-engine.md`


---

## R6 — Hybrid Storage Column Coupling (Extensibility Risk)

**Problem:** `leads` and `deals` migrations hardcode searchable columns (`preferred_country`, `preferred_study_level`, `score`, `country`). These were declared for education and general CRM. When a 6th or 7th domain profile needs a *different* searchable/filterable column (e.g. `territory` for pharma, `property_type` for real estate), a **new migration is required**. This breaks the "add domain = add JSON only" promise for that specific case.

**Scope of impact:** Only affects domains that need server-side filtering on a custom field. Domains that only need display-only custom fields (no filter/sort) are unaffected — they use `custom_field_values` as-is.

**Current state:** The 5 v1 profiles fit within the existing hybrid columns. Risk materialises at profile 6+.

**Mitigation options (choose one when the time comes):**

1. **Accept the migration** — document that "new filterable domain column = one migration file." Keep it explicit, not magic. This is the simplest and most honest approach. Cost: one PHP migration file per new filterable column.

2. **Domain extension table** — `leads_ext_{domain}` table with only that domain's searchable columns, joined via `HasDomainExtension` trait. Zero changes to `leads` migration. Cost: new table per domain, join on every query for that domain.

3. **EAV with indexed JSON** — store searchable values in `custom_field_values.value` as a JSON column with a generated virtual column + index. Cost: MySQL/PostgreSQL version dependency, more complex filter logic.

**Recommended for v1:** Option 1 (accept the migration). It is the most transparent, easiest to debug, and cheapest to implement. Document it in the pack authoring guide: "if a new pack requires a filterable field that doesn't exist as a real column in the leads/deals migration, add a migration."

**The three cracks that do NOT require migration (config-only):**
- New display-only custom fields → pack JSON only
- New pipeline stages → pack JSON only
- New label rebranding → pack JSON only

**Implemented consideration in:** `engines/field-engine.md` (hybrid storage section)
