Skip to content
Merged
Show file tree
Hide file tree
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
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ This file is the cross-system architecture index. Detailed designs live in domai
| Assistant scheduling deep dive | [`assistant/docs/architecture/scheduling.md`](assistant/docs/architecture/scheduling.md) |
| Assistant security deep dive | [`assistant/docs/architecture/security.md`](assistant/docs/architecture/security.md) |
| Gateway SMS parity checklist | [`gateway/docs/sms-twilio-parity-checklist.md`](gateway/docs/sms-twilio-parity-checklist.md) |
| Trusted contact access design | [`assistant/docs/trusted-contact-access.md`](assistant/docs/trusted-contact-access.md) |
| Trusted contacts operator runbook | [`assistant/docs/runbook-trusted-contacts.md`](assistant/docs/runbook-trusted-contacts.md) |

## Cross-Cutting Invariants

- Public ingress is gateway-only; external webhook/API routes are implemented in `gateway/` and forwarded internally.
- Production LLM calls go through the provider abstraction, not provider SDKs in feature code.
- Notification producers emit through `emitNotificationSignal()` to preserve decisioning and audit invariants.
- Memory extraction/recall must enforce actor-role provenance gates for untrusted actors.
- Trusted contact ingress ACL is channel-agnostic; identity binding adapts per channel (chat ID, E.164 phone, external user ID) without channel-specific branching.

## System Overview

Expand Down
49 changes: 49 additions & 0 deletions assistant/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,55 @@ These can be set via environment variables or stored in the credential vault (ke

**SMS Compliance & Admin**: The `twilio_config` IPC contract extends beyond credential and number management with compliance and admin actions: `sms_compliance_status` detects toll-free vs local number type and fetches verification status; `sms_submit_tollfree_verification`, `sms_update_tollfree_verification`, and `sms_delete_tollfree_verification` manage the Twilio toll-free verification lifecycle; `release_number` removes a phone number from the Twilio account and clears all local references. All compliance actions validate required fields and Twilio enum values before calling the API.

### Trusted Contact Access (Channel-Agnostic)

External users who are not the guardian can gain access to the assistant through a guardian-mediated verification flow. The flow is channel-agnostic — it works identically on Telegram, SMS, voice, and any future channel.

**Full design doc:** [`docs/trusted-contact-access.md`](docs/trusted-contact-access.md)

**Flow summary:**
1. Unknown user messages the assistant on any channel.
2. Ingress ACL (`inbound-message-handler.ts`) rejects the message and emits an `ingress.access_request` notification signal to the guardian.
3. Guardian approves or denies via callback button or conversational intent (routed through `guardian-approval-interception.ts`).
4. On approval, an identity-bound verification session with a 6-digit code is created (`access-request-decision.ts` → `channel-guardian-service.ts`).
5. Guardian gives the code to the requester out-of-band.
6. Requester enters the code; identity binding is verified, the challenge is consumed, and an active member record is created in `assistant_ingress_members`.
7. All subsequent messages are accepted through the ingress ACL.

**Channel-agnostic design:** The entire flow operates on abstract `ChannelId` and `externalUserId`/`externalChatId` fields. Identity binding adapts per channel: Telegram uses chat IDs, SMS/voice use E.164 phone numbers, HTTP API uses caller-provided identity. No channel-specific branching exists in the trusted contact code paths.

**Lifecycle states:** `requested → pending_guardian → verification_pending → active | denied | expired`

**Notification signals:** The flow emits signals at each lifecycle transition via `emitNotificationSignal()`:
- `ingress.access_request` — non-member denied, guardian notified
- `ingress.trusted_contact.guardian_decision` — guardian approved or denied
- `ingress.trusted_contact.verification_sent` — code created and delivered
- `ingress.trusted_contact.activated` — requester verified, member active
- `ingress.trusted_contact.denied` — guardian explicitly denied

**HTTP API (for management):**

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v1/ingress/members` | GET | List trusted contacts (filterable by channel, status, policy) |
| `/v1/ingress/members` | POST | Upsert a member (add/update trusted contact) |
| `/v1/ingress/members/:id` | DELETE | Revoke a trusted contact |
| `/v1/ingress/members/:id/block` | POST | Block a member |

**Key source files:**

| File | Purpose |
|------|---------|
| `src/runtime/routes/inbound-message-handler.ts` | Ingress ACL, non-member rejection, verification code interception |
| `src/runtime/routes/access-request-decision.ts` | Guardian decision → verification session creation |
| `src/runtime/routes/guardian-approval-interception.ts` | Routes guardian decisions (button + conversational) to access request handler |
| `src/runtime/channel-guardian-service.ts` | Verification challenge lifecycle, identity binding, rate limiting |
| `src/runtime/routes/ingress-routes.ts` | HTTP API handlers for member/invite management |
| `src/runtime/ingress-service.ts` | Business logic for member CRUD |
| `src/memory/ingress-member-store.ts` | Member record persistence |
| `src/memory/channel-guardian-store.ts` | Approval request and verification challenge persistence |
| `src/config/vellum-skills/trusted-contacts/SKILL.md` | Skill teaching the assistant to manage contacts via HTTP API |

---


Expand Down
283 changes: 283 additions & 0 deletions assistant/docs/runbook-trusted-contacts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,283 @@
# Trusted Contacts — Operator Runbook

Operational procedures for inspecting, managing, and debugging the trusted contact access flow. All HTTP commands use the runtime API (default `http://localhost:7821`) with bearer authentication.

## Prerequisites

```bash
# Read the bearer token
TOKEN=$(cat ~/.vellum/http-token)

# Base URL (adjust if using a non-default port)
BASE=http://localhost:7821
```

## 1. Inspect Trusted Contacts (Members)

### List all active trusted contacts

```bash
curl -s "$BASE/v1/ingress/members?status=active" \
-H "Authorization: Bearer $TOKEN" | jq
```

### Filter by channel

```bash
# Telegram contacts only
curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \
-H "Authorization: Bearer $TOKEN" | jq

# SMS contacts only
curl -s "$BASE/v1/ingress/members?sourceChannel=sms&status=active" \
-H "Authorization: Bearer $TOKEN" | jq
```

### List all members (including revoked and blocked)

```bash
curl -s "$BASE/v1/ingress/members" \
-H "Authorization: Bearer $TOKEN" | jq
```

Response shape:
```json
{
"ok": true,
"members": [
{
"id": "uuid",
"sourceChannel": "telegram",
"externalUserId": "123456789",
"externalChatId": "123456789",
"displayName": "Alice",
"username": "alice_handle",
"status": "active",
"policy": "allow",
"lastSeenAt": 1700000000000,
"createdAt": 1699000000000
}
]
}
```

## 2. Inspect Pending Access Requests

Access requests are stored in the `channel_guardian_approval_requests` table. Use SQLite to inspect pending requests directly.

### Via SQLite CLI

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT id, channel, requester_external_user_id, requester_chat_id, \
guardian_external_user_id, status, tool_name, created_at, expires_at \
FROM channel_guardian_approval_requests \
WHERE tool_name = 'ingress_access_request' AND status = 'pending' \
ORDER BY created_at DESC;"
```

### Check all access requests (including resolved)

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT id, channel, requester_external_user_id, status, \
decided_by_external_user_id, created_at \
FROM channel_guardian_approval_requests \
WHERE tool_name = 'ingress_access_request' \
ORDER BY created_at DESC LIMIT 20;"
```

## 3. Inspect Pending Verification Sessions

Verification challenges are stored in `channel_guardian_verification_challenges`. Active sessions have `status = 'awaiting_response'` and `expires_at > now`.

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT id, channel, status, identity_binding_status, \
expected_external_user_id, expected_chat_id, expected_phone_e164, \
expires_at, created_at \
FROM channel_guardian_verification_challenges \
WHERE status IN ('awaiting_response', 'pending_bootstrap') \
AND expires_at > $(date +%s)000 \
ORDER BY created_at DESC;"
```

## 4. Force-Revoke a Trusted Contact

### Via HTTP API

First, find the member's `id` from the list endpoint, then revoke:

```bash
# Find the member
MEMBER_ID=$(curl -s "$BASE/v1/ingress/members?sourceChannel=telegram&status=active" \
-H "Authorization: Bearer $TOKEN" | jq -r '.members[] | select(.externalUserId == "TARGET_USER_ID") | .id')

# Revoke with reason
curl -s -X DELETE "$BASE/v1/ingress/members/$MEMBER_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "Revoked by operator"}' | jq
```

### Block a member (stronger than revoke)

Blocking prevents the member from re-entering the flow without explicit unblocking.

```bash
curl -s -X POST "$BASE/v1/ingress/members/$MEMBER_ID/block" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"reason": "Blocked by operator"}' | jq
```

### Via SQLite (emergency)

If the HTTP API is unavailable:

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"UPDATE assistant_ingress_members \
SET status = 'revoked', revoked_reason = 'Emergency operator revocation', \
updated_at = $(date +%s)000 \
WHERE external_user_id = 'TARGET_USER_ID' AND source_channel = 'telegram';"
```

## 5. Debug Verification Failures

### Check rate limit state

If a user is getting "invalid or expired code" errors, they may be rate-limited:

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT * FROM channel_guardian_rate_limits \
WHERE external_user_id = 'TARGET_USER_ID' \
OR chat_id = 'TARGET_CHAT_ID' \
ORDER BY created_at DESC LIMIT 5;"
```

### Reset rate limits for a user

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"DELETE FROM channel_guardian_rate_limits \
WHERE external_user_id = 'TARGET_USER_ID' AND channel = 'telegram';"
```

### Check verification challenge state

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT id, channel, status, identity_binding_status, \
expected_external_user_id, expected_chat_id, expected_phone_e164, \
expires_at, consumed_by_external_user_id \
FROM channel_guardian_verification_challenges \
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 |
| "Invalid or expired code" (old code) | Code TTL expired (10 min) | Guardian must re-approve to generate a new code |
| 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 for channel | Verify guardian is bound: check `channel_guardian_bindings` table |

## 6. Check Notification Delivery Status

### Check if the access request notification was delivered

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT ne.id, ne.source_event_name, ne.dedupe_key, ne.created_at, \
nd.channel, nd.status, nd.confidence \
FROM notification_events ne \
LEFT JOIN notification_decisions nd ON nd.event_id = ne.id \
WHERE ne.source_event_name LIKE 'ingress.%' \
ORDER BY ne.created_at DESC LIMIT 20;"
```

### Check delivery records

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"SELECT ndel.id, ndel.channel, ndel.status, ndel.error_message, \
ndel.created_at, ne.source_event_name \
FROM notification_deliveries ndel \
JOIN notification_events ne ON ne.id = ndel.event_id \
WHERE ne.source_event_name LIKE 'ingress.%' \
ORDER BY ndel.created_at DESC LIMIT 20;"
```

### Check lifecycle signals

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"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 member:

```bash
curl -s -X POST "$BASE/v1/ingress/members" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceChannel": "telegram",
"externalUserId": "123456789",
"externalChatId": "123456789",
"displayName": "Alice",
"policy": "allow",
"status": "active"
}' | jq
```

For SMS contacts, use the E.164 phone number as the external user/chat ID:

```bash
curl -s -X POST "$BASE/v1/ingress/members" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceChannel": "sms",
"externalUserId": "+15551234567",
"externalChatId": "+15551234567",
"displayName": "Bob",
"policy": "allow",
"status": "active"
}' | jq
```

## 8. Clean Up Expired Data

### Purge expired verification sessions

Expired sessions are already invisible to the verification flow (filtered by `expires_at`), but you can clean them up:

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"DELETE FROM channel_guardian_verification_challenges \
WHERE expires_at < $(date +%s)000 \
AND status IN ('awaiting_response', 'pending_bootstrap');"
```

### Purge expired approval requests

The `sweepExpiredGuardianApprovals()` timer handles this automatically every 60 seconds, but manual cleanup:

```bash
sqlite3 ~/.vellum/workspace/data/db/assistant.db \
"UPDATE channel_guardian_approval_requests \
SET status = 'expired' \
WHERE status = 'pending' AND expires_at < $(date +%s)000;"
```
Loading