# Handoff Gap Analysis (repository-based)

> Read-only analysis. No implementation. The repository is the source of truth.

**Scope:** `App\Http\Controllers\Api\V1\Ai\Agents\HandoffController`, `App\Services\Agents\HandoffService`,
`App\Models\Handoff`, migrations `2026_06_26_110004_create_handoffs_table`,
`2026_06_29_130000_add_investor_subject_to_handoffs`, `2026_07_02_160000_add_investor_sequence_unique_to_handoffs`,
`AgentApiController` (envelope + `mapQueryException`), `tests/Feature/Agents/HandoffHardeningTest`.

**Legend — blocks production:** `NO` (safe as-is) · `CONTROLLED` (safe for a single-caller/pre-checked rollout; fix before unsupervised multi-caller) · `YES` (hard blocker).

---

## 1. Idempotency

- **Current behavior:** None at the backend. `HandoffService::create()` assigns a monotonic `sequence_number` (`max+1` under `lockForUpdate`) and inserts. A repeated logical POST creates a **new row**.
- **Evidence:** `HandoffService::create()`; `handoffs` uniqueness is only `(startup_id, sequence_number)` + `(investor_id, sequence_number)` — both on the server-assigned sequence, which never collides.
- **Architectural risk:** Duplicate handoffs for the same `(entity, source_run_id, from_agent, to_agent, schema_version)` if a caller retries; `GET latest` then returns the newest duplicate.
- **Production impact:** Medium — retries/at-least-once delivery (webhooks, Make) can double-write.
- **Recommended approach:** Add a **unique logical tuple** `(startup_id|investor_id, source_run_id, from_agent, to_agent, stage, schema_version)` OR an idempotency key column; on conflict return the existing row (idempotent 200) or `409`. Reuse the existing `mapQueryException()` (already maps unique violations → 409). Additive migration + one `firstOrCreate`-style guard in `create()`.
- **Blocks production:** **CONTROLLED** (safe with the mandatory caller pre-`GET latest` check; fix for unsupervised production).

## 2. Supersede behavior

- **Current behavior:** Implemented and hardened. Creates a new immutable row inheriting subject/source_run/from_agent/to_agent/stage/schema_version/retention_class; sets old `superseded_by = new.id`; new row `superseded_by = null`. Re-superseding an already-superseded handoff → **409** (locked re-check inside the transaction).
- **Evidence:** `HandoffService::supersede()`; `Handoff` immutability guard (`MUTABLE_AFTER_CREATE = ['superseded_by']`, `deleting` throws, no `updated_at`); `HandoffHardeningTest::{test_supersede_live_handoff_links_chain, test_superseding_an_already_superseded_handoff_returns_409, test_supersede_service_throws_on_already_superseded}`.
- **Architectural risk:** Low. Single linear chain; no branching; history immutable.
- **Production impact:** Low.
- **Recommended approach:** None required. Optional: expose a distinct audit event name is already present (`agent.handoff_superseded`).
- **Blocks production:** **NO**.

## 3. Correction behavior

- **Current behavior:** Correction = supersede (append a corrected immutable row; original retained, linked). There is no in-place edit path (model blocks it).
- **Evidence:** `POST /api/v1/ai/handoffs/{id}/supersede`; `Handoff::booted()` immutability guard.
- **Architectural risk:** Low.
- **Production impact:** Low.
- **Recommended approach:** None. The model matches the approved "immutable + supersede" design.
- **Blocks production:** **NO**.

## 4. Retention policy

- **Current behavior:** `retention_class` column, `string(40)`, default `'standard'`; coalesced null→`'standard'` in `create()`. Accepts **any** ≤40-char string. **No consumer** reads it for retention; it is metadata-only.
- **Evidence:** `2026_06_26_110004` migration; `HandoffService::create()` coalesce; repository-wide grep finds no code that acts on `handoffs.retention_class`.
- **Architectural risk:** Low — a free-text field with no behavior can drift (typos, unknown classes) with no effect or enforcement.
- **Production impact:** Low.
- **Recommended approach:** Add a config-driven allow-list (e.g., `standard`, `extended`, `legal_hold`) validated at `store`/`supersede`; keep it metadata until a retention job exists. Purely additive validation.
- **Blocks production:** **NO**.

## 5. Schema-version allow-list

- **Current behavior:** `schema_version` validated only as `required|string|max:40` — **any** string accepted; no supported-version list; unsupported versions are stored and served.
- **Evidence:** `HandoffController::store()` rules; no allow-list in code.
- **Architectural risk:** Medium — a consumer cannot rely on `schema_version` being a known value; a typo (`v1 ` / `V1`) produces a distinct, silently-accepted contract.
- **Production impact:** Medium (interoperability).
- **Recommended approach:** Config-driven allow-list (e.g., `['v1']`) validated at `store` + `supersede`; reject unknown with 422. Old rows remain readable (validation applies to new writes only). Additive.
- **Blocks production:** **CONTROLLED** (fine while a single producer emits `v1`; harden before multiple producers).

## 6. Automated production triggers

- **Current behavior:** None. No webhook/event/queue fires on AgentRun completion; handoffs are created only by an explicit authenticated `POST /handoffs`. Only a **pollable** `GET /agent-runs` exists.
- **Evidence:** `AgentRunService` sets `completed_at` but dispatches no event; grep of `app/Services/Agents` finds no `Event::`/`dispatch`/`Http::post`/broadcast.
- **Architectural risk:** N/A to the handoff model itself — this is orchestration (Make) work, not a handoff defect.
- **Production impact:** High for **full automation**; none for a manual/poll-driven flow.
- **Recommended approach:** Per the approved architecture (Runtime/backend owns creation by default; Make as temporary fallback), implement the trigger as **orchestration** that calls the existing `POST /handoffs` after a completed run — **no new execution path**. Must include the mandatory pre-`GET latest` duplicate check until §1 lands.
- **Blocks production:** **CONTROLLED** (automation gate; the manual path already works and is safe).

## 7. Replay protection

- **Current behavior:** No dedicated replay guard. The P1 **semantic `source_run_id` validation** prevents a handoff referencing a run of the wrong subject/agent/stage or a non-completed run, but it does **not** stop a second valid POST for the same run (that is §1 idempotency).
- **Evidence:** `HandoffController::validateSourceRun()` (ownership + completed + `agent_key==from_agent` + `stage==run.agent_key`); `HandoffHardeningTest`.
- **Architectural risk:** Medium — an at-least-once caller can replay a valid create.
- **Production impact:** Medium.
- **Recommended approach:** Solved together with §1 (logical-tuple/idempotency key). No separate mechanism needed.
- **Blocks production:** **CONTROLLED** (couples to §1).

## 8. Duplicate prevention

- **Current behavior:** Backend does **not** dedup by logical content. Only the server-assigned `sequence_number` is unique per subject (never collides). Scenario-level dedup (Make) is the only current guard.
- **Evidence:** `handoffs` unique indexes; `HandoffService::create()`.
- **Architectural risk:** Medium.
- **Production impact:** Medium (same as §1/§7).
- **Recommended approach:** Same as §1 — a logical-tuple unique index is the single fix that closes §1, §7, and §8.
- **Blocks production:** **CONTROLLED**.

## 9. Audit guarantees

- **Current behavior:** `create` → `agent.handoff_created`; `supersede` → `agent.handoff_superseded`, both via `AgentAuditLogger` → `activity_logs` (module `agents`, source `api`, actor + description). **Not** audited: reads, validation failures (return 422 without an audit row), duplicate skips, auth failures (OAuth anomaly signals exist separately in `McpAuth`/`AnomalyDetector`).
- **Evidence:** `HandoffService::{create,supersede}`; `AgentAuditLogger`.
- **Architectural risk:** Low — write actions are covered; read/rejection observability is thin.
- **Production impact:** Low.
- **Recommended approach:** Optionally add audit rows for `handoff_duplicate_skipped` (once §1 exists) and validation failures if forensic coverage is required. Not required for correctness.
- **Blocks production:** **NO**.

---

## Consolidated verdict

| # | Item | Implemented? | Blocks production |
|---|---|---|---|
| 1 | Idempotency | No | CONTROLLED |
| 2 | Supersede | Yes | NO |
| 3 | Correction | Yes (=supersede) | NO |
| 4 | Retention allow-list | No (metadata-only) | NO |
| 5 | Schema-version allow-list | No | CONTROLLED |
| 6 | Automated trigger | No | CONTROLLED (automation) |
| 7 | Replay protection | Partial (semantic only) | CONTROLLED |
| 8 | Duplicate prevention | No (scenario-level only) | CONTROLLED |
| 9 | Audit (create/supersede) | Yes | NO |

**Single highest-leverage fix:** a **logical-tuple unique index + idempotent create** closes §1, §7, and §8 at once, reusing the existing `mapQueryException` 409 mapping — additive, no architecture change. **No item is a hard blocker** for a controlled single-producer rollout that performs the mandatory pre-`GET latest` duplicate check; §1/§7/§8 should be closed before unsupervised, multi-caller production automation.
