Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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. Reminder routing metadata (`routingIntent`, `routingHints`) flows through the signal and is enforced post-decision to control multi-channel fanout.
- 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 @@ -134,6 +134,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 gateway API (default `http://localhost:7830`) 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:7830
```

## 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