# Numu — AI Agent Decision → Approval → Execution Orchestration — Report

**Branch:** `feature/managed-agents` (uncommitted). The missing orchestration layer
is now wired end-to-end. **No existing action / label / notification / automation logic
was changed** — execution delegates to the canonical `LabelActionService::apply()`.
**Verified live:** 21/21 assertions across all 4 scenarios pass; 14/14 agent tests pass.

---

## 1. What was missing → what's now wired

| Before | After |
|---|---|
| `createFromRun()` existed but nothing called it | Auto-created by the decision orchestrator when policy = needs_approval |
| No decision endpoint | `POST /api/v1/ai/agent-runs/{run}/decision` |
| Policy resolve was a passive query | Decision orchestrator branches always_allow / needs_approval / blocked |
| always_allow had no executor | Executes the **native action** now (claims an execution slot, runs `apply()`) |
| Approve didn't execute | Admin/API Approve runs the native action + records results |
| Approval lacked agent context | Stores confidence_score, agent_payload, policy_snapshot |

---

## 2. New components (all reuse, no duplication)

**Created**
- `app/Services/Agents/AgentDecisionService.php` — the orchestrator. Records the agent's
  decision on the run → `ActionPolicyService::resolve` → branches:
  always_allow → execute now; needs_approval → `createFromRun`; blocked → record blocked. Audits each step.
- `app/Services/Agents/AgentActionExecutor.php` — resolves an action OPTION from its slug
  and delegates to the **existing** `StartupLabelActionService::apply($startup, $optionId, $opts, $request, source='agent')`
  (fires `StartupLabelOptionChanged` → notifications / group move / meeting-cancel + activity logs). Zero action logic duplicated.
- `app/Services/Agents/ApprovalExecutionService.php` — approve → execute native action + record (idempotent); reject/cancel → close + mark run blocked.
- Migration `…add_agent_decision_fields_to_approval_requests` — `confidence_score`, `agent_payload`, `policy_snapshot` (nullable).

**Modified**
- `ApprovalService::createFromRun` — stores the 3 new fields.
- `AgentApprovalRequest` — fillable + casts (float / array / array).
- `AgentRunController` — `decision()` endpoint.
- `ApprovalApiController` — `decide` now executes on approve; added `approve()` / `reject()`.
- `Admin\Agents\ApprovalController::decide` — routes through `ApprovalExecutionService` (executes on Approve) and resolves the admin as the action actor.
- `ActivityContext` — `SOURCE_AGENT = 'agent'`.
- `routes/api.php` — decision + approve + reject routes.
- `tests/Feature/Agents/BuildsAgentSchema` — the 3 new columns (test parity).

**Schema change:** only the 3 additive nullable approval columns. No other table touched.

---

## 3. The decision endpoint

```
POST /api/v1/ai/agent-runs/{run}/decision        Auth: Bearer naat_… (McpAuth:full)
{
  "action_slug": "request_meeting",
  "confidence": 0.87,
  "reason": "Startup passed prescreen analysis",
  "payload": { "note": "AI recommendation" },
  "environment": "prod",          // optional, default prod
  "risk_level": "expected"         // optional: none|expected|high
}
```
**Does:** loads run → records `proposed_action` + `decision_payload` (analysis=completed,
immutable after) → resolves policy → branches → audits → returns the final state:
```json
{ "data": {
  "policy": "always_allow|needs_approval|blocked",
  "policy_source": "override|designed|default",
  "decision": "executed|pending_approval|blocked|failed|duplicate",
  "executed": true,
  "approval_id": 12,                 // when needs_approval
  "execution_request_id": 8,         // when executed
  "result": { "ok": true, "action": "...", "option_id": 243, "changed": true },
  "run": { … fresh agent_runs row … }
}}
```

---

## 4. Postman / curl — the 4 scenarios

> Headers (all): `Authorization: Bearer naat_…`, `Content-Type: application/json`, `Accept: application/json`.
> Base `http://127.0.0.1:8080`. Pick a real `startup_id`; unique `correlation_id`/`idempotency_key` per run.

### Scenario 1 — Intake Triage · move_to_review · always_allow → executes
```bash
# (one-time) set the policy
curl -X PUT $B/api/v1/ai/action-policies/intake_triage/move_to_review \
  -d '{"policy":"always_allow","environment":"prod","reason":"qa"}'

# create the run
curl -X POST $B/api/v1/ai/agent-runs \
  -d '{"startup_id":457,"agent_key":"intake_triage","trigger_type":"manual",
       "correlation_id":"s1-1","idempotency_key":"s1-run-1"}'   # → {data:{id: RUN}}

# decide  →  executes immediately
curl -X POST $B/api/v1/ai/agent-runs/RUN/decision \
  -d '{"action_slug":"move_to_review","confidence":0.93,"reason":"clean intake"}'
```
**Expect:** `policy=always_allow, executed=true`; startup `action_option_id` = move_to_review option; run `execution_status=executed`; 1 execution_request `succeeded`; **0 approvals**.

### Scenario 2 — Pre-screen · request_more_info · needs_approval → approval, paused
```bash
curl -X PUT $B/api/v1/ai/action-policies/prescreen/request_more_info \
  -d '{"policy":"needs_approval","environment":"prod"}'
curl -X POST $B/api/v1/ai/agent-runs -d '{"startup_id":457,"agent_key":"prescreen","trigger_type":"manual","correlation_id":"s2-1","idempotency_key":"s2-run-1"}'
curl -X POST $B/api/v1/ai/agent-runs/RUN/decision \
  -d '{"action_slug":"request_more_info","confidence":0.77,"reason":"need deck","payload":{"note":"AI rec"}}'
```
**Expect:** `policy=needs_approval, executed=false, approval_id=N`; approval PENDING in **/admin/approvals** with confidence + payload + policy snapshot; **startup unchanged**.

### Scenario 3 — Committee Analysis · move_to_prelist · needs_approval → waits, then approve
```bash
curl -X POST $B/api/v1/ai/agent-runs/RUN/decision -d '{"action_slug":"move_to_prelist","confidence":0.81,"reason":"strong memo"}'
# admin approves (UI button) OR API:
curl -X POST $B/api/v1/ai/approvals/N/approve -d '{"comment":"agreed"}'
```
**Expect:** approval APPROVED → native action executes → startup moves to pre-list; run `execution_status=executed`; execution_request `succeeded`. **Reject** instead: `POST /approvals/N/reject` → REJECTED, nothing executes, run `blocked`.

### Scenario 4 — Any agent · reject · blocked → denied
```bash
curl -X PUT $B/api/v1/ai/action-policies/intake_triage/reject -d '{"policy":"blocked","environment":"prod"}'
curl -X POST $B/api/v1/ai/agent-runs/RUN/decision -d '{"action_slug":"reject","confidence":0.3,"reason":"n/a"}'
```
**Expect:** `policy=blocked, executed=false`; **no approval**; run `execution_status=blocked`; startup unchanged. (An unassigned agent+action pair also resolves to `blocked`, `source=default`.)

---

## 5. Verification report (live, against startup 457, auto-reverted)

```
SCENARIO 1 always_allow → executes ...... 6/6 PASS  (executed, action_option_id changed, exec-request succeeded, no approval)
SCENARIO 2/3 needs_approval ............. 9/9 PASS  (PENDING approval w/ confidence+payload+snapshot, unchanged → approve → executed → changed)
SCENARIO 4 blocked ...................... 5/5 PASS  (denied, no approval, run blocked, unchanged)
DEFAULT-BLOCKED unassigned pair ......... 1/1 PASS  (source=default)
TOTAL: 21/21 PASS  · startup reverted · test data cleaned
Agent test suite: 14/14 PASS
```

**Idempotency:** the always_allow path keys the execution slot by `agent-run-{id}:{slug}`,
approve by `approval-{id}:{slug}` — a duplicate decision/approve returns the stored result
(the native action never repeats). `apply()` is also a no-op if the action is unchanged.

**Backward compatibility:** existing `/decide`, `/analysis-result`, `/execution-results`,
labels, options, notifications, automations all unchanged; the 3 new approval columns are
nullable; pre-existing approvals/tests unaffected.

---

## 6. Sequence diagrams

### always_allow
```
Make→POST /agent-runs ▶ agent_runs(queued)                              ▶audit run_created
Make→POST /agent-runs/{r}/decision {action_slug,confidence,reason,payload}
        ├ AgentRunService.recordAnalysisResult ▶ proposed_action frozen  ▶audit run_analysis
        ├ ActionPolicyService.resolve ▶ "always_allow"                   ▶audit agent.decision
        ├ ExecutionRequestService.claim ▶ execution_requests(processing) ▶audit execution_claimed
        ├ AgentActionExecutor → StartupLabelActionService.apply()
        │     ▶ startups.action_option_id changed
        │     ▶ StartupLabelOptionChanged → notifications / group move / meeting-cancel + activity_logs
        ├ recordExecutionResult(executed)                                ▶audit run_execution
        └ recordResult(succeeded)                                        ▶audit execution_result
   response: {policy:always_allow, executed:true, execution_request_id, result}
```

### needs_approval
```
Make→POST /agent-runs/{r}/decision
        ├ recordAnalysisResult ▶ proposed_action                         ▶audit run_analysis
        ├ resolve ▶ "needs_approval"                                     ▶audit agent.decision
        └ ApprovalService.createFromRun ▶ agent_approval_requests(PENDING,
              confidence_score, agent_payload, policy_snapshot)          ▶audit approval_created
   response: {policy:needs_approval, executed:false, approval_id}
   ── HOLD: native action NOT run; startup unchanged ──
Admin→open /admin/approvals/{id} ▶ viewed_at (timeline)
Admin→Approve (or POST /approvals/{id}/approve)
        ├ ApprovalService.decide ▶ APPROVED (one-shot)                   ▶audit approval_approve
        ├ ExecutionRequestService.claim(approval-{id})                   ▶audit execution_claimed
        ├ AgentActionExecutor.apply() ▶ startup changes + automations
        ├ recordExecutionResult(executed) + recordResult(succeeded)      ▶audit run_execution / approval_executed
   [Reject] decide(reject) ▶ REJECTED ▶ run blocked ▶audit approval_reject — nothing executes
```

### blocked
```
Make→POST /agent-runs/{r}/decision
        ├ recordAnalysisResult ▶ proposed_action                         ▶audit run_analysis
        ├ resolve ▶ "blocked" (designed OR default-unassigned)           ▶audit agent.decision
        └ recordExecutionResult(blocked)                                 ▶audit run_execution(blocked)
   response: {policy:blocked, executed:false, approval_id:null}
   ── native action NOT executed · approval NOT created ──
```

---

## 7. Endpoint quick-reference (full set)

| Method | Path | Purpose |
|---|---|---|
| POST | `/api/v1/ai/agent-runs` | create run |
| POST | `/api/v1/ai/agent-runs/{run}/decision` | **NEW** — orchestrated decision |
| PUT  | `/api/v1/ai/action-policies/{agent}/{action}` | set designed policy |
| GET  | `/api/v1/ai/action-policies/{agent}/{action}` | resolve policy |
| GET  | `/api/v1/ai/approvals` · `/{id}` | list / detail |
| POST | `/api/v1/ai/approvals/{id}/approve` | **NEW** — approve + execute |
| POST | `/api/v1/ai/approvals/{id}/reject` | **NEW** — reject + close |
| POST | `/api/v1/ai/approvals/{id}/decide` | unified (approve executes) |
| POST | `/api/v1/ai/execution-requests` · `/{key}/result` | durable idempotency store |
| Web  | `/admin/approvals/{id}/decide` | admin Approve/Reject (now executes) |

**Auth:** all `/api/v1/ai/*` agent endpoints require `McpAuth:full` (OAuth `numu:full` or Sanctum `naat_` full).

---

## 8. Still open (next, when Make connects)
- Make blueprints call `/decision` + poll `/approvals` (the loop is ready).
- Investor execution: `agent_runs` is startup-scoped; add `investor_id` to runs to route
  `InvestorLabelActionService` (the executor already injects it).
- `POST /agents` could accept `agent_type` for Make-created investor agents.
- Schedule `approvals:expire` in prod cron.
