Skip to content

Latest commit

 

History

History
377 lines (295 loc) · 16.8 KB

File metadata and controls

377 lines (295 loc) · 16.8 KB

Trusted Contacts — Operator Runbook

Operational procedures for inspecting, managing, and debugging the trusted contact access flow.

Two databases hold this state — pick the right one before reaching for SQL:

Data Database Location
Contact ACL (contacts role, contact_channels status/policy/reasons), guardian requests (guardian_requests + guardian_request_deliveries), verification sessions, rate limits, invites Gateway DB (gateway.sqlite) $GATEWAY_SECURITY_DIR/gateway.sqlite (Docker: the /gateway-security volume; local fallback: ~/.vellum/protected/)
Notification pipeline, contact info/identity mirror Assistant DB (assistant.db) $VELLUM_WORKSPACE_DIR/data/db/assistant.db (local fallback: ~/.vellum/workspace/)

Prefer the CLI and HTTP surfaces below over raw SQL — they go through the gateway's validation (status vocabulary, revoke-of-blocked guard) and emit the change events clients rely on. Raw SQL is break-glass only.

Break-glass SQLite rules: the running gateway holds a live connection to gateway.sqlite (and the daemon to assistant.db). Open read-only for inspection (sqlite3 "file:$GW_DB?mode=ro"). For writes, stop the services first (vellum sleep), run the statement, then vellum wake — a write against a live DB races the owning process and can hit SQLite lock contention.

Prerequisites

# Base URL — gateway (adjust if GATEWAY_PORT overrides the default)
BASE=http://localhost:7830

# Bearer token: for operator use, retrieve from the daemon process environment
# or use `assistant` CLI commands which handle auth automatically over IPC.
TOKEN=<your-bearer-token>

# Break-glass DB paths (fall back to the services' defaults when the env vars are unset)
GW_DB="${GATEWAY_SECURITY_DIR:-$HOME/.vellum/protected}/gateway.sqlite"
AST_DB="${VELLUM_WORKSPACE_DIR:-$HOME/.vellum/workspace}/data/db/assistant.db"

1. Inspect Trusted Contacts

Via CLI (preferred)

assistant contacts list --role contact
assistant contacts list --role guardian
assistant contacts list --channel-type telegram
assistant contacts get <contactId>
gateway contacts list
gateway contacts get <contactId>

Via HTTP API

# List all active trusted contacts
curl -s "$BASE/v1/contacts?role=contact" \
  -H "Authorization: Bearer $TOKEN" | jq

# Telegram contacts only
curl -s "$BASE/v1/contacts?channelType=telegram" \
  -H "Authorization: Bearer $TOKEN" | jq

# Voice contacts only
curl -s "$BASE/v1/contacts?channelType=phone" \
  -H "Authorization: Bearer $TOKEN" | jq

# All contacts (including revoked and blocked)
curl -s "$BASE/v1/contacts" \
  -H "Authorization: Bearer $TOKEN" | jq

Response shape (ACL fields come from the gateway DB; info fields like notes are joined from the assistant DB):

{
  "ok": true,
  "contacts": [
    {
      "id": "uuid",
      "displayName": "Alice",
      "role": "contact",
      "notes": null,
      "contactType": "human",
      "principalId": null,
      "interactionCount": 12,
      "createdAt": 1699000000000,
      "updatedAt": 1700000000000,
      "channels": [
        {
          "id": "channel-uuid",
          "contactId": "uuid",
          "type": "telegram",
          "address": "123456789",
          "isPrimary": true,
          "externalChatId": "123456789",
          "externalUserId": "123456789",
          "status": "active",
          "policy": "allow",
          "verifiedAt": 1699500000000,
          "verifiedVia": "challenge",
          "revokedReason": null,
          "blockedReason": null,
          "lastSeenAt": 1700000000000,
          "createdAt": 1699000000000
        }
      ]
    }
  ]
}

address is the canonical channel identity (Telegram user ID, E.164 phone number, etc.). externalUserId in responses is a compat alias for address — the gateway contact_channels table itself has no external_user_id column.

2. Inspect Pending Access Requests

Access requests live in the gateway DB table guardian_requests (kind = 'access_request', tool_name = 'ingress_access_request'). There is no source_type column — derive it from source_channel (phone → voice, vellum → desktop, anything else → channel); the requester-side conversation is source_conversation_id. Delivered approval cards are tracked per surface in guardian_request_deliveries (request_id → guardian_requests.id).

sqlite3 "file:$GW_DB?mode=ro" \
  "SELECT id, source_channel, requester_external_user_id, requester_chat_id, \
   guardian_external_user_id, status, request_code, created_at, expires_at \
   FROM guardian_requests \
   WHERE kind = 'access_request' AND status = 'pending' \
   ORDER BY created_at DESC;"

Check all access requests (including resolved)

sqlite3 "file:$GW_DB?mode=ro" \
  "SELECT id, source_channel, requester_external_user_id, status, \
   decided_by_external_user_id, created_at \
   FROM guardian_requests \
   WHERE kind = 'access_request' \
   ORDER BY created_at DESC LIMIT 20;"

3. Inspect Pending Verification Sessions

Verification sessions live in the gateway DB table channel_verification_sessions. Live sessions are in one of the interceptable statuses — pending (inbound guardian challenge), pending_bootstrap (unbound bootstrap), awaiting_response (identity-bound outbound) — with expires_at > now.

sqlite3 "file:$GW_DB?mode=ro" \
  "SELECT id, channel, status, verification_purpose, identity_binding_status, \
   expected_external_user_id, expected_chat_id, expected_phone_e164, \
   expires_at, created_at \
   FROM channel_verification_sessions \
   WHERE status IN ('pending', 'pending_bootstrap', 'awaiting_response') \
   AND expires_at > $(date +%s)000 \
   ORDER BY created_at DESC;"

verification_purpose is guardian for guardian binding flows and trusted_contact for the access-request handshake.

3b. Contact writes

Contact writes are gateway-owned, and they reach it two ways. An operator ACL write (a risk ceiling) goes through the gateway CLI directly, not through assistant contacts. From the host, run the same command via vellum exec --service gateway --. A write that changes the contact graph itself (the record, or a channel binding) goes through assistant contacts, which opens a form in the guardian's app and writes nothing until they submit it; the gateway still performs the write.

gateway contacts set-risk-threshold <contactId> --threshold high

inherit clears the risk ceiling so the contact follows room and trust-class settings. Stored vocabulary is none, low, medium, or high.

Binding an address to a contact goes through the guardian instead of the operator. The command below opens a form in the guardian's app naming that contact, and the gateway writes the channel only when they submit it. An address already held by a different contact is refused, naming that contact.

assistant contacts channels add <contactId> --channel <type> --address <addr>
assistant contacts channels add <contactId> --channel email --address alice@example.com

4. Force-Revoke a Trusted Contact

Via CLI (preferred)

# Find the channel ID
assistant contacts list --channel-address "TARGET_ADDRESS"
assistant contacts get <contactId>

# Revoke with reason
assistant contacts channels update-status <channelId> --status revoked --reason "Revoked by operator"

# Block (stronger than revoke — cannot re-enter the flow without explicit unblocking)
assistant contacts channels update-status <channelId> --status blocked --reason "Blocked by operator"

Via HTTP API

# Find the contact's channel ID (address = canonical channel identity)
CHANNEL_ID=$(curl -s "$BASE/v1/contacts?channelType=telegram" \
  -H "Authorization: Bearer $TOKEN" | jq -r '.contacts[].channels[] | select(.address == "TARGET_ADDRESS") | .id')

# Revoke with reason
curl -s -X PATCH "$BASE/v1/contact-channels/$CHANNEL_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "revoked", "reason": "Revoked by operator"}' | jq

# Block
curl -s -X PATCH "$BASE/v1/contact-channels/$CHANNEL_ID" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"status": "blocked", "reason": "Blocked by operator"}' | jq

Via SQLite (emergency)

If both the CLI and HTTP API are unavailable, write the gateway DB — it is the ACL source of truth; the assistant DB carries no ACL columns. Stop the gateway first. Channel identity is (type, address).

sqlite3 "$GW_DB" \
  "UPDATE contact_channels \
   SET status = 'revoked', revoked_reason = 'Emergency operator revocation', \
   updated_at = $(date +%s)000 \
   WHERE type = 'telegram' AND address = 'TARGET_ADDRESS';"

Note: raw SQL bypasses the API's guard that refuses to downgrade a blocked channel to revoked — check the current status first.

5. Debug Verification Failures

Check rate limit state

If a user is getting "invalid or expired code" errors, they may be rate-limited. Rate limits live in the gateway DB — one row per actor with a sliding window of attempt timestamps:

sqlite3 "file:$GW_DB?mode=ro" \
  "SELECT channel, actor_external_user_id, actor_chat_id, \
   attempt_timestamps_json, locked_until, updated_at \
   FROM channel_guardian_rate_limits \
   WHERE actor_external_user_id = 'TARGET_USER_ID' \
   OR actor_chat_id = 'TARGET_CHAT_ID';"

The actor is locked out while locked_until is in the future.

Reset rate limits for a user

Stop the gateway first, then:

sqlite3 "$GW_DB" \
  "DELETE FROM channel_guardian_rate_limits \
   WHERE actor_external_user_id = 'TARGET_USER_ID' AND channel = 'telegram';"

Check verification session state

sqlite3 "file:$GW_DB?mode=ro" \
  "SELECT id, channel, status, verification_purpose, identity_binding_status, \
   expected_external_user_id, expected_chat_id, expected_phone_e164, \
   expires_at, consumed_by_external_user_id, consumed_by_chat_id \
   FROM channel_verification_sessions \
   WHERE expected_external_user_id = 'TARGET_USER_ID' \
   OR expected_chat_id = 'TARGET_CHAT_ID' \
   ORDER BY created_at DESC LIMIT 5;"

Common verification failure causes

Symptom Likely cause Resolution
"Invalid or expired code" (correct code) Identity mismatch: the code was entered from a different user/chat than expected Verify the requester is using the same account that originally requested access
"Invalid or expired code" (correct code, correct user) Rate-limited (5+ failures in 15 min window) Wait 30 minutes or reset rate limits via SQLite (gateway DB)
"Invalid or expired code" (old code) Code TTL expired (10 min) Guardian must re-approve to generate a new code
"Invalid or expired code" (blocked/revoked channel) The gateway ACL row for this actor is blocked (or revoked for contact flows) A correct code does not restore trust for a blocked actor. Unblock the channel first (see §4), then re-run the flow
Code never delivered to guardian deliverChannelReply failed Check daemon logs for "Failed to deliver verification code to guardian"
No notification to guardian No guardian binding resolvable Verify with assistant contacts list --role guardian: the gateway contacts table needs a role = 'guardian' row with an active contact_channels entry

6. Check Notification Delivery Status

These tables live in the assistant DB. Deliveries hang off decisions (notification_deliveries.notification_decision_id → notification_decisions.id → notification_events.id).

Check if the access request notification was decided

sqlite3 "file:$AST_DB?mode=ro" \
  "SELECT ne.id, ne.source_event_name, ne.dedupe_key, ne.created_at, \
   nd.should_notify, nd.selected_channels, nd.confidence \
   FROM notification_events ne \
   LEFT JOIN notification_decisions nd ON nd.notification_event_id = ne.id \
   WHERE ne.source_event_name LIKE 'ingress.%' \
   ORDER BY ne.created_at DESC LIMIT 20;"

Check delivery records

sqlite3 "file:$AST_DB?mode=ro" \
  "SELECT ndel.id, ndel.channel, ndel.status, ndel.error_message, \
   ndel.created_at, ne.source_event_name \
   FROM notification_deliveries ndel \
   JOIN notification_decisions nd ON nd.id = ndel.notification_decision_id \
   JOIN notification_events ne ON ne.id = nd.notification_event_id \
   WHERE ne.source_event_name LIKE 'ingress.%' \
   ORDER BY ndel.created_at DESC LIMIT 20;"

Check lifecycle signals

sqlite3 "file:$AST_DB?mode=ro" \
  "SELECT source_event_name, source_channel, dedupe_key, created_at \
   FROM notification_events \
   WHERE source_event_name LIKE 'ingress.trusted_contact.%' \
   ORDER BY created_at DESC LIMIT 20;"

7. Manually Add a Trusted Contact (Bypass Verification)

If the verification flow cannot be completed, an operator can directly create an active contact. The upsert writes the gateway ACL (source of truth) and mirrors identity/info to the assistant DB:

curl -s -X POST "$BASE/v1/contacts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Alice",
    "channels": [{
      "type": "telegram",
      "address": "123456789",
      "externalChatId": "123456789",
      "status": "active",
      "policy": "allow"
    }]
  }' | jq

For voice contacts, use the E.164 phone number as the address:

curl -s -X POST "$BASE/v1/contacts" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "displayName": "Bob",
    "channels": [{
      "type": "phone",
      "address": "+12125550142",
      "externalChatId": "+12125550142",
      "status": "active",
      "policy": "allow"
    }]
  }' | jq

To mark an existing unverified channel verified without the code handshake, use the manual verify endpoint (guardian-authenticated; stamps verifiedVia: "manual"):

curl -s -X POST "$BASE/v1/contact-channels/$CHANNEL_ID/verify" \
  -H "Authorization: Bearer $TOKEN" | jq

8. Clean Up Expired Data

Purge expired verification sessions

Expired sessions are already invisible to the verification flow (every lookup filters on expires_at), but you can clean them out of the gateway DB (stop the gateway first):

sqlite3 "$GW_DB" \
  "DELETE FROM channel_verification_sessions \
   WHERE expires_at < $(date +%s)000 \
   AND status IN ('pending', 'pending_bootstrap', 'awaiting_response');"

Expire stale access requests

The daemon's guardian expiry sweep (runGuardianExpirySweep(), every 60 seconds) asks the gateway to CAS-expire stale pending requests, then withdraws the approval cards and notifies the requester. Manual fallback against the gateway DB (stop the gateway first; this skips the daemon-side card withdrawal):

sqlite3 "$GW_DB" \
  "UPDATE guardian_requests \
   SET status = 'expired' \
   WHERE status = 'pending' AND expires_at < $(date +%s)000;"