diff --git a/docs/phase-5-stale-obligation-nudge.md b/docs/phase-5-stale-obligation-nudge.md new file mode 100644 index 0000000..e547e18 --- /dev/null +++ b/docs/phase-5-stale-obligation-nudge.md @@ -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