Trigger State Machine — States, Transitions, and Failure Modes¶
Overview¶
The trigger engine is the heart of the platform. It manages: - Dead man's switches - Scheduled releases - Event-based triggers (death verification) - Conditional triggers (combinations)
Core Principle: Irreversibility is delayed, not denied. Every state has a challenge path.
Trigger Types¶
1. Dead Man's Switch (dead_man_switch)¶
Fires if user fails to check in within configured interval.
Configuration:
{
type: 'dead_man_switch',
check_interval_days: 7, // How often user must check in
grace_period_days: 3, // Warning period before execution
reminder_channels: ['email', 'sms', 'push'],
secondary_contacts: ['contact_id_1', 'contact_id_2'],
require_secondary_confirmation: true
}
2. Scheduled (scheduled)¶
Fires at a specific date/time.
Configuration:
{
type: 'scheduled',
execute_at: '2030-01-01T00:00:00Z',
require_confirmation: true, // User must confirm before execution
confirmation_window_days: 7
}
3. Death Verification (death_verification)¶
Fires when death is reported and verified through process.
Configuration:
{
type: 'death_verification',
require_certificate: true,
require_secondary_confirmation: true,
mandatory_delay_days: 7,
challenge_window_days: 14
}
4. Event-Based (event)¶
Fires when specific event occurs.
Configuration:
{
type: 'event',
event_type: 'document_uploaded',
event_filter: { document_type: 'death_certificate' },
require_verification: true
}
5. Conditional (conditional) — v2¶
Fires when combination of conditions are met.
Configuration:
{
type: 'conditional',
conditions: {
operator: 'AND',
items: [
{ trigger_id: 'trigger_1', state: 'triggered' },
{ trigger_id: 'trigger_2', state: 'triggered' }
]
}
}
State Definitions¶
Core States¶
┌─────────────────────────────────────────────────────────────────────┐
│ TRIGGER STATES │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ draft │────▶│ armed │────▶│ triggered│────▶│ pending_ │ │
│ │ │ │ │ │ │ │ execution│ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │ │ │
│ │ │ │ ▼ │
│ │ │ │ ┌──────────┐ │
│ │ │ │ │executing │ │
│ │ │ │ └──────────┘ │
│ │ │ │ │ │
│ │ │ │ ▼ │
│ │ │ │ ┌──────────┐ │
│ │ │ │ │ released │ │
│ │ │ │ └──────────┘ │
│ │ │ │ │ │
│ │ │ │ ▼ │
│ │ │ │ ┌──────────┐ │
│ │ │ │ │finalized │ │
│ │ │ │ └──────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ deleted │ │ disarmed │ │ aborted │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ FAILURE STATES: │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │execution_│ │notificat_│ │ system_ │ │
│ │ failed │ │ion_failed│ │ failure │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
State Descriptions¶
| State | Description | User Visible | Abort Allowed |
|---|---|---|---|
draft |
Trigger created but not activated | "Not active" | N/A (delete) |
armed |
Active, waiting for condition | "Active - monitoring" | Yes |
triggered |
Condition met, challenge window open | "Triggered - awaiting confirmation" | Yes |
pending_execution |
Challenge window passed, abort window open | "Executing soon - abort available" | Yes |
executing |
Actions in progress | "Executing..." | Limited |
released |
Access granted, data released | "Released" | Reversal possible |
finalized |
Irreversible completion | "Complete" | No |
disarmed |
User disabled trigger | "Disabled" | N/A |
aborted |
Execution stopped | "Aborted" | N/A |
deleted |
Trigger removed | N/A | N/A |
execution_failed |
Action failed, retry pending | "Error - retrying" | Yes |
notification_failed |
Notification failed | "Notification error" | Yes |
system_failure |
System error, manual intervention | "System error - support notified" | Manual |
Transition Table¶
Happy Path¶
| From | To | Preconditions | Side Effects |
|---|---|---|---|
draft |
armed |
User activates | Audit log, start monitoring |
armed |
triggered |
Trigger condition met | Audit log, send alerts, start challenge timer |
triggered |
pending_execution |
Challenge window elapsed, no abort | Audit log, send final warning |
pending_execution |
executing |
Abort window elapsed | Audit log, begin actions |
executing |
released |
All actions complete | Audit log, notify recipients |
released |
finalized |
Finalization criteria met OR 7 days elapsed | Audit log, lock state |
Abort/Disable Path¶
| From | To | Preconditions | Side Effects |
|---|---|---|---|
armed |
disarmed |
User request | Audit log |
triggered |
aborted |
User abort OR secondary contact abort | Audit log, notify |
pending_execution |
aborted |
User abort (with confirmation) | Audit log, notify |
executing |
aborted |
Limited: only incomplete actions aborted | Audit log, partial rollback |
released |
aborted |
Reversal request within 7 days | Audit log, access revoked |
Failure Path¶
| From | To | Preconditions | Side Effects |
|---|---|---|---|
executing |
execution_failed |
Action throws error | Audit log, alert ops, schedule retry |
executing |
notification_failed |
Notification fails | Audit log, try alternate channel |
| Any | system_failure |
System error | Audit log, alert ops, hold state |
Recovery Path¶
| From | To | Preconditions | Side Effects |
|---|---|---|---|
execution_failed |
executing |
Retry successful | Audit log |
notification_failed |
executing |
Alternate channel successful | Audit log |
system_failure |
Previous state | Manual intervention | Audit log, explain recovery |
Timing Fields¶
Every trigger tracks:
CREATE TABLE vault_core.triggers (
id UUID PRIMARY KEY,
-- Configuration
trigger_type TEXT NOT NULL,
config JSONB NOT NULL,
actions JSONB NOT NULL,
-- Current state
status TEXT NOT NULL DEFAULT 'draft',
-- Timing (all nullable until relevant)
created_at TIMESTAMPTZ DEFAULT NOW(),
armed_at TIMESTAMPTZ,
last_check_in TIMESTAMPTZ,
next_check_required TIMESTAMPTZ,
-- Trigger timing
condition_met_at TIMESTAMPTZ, -- When trigger condition was met
triggered_at TIMESTAMPTZ, -- When entered 'triggered' state
challenge_window_ends_at TIMESTAMPTZ,
-- Execution timing
eligible_at TIMESTAMPTZ, -- When execution becomes allowed
abort_window_ends_at TIMESTAMPTZ,
execution_started_at TIMESTAMPTZ,
execution_completed_at TIMESTAMPTZ,
-- Release timing
released_at TIMESTAMPTZ,
reversal_window_ends_at TIMESTAMPTZ,
finalized_at TIMESTAMPTZ,
-- Failure tracking
last_error TEXT,
retry_count INT DEFAULT 0,
next_retry_at TIMESTAMPTZ
);
Why these fields matter:
- eligible_at proves we waited the required time
- abort_window_ends_at proves abort was possible
- finalized_at proves when irreversibility occurred
- All fields are immutable once set (append-only)
Dead Man's Switch Logic¶
Check-In Flow¶
User checks in
│
▼
Update last_check_in = NOW()
Calculate next_check_required = NOW() + check_interval_days
│
▼
Reset any warning states
Monitoring Flow (runs every 5 minutes)¶
For each armed dead_man_switch trigger:
│
├─ If NOW() < next_check_required
│ └─ No action
│
├─ If NOW() >= next_check_required AND NOW() < (next_check_required + grace_period)
│ └─ Send reminders (if not already sent)
│ └─ Contact secondary contacts (if configured)
│ └─ Remain in 'armed' state
│
└─ If NOW() >= (next_check_required + grace_period)
│
├─ If secondary_confirmation_required AND NOT secondary_confirmed
│ └─ Escalate to secondaries, extend grace
│
└─ If all confirmations received OR not required
└─ Transition to 'triggered'
└─ Start challenge window
Multi-Signal Verification¶
A trigger NEVER fires on a single signal. Minimum verification:
async function shouldTriggerFire(trigger: Trigger): Promise<boolean> {
const signals = [];
// Signal 1: Check-in missed
if (isCheckInOverdue(trigger)) {
signals.push('check_in_missed');
}
// Signal 2: Email reminder not acknowledged
if (reminderSent(trigger) && !reminderAcknowledged(trigger)) {
signals.push('reminder_ignored');
}
// Signal 3: SMS verification failed
if (smsSent(trigger) && !smsConfirmed(trigger)) {
signals.push('sms_unconfirmed');
}
// Signal 4: Secondary contact confirms concern
if (secondaryContactedAndConfirmed(trigger)) {
signals.push('secondary_confirmed');
}
// Require minimum 2 signals
return signals.length >= 2;
}
Death Verification Flow¶
Process Recording (Not Truth Assertion)¶
Executor initiates death claim
│
▼
Record: "Executor {id} initiated death claim at {timestamp}"
│
▼
Executor uploads certificate
│
▼
Record: "Document {id} uploaded by {executor_id} at {timestamp}"
Record: "Document type: death_certificate"
(NO assertion that document is valid or person is dead)
│
▼
If secondary confirmation required:
│
├─ Notify secondary contact
│
├─ Record: "Secondary confirmation requested from {contact_id} at {timestamp}"
│
└─ Wait for confirmation
│
├─ Record: "Secondary {contact_id} confirmed at {timestamp}"
│
└─ OR timeout (extend delay, escalate)
│
▼
Mandatory delay begins (e.g., 7 days)
Record: "Mandatory delay started at {timestamp}, ends at {end_timestamp}"
│
▼
If user authenticates during delay:
│
├─ Abort trigger
│
└─ Record: "User {id} authenticated at {timestamp}, death claim aborted"
│
▼
Delay elapsed, enter 'pending_execution'
Challenge window begins (e.g., 14 days)
│
▼
Challenge window elapsed without challenge
│
▼
Execute release actions
Record: "Release executed at {timestamp} per process: death_verification"
Abort Mechanics¶
User Abort¶
async function userAbort(triggerId: string, userId: string, reason: string) {
const trigger = await getTrigger(triggerId);
// Check if abort is allowed in current state
if (!ABORTABLE_STATES.includes(trigger.status)) {
throw new Error(`Cannot abort trigger in state: ${trigger.status}`);
}
// For late-stage aborts, require typed confirmation
if (trigger.status === 'pending_execution') {
// This is handled in UI - typed confirmation required
}
await auditLog.record({
action: 'trigger_aborted',
triggerId,
userId,
previousState: trigger.status,
reason,
timestamp: new Date()
});
await updateTriggerState(triggerId, 'aborted', {
aborted_at: new Date(),
aborted_by: userId,
abort_reason: reason
});
// Notify relevant parties
await notifyAbort(trigger);
}
Secondary Contact Abort¶
Secondary contacts can abort if they believe the user is alive:
async function secondaryAbort(triggerId: string, contactId: string, evidence: string) {
const trigger = await getTrigger(triggerId);
// Verify contact is authorized for this trigger
if (!trigger.config.secondary_contacts.includes(contactId)) {
throw new Error('Contact not authorized for this trigger');
}
await auditLog.record({
action: 'secondary_abort_requested',
triggerId,
contactId,
evidence,
timestamp: new Date()
});
// Secondary abort enters review state, not immediate abort
await updateTriggerState(triggerId, 'abort_review', {
abort_requested_by: contactId,
abort_evidence: evidence,
review_deadline: addDays(new Date(), 3)
});
// Notify user (if reachable) and other contacts
await notifyAbortReview(trigger);
}
Failure Handling¶
Execution Failure¶
async function handleExecutionFailure(triggerId: string, error: Error) {
const trigger = await getTrigger(triggerId);
await auditLog.record({
action: 'execution_failed',
triggerId,
error: error.message,
stack: error.stack,
retryCount: trigger.retry_count
});
if (trigger.retry_count < MAX_RETRIES) {
// Schedule retry with exponential backoff
const backoff = Math.pow(2, trigger.retry_count) * 60 * 1000; // minutes
await updateTriggerState(triggerId, 'execution_failed', {
last_error: error.message,
retry_count: trigger.retry_count + 1,
next_retry_at: new Date(Date.now() + backoff)
});
} else {
// Max retries exceeded, escalate
await updateTriggerState(triggerId, 'system_failure', {
last_error: error.message,
requires_manual_intervention: true
});
await alertOps({
severity: 'critical',
triggerId,
message: 'Trigger execution failed after max retries'
});
// Notify user/executor of issue
await notifyExecutionIssue(trigger);
}
}
Recovery¶
async function recoverFromFailure(triggerId: string, operatorId: string, action: string) {
const trigger = await getTrigger(triggerId);
if (trigger.status !== 'system_failure') {
throw new Error('Trigger not in failure state');
}
await auditLog.record({
action: 'manual_recovery',
triggerId,
operatorId,
recoveryAction: action,
previousState: trigger.status
});
switch (action) {
case 'retry':
await updateTriggerState(triggerId, 'executing');
await executeActions(triggerId);
break;
case 'skip_failed_action':
await markActionSkipped(triggerId, trigger.failed_action_id);
await continueExecution(triggerId);
break;
case 'abort':
await updateTriggerState(triggerId, 'aborted', {
aborted_by: operatorId,
abort_reason: 'Manual abort after system failure'
});
break;
}
}
Invariants¶
These must ALWAYS be true:
-
No transition without audit: Every state change has a corresponding audit entry committed atomically.
-
No execution without eligibility:
executingstate requireseligible_at < NOW(). -
No finalization without release:
finalizedstate requiresreleased_atto be set. -
Abort window always exists:
pending_executionstate always hasabort_window_ends_atset. -
Multi-signal for irreversibility: Transition to
pending_executionrequires >= 2 confirmation signals. -
Timing fields are immutable: Once set, timing fields cannot be changed (only new fields added).
-
User always informed: Every state has a corresponding user-visible message.
Testing Requirements¶
Unit Tests¶
- [ ] Each state transition has a test
- [ ] Invalid transitions throw errors
- [ ] Audit log is written for every transition
- [ ] Timing fields are set correctly
- [ ] Multi-signal verification works
- [ ] Abort works from each abortable state
Integration Tests¶
- [ ] Full dead man's switch cycle (arm → miss check-in → trigger → execute → release)
- [ ] Full death verification cycle
- [ ] Abort at each stage
- [ ] Failure and recovery
- [ ] Concurrent operations don't corrupt state
Chaos Tests¶
- [ ] Worker crash during execution (state preserved, resumable)
- [ ] Database failure during transition (transaction rolled back)
- [ ] Notification failure (alternate channel used)
- [ ] Clock skew (timing based on server time, not client)
This document defines the trigger state machine. Implementation must match this specification exactly. All transitions must be auditable.