diff --git a/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py b/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py
new file mode 100644
index 00000000..1217cb39
--- /dev/null
+++ b/backend/alembic/versions/71efd2102f49_add_pending_status_to_suggested_fixes.py
@@ -0,0 +1,60 @@
+"""add applied_pending status + pending_reason to session_suggested_fixes
+
+Adds the `applied_pending` non-terminal status (engineer ran the fix but
+verification is deferred — waiting on client, async sync, etc) alongside
+the existing `applied_partial` status. Mirrors partial_notes with a new
+pending_reason column for the "what are you waiting on?" prose.
+
+Revision ID: c0f3a4b7e91d
+Revises: 71efd2102f49
+Create Date: 2026-04-30
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "c0f3a4b7e91d"
+down_revision: Union[str, None] = "71efd2102f49"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.add_column(
+ "session_suggested_fixes",
+ sa.Column("pending_reason", sa.Text(), nullable=True),
+ )
+ op.drop_constraint(
+ "ck_session_suggested_fixes_status",
+ "session_suggested_fixes",
+ type_="check",
+ )
+ op.create_check_constraint(
+ "ck_session_suggested_fixes_status",
+ "session_suggested_fixes",
+ "status IN ('proposed', 'applied_success', 'applied_failed', "
+ "'applied_partial', 'applied_pending', 'dismissed')",
+ )
+
+
+def downgrade() -> None:
+ op.execute(
+ "UPDATE session_suggested_fixes "
+ "SET status = 'applied_partial', "
+ " partial_notes = COALESCE(partial_notes, pending_reason) "
+ "WHERE status = 'applied_pending'"
+ )
+ op.drop_constraint(
+ "ck_session_suggested_fixes_status",
+ "session_suggested_fixes",
+ type_="check",
+ )
+ op.create_check_constraint(
+ "ck_session_suggested_fixes_status",
+ "session_suggested_fixes",
+ "status IN ('proposed', 'applied_success', 'applied_failed', "
+ "'applied_partial', 'dismissed')",
+ )
+ op.drop_column("session_suggested_fixes", "pending_reason")
diff --git a/backend/app/api/endpoints/session_suggested_fixes.py b/backend/app/api/endpoints/session_suggested_fixes.py
index fb3ef8ec..11ba5b46 100644
--- a/backend/app/api/endpoints/session_suggested_fixes.py
+++ b/backend/app/api/endpoints/session_suggested_fixes.py
@@ -318,6 +318,11 @@ async def patch_suggested_fix_outcome(
status_code=status.HTTP_400_BAD_REQUEST,
detail="notes are required when outcome is applied_partial",
)
+ if body.outcome == "applied_pending" and not (body.notes and body.notes.strip()):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="notes are required when outcome is applied_pending",
+ )
TERMINAL = {"applied_success", "applied_failed", "dismissed"}
if fix.status in TERMINAL:
@@ -329,6 +334,10 @@ async def patch_suggested_fix_outcome(
fix.status = body.outcome
if body.outcome == "applied_partial":
fix.partial_notes = (body.notes or "").strip() or None
+ elif body.outcome == "applied_pending":
+ # Pending is parked, not terminal — keep applied_at, do NOT stamp
+ # verified_at. Reason explains what the engineer is waiting on.
+ fix.pending_reason = (body.notes or "").strip() or None
elif body.outcome == "applied_failed":
fix.failure_reason = (body.notes or "").strip() or None
fix.verified_at = now
diff --git a/backend/app/models/session_suggested_fix.py b/backend/app/models/session_suggested_fix.py
index 9cedf1b0..fff133aa 100644
--- a/backend/app/models/session_suggested_fix.py
+++ b/backend/app/models/session_suggested_fix.py
@@ -37,7 +37,7 @@ class SessionSuggestedFix(Base):
),
CheckConstraint(
"status IN ('proposed', 'applied_success', 'applied_failed', "
- "'applied_partial', 'dismissed')",
+ "'applied_partial', 'applied_pending', 'dismissed')",
name="ck_session_suggested_fixes_status",
),
)
@@ -81,6 +81,7 @@ class SessionSuggestedFix(Base):
DateTime(timezone=True), nullable=True
)
partial_notes: Mapped[str | None] = mapped_column(Text, nullable=True)
+ pending_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
failure_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_outcome_proposal: Mapped[dict[str, Any] | None] = mapped_column(
JSONB, nullable=True
diff --git a/backend/app/schemas/session_suggested_fix.py b/backend/app/schemas/session_suggested_fix.py
index 2672c764..c23f506f 100644
--- a/backend/app/schemas/session_suggested_fix.py
+++ b/backend/app/schemas/session_suggested_fix.py
@@ -20,6 +20,7 @@ FixStatus = Literal[
"applied_success",
"applied_failed",
"applied_partial",
+ "applied_pending",
"dismissed",
]
@@ -40,6 +41,7 @@ class SessionSuggestedFixResponse(BaseModel):
applied_at: datetime | None
verified_at: datetime | None
partial_notes: str | None
+ pending_reason: str | None
failure_reason: str | None
ai_outcome_proposal: dict[str, Any] | None
@@ -91,7 +93,11 @@ class SessionSuggestedFixDecisionResponse(BaseModel):
# Subset of FixStatus that the engineer can set via the outcome endpoint —
# `proposed` is excluded because you can't un-decide a fix back to "proposed".
FixOutcome = Literal[
- "applied_success", "applied_failed", "applied_partial", "dismissed"
+ "applied_success",
+ "applied_failed",
+ "applied_partial",
+ "applied_pending",
+ "dismissed",
]
@@ -103,14 +109,18 @@ class SessionSuggestedFixOutcomeRequest(BaseModel):
engineer took); outcome captures whether the fix actually worked.
Allowed transitions:
- - from `proposed` or `applied_partial`: any outcome is valid
- (partial is parked, not terminal — the engineer may update notes,
- abandon via dismiss, or advance to success/failed)
+ - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
+ is valid. Partial means "did some of it"; pending means "did all of
+ it but verification is deferred (waiting on client, async sync, etc)".
+ Both are parked, not terminal — the engineer may advance them to
+ success/failed/dismiss.
- from any terminal outcome (`applied_success`, `applied_failed`,
`dismissed`): server returns 409
"""
outcome: FixOutcome
- # Required for applied_partial, optional for applied_failed, ignored otherwise.
+ # Required for applied_partial AND applied_pending; optional for
+ # applied_failed; ignored otherwise. For pending, this is the
+ # "what are you waiting on?" reason (e.g. "client power-cycling router").
notes: str | None = Field(None, max_length=500)
diff --git a/backend/app/services/escalation_package_generator.py b/backend/app/services/escalation_package_generator.py
index 62978f27..1e0b7f0c 100644
--- a/backend/app/services/escalation_package_generator.py
+++ b/backend/app/services/escalation_package_generator.py
@@ -63,6 +63,9 @@ the active suggested fix, as given in the input bundle under "Outcome status":>
provided. State that it did not resolve the issue.
- applied_partial: Include the fix as a partially tried path. Include partial \
notes if provided. Indicate it was not fully completed or not verified.
+- applied_pending: List the fix as applied but awaiting verification. Include \
+the pending reason if provided (e.g. "client power-cycling router"). Make it \
+clear the next engineer should follow up to confirm it worked.
- applied_success: Note that the fix was applied and verified but escalation \
is still needed for another reason (unusual — reflect this accurately).
- dismissed: Do not mention the fix as a tried path; it was only considered.
@@ -80,6 +83,8 @@ symptoms are still being narrowed."
- applied_failed or dismissed: Say the proposed fix did not hold or was set \
aside. State any remaining uncertainty.
- applied_partial: Note the partial application and what remains open.
+- applied_pending: Note that the fix is in place but unverified. Reference the \
+pending reason. Frame this as the leading hypothesis pending confirmation.
- applied_success: Unusual in an escalate path — state the fix resolved the \
original symptom but a new or related issue requires escalation.
@@ -92,6 +97,8 @@ accordingly — e.g. suggest alternatives or deeper investigation paths, \
drawing on the failure reason if provided. \
If the fix is partially applied (applied_partial), the first step is typically \
to complete or verify it. \
+If the fix is pending verification (applied_pending), the first step is \
+typically to confirm whether the fix held — reference what was being waited on. \
If the fix is still proposed (no outcome), the first step is to try it if \
confidence is high (>80%).>
@@ -299,6 +306,8 @@ class EscalationPackageGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
+ if active_fix.pending_reason:
+ lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
diff --git a/backend/app/services/resolution_note_generator.py b/backend/app/services/resolution_note_generator.py
index 974840eb..d2febc1c 100644
--- a/backend/app/services/resolution_note_generator.py
+++ b/backend/app/services/resolution_note_generator.py
@@ -83,6 +83,10 @@ state means the engineer resolved the issue another way; the note should cover \
that actual resolution, not just the failed attempt.
- applied_partial: Note that the fix was partially applied. If partial_notes \
are provided, include them. Then describe the final resolution path taken.
+- applied_pending: Note that the fix was applied and verification is pending. \
+If pending_reason is provided, include it (e.g. "awaiting client power-cycle"). \
+Frame the resolution as provisional — the fix is in place but not yet \
+confirmed. Do not write closure language.
- dismissed: Treat the fix as considered and set aside. Do not center the note \
on it. Describe the resolution based on what was actually confirmed and done.
- proposed (no outcome yet): Write "Resolution not yet applied — fix proposed: \
@@ -322,6 +326,8 @@ class ResolutionNoteGeneratorService:
lines.append(f"Verified at: {active_fix.verified_at.isoformat()}")
if active_fix.partial_notes:
lines.append(f"Partial notes: {active_fix.partial_notes}")
+ if active_fix.pending_reason:
+ lines.append(f"Pending reason: {active_fix.pending_reason}")
if active_fix.failure_reason:
lines.append(f"Failure reason: {active_fix.failure_reason}")
diff --git a/backend/tests/test_fix_outcome_endpoint.py b/backend/tests/test_fix_outcome_endpoint.py
index 432a5c22..5fb0eca7 100644
--- a/backend/tests/test_fix_outcome_endpoint.py
+++ b/backend/tests/test_fix_outcome_endpoint.py
@@ -193,6 +193,95 @@ async def test_applied_at_auto_stamped_on_first_outcome(
assert body["verified_at"] is not None
+@pytest.mark.asyncio
+async def test_pending_requires_notes(
+ client: AsyncClient, test_user, auth_headers, test_db
+):
+ """applied_pending requires notes (the "what are you waiting on?" reason)."""
+ session_id, fix_id = await _make_session_with_fix(test_db, test_user)
+
+ r = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ headers=auth_headers,
+ json={"outcome": "applied_pending"},
+ )
+ assert r.status_code == 400
+ assert "notes" in r.text.lower()
+
+
+@pytest.mark.asyncio
+async def test_pending_stores_reason_and_stamps_applied_at(
+ client: AsyncClient, test_user, auth_headers, test_db
+):
+ """applied_pending stores notes under pending_reason and stamps applied_at
+ but NOT verified_at — the fix is parked, not verified."""
+ session_id, fix_id = await _make_session_with_fix(test_db, test_user)
+ r = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ headers=auth_headers,
+ json={"outcome": "applied_pending", "notes": "client power-cycling router"},
+ )
+ assert r.status_code == 200, r.text
+ body = r.json()
+ assert body["status"] == "applied_pending"
+ assert body["pending_reason"] == "client power-cycling router"
+ assert body["applied_at"] is not None
+ assert body["verified_at"] is None
+ assert body["partial_notes"] is None
+ assert body["failure_reason"] is None
+
+
+@pytest.mark.asyncio
+async def test_pending_to_success_allowed(
+ client: AsyncClient, test_user, auth_headers, test_db
+):
+ """pending is non-terminal — engineer can advance to success once verified."""
+ session_id, fix_id = await _make_session_with_fix(test_db, test_user)
+
+ r1 = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ headers=auth_headers,
+ json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
+ )
+ assert r1.status_code == 200
+
+ r2 = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ headers=auth_headers,
+ json={"outcome": "applied_success"},
+ )
+ assert r2.status_code == 200
+ body = r2.json()
+ assert body["status"] == "applied_success"
+ assert body["verified_at"] is not None
+ # pending_reason is preserved as audit trail
+ assert body["pending_reason"] == "waiting on AD replication"
+
+
+@pytest.mark.asyncio
+async def test_pending_reason_can_be_updated(
+ client: AsyncClient, test_user, auth_headers, test_db
+):
+ """pending→pending with new notes updates the stored pending_reason."""
+ session_id, fix_id = await _make_session_with_fix(test_db, test_user)
+
+ r1 = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ json={"outcome": "applied_pending", "notes": "waiting on AD replication"},
+ headers=auth_headers,
+ )
+ assert r1.status_code == 200
+ assert r1.json()["pending_reason"] == "waiting on AD replication"
+
+ r2 = await client.patch(
+ f"/api/v1/ai-sessions/{session_id}/suggested-fixes/{fix_id}/outcome",
+ json={"outcome": "applied_pending", "notes": "now waiting on client to confirm login"},
+ headers=auth_headers,
+ )
+ assert r2.status_code == 200
+ assert r2.json()["pending_reason"] == "now waiting on client to confirm login"
+
+
@pytest.mark.asyncio
async def test_failed_outcome_stores_notes_as_failure_reason(
client: AsyncClient, test_user, auth_headers, test_db
diff --git a/frontend/src/api/sessionSuggestedFixes.ts b/frontend/src/api/sessionSuggestedFixes.ts
index bd440556..fd6a96a0 100644
--- a/frontend/src/api/sessionSuggestedFixes.ts
+++ b/frontend/src/api/sessionSuggestedFixes.ts
@@ -13,12 +13,14 @@ export type FixStatus =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
+ | 'applied_pending'
| 'dismissed'
export type FixOutcome =
| 'applied_success'
| 'applied_failed'
| 'applied_partial'
+ | 'applied_pending'
| 'dismissed'
export interface AIOutcomeProposal {
@@ -41,6 +43,7 @@ export interface SessionSuggestedFix {
applied_at: string | null
verified_at: string | null
partial_notes: string | null
+ pending_reason: string | null
failure_reason: string | null
ai_outcome_proposal: AIOutcomeProposal | null
superseded_at: string | null
@@ -126,11 +129,12 @@ export const sessionSuggestedFixesApi = {
/**
* Record the outcome of applying a suggested fix. Transition rules:
- * - from `proposed` or `applied_partial`: any outcome is valid (partial is
- * parked, not terminal — engineer may update notes, abandon via dismiss,
- * or advance to success/failed).
+ * - from `proposed`, `applied_partial`, or `applied_pending`: any outcome
+ * is valid. Partial = "did some of it"; pending = "did all of it but
+ * verification is deferred". Both are parked, not terminal.
* - from a terminal status (`applied_success`, `applied_failed`, `dismissed`):
* server returns 409.
+ * - `applied_pending` requires `notes` (the "what are you waiting on?" reason).
*/
async patchOutcome(
sessionId: string,
diff --git a/frontend/src/components/pilot/ProposalBanner.tsx b/frontend/src/components/pilot/ProposalBanner.tsx
index 1bd91475..4d96592a 100644
--- a/frontend/src/components/pilot/ProposalBanner.tsx
+++ b/frontend/src/components/pilot/ProposalBanner.tsx
@@ -10,7 +10,7 @@
* + 07-verify-states.html.
*/
import { useState } from 'react'
-import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info } from 'lucide-react'
+import { Sparkles, Check, ChevronDown, X, MoreHorizontal, Info, Clock3 } from 'lucide-react'
import { cn } from '@/lib/utils'
import type {
SessionSuggestedFix,
@@ -21,6 +21,7 @@ export type BannerMode =
| 'proposed' // AI just proposed; engineer hasn't applied yet
| 'verifying' // Engineer clicked Apply; awaiting outcome
| 'partial' // Applied partially; awaiting finish or terminal outcome
+ | 'pending' // Applied fully; verification deferred (waiting on client, etc)
| 'ai_confirming' // AI emitted [FIX_OUTCOME]; engineer confirms
| 'nudge' // Compact nudge shown after N post-apply messages
@@ -45,6 +46,7 @@ export function ProposalBanner(props: ProposalBannerProps) {
case 'proposed': return