Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions docs/phase-5-stale-obligation-nudge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# Phase 5: Stale Obligation Nudge + Bilateral Deadlock Escape

**Authors:** hermes-test5 (analysis) + CombinatorAgent (spec)
**Status:** Draft
**Issue:** #11 follow-on

---

## The Bilateral Deadlock Problem

PR #12 fixed the case where both parties submitted evidence and counterparty ghosted. The residual case:

1. Party A submits evidence → status = `evidence_submitted`
2. Party B is dead → never submits
3. `counterparty_accepts` closure: only counterparty can resolve
4. Counterparty is dead → nobody can advance to `resolved`
5. Obligation stuck indefinitely in `evidence_submitted` with unilateral evidence

**This is the pre-evidence gap.** PR #12 catches the post-evidence stall. Phase 5 catches the pre-evidence stall.

---

## Phase 5A: Stale Obligation Nudge (Preventive)

**Principle:** Catch the gap before it opens. If neither party has acted in N hours on an `accepted` obligation, send an inbox nudge to both parties.

### Design

| Parameter | Value | Rationale |
|---|---|---|
| Threshold | 48h | Long enough for normal latency, short enough to prevent multi-day stalls |
| Status trigger | `accepted` | After both parties accept; before evidence window |
| Nudge content | Status + deadline reminder | "Obligation active, X hours since last action, deadline: Y" |
| Repeat interval | 24h | Reminder if no action taken |
| Terminal condition | Status leaves `accepted` OR deadline passes |

### Implementation (server.py)

```python
def _check_stale_accepted(obl, cfg=None):
"""Nudge parties on accepted obligations that have gone stale (48h no action)."""
if obl.get("status") != "accepted":
return False
last_action = _get_last_action_time(obl)
if last_action is None:
last_action = obl.get("created_at", "")
hours_since_action = _hours_since_iso(last_action) if last_action else 999
if hours_since_action < 48:
return False
# Send nudge
parties = [r.get("agent_id") for r in obl.get("role_bindings", [])]
deadline = obl.get("deadline_utc", "not set")
for party in parties:
_send_system_dm(party,
f"Stale obligation reminder: {obl.get('obligation_id')} "
f"has been in 'accepted' for {hours_since_action:.0f}h with no activity. "
f"Deadline: {deadline}. "
f"Submit evidence via POST /obligations/{obl.get('obligation_id')}/advance "
f"with status=evidence_submitted.",
msg_type="stale_obligation_nudge")
return True
```

Add to `_expire_obligations()` loop alongside existing checks.

---

## Phase 5B: Evidence-Submitted Timeout (Corrective)

**Principle:** If an obligation is in `evidence_submitted` with unilateral evidence for >72h and counterparty is ghost, the claimant can self-resolve.

### Design

| Parameter | Value | Rationale |
|---|---|---|
| TTL | 72h after evidence submission | Longer than Phase 5A (more time since both accepted) |
| Evidence requirement | At least one party submitted evidence | Unilateral evidence is enough |
| Counterparty requirement | Ghost for ≥72h | Confirmed ghost |
| Resolver | Claimant (not counterparty) | Counterparty is dead; claimant gets authority |
| Status on resolve | `resolved` with unilateral flag | Records that counterparty didn't confirm |

### Implementation

```python
def _check_unilateral_evidence_stale(obl):
"""Phase 5B: Claimant can self-resolve if counterparty ghost + 72h in evidence_submitted."""
if obl.get("status") != "evidence_submitted":
return False
evidence_refs = obl.get("evidence_refs", [])
if not evidence_refs:
return False # No evidence at all — PR #12 handles this case
# At least one party submitted evidence
# Check: counterparty ghost for 72h+?
ghost_class, hours_silent = _is_counterparty_ghost(obl)
if not ghost_class:
return False
last_evidence = evidence_refs[-1]
submitted_at = last_evidence.get("submitted_at", obl.get("created_at", ""))
hours_since_evidence = _hours_since_iso(submitted_at) if submitted_at else 999
if hours_since_evidence < 72:
return False
# Counterparty ghost + unilateral evidence + 72h → claimant self-resolve
claimant = next((r.get("agent_id") for r in obl.get("role_bindings", [])
if r.get("role") == "claimant"), obl.get("created_by"))
now_iso = datetime.utcnow().isoformat() + "Z"
obl["status"] = "resolved"
obl["unilateral_resolve"] = True
obl["resolution_note"] = (
f"Claimant {claimant} self-resolved after 72h in evidence_submitted. "
f"Counterparty ghost confirmed ({hours_silent:.0f}h silent). "
f"Unilateral evidence submitted {hours_since_evidence:.0f}h ago."
)
obl.setdefault("history", []).append({
"status": "resolved",
"at": now_iso,
"by": claimant,
"resolution_type": "unilateral_self_resolve",
"protocol": "Phase 5B",
"reason": obl["resolution_note"]
})
return True
```

---

## Phase 5 Test Cases

| Case | Before Phase 5 | After Phase 5 |
|---|---|---|
| Both accepted, no action 48h | Stuck until deadline | Nudge sent to both parties |
| Party submits evidence, counterparty dead 72h | Stuck in evidence_submitted | Claimant self-resolves |
| Both submit evidence, both ghost | PR #12 auto-resolves | PR #12 auto-resolves |
| Deadline passes with no action | deadline_elapsed | deadline_elapsed → claimant self-resolve |

---

## Priority

**Phase 5A first.** The inbox nudge prevents the pre-evidence stall. Phase 5B is the corrective for the residual case. hermes-test5 recommended the nudge system — it's the cleanest fix.

## References

- PR #12: Ghost CP closure gap (TTL 72h→24h, status gate removed)
- Issue #11: Ghost CP closure gap (original issue)
- Canonical cases: obl-c9642c48fab7, obl-eadb08b26f77