ServicesSuccession

Cases and Execution

A case is one triggered run of a plan. It pins the plan version it opened under, walks a phase machine designed to give the subject every chance to come back, and ends either stood down, voided, or executed against a signed receipt.

Opening a case

snug succession case open --plan plan_11eae22fb8114757b5b40c9fbf5f6780 \
  --trigger explicit --event account_deleted

snug succession case open --plan plan_11eae22fb8114757b5b40c9fbf5f6780 \
  --trigger admin --reason "owner verified deceased"

An admin trigger requires a platform admin token and a non-empty --reason; without one the call is 400 invalid_request ("admin invocation requires a reason string"), and a non-admin caller is 403 insufficient_permissions. Explicit-event and inactivity triggers instead need subject authority - the subject itself, or an admin.

Nothing checks the trigger against the plan. A case opens under whatever trigger you send, whether or not the plan declares it; the trigger is recorded on the case as provenance, not enforced as a precondition.

Both deadlines are computed at open time from the plan's safeguards and frozen onto the case: grace_ends_at is now + grace_period_days, and contestation_ends_at is grace_ends_at + contestation_window_days.

{
  "status": 201,
  "msg": "Created",
  "data": {
    "case_id": "case_02c4eb1c878f45999ca1371a4e435a9c",
    "plan_id": "plan_11eae22fb8114757b5b40c9fbf5f6780",
    "plan_version": 1,
    "subject": { "kind": "guild", "id": "thorn-hall" },
    "trigger": { "kind": "inactivity", "source": "liveness", "threshold_days": 30 },
    "trigger_reason": null,
    "opened_by": "docs-wave-admin",
    "phase": "dead_man_switch",
    "designated_successor": null, "challenges": [],
    "grace_ends_at": "2026-09-04T18:50:53.174028Z",
    "contestation_ends_at": "2026-09-18T18:50:53.174028Z",
    "execution_receipt": null,
    "created_at": "2026-08-28T18:50:53.174654Z", "updated_at": "..."
  }
}

The phases

A case opens in dead_man_switch if the plan has a dead_man_switch safeguard, otherwise in grace.

PhaseMeaning
dead_man_switchThe confirmation ladder is running; each missed interval increments a counter
graceThe ladder exhausted (or there was none); still waiting the grace period out
succession_resolutionGrace is over; the successor is being settled
contestationAt least one challenge is on file
partially_executedExecution started and a leg did not complete
executedEvery leg reached a terminal success and a receipt was written
stood_downThe subject answered; false alarm
voidedA challenge was upheld

Three things move a case forward on demand: standing down, resolving a challenge, and executing. Everything time-driven - the dead-man ladder advancing, grace expiring into resolution, resolution expiring into contestation - belongs to a background sweeper that ticks every SUCCESSION_SWEEPER_INTERVAL_SECONDS. On a sweeper-enabled server a case opened with both windows at zero walked grace to succession_resolution to contestation on its own within two ticks.

The ladder is the exception: its steps are interval_days multiplied by SUCCESSION_HEARTBEAT_WINDOW_SECONDS, which defaults to 86400, so the shortest possible confirmation interval is a real day. The dead_man_switch to grace escalation is therefore the one transition in the table above not observed directly.

The sweeper is off by default (SUCCESSION_SWEEPER_ENABLED=false), and with it off a case sits in the phase it opened in indefinitely - verified on a default-configured server, where a case whose grace_ends_at had already passed stayed in grace through several minutes of polling. That also puts succession_resolution and contestation out of reach, and with them the whole contestation flow below. If deadlines are meant to mean anything in your deployment, turn the sweeper on.

Standing down

A live subject cancels its own succession. The endpoint is the "I am still here" signal, callable by the subject or an admin during dead_man_switch or grace:

snug succession case respond --id case_02c4eb1c878f45999ca1371a4e435a9c
{
  "case_id": "case_02c4eb1c878f45999ca1371a4e435a9c",
  "phase": "stood_down",
  "message": "Case stood down; you are confirmed present"
}

Standing down releases the subject's open-case slot, so a later trigger can open a fresh case against the same plan. Calling it twice, or in any later phase, is 409 invalid_phase; so is executing a stood-down case.

The ladder itself is invisible from outside. A case stores confirmations_required, confirmations_missed, and next_heartbeat_deadline, but none of the three appear in the case response, so you cannot see how many pings are left.

Contestation

Anyone authenticated may file a challenge against a case in succession_resolution or contestation, up to contestation_ends_at:

snug succession challenge file --case case_01HXJ \
  --claim "Heir 0 faked the inactivity window" \
  --evidence timeline:evt_4411

Filing moves the case to contestation and returns the challenge_id plus the route that should adjudicate it, taken from the plan's safeguards.challenge_resolution and defaulting to hitl. The challenge then shows up in the case's challenges array, attributed to its filer:

{ "challenge_id": "chal_6a086293e4614ee0a5860d6a17bb9f4b",
  "by": { "kind": "auth_user", "id": "docs-wave" },
  "claim": "heir 0 fabricated the inactivity window",
  "evidence": [ "timeline:evt_4411", "kv:audit-note" ],
  "status": "under_review", "route": "hitl",
  "filed_at": "2026-08-28T19:09:58.676855Z" }

Filing is not gated by subject authority - a plain user filed the challenge above against a guild they have no control over. Filing in a phase that does not accept one is 409 invalid_phase ("challenges may only be filed during resolution or contestation, not Grace"), an empty claim is 400 BAD_REQUEST, and an unknown case is 404 case_not_found.

The deadline is enforced separately from the phase: once contestation_ends_at has passed, filing is 409 contestation_closed even though the case is sitting in the contestation phase. A plan with contestation_window_days: 0 therefore reaches that phase already closed to challenges.

While any challenge is under_review the case is frozen: execute returns 409 invalid_phase, "execution is frozen while challenges are under review".

Resolution is admin-only:

snug succession challenge resolve --case case_01HXJ --challenge chal_01HXK --dismiss
snug succession challenge resolve --case case_01HXJ --challenge chal_01HXK \
  --uphold --note "claim substantiated"

Dismissing returns the case to succession_resolution - unless other challenges are still under review, in which case it stays in contestation. Upholding responds { "status": "upheld", "phase": "voided" }: terminal, and it frees the subject's open-case slot, verified by opening a new case against the same plan immediately afterwards.

A non-admin caller gets 403 insufficient_permissions, an unknown challenge 404 challenge_not_found, and resolving one twice 409 invalid_phase ("challenge ... is already Dismissed"). The optional --note has no field of its own - it is appended to the challenge's claim, which afterwards reads original claim [resolution: your note].

Executing the will

Execution requires a platform admin token; the subject itself cannot execute its own case.

snug succession case execute --id case_ef989ee621bf49b69c80ded536996a1a

The saga walks the plan's dispositions in order. For a treasury balance it moves the source wallet's entire available balance - there is no partial amount. transfer sends it to the successor, or to the disposition's explicit to. split floors each share and credits the flooring remainder to the first heir, so the source always ends at zero. A wallet that is already empty is a successful leg with no transfer (dep_ref: null). An Item disposition moves the whole item into the heir's to_container and reports the item id as its dep_ref.

{
  "case_id": "case_ef989ee621bf49b69c80ded536996a1a",
  "phase": "executed",
  "receipt": {
    "case_id": "case_ef989ee621bf49b69c80ded536996a1a",
    "plan_version": 1,
    "successor": { "kind": "auth_user", "id": "heir-primary" },
    "legs": [
      { "leg_index": 0,
        "asset": { "kind": "treasury_balance", "domain": "estate", "currency_id": "gold" },
        "rule": { "kind": "split", "shares": [ ... ] },
        "status": "succeeded", "detail": null,
        "dep_ref": "txn_c18d0311f6eb4c9394afc54698d6f02a" }
    ],
    "receipt_hash": "sha256:1b89c57693c510c7ea6140c78975eb467ea050a29f518d65536825c7be6ab7ca",
    "executed_at": "2026-08-28T18:51:16.541105Z"
  }
}

Each dep_ref is the downstream Treasury transaction or Item transfer the leg produced, so the receipt is a joinable audit trail. receipt_hash is a SHA-256 over the case, plan version, successor, and succeeded legs. Executing an already-executed case is a no-op that returns the original receipt unchanged - verified by re-running the split above and confirming the hash, timestamp, and wallet balances did not move.

Partial execution is the failure mode to plan for. The saga stops at the first leg that does not succeed, and the case lands in partially_executed with receipt: null. Re-running execute resumes rather than repeats: a leg that already succeeded is skipped, verified by refunding a drained source wallet between two runs and watching the money stay put. But the response tells you only the phase - the per-leg status and detail explaining what broke are stored on the case and returned by no endpoint, so diagnosing a stall means checking the downstream service yourself. Three ways to get there:

  • a name or role disposition, which is accepted but not executable
  • an escrow disposition on a currency, which is deferred
  • a burn disposition, which needs the wallet named by SUCCESSION_BURN_SINK_WALLET to exist in the same domain

A partially_executed case also keeps holding the subject's open-case slot, and nothing but a successful re-run clears it, so a subject whose will contains a leg that can never succeed can never open another case.

Listing cases

snug succession case list --subject guild:thorn-hall
snug succession case list --phase executed --page-size 25

Cases come back newest first. --phase takes a phase name exactly as spelled in the table above; an unrecognized value is not an error, it just matches nothing and returns an empty page.

Full parameters and schemas are in the Succession API reference.

On this page