# BUSINESS_ACTIONS_API_REVIEW

> Does the MCP/AI layer execute Numu's **official business workflow** (with all side effects), or does it bypass it with direct `PATCH`/DB writes? Reviewed against the actual code (2026-06-26).

## TL;DR verdict

| Concern | Verdict |
|---|---|
| Startup **action** transitions (Approve / Reject / Move-to-Review / Committee / Demo / Archive / Pass / Request-Info / Request-Meeting) | ✅ **Safe.** The MCP routes them through the SAME `StartupLabelActionService` the dashboard uses — event + notifications + group-move + meeting-cancel + audit all fire. It is **not** a plain DB write. |
| Investor **action** transitions | ✅ **Safe.** Routed through `InvestorLabelActionService` (+ `InvestorWorkflowObserver`). |
| Demo / Committee **create** | ✅ **RESOLVED (2026-06-26).** `create` still builds silently (by design — no audience yet), and a new **`announce`** endpoint fires `DemoCreated` / `CommitteeCreated` (the SAME events/automations as the dashboard) once audience + slots are built. See §4. |
| Demo / Committee **attendance, slots, audience, members, evaluations** | ✅ **Safe.** Routed through the shared services (`DemoAttendanceService`, `SlotScheduleService`, `CommitteeEvaluationService`) — same as the dashboard. |
| Plain label/profile fields (`status_option_id`, `sector`, `traction`, bio, …) | ✅ **Correctly** a plain update — these carry **no** workflow side effects by design. |

**Bottom line:** the MCP does **not** bypass the business logic for state transitions — the one thing the review was worried about (changing `action`/status directly) is already handled correctly. The only true bypass is that **demo/committee creation cannot trigger its audience-notification automations via the API**.

---

## 1. How Numu models "business actions" (important context)

There is **no** `POST /startups/{id}/approve` style endpoint anywhere in the system — **not even in the dashboard**. Every startup pipeline transition (Approve, Reject, Move-to-Committee, Archive, Pass, Request-More-Info, Request-Meeting, …) is modelled as **one field**: `startups.action_option_id` (an `action`-type label option).

The official workflow is: **change the action field *through the service*, not by writing the column.**

```
UI button (show.blade.php:1503)         AI tool (startup_update / startup_bulk_update)
        │                                        │
   PATCH /admin/startups/{id}/label        AiStartupWriteController::applyChanges
        │                                        │   (detects action_option_id)
   StartupController::updateLabel ───────────────┤
        │                                        │
        └────────►  StartupLabelActionService::apply($startup, $optionId, …, $source)
                         │  (extends AbstractLabelActionService — app/Services/Actions/AbstractLabelActionService.php:99)
                         │  1. forceFill(action_option_id [+ rejection_reason / request_info]) + writeAuditRows  (in a txn)
                         │  2. AFTER COMMIT: StartupLabelOptionChanged::dispatch(...)
                         ▼
        ┌──────────────────────────────┬──────────────────────────────┬──────────────────────────────┐
   DispatchStartupLabelNotifications   MoveStartupToGroupOnActionChange   CancelSubjectMeetingsOnLabelAction
   (email/sms/whatsapp/in-app per       (sets group_id + monday_group_*,   (cancels upcoming MS-Bookings
    the option's `methods` config)       writes activity log)               meetings if option ∈ auto_cancel)
```

**So "uses PATCH" is only a problem if the PATCH writes the column directly and skips the service.** The MCP does **not** do that — see §3.

Side effects that the service path produces (verified):
- **DB:** `action_option_id`; `rejection_reason` (when moving to a rejection option, ids `config('notifications.rejection_option_ids')` = `[171,1]`); `request_info` (Request-More-Info); and `group_id` + `monday_group_id` + `monday_group_title` via the group-move listener.
- **Email / SMS / WhatsApp / in-app:** per the chosen option's `methods` array + `notifications_enabled` flag (`DispatchStartupLabelNotifications`).
- **Meeting cancellation:** MS-Bookings appointments cancelled via Graph API for rejection options (`CancelSubjectMeetingsOnLabelAction`).
- **Activity logs:** the action transition, plus dedicated `rejected` / `request_info` rows, plus the group-move log.
- **Event:** `StartupLabelOptionChanged` (the spine of the above).

---

## 2. Required review table

Legend — **Native API Exists** = an official workflow endpoint that runs the side effects (for actions this is the action-service path, the same mechanism the UI uses). **MCP Uses Native** = the MCP tool routes through that path. **PATCH-only** = ⚠️ writes the DB column directly and skips the workflow. Email/SMS/WhatsApp = ✓ if that channel can fire for this action (for action options it is **per-option config**).

| Business Action | Native API Exists | MCP Uses Native API | PATCH-only (bypass) | Emails | SMS | WhatsApp | Automations | Logs |
|---|---|---|---|---|---|---|---|---|
| **Startup — Approve / Move-to-Review** | ✅ via `StartupLabelActionService` | ✅ `startup_update` (action_option_id) | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Startup — Reject** | ✅ same service | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ + meeting-cancel | ✅ (`rejected` row) |
| **Startup — Move to Committee** | ✅ same service (+ group move) | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Startup — Move to Demo / Listing** | ✅ same service (+ group move) | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Startup — Request More Info** | ✅ same service (captures `request_info`) | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ (`request_info` row) |
| **Startup — Request Meeting** | ✅ same service (option carries booking link) | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Startup — Archive / Pass** | ✅ same service (+ group move) | ✅ `startup_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Startup — Clear action** | ✅ same service (null) | ✅ `startup_update` (action_option_id:null) | ❌ No | — | — | — | — | ✅ |
| **Investor — action transition** | ✅ `InvestorLabelActionService` (+ observer) | ✅ `investor_update` | ❌ No | per-option | per-option | per-option | label-notif¹ | ✅ |
| **Demo — create** (build only) | ✅ | ✅ `demo_create` (silent build, by design) | ❌ No | — | — | — | — | ✅ |
| **Demo — announce** *(new)* | ✅ fires `DemoCreated` | ✅ `demo_announce` | ❌ No | ✓ | ✓ | ✓ | ✅ `demo_created` **fired** | ✅ |
| **Committee — create** (build only) | ✅ | ✅ `committee_create` (silent build) | ❌ No | — | — | — | — | ✅ |
| **Committee — announce** *(new)* | ✅ fires `CommitteeCreated` | ✅ `committee_announce` | ❌ No | ✓ | ✓ | ✓ | ✅ `committee_created` **fired** | ✅ |
| **Demo/Committee — set audience / members** | ✅ pivot sync | ✅ `demo_set_audience` / `committee_set_members` | ❌ No | — | — | — | — | ✅ |
| **Demo/Committee — set slots** | ✅ `SlotScheduleService` | ✅ `demo_set_slots` / `committee_set_slots` | ❌ No | — | — | — | — | ✅ |
| **Committee — set evaluations** | ✅ `CommitteeEvaluationService` | ✅ `committee_set_evaluations` | ❌ No | — | — | — | — | ✅ |
| **Demo/Committee — attendance (admin status)** | ✅ `recordAdminTransition` | ✅ `*_set_*_attendance` | ❌ No | — | — | — | —² | ✅ (attendance log) |

¹ **label-notif** = the per-option notification channels (`DispatchStartupLabelNotifications`), config-driven on each action option — this is Numu's notification mechanism for action changes, distinct from the time/creation **automation engine**.
² Admin-set attendance uses the `admin_status` axis and intentionally does **not** fire the `demo_confirmation` automation — that automation is for **investor self-confirmation** (`DemoAttendanceConfirmed`), a different axis. Not a gap.

---

## 3. Why the action path is safe (the decisive code)

**MCP → service, not a raw write.** `AiStartupWriteController` strips `action_option_id` out of the plain field fill and hands it to the service:

```php
// app/Http/Controllers/Api/V1/Ai/AiStartupWriteController.php:184-200
$actionProvided = array_key_exists('action_option_id', $changes);
$plain = $changes;
if ($actionProvided) {
    unset($plain['action_option_id'], $plain['rejection_reason'], $plain['request_info']);
    app(\App\Services\Actions\StartupLabelActionService::class)->apply(
        $startup,
        $changes['action_option_id'],
        ['reason' => $changes['rejection_reason'] ?? null, 'request_info' => $changes['request_info'] ?? null],
        $request,
        \App\Support\ActivityContext::SOURCE_CLAUDE,      // ← tagged as Claude in the audit
    );
}
// only NON-action fields reach the plain fill:
if ($plain !== []) { $startup->fill($plain)->save(); }
```

`apply()` then dispatches the event **after commit**, exactly like the UI:

```php
// app/Services/Actions/AbstractLabelActionService.php:138-145
($this->eventClass())::dispatch($subject, self::LABEL_KEY, $newOption, $oldOption, $request->user()?->getKey());
```

Investor side is identical (`AiInvestorWriteController:215-224` → `InvestorLabelActionService::apply(...)`).

> The codebase even documents this as the intended contract — `StartupController.php:848`: *"The UI, REST API, and Claude all call StartupLabelActionService so the FULL lifecycle … runs identically on every path."* — and the review confirms the AI controllers honour it.

---

## 4. ✅ RESOLVED — demo/committee creation automations (announce)

**Status (2026-06-26): implemented + verified.** New endpoints fire the dashboard's exact creation events/automations once the audience + slots are built:

| Method | URL | MCP tool | Behaviour |
|---|---|---|---|
| POST | `/api/v1/demos/{demo}/announce` | `demo_announce` | Fires `DemoCreated` → `demo_created` blast (effective investors + startups). 422 `not_ready` if audience or slots empty. Idempotent (guarded by `demos.announced_at`). Not rollback-able. |
| POST | `/api/v1/committees/{committee}/announce` | `committee_announce` | Fires `CommitteeCreated` → `committee_created` blast (members + startups). Same guards + idempotency (`committees.announced_at`). |

Design: `create` builds the demo/committee **silently** (no audience exists yet); `announce` is the explicit "it's ready — notify everyone" step, using the **same `DemoCreated`/`CommitteeCreated` events** the dashboard dispatches (so identical listeners/automations run). The dashboard's `store()` now stamps `announced_at` too, so a dashboard-created entity is never re-blasted by a later `announce`. Verified live: 422-guard before audience/slots, event fires exactly once, second call is a no-op (no re-blast), audit row written.

<details><summary>Original gap (for the record)</summary>

The dashboard's create path fires the creation event:

```php
// app/Http/Controllers/Admin/DemoController.php:189      DemoCreated::dispatch($demo);
// app/Http/Controllers/Admin/CommitteeController.php:167 CommitteeCreated::dispatch($committee);
```

→ `DispatchDemoCreationAutomations` / `DispatchCommitteeCreationAutomations` → per-recipient `DispatchAutomationJob` for `trigger_key = demo_created` / `committee_created` (the email/SMS/WhatsApp blast to the whole audience).

The MCP create path does **not**:

```php
// app/Http/Controllers/Api/V1/Ai/AiDemoWriteController.php:239  (committee: AiCommitteeWriteController:216)
$demo = Demo::query()->create([... 'status' => Demo::STATUS_ACTIVE, ...]);   // ← no DemoCreated::dispatch
```

**Effect:** a demo/committee created via the API is **silent** — no investor/startup notifications — unlike one created in the dashboard.

**Nuance (why a naive auto-fire is wrong):** the AI builds a demo in *multiple* steps (`demo_create` → `demo_set_audience` → `demo_set_slots`). Firing `DemoCreated` inside `demo_create` would notify *before* the audience exists. The correct fix is a deliberate **"announce/publish"** step.

### Recommended fixes
1. **Add a publish/announce action** — `POST /api/v1/demos/{demo}/announce` (MCP `demo_announce`) + the committee equivalent, that fires `DemoCreated` / `CommitteeCreated` once the audience + slots are set. This makes the audience notification an explicit, auditable business action (and rollback-aware) rather than an accidental side effect of `create`.
2. *(Optional, clarity)* Add explicit business-action tools — `startup_set_action` / `investor_set_action` — that thin-wrap the existing `*LabelActionService`. Behaviour is identical to `startup_update` today, but a named tool is more discoverable and removes any chance an AI sets a transition through some other field path.

Neither changes the safety of what already exists; they close the creation-notification gap and make the action surface explicit.

</details>

> Fix #1 above is now implemented (see the table at the top of §4). **Fix #2 is now implemented too** — see §4b.

## 4b. ✅ Explicit business-action tool — `startup_set_action`

**Status (2026-06-26): implemented + verified.** A first-class, discoverable alias for the action workflow.

| Method | URL | MCP tool |
|---|---|---|
| POST | `/api/v1/startups/{startup}/action` | `startup_set_action` |

- **Input:** `startup_id`, `action` (slug **or** option id), `reason` (optional), `metadata` (optional object).
- **Pure alias — NO new logic, NO direct DB write.** It resolves the slug/id and calls the SAME `StartupLabelActionService::apply(..., SOURCE_CLAUDE)` that the dashboard + `startup_update` use, so every side effect runs identically: **group move, Email/SMS/WhatsApp/in-app notifications, Microsoft-Bookings cleanup (on reject), activity logs, automations, and the `StartupLabelOptionChanged` event.**
- `reason` → the service keeps it as `rejection_reason` (reject) or `request_info` (request-info) as the action requires. `metadata` is recorded on the AI audit trail only.
- **Slug resolution** matches the option `value` then `name_en` (normalised — `committee`, `Committee`, `move-to-committee` all resolve). Unknown action → 422 with the environment's **full action catalog** in `details.available`.
- Action slugs are **environment-configurable** (this environment: `reject`, `move_to_review`, `move_to_listing_app`, `move_to_archive`, `request_meeting`, `no_action`, `committee`, `follow_up_email`, `initial_dd`, …). The wish-list names (`approve`, `pass`, `move_to_committee`, …) may not exist verbatim — discover the real ones via `GET /api/v1/ai/label-options?label_key=action` or the 422 `available` list.
- Verified live: by-slug → event fired; by-name → resolves; no-op → no event; by-id + reason → `rejection_reason` set by the service; unknown → 422 with catalog. **NOT rollback-able** (sends can't be un-sent).

---

## 5. Verification index (file:line)

| Claim | Evidence |
|---|---|
| Actions = one `action_option_id` field, no per-action endpoints | `resources/views/admin/startups/show.blade.php:1503`; `routes/web.php:321` (`PATCH …/label`) |
| Official service + lifecycle | `app/Services/Actions/AbstractLabelActionService.php:99-151`; subclasses `StartupLabelActionService.php`, `InvestorLabelActionService.php` |
| 3 listeners (notif / group-move / meeting-cancel) | `app/Listeners/{DispatchStartupLabelNotifications,MoveStartupToGroupOnActionChange,CancelSubjectMeetingsOnLabelAction}.php` |
| MCP startup action → service (not raw write) | `app/Http/Controllers/Api/V1/Ai/AiStartupWriteController.php:184-200` |
| MCP investor action → service | `app/Http/Controllers/Api/V1/Ai/AiInvestorWriteController.php:215-224` |
| Dashboard fires creation events | `app/Http/Controllers/Admin/DemoController.php:189`; `app/Http/Controllers/Admin/CommitteeController.php:167` |
| MCP create does NOT fire them | `app/Http/Controllers/Api/V1/Ai/AiDemoWriteController.php:239`; `AiCommitteeWriteController.php:216` |
| Admin attendance axis ≠ confirmation automation | `app/Services/Demos/DemoAttendanceService.php:176-209` (recordAdminTransition, no event) vs `:164` (self-confirm fires `DemoAttendanceConfirmed`) |
| Trigger-key catalog | `app/Models/Automation.php:26-41` |
| **Fix — announce fires same events** | `app/Http/Controllers/Api/V1/Ai/AiDemoWriteController.php::announce` (`\App\Events\DemoCreated::dispatch`); `AiCommitteeWriteController::announce` (`CommitteeCreated::dispatch`) |
| **Fix — idempotency column** | migration `2026_06_26_100000_add_announced_at_to_demos_and_committees`; dashboard stamps it at `DemoController.php:191` / `CommitteeController.php:169` |
| **Fix — routes + MCP tools** | `routes/api.php` (`api.v1.demos.announce`, `api.v1.committees.announce`); `McpToolRegistry` (`demo_announce`, `committee_announce`) |

---

*Generated 2026-06-26. Scope: AI/MCP Full connector vs. dashboard business workflow.*
