@vellumai/vellum-gateway 0.10.8 → 0.10.9-dev.202607152225.ec6fdbf

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/AGENTS.md +23 -1
  2. package/ARCHITECTURE.md +95 -135
  3. package/README.md +9 -4
  4. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  5. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  6. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  7. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/package.json +1 -0
  8. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  9. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  10. package/node_modules/@vellumai/gateway-client/src/__tests__/guardian-request-contract.test.ts +575 -0
  11. package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +20 -0
  12. package/node_modules/@vellumai/gateway-client/src/guardian-request-contract.ts +687 -0
  13. package/node_modules/@vellumai/gateway-client/src/index.ts +82 -0
  14. package/node_modules/@vellumai/service-contracts/package.json +1 -0
  15. package/node_modules/@vellumai/service-contracts/src/__tests__/redacted-credential.test.ts +200 -0
  16. package/node_modules/@vellumai/service-contracts/src/redacted-credential.ts +226 -0
  17. package/package.json +3 -3
  18. package/src/__tests__/contact-rich-read.test.ts +2 -2
  19. package/src/__tests__/contacts-control-plane-proxy.test.ts +260 -6
  20. package/src/__tests__/contacts-scoped-route-fallthrough.test.ts +122 -0
  21. package/src/__tests__/credential-requests.test.ts +83 -12
  22. package/src/__tests__/data-migration-m0008-upsert-acl-columns-from-assistant.test.ts +96 -23
  23. package/src/__tests__/data-migration-m0015-guardian-requests-backfill.test.ts +583 -0
  24. package/src/__tests__/data-migration-m0016-drop-assistant-guardian-tables.test.ts +557 -0
  25. package/src/__tests__/data-migration-m0017-coerce-escalate-policy.test.ts +180 -0
  26. package/src/__tests__/guardian-request-decide.test.ts +534 -0
  27. package/src/__tests__/helpers/contact-route-table.ts +48 -0
  28. package/src/__tests__/invite-route-auth.test.ts +14 -37
  29. package/src/__tests__/ipc-contact-routes.test.ts +227 -3
  30. package/src/__tests__/live-voice-websocket.test.ts +179 -48
  31. package/src/__tests__/m0006-reconcile-contacts.test.ts +90 -20
  32. package/src/__tests__/route-schema-guard.test.ts +30 -1
  33. package/src/__tests__/runtime-client.test.ts +63 -1
  34. package/src/__tests__/speech-relay-websocket.test.ts +649 -0
  35. package/src/__tests__/twilio-webhooks.test.ts +21 -1
  36. package/src/approvals/guardian-request-service.ts +509 -0
  37. package/src/db/__tests__/guardian-request-store.test.ts +844 -0
  38. package/src/db/contact-store.ts +112 -54
  39. package/src/db/data-migrations/assistant-contact-acl-columns.ts +17 -0
  40. package/src/db/data-migrations/index.ts +7 -0
  41. package/src/db/data-migrations/m0006-reconcile-contacts-from-assistant.ts +32 -1
  42. package/src/db/data-migrations/m0008-upsert-acl-columns-from-assistant.ts +35 -2
  43. package/src/db/data-migrations/m0015-guardian-requests-backfill.ts +261 -0
  44. package/src/db/data-migrations/m0016-drop-assistant-guardian-tables.ts +233 -0
  45. package/src/db/data-migrations/m0017-coerce-escalate-policy.ts +61 -0
  46. package/src/db/guardian-request-store.ts +896 -0
  47. package/src/db/schema.ts +97 -0
  48. package/src/feature-flag-registry.json +15 -63
  49. package/src/http/routes/contacts-control-plane-proxy.ts +150 -24
  50. package/src/http/routes/contacts-control-plane-route-table.ts +126 -0
  51. package/src/http/routes/contacts-control-plane-routes.ts +6 -3
  52. package/src/http/routes/live-voice-websocket.ts +65 -5
  53. package/src/http/routes/speech-relay-websocket.ts +481 -0
  54. package/src/index.ts +103 -128
  55. package/src/ipc/__tests__/guardian-request-routes.test.ts +765 -0
  56. package/src/ipc/assistant-client.test.ts +42 -0
  57. package/src/ipc/assistant-client.ts +13 -11
  58. package/src/ipc/contact-handlers.ts +13 -1
  59. package/src/ipc/credential-request-handlers.ts +50 -7
  60. package/src/ipc/guardian-request-handlers.ts +248 -0
  61. package/src/risk/command-registry/commands/assistant.ts +5 -2
  62. package/src/runtime/client.ts +53 -0
  63. package/src/velay/client.test.ts +53 -1
  64. package/src/velay/client.ts +61 -3
  65. package/src/verification/contact-helpers.ts +242 -190
  66. package/src/__tests__/contacts-control-plane-route-match.test.ts +0 -107
  67. package/src/http/routes/contacts-control-plane-route-match.ts +0 -83
package/AGENTS.md CHANGED
@@ -29,6 +29,19 @@ All assistant API requests from clients, CLI, skills, and user-facing tooling **
29
29
 
30
30
  **SKILL.md gateway URL pattern:** For gateway control-plane writes/actions that are not exposed through a CLI read command, use `$INTERNAL_GATEWAY_BASE_URL` (injected by `bash` and `host_bash`). Do not hardcode `localhost`/ports in skill examples, and do not instruct users/agents to manually export the variable from Settings. For public ingress URLs (e.g. OAuth redirect URIs, webhook registration), use `assistant config get ingress.publicBaseUrl` or load the `public-ingress` skill — do not inject public URLs as environment variables.
31
31
 
32
+ ### Control-Plane Route Shapes — Clients Emit Scoped, Boundaries Strip to Flat
33
+
34
+ Clients always **emit** assistant-scoped URLs (`/v1/assistants/{id}/...`) — multi-tenant cloud routing needs the id. A single-assistant boundary strips the prefix before the gateway routes the request:
35
+
36
+ - **Cloud/managed:** the platform's Django `RuntimeProxyView` strips `/v1/assistants/{id}/` and forwards the flat path to the gateway.
37
+ - **Self-hosted (macOS Electron / local / remote gateway):** `rewriteForSelfHostedIngress` (`clients/web/src/lib/api-interceptors.ts`) flattens contact-family paths (`contacts`, `contact-channels`) to the same flat shape; every other segment is forwarded scoped, verbatim.
38
+
39
+ Route-registration consequences (`gateway/src/index.ts`):
40
+
41
+ - **Contacts** (`/v1/contacts...`, `/v1/contact-channels...`) are served on **flat routes only** — both boundaries deliver flat, so no assistant-scoped contact mirrors exist. The self-hosted flattening covers the **entire** `contacts`/`contact-channels` segment family, not just the CRUD writes: invites requests land on the gateway's flat edge-scoped invite routes (the same gateway invite engine the daemon relays to), and daemon-only subpaths (`contacts/search`, `contacts/prompt`) match no flat registration and fall through the runtime-proxy catch-all to the daemon verbatim.
42
+ - **Other control-plane families** (channel-admission-policy, channel-permission-overrides, trust-rules, backups) register **flat + assistant-scoped variants**: the self-hosted rewrite forwards their paths verbatim, so a scoped mirror must catch that traffic. These stores are gateway-global — the scoped variant matches and discards the id.
43
+ - **Invariant: never remove a flat gateway control-plane route.** Cloud's prefix strip means the flat family is the load-bearing production path even when repo-local clients appear to emit only scoped URLs. The flat channel-admission-policy routes were removed once on that mistaken theory (`2fb435314f`) and had to be restored before #35150 merged (`53fcfa7cc2` re-added the schema entries the removal took out).
44
+
32
45
  ### Trust Management in Docker Mode
33
46
 
34
47
  In Docker mode, the gateway is the sole owner of trust rule storage. Trust files (`trust.json`, `actor-token-signing-key`) live on the gateway security volume (`/gateway-security`), configured via `GATEWAY_SECURITY_DIR`. No other container has access to this volume.
@@ -97,7 +110,7 @@ For exempt ids, `PUT /v1/assistants/:id/channel-admission-policy/:channelType` r
97
110
  - They are **not** exempt — the runtime still evaluates rank-vs-floor, so a real inbound channel like `whatsapp` keeps its admission floor.
98
111
  - Their floor is pinned to the seed default; `seedAdmissionPolicyDefaults` **overwrites** any drifted/legacy row at startup (e.g. a stale `whatsapp = no_one`), so a stranded floor can't silently block a channel the user can no longer see or reset. The guardian is always max-rank on `vellum`, so its `guardian_only` seed default never locks them out — there is **no** `no_one` picker or 422 kill-switch path for hidden channels.
99
112
 
100
- Only the **assistant-scoped** routes (`/v1/assistants/:id/channel-admission-policy/...`) exist; admission policy is gateway-global so the id is matched and discarded. (The flat `/v1/channel-admission-policy/...` variants were removed with the CLI that used them.)
113
+ Both the flat (`/v1/channel-admission-policy/...`) and assistant-scoped (`/v1/assistants/:id/channel-admission-policy/...`) route variants are registered. The flat routes carry all cloud traffic — the platform strips the scoped prefix before forwarding — and must never be removed (see "Control-Plane Route Shapes" above). Admission policy is gateway-global, so the scoped variant matches and discards the id.
101
114
 
102
115
  **Split enforcement** (locked decision):
103
116
 
@@ -153,3 +166,12 @@ The gateway classifies the actor at ingress (keyed on `actorExternalId`) and for
153
166
  **Adding a capability**: add the field to `CapabilitySet` + the three class records (`GUARDIAN_CAPABILITIES`, `CONTACT_CAPABILITIES`, `UNKNOWN_CAPABILITIES`) + the `MATRIX` in `capabilities.test.ts`. **Adding a trust class**: add a member to `TrustClass` (the `Record<TrustClass, …>` tables then fail to compile until every column is filled) and a matrix row.
154
167
 
155
168
  **Intentionally NOT capability-gated** (these are identity / admission-flow decisions, not permissions, and stay raw class checks): `calls/*` guardian-identity call routing, `inbound-message-handler` heartbeat/timezone side-effects, `surface-action-routes` drift-heal re-resolution, and `channel-retry-sweep` trust-class parsing.
169
+
170
+ ## Guardian Requests (gateway-owned)
171
+
172
+ The gateway owns guardian approval requests and their delivery records: the `guardian_requests` and `guardian_request_deliveries` tables (`src/db/guardian-request-store.ts`), fronted by `src/approvals/guardian-request-service.ts` and exposed to the daemon over the `guardian_requests_*` IPC routes (`src/ipc/guardian-request-handlers.ts`; shared contract in `packages/gateway-client/src/guardian-request-contract.ts`). The daemon holds no request state — it relays everything through `assistant/src/channels/gateway-guardian-requests.ts` and keeps the daemon-domain side effects: notifications, card delivery/withdrawal, pending-interaction resume, grant minting into its `scoped_approval_grants`.
173
+
174
+ - **Decide atomicity (the core invariant).** `guardian_requests_decide` commits the status CAS (`pending → approved|denied`, first-writer-wins) and the decision's ACL outcome (`activate_member`, `seed_unverified`, `block`, `mint_outbound_session`) in ONE gateway transaction. A failed outcome rolls back the CAS — the request stays `pending` and the guardian can decide again; an `approved` request without its ACL write cannot exist, and deciding twice never applies two outcomes. There is no reopen path.
175
+ - **Schema notes.** There is no `source_type` column — the wire DTO's `sourceType` is derived from `source_channel` (`phone` → voice, `vellum` → desktop, else channel), and the list route translates a `sourceType` filter into `source_channel` predicates. The requester-side conversation is `source_conversation_id`. Request ids are caller-supplied and load-bearing (deterministic access-request ids; `tool_approval` rows reuse the pending-interaction id as PK).
176
+ - **Expiry is daemon-keyed for interaction-bound kinds.** `tool_approval`/`pending_question` requests die with the daemon's in-memory `pendingInteractions` map, so the daemon calls `guardian_requests_expire_interaction_bound` at boot; the gateway never expires them on its own restart. Persistent kinds (`access_request`, `tool_grant_request`) expire past `expires_at` via the daemon's 60s `guardian_requests_sweep_expired` sweep, which returns the expired rows for daemon-side card withdrawal and requester notices.
177
+ - **Provenance.** The tables moved here from the assistant DB: gateway data migration m0015 backfilled them, and m0016 (checkpoint-gated on m0015, with a final catch-up copy) dropped the assistant-side tables.
package/ARCHITECTURE.md CHANGED
@@ -44,11 +44,11 @@ The request carries base64-encoded WAV audio and a MIME type. The daemon resolve
44
44
 
45
45
  **Key source files:**
46
46
 
47
- | File | Purpose |
48
- | ------------------------------------------------ | ------------------------------------------------------------------------- |
49
- | `gateway/src/http/routes/runtime-proxy.ts` | Assistant-scoped path rewriting (`/v1/assistants/:id/...` → `/v1/...`) |
50
- | `assistant/src/runtime/routes/stt-routes.ts` | Daemon HTTP endpoint: validates audio, resolves transcriber, returns text |
51
- | `clients/web/src/domains/chat/voice/stt-api.ts` | Web client: POSTs audio to the gateway, returns a typed result |
47
+ | File | Purpose |
48
+ | ----------------------------------------------- | ------------------------------------------------------------------------- |
49
+ | `gateway/src/http/routes/runtime-proxy.ts` | Assistant-scoped path rewriting (`/v1/assistants/:id/...` → `/v1/...`) |
50
+ | `assistant/src/runtime/routes/stt-routes.ts` | Daemon HTTP endpoint: validates audio, resolves transcriber, returns text |
51
+ | `clients/web/src/domains/chat/voice/stt-api.ts` | Web client: POSTs audio to the gateway, returns a typed result |
52
52
 
53
53
  ### STT Streaming WebSocket Proxy
54
54
 
@@ -73,13 +73,13 @@ Clients open WebSocket connections through the gateway to the daemon's real-time
73
73
 
74
74
  **Key source files:**
75
75
 
76
- | File | Purpose |
77
- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
78
- | `gateway/src/http/routes/stt-stream-websocket.ts` | WebSocket upgrade handler (`createSttStreamWebsocketHandler`) and proxy handlers (`getSttStreamWebsocketHandlers`) |
79
- | `gateway/src/index.ts` | Route registration: wires upgrade handler to the gateway's Bun HTTP server |
80
- | `assistant/src/runtime/http-server.ts` | Daemon-side WebSocket upgrade at `/v1/stt/stream`, session creation and registry |
81
- | `assistant/src/stt/stt-stream-session.ts` | Runtime session orchestrator: drives the `StreamingTranscriber` from the WebSocket |
82
- | `clients/web/src/domains/chat/voice/dictation-stream.ts` | Web client: opens the gateway WebSocket, parses transcript events, reports failures |
76
+ | File | Purpose |
77
+ | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
78
+ | `gateway/src/http/routes/stt-stream-websocket.ts` | WebSocket upgrade handler (`createSttStreamWebsocketHandler`) and proxy handlers (`getSttStreamWebsocketHandlers`) |
79
+ | `gateway/src/index.ts` | Route registration: wires upgrade handler to the gateway's Bun HTTP server |
80
+ | `assistant/src/runtime/http-server.ts` | Daemon-side WebSocket upgrade at `/v1/stt/stream`, session creation and registry |
81
+ | `assistant/src/stt/stt-stream-session.ts` | Runtime session orchestrator: drives the `StreamingTranscriber` from the WebSocket |
82
+ | `clients/web/src/domains/chat/voice/dictation-stream.ts` | Web client: opens the gateway WebSocket, parses transcript events, reports failures |
83
83
 
84
84
  ### Assistant Feature Flags API
85
85
 
@@ -174,7 +174,9 @@ Runtime health is exposed directly by the gateway at `GET /v1/health` and forwar
174
174
 
175
175
  ### Telegram + Contacts Control-Plane Proxies
176
176
 
177
- Telegram integration setup/config endpoints and contacts/invites endpoints are also exposed directly by the gateway for dedicated auth handling. Contact endpoints forward to runtime handlers; invite endpoints are gateway-native — they run against the gateway DB's `ingress_invites` table with no runtime round-trip (the outbound-call relay is the one exception: the gateway validates its invite row, then delegates the provider call to the assistant).
177
+ Telegram integration setup/config endpoints and contacts/invites endpoints are also exposed directly by the gateway for dedicated auth handling. Contact endpoints are gateway-native — the gateway DB is the ACL source of truth, with a best-effort identity/info mirror to the assistant DB; search-filtered list reads relay through the runtime (which owns filter/search + info) and get an authoritative gateway ACL overlay. Invite endpoints are gateway-native — they run against the gateway DB's `ingress_invites` table with no runtime round-trip (the outbound-call relay is the one exception: the gateway validates its invite row, then delegates the provider call to the assistant).
178
+
179
+ All contact paths are registered flat only (`/v1/contacts...`, `/v1/contact-channels...`); the gateway registers no assistant-scoped contact variants. Clients emit assistant-scoped URLs (`/v1/assistants/{id}/...`), and the deployment boundary strips the prefix before the gateway routes the request: cloud's Django `RuntimeProxyView` strips it for every route, and the self-hosted web client's `rewriteForSelfHostedIngress` (`clients/web/src/lib/api-interceptors.ts`) flattens the contact family the same way.
178
180
 
179
181
  **Forwarded Telegram endpoints:**
180
182
 
@@ -184,14 +186,17 @@ Telegram integration setup/config endpoints and contacts/invites endpoints are a
184
186
  | POST | `/v1/integrations/telegram/commands` |
185
187
  | POST | `/v1/integrations/telegram/setup` |
186
188
 
187
- **Forwarded contact endpoints:**
189
+ **Contact endpoints (gateway-native):**
188
190
 
189
- | Method | Path |
190
- | -------- | ---------------------------------------- |
191
- | GET/POST | `/v1/contacts` |
192
- | GET | `/v1/contacts/:contactId` |
193
- | POST | `/v1/contacts/merge` |
194
- | PATCH | `/v1/contact-channels/:contactChannelId` |
191
+ | Method | Path |
192
+ | ---------- | ----------------------------------------------- |
193
+ | GET/POST | `/v1/contacts` |
194
+ | GET/DELETE | `/v1/contacts/:contactId` |
195
+ | POST | `/v1/contacts/merge` |
196
+ | POST | `/v1/contacts/prompt/submit` |
197
+ | POST | `/v1/contacts/guardian/channel` |
198
+ | PATCH | `/v1/contact-channels/:contactChannelId` |
199
+ | POST | `/v1/contact-channels/:contactChannelId/verify` |
195
200
 
196
201
  **Gateway-native invite endpoints:**
197
202
 
@@ -205,16 +210,16 @@ Telegram integration setup/config endpoints and contacts/invites endpoints are a
205
210
  **Authentication boundary:**
206
211
 
207
212
  - Gateway validates the caller's JWT bearer token.
208
- - Forwarded requests reach runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile); invite endpoints are served from the gateway DB after the same bearer-auth check.
213
+ - Native contact/invite endpoints are served from the gateway DB after that bearer-auth check; runtime-relayed requests (Telegram control plane, contact search reads) reach the runtime with a minted JWT (`gateway_ingress_v1` or `gateway_service_v1` scope profile).
209
214
  - Upstream 4xx/5xx responses are passed through, while connection errors return `502` and timeouts return `504`.
210
215
 
211
216
  **Key source files:**
212
217
 
213
- | File | Purpose |
214
- | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
215
- | `gateway/src/http/routes/telegram-control-plane-proxy.ts` | Telegram control-plane proxy handlers and upstream forwarding |
216
- | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Contact proxy handlers plus gateway-native invite handlers (mint, list, revoke, redeem, call relay) |
217
- | `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/integrations/telegram/*` and `/v1/contacts/invites/*` |
218
+ | File | Purpose |
219
+ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
220
+ | `gateway/src/http/routes/telegram-control-plane-proxy.ts` | Telegram control-plane proxy handlers and upstream forwarding |
221
+ | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Gateway-native contact handlers (with runtime search relay) plus gateway-native invite handlers (mint, list, revoke, redeem, call relay) |
222
+ | `gateway/src/index.ts` | Route registration and bearer-auth enforcement for `/v1/integrations/telegram/*` and `/v1/contacts*` |
218
223
 
219
224
  ### Twilio Control-Plane Proxy
220
225
 
@@ -400,9 +405,9 @@ Runtime detects needs_confirmation
400
405
 
401
406
  **Conversational approval turn:** When a text message arrives while an approval is pending (e.g., non-Telegram channels or user typing a reply instead of clicking a button), a **conversational approval turn** is run via `runApprovalConversationTurn()` from `approval-conversation-turn.ts`. The conversational engine uses LLM structured output (native `tool_use`) to classify user intent as: `keep_pending` (reply without deciding), `approve_once`, `approve_always`, or `reject`. Non-decision messages receive a natural assistant reply and the run stays pending — no reminder spam. The engine fails closed: any model failure returns `keep_pending` with a deterministic fallback asking the user to try again. Callback/button handling remains deterministic and unchanged. The `channelSupportsRichApprovalUI()` function determines whether to send the structured `promptText` (for rich channels like Telegram) or the `plainTextFallback` string (for all other channels). Currently only `telegram` is classified as a rich channel.
402
407
 
403
- **Guardian-aware routing:** When a guardian binding exists for the channel, the approval flow resolves the sender's actor role (`guardian` vs `non-guardian`). Non-guardian actors have `forcePromptSideEffects` set on the session so all side-effect tools trigger approval prompts regardless of existing allow rules. Approval prompts for non-guardian actions are routed to the guardian's delivery chat (not the requester's chat), and a `channelGuardianApprovalRequest` record is created. When the guardian approves or denies, the decision is applied to the underlying run and the requester's chat is notified of the outcome. Guardian actors follow the standard approval flow. Guardian approval follow-ups also use the conversational engine with role-specific context; `approve_always` is downgraded to `approve_once` for guardian approvals since permanent allow-rules require guardian authority. All guardian state (bindings, challenges, approval requests) is scoped to the `(assistantId, channel)` pair -- the `assistantId` parameter flows through `handleChannelInbound`, `validateAndConsumeVerification`, `isGuardian`, `getGuardianBinding`, and `createApprovalRequest`.
408
+ **Guardian-aware routing:** When a guardian binding exists for the channel, the approval flow resolves the sender's actor role (`guardian` vs `non-guardian`). Non-guardian actors have `forcePromptSideEffects` set on the session so all side-effect tools trigger approval prompts regardless of existing allow rules. Approval prompts for non-guardian actions are routed to the guardian's delivery chat (not the requester's chat), and a guardian request (kind `tool_approval`) is recorded in the gateway-owned `guardian_requests` table via `guardian_requests_create`. When the guardian approves or denies, the decision commits through `guardian_requests_decide`, is applied to the underlying run, and the requester's chat is notified of the outcome. Guardian actors follow the standard approval flow. Guardian approval follow-ups also use the conversational engine with role-specific context; `approve_always` is downgraded to `approve_once` for guardian approvals since permanent allow-rules require guardian authority. All guardian state (bindings, challenges, guardian requests) is scoped to the `(assistantId, channel)` pair -- the `assistantId` parameter flows through `handleChannelInbound`, `validateAndConsumeVerification`, `isGuardian`, and `getGuardianBinding`.
404
409
 
405
- **Proactive expiry sweep:** The runtime runs a periodic sweep every 60 seconds (`sweepExpiredGuardianApprovals`) that finds guardian approval requests past the 30-minute TTL, auto-denies the underlying runs, and notifies both the requester and guardian via the gateway's per-channel `/deliver/<channel>` endpoint. This ensures expired approvals are closed without waiting for follow-up traffic from either party. The sweep is started automatically whenever a run orchestrator is available.
410
+ **Proactive expiry sweep:** The daemon runs a periodic sweep every 60 seconds (`assistant/src/runtime/routes/guardian-expiry-sweep.ts`) that asks the gateway to CAS-expire pending guardian requests past their `expiresAt` (`guardian_requests_sweep_expired`), then fans out card withdrawals and requester expiry notices from the returned rows. This ensures expired approvals are closed without waiting for follow-up traffic from either party.
406
411
 
407
412
  **Gateway-origin ingress contract:** The JWT token exchanged during gateway-to-runtime authentication proves gateway origin (via the `aud=vellum-daemon` claim). No separate header is required.
408
413
 
@@ -417,7 +422,7 @@ Runtime detects needs_confirmation
417
422
  | `assistant/src/runtime/routes/channel-routes.ts` | Integration point: approval interception, actor role resolution, guardian approval routing, deliver-once guard, fail-closed prompt delivery |
418
423
  | `assistant/src/runtime/channel-verification-service.ts` | Guardian binding lookups: `isGuardian()`, `getGuardianBinding()` |
419
424
  | `assistant/src/memory/delivery-channels.ts` | `claimRunDelivery()` — in-memory deliver-once guard for terminal reply idempotency |
420
- | `assistant/src/memory/guardian-approvals.ts` | CRUD for guardian approval requests: `createApprovalRequest()`, `getPendingApprovalByGuardianChat()`, `updateApprovalDecision()` |
425
+ | `assistant/src/channels/gateway-guardian-requests.ts` | Typed daemon client for the gateway-owned `guardian_requests` lifecycle (`guardian_requests_create` / `_decide` / `_sweep_expired`) |
421
426
  | `assistant/src/runtime/gateway-client.ts` | `deliverApprovalPrompt()` — sends approval payload to gateway |
422
427
  | `gateway/src/telegram/send.ts` | `buildInlineKeyboard()` — renders approval actions as Telegram inline buttons |
423
428
  | `gateway/src/telegram/normalize.ts` | `callback_query` normalization into `GatewayInboundEvent` (DM-only, drops callbacks without data) |
@@ -505,14 +510,8 @@ flowchart TD
505
510
 
506
511
  POLICY_CHECK -- deny --> DENY_POLICY["Deny: policy_deny"]
507
512
  POLICY_CHECK -- allow --> RECORD
508
- POLICY_CHECK -- escalate --> RECORD
509
-
510
- RECORD --> ESCALATE_CHECK{"Policy = escalate?"}
511
- ESCALATE_CHECK -- Yes --> HAS_BINDING{"Guardian binding<br/>exists?"}
512
- HAS_BINDING -- No --> DENY_ESCALATE["Deny: escalate_no_guardian"]
513
- HAS_BINDING -- Yes --> CREATE_APPROVAL["Create approval request<br/>+ notify guardian (dual-surface)"]
514
513
 
515
- ESCALATE_CHECK -- No --> VERIFY_CHECK{"Guardian verify<br/>code?"}
514
+ RECORD --> VERIFY_CHECK{"Guardian verify<br/>code?"}
516
515
  VERIFY_CHECK -- Yes --> VERIFY["Validate challenge<br/>→ create guardian binding"]
517
516
  VERIFY_CHECK -- No --> ROLE_RESOLVE["Resolve actor role<br/>(trust-context-resolver)"]
518
517
  ROLE_RESOLVE --> APPROVAL_INTERCEPT["Approval interception<br/>+ message processing"]
@@ -560,23 +559,23 @@ sequenceDiagram
560
559
  GW-->>Guardian: Confirmation of decision
561
560
  ```
562
561
 
563
- The `channelGuardianApprovalRequests` table tracks per-run approval state. Each request records the requester, guardian, tool name, risk level, and decision outcome.
562
+ Approval state lives in the gateway's `guardian_requests` table (kind `tool_approval`), with per-surface card deliveries in `guardian_request_deliveries`. Each request records the requester, guardian, tool name, risk level, and decision outcome; decisions commit atomically via the gateway's `guardian_requests_decide` IPC route.
564
563
 
565
564
  **Key modules:**
566
565
 
567
- | Module | Purpose |
568
- | --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
569
- | `assistant/src/memory/guardian-approvals.ts` | CRUD for guardian approval requests |
570
- | `assistant/src/memory/channel-verification-sessions.ts` | Guardian binding types and verification challenge persistence |
571
- | `assistant/src/runtime/channel-verification-service.ts` | Challenge creation/validation, guardian identity checks (`isGuardian()`, `getGuardianBinding()`) -- all accept `assistantId` |
572
- | `assistant/src/runtime/trust-context-resolver.ts` | Actor role classification: guardian / non-guardian / unverified_channel based on binding state + sender identity |
573
- | `assistant/src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, verification-code intercept, escalation creation, actor role resolution |
574
- | `assistant/src/runtime/routes/channel-routes.ts` | Approval routing to guardian, proactive expiry sweep (`sweepExpiredGuardianApprovals`, `startGuardianExpirySweep`) |
575
- | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel ASK_GUARDIAN dispatch: creates guardian_action_requests, fans out to mac/telegram, manages deliveries |
576
- | `assistant/src/calls/guardian-action-sweep.ts` | Periodic 60s sweep for expired guardian action requests; sends expiry notices to delivery channels |
577
- | `assistant/src/memory/guardian-action-store.ts` | CRUD for guardian_action_requests and guardian_action_deliveries tables; first-writer-wins resolution via atomic status check |
566
+ | Module | Purpose |
567
+ | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
568
+ | `gateway/src/db/guardian-request-store.ts` | Gateway-side store for `guardian_requests` / `guardian_request_deliveries`; first-writer-wins decision via atomic status CAS |
569
+ | `assistant/src/channels/gateway-guardian-requests.ts` | Daemon-side client: typed wrappers over the gateway's `guardian_requests_*` IPC routes |
570
+ | `assistant/src/channels/gateway-verification-sessions.ts` | Guardian binding types and verification-session relay to the gateway store |
571
+ | `assistant/src/runtime/channel-verification-service.ts` | Challenge creation/validation, guardian identity checks (`isGuardian()`, `getGuardianBinding()`) -- all accept `assistantId` |
572
+ | `assistant/src/runtime/trust-context-resolver.ts` | Actor role classification: guardian / non-guardian / unverified_channel based on binding state + sender identity |
573
+ | `assistant/src/runtime/routes/inbound-message-handler.ts` | Ingress ACL enforcement, verification-code intercept, actor role resolution |
574
+ | `assistant/src/runtime/routes/guardian-expiry-sweep.ts` | Proactive 60s expiry sweep: gateway CAS-expiry plus card-withdrawal and requester-notice fan-out |
575
+ | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel ASK_GUARDIAN dispatch: creates gateway guardian requests, fans out to mac/telegram, records deliveries |
576
+ | `assistant/src/calls/guardian-action-sweep.ts` | Expiry notice delivery to guardian destinations (vellum conversation message or direct channel reply) |
578
577
 
579
- ### Ingress Membership and Escalation
578
+ ### Ingress Membership
580
579
 
581
580
  The ingress membership system extends the guardian security model to support controlled cross-user access. External users interact with the assistant through channels (Telegram, WhatsApp) under an invite-based membership system with per-member access policies.
582
581
 
@@ -587,85 +586,46 @@ The channel inbound handler (`inbound-message-handler.ts`) enforces an access co
587
586
  1. When `actorExternalId` is present, the handler looks up the sender in the `contacts` table via `findContactChannel` by `(channelType, externalUserId)` or `(channelType, externalChatId)`.
588
587
  2. If no member record exists, the message is denied (`not_a_member`).
589
588
  3. If a member exists but is not `active` (e.g., `revoked`, `blocked`), the message is denied.
590
- 4. If the member's `policy` is `deny`, the message is rejected. If `allow`, the message proceeds to normal processing. If `escalate`, the message is held for guardian approval.
589
+ 4. If the member's `policy` is `deny`, the message is rejected. If `allow`, the message proceeds to normal processing.
591
590
 
592
591
  **Invite-based onboarding:** Invite tokens are minted by the gateway via the invite HTTP API and stored SHA-256 hashed on the gateway DB's `ingress_invites` row -- the raw token is returned exactly once at creation time. External users redeem invites by sending the token as a channel message, which atomically creates a member record with `active` status and `allow` policy.
593
592
 
594
- **Relationship to guardian verification:** Guardian verification and ingress contact management are independent systems. Guardian verification establishes who controls the assistant on a channel (the trust anchor for approvals and escalations). Ingress contacts control who can interact with the assistant. Escalation (`policy=escalate`) depends on a guardian binding existing for the channel -- without one, escalated messages are denied (fail-closed).
595
-
596
- #### Escalation Data Flow
597
-
598
- When a member's policy is `escalate`:
599
-
600
- ```mermaid
601
- sequenceDiagram
602
- participant Ext as External User
603
- participant GW as Gateway
604
- participant RT as Runtime (channel-routes)
605
- participant DB as SQLite
606
- participant Guardian as Guardian (Channel)
607
-
608
- Ext->>GW: Send message via channel
609
- GW->>RT: POST /channels/inbound
610
- RT->>DB: Look up ingress member -> policy = escalate
611
- RT->>DB: Store raw payload (delivery-crud)
612
- RT->>DB: Create channel_guardian_approval_request
613
- RT->>RT: emitNotificationSignal (escalation alert)
614
- RT->>GW: Notify guardian via notification pipeline
615
- GW->>Guardian: Deliver escalation notice (Telegram/desktop)
616
-
617
- alt Guardian approves
618
- Guardian->>RT: Approve decision
619
- RT->>DB: Resolve approval request
620
- RT->>DB: Recover stored payload
621
- RT->>RT: Process message through agent pipeline
622
- RT->>GW: Deliver assistant reply
623
- GW->>Ext: Reply via channel
624
- else Guardian denies
625
- Guardian->>RT: Deny decision
626
- RT->>GW: Deliver refusal message
627
- GW->>Ext: Refusal via channel
628
- end
629
- ```
630
-
631
- Escalation alerts are routed through the canonical notification pipeline (`emitNotificationSignal`), which delivers to all configured channels (Telegram push, desktop notification). The guardian can approve or deny from any channel. All decisions write to the same `channel_guardian_approval_requests` table.
632
-
633
- If no guardian binding exists for the channel, escalation fails closed -- the message is denied with `escalate_no_guardian`.
593
+ **Relationship to guardian verification:** Guardian verification and ingress contact management are independent systems. Guardian verification establishes who controls the assistant on a channel (the trust anchor for approvals). Ingress contacts control who can interact with the assistant.
634
594
 
635
595
  #### SQLite Tables
636
596
 
637
- **Assistant DB** (`assistant.db` — current owner of contact info, migrating to gateway):
597
+ **Assistant DB** (`assistant.db` — owner of contact identity/info; ACL fields are a best-effort mirror of the gateway DB):
638
598
 
639
- | Table | Purpose |
640
- | ------------------ | ---------------------------------------------------------------------- |
641
- | `contacts` | Contact records with role, relationship, and per-contact metadata |
642
- | `contact_channels` | Channel bindings per contact with access policy (allow/deny/escalate) |
599
+ | Table | Purpose |
600
+ | ------------------ | ----------------------------------------------------------------- |
601
+ | `contacts` | Contact records with role, relationship, and per-contact metadata |
602
+ | `contact_channels` | Channel bindings per contact with access policy (allow/deny) |
643
603
 
644
- **Gateway DB** (`gateway.sqlite` — canonical owner of invites, future owner of auth/authz):
604
+ **Gateway DB** (`gateway.sqlite` — canonical owner of invites and contact auth/authz):
645
605
 
646
- | Table | Purpose |
647
- | ------------------ | ----------------------------------------------------------------------- |
648
- | `contacts` | Contact auth/authz: id, display_name, role, principal_id |
649
- | `contact_channels` | Channel bindings with policy, status, external IDs, verification state |
606
+ | Table | Purpose |
607
+ | ------------------ | ------------------------------------------------------------------------------------ |
608
+ | `contacts` | Contact auth/authz: id, display_name, role, principal_id |
609
+ | `contact_channels` | Channel bindings with policy, status, external IDs, verification state |
650
610
  | `ingress_invites` | Canonical invite store — token/code hashes, expiry, use counts, voice/display fields |
651
611
 
652
612
  The gateway's `ingress_invites` table is the sole invite store: mint, list, revoke, and redemption all run gateway-natively, and the daemon relays its invite surfaces here over IPC. The gateway data migrations `m0007`/`m0009` reference `assistant_ingress_invites` — a legacy assistant table absent from the current assistant schema — only as a one-time backfill source.
653
613
 
654
- The gateway declares `contacts` and `contact_channels` tables and exposes them via IPC (`list_contacts`, `get_contact`, `get_contact_by_channel`, `get_channels_for_contact`). Contact endpoint cutover and data migration are in progress — the gateway will become the canonical owner once dual-writing is enabled.
614
+ The gateway declares `contacts` and `contact_channels` tables and exposes them via IPC (`list_contacts`, `get_contact`, `get_contact_by_channel`, `get_channels_for_contact`). The gateway DB is the canonical owner of contact ACL (role, principal, channel status/policy/verification): contact writes land gateway-first, with identity/info fields mirrored to the assistant DB best-effort.
655
615
 
656
616
  #### Key Modules
657
617
 
658
- | Module | Purpose |
659
- | --------------------------------------------------------- | -------------------------------------------------------------------------- |
660
- | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Gateway-native invite lifecycle (mint, list, revoke, redeem) shared by HTTP and IPC |
661
- | `gateway/src/ipc/invite-handlers.ts` | IPC routes relaying the daemon's invite surfaces to the native functions |
662
- | `gateway/src/verification/invite-redemption.ts` | Redemption engine — validation, atomic claim, ACL activation |
663
- | `assistant/src/contacts/contact-store.ts` | Contact and channel lookups (findContactChannel, guardian bindings) |
618
+ | Module | Purpose |
619
+ | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
620
+ | `gateway/src/http/routes/contacts-control-plane-proxy.ts` | Gateway-native invite lifecycle (mint, list, revoke, redeem) shared by HTTP and IPC |
621
+ | `gateway/src/ipc/invite-handlers.ts` | IPC routes relaying the daemon's invite surfaces to the native functions |
622
+ | `gateway/src/verification/invite-redemption.ts` | Redemption engine — validation, atomic claim, ACL activation |
623
+ | `assistant/src/contacts/contact-store.ts` | Contact and channel lookups (findContactChannel, guardian bindings) |
664
624
  | `assistant/src/contacts/contacts-write.ts` | Contact and channel identity/info writes (upsert, redemption info mirror — ACL is gateway-owned) |
665
- | `assistant/src/ipc/routes/invite-ipc-routes.ts` | `invite_redeemed` info mirror — local contact/channel identity upsert |
666
- | `assistant/src/runtime/routes/inbound-message-handler.ts` | ACL enforcement point -- member lookup, policy check, escalation creation |
667
- | `gateway/src/db/contact-store.ts` | Gateway-side ContactStore — contact/channel reads and invite CRUD |
668
- | `gateway/src/ipc/contact-handlers.ts` | IPC route handlers for contact reads |
625
+ | `assistant/src/ipc/routes/invite-ipc-routes.ts` | `invite_redeemed` info mirror — local contact/channel identity upsert |
626
+ | `assistant/src/runtime/routes/inbound-message-handler.ts` | ACL enforcement point -- member lookup, policy check |
627
+ | `gateway/src/db/contact-store.ts` | Gateway-side ContactStore — contact/channel reads and invite CRUD |
628
+ | `gateway/src/ipc/contact-handlers.ts` | IPC route handlers for contact reads |
669
629
 
670
630
  ### Telegram Credential Flow
671
631
 
@@ -985,8 +945,8 @@ sequenceDiagram
985
945
  | `assistant/src/calls/call-store.ts` | CRUD operations for call sessions, call events, and pending questions in SQLite via Drizzle ORM |
986
946
  | `assistant/src/calls/call-domain.ts` | Shared domain functions (`startCall`, `getCallStatus`, `cancelCall`, `answerCall`, `relayInstruction`) used by both tools and HTTP routes |
987
947
  | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel dispatch engine: fans out ASK_GUARDIAN questions to mac/telegram, creates server-side guardian conversations, manages deliveries |
988
- | `assistant/src/memory/guardian-action-store.ts` | CRUD for guardian action requests and deliveries; first-writer-wins resolution via atomic status check |
989
- | `assistant/src/calls/guardian-action-sweep.ts` | Periodic 60s sweep for expired guardian action requests; sends expiry notices to all delivery channels |
948
+ | `gateway/src/db/guardian-request-store.ts` | Gateway-side store for guardian requests and deliveries; first-writer-wins resolution via atomic status CAS (`guardian_requests_decide`) |
949
+ | `assistant/src/calls/guardian-action-sweep.ts` | Expiry notices for expired guardian requests, sent to all delivery destinations |
990
950
  | `assistant/src/calls/call-domain.ts:createInboundVoiceSession()` | Creates or reuses a voice session for an inbound call keyed by CallSid (idempotent replay protection) |
991
951
  | `assistant/src/runtime/channel-verification-service.ts` | Channel verification session lifecycle: create session with six-digit code, find pending sessions, validate and consume on match |
992
952
  | `assistant/src/calls/call-state-machine.ts` | Deterministic state transition validator with allowed-transition table and terminal-state enforcement |
@@ -1038,22 +998,22 @@ The `validateTransition(current, next)` function is called by `updateCallSession
1038
998
 
1039
999
  When the LLM emits `[ASK_GUARDIAN: question]` during a voice call, the controller creates a pending question and calls `dispatchGuardianQuestion()` on the guardian dispatch engine. The dispatch engine handles the full cross-channel fan-out:
1040
1000
 
1041
- 1. **Request creation**: A `guardian_action_request` row is created with a unique 6-character hex request code, the question text, a `pending` status, and an expiry timestamp.
1001
+ 1. **Request creation**: A `guardian_requests` row (kind `pending_question`) is created gateway-side via `guardian_requests_create` with a unique 6-character hex request code, the question text, a `pending` status, and an expiry timestamp.
1042
1002
 
1043
1003
  2. **Delivery fan-out via notification pipeline**: The guardian dispatch calls `emitNotificationSignal()` and uses the same notification decision + broadcaster path as every other producer.
1044
1004
  - **Vellum**: Conversation pairing happens in the notification broadcaster. The resulting `notification_conversation_created` event surfaces the conversation in the desktop UI.
1045
1005
  - **Telegram**: Delivery is handled by channel adapters selected by the notification decision and guarded by configured bindings.
1046
- - Guardian dispatch records `guardian_action_deliveries` from pipeline delivery results. It also uses the per-dispatch `onConversationCreated` callback so vellum delivery rows are created as soon as conversation pairing occurs (without waiting for slower channels).
1006
+ - Guardian dispatch records `guardian_request_deliveries` (through the `guardian-delivery-recorder.ts` single write sink) from pipeline delivery results. It also uses the per-dispatch `onConversationCreated` callback so vellum delivery rows are created as soon as conversation pairing occurs (without waiting for slower channels).
1047
1007
 
1048
- 3. **Answer resolution**: The first channel to respond wins. Answer resolution uses an atomic `WHERE status = 'pending'` check on the `guardian_action_requests` table -- only the first writer succeeds in transitioning the request to `answered` status. The winning answer text and responding channel are recorded on the request row.
1008
+ 3. **Answer resolution**: The first channel to respond wins. Decisions commit via the gateway's `guardian_requests_decide` route, which performs an atomic `WHERE status = 'pending'` compare-and-set on the `guardian_requests` table -- only the first writer succeeds in transitioning the request to a decided status. The winning answer text and deciding identity are recorded on the request row.
1049
1009
 
1050
1010
  4. **Stale responses**: Channels that lose the race (respond after another channel has already answered) receive a "already answered" notice informing them that the question was resolved by another channel.
1051
1011
 
1052
1012
  5. **Request-code disambiguation**: When a guardian has multiple pending requests across concurrent calls, they prefix their answer with the 6-character hex request code to indicate which question they are answering. This allows unambiguous routing even when questions arrive on the same channel in quick succession.
1053
1013
 
1054
- 6. **Expiry sweep**: The `guardian-action-sweep.ts` module runs a periodic 60-second interval sweep. It finds requests that have passed their expiry timestamp and transitions them to `expired` status. Expiry notices are sent to all delivery channels associated with the expired request.
1014
+ 6. **Expiry sweep**: The daemon's `guardian-expiry-sweep.ts` runs a periodic 60-second sweep that asks the gateway to CAS-expire pending requests past their expiry timestamp (`guardian_requests_sweep_expired`). Expiry notices are sent to all delivery destinations associated with each expired request (`guardian-action-sweep.ts`).
1055
1015
 
1056
- 7. **Separation from channel guardian approvals**: Guardian action requests are SEPARATE from `channelGuardianApprovalRequests` (the existing channel tool-approval system). The channel guardian approval system handles tool-use permission grants (approve/deny a specific tool invocation). Guardian action requests handle free-form questions from voice calls that require human input to continue the conversation.
1016
+ 7. **One store, many kinds**: Voice questions share the gateway's `guardian_requests` store with every other guardian request, distinguished by `kind` — `tool_approval` and `tool_grant_request` rows gate specific tool invocations, `pending_question` rows carry free-form questions from voice calls that require human input to continue the conversation, and `access_request` rows gate unknown-sender access.
1057
1017
 
1058
1018
  #### macOS Notification + Deep-Link Flow
1059
1019
 
@@ -1061,7 +1021,7 @@ When a guardian question is dispatched while the macOS app is backgrounded, the
1061
1021
 
1062
1022
  ### SQLite Tables
1063
1023
 
1064
- All five tables live in `$VELLUM_WORKSPACE_DIR/data/db/assistant.db` alongside existing tables:
1024
+ The three call tables live in `$VELLUM_WORKSPACE_DIR/data/db/assistant.db` alongside existing tables; guardian requests and their deliveries live in the gateway DB (the assistant-side copies were dropped by gateway migration m0016):
1065
1025
 
1066
1026
  - **`call_sessions`** — One row per call (inbound or outbound). Tracks conversation association, provider info (Twilio CallSid), phone numbers, task description (null for inbound calls), status lifecycle (`initiated` -> `ringing` -> `in_progress` -> `waiting_on_user` -> `completed`/`failed`), and timestamps. For inbound calls, the session is keyed by CallSid via `createInboundVoiceSession()` with idempotent replay protection. Foreign key to `conversations(id)` with cascade delete.
1067
1027
 
@@ -1069,9 +1029,9 @@ All five tables live in `$VELLUM_WORKSPACE_DIR/data/db/assistant.db` alongside e
1069
1029
 
1070
1030
  - **`call_pending_questions`** — Tracks questions the AI asks the user during a call (via the `[ASK_GUARDIAN: ...]` pattern). Status lifecycle: `pending` -> `answered`/`expired`/`cancelled`. Foreign key to `call_sessions(id)` with cascade delete.
1071
1031
 
1072
- - **`guardian_action_requests`** — Cross-channel guardian consultation requests. One row per ASK_GUARDIAN question from a voice call. Tracks question text, request code (6-char hex), status lifecycle (`pending` -> `answered`/`expired`/`cancelled`), answer text, which channel answered, and expiry timestamp.
1032
+ - **`guardian_requests`** (gateway DB) — Guardian requests of every kind, including one row per ASK_GUARDIAN question from a voice call (kind `pending_question`). Tracks question/activity text, request code (6-char hex), status lifecycle (`pending` -> `approved`/`denied`/`expired`/`cancelled`), answer text, deciding identity, and expiry timestamp.
1073
1033
 
1074
- - **`guardian_action_deliveries`** — Per-channel delivery tracking for guardian action requests. One row per (request, channel) pair. Tracks delivery status (`pending` -> `sent` -> `answered`/`expired`/`cancelled`), destination conversation/chat IDs, and response timestamps.
1034
+ - **`guardian_request_deliveries`** (gateway DB) — Per-surface delivery tracking for guardian requests. One row per (request, surface) pair. Tracks delivery status, destination channel, and destination conversation/chat/message IDs — the addressing that lets a delivered card be withdrawn or matched to an inbound reaction/reply.
1075
1035
 
1076
1036
  ### Gateway Twilio Webhook Ingress
1077
1037
 
@@ -1154,20 +1114,20 @@ Malformed or unprocessable provider callback payloads are logged as dead-letter
1154
1114
 
1155
1115
  Call behavior is controlled via the `calls` config block in the assistant configuration (`config/schema.ts`). All values have sensible defaults and are validated via Zod:
1156
1116
 
1157
- | Field | Type | Default | Description |
1158
- | --------------------------------- | -------- | --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1159
- | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1160
- | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1161
- | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1162
- | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1163
- | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1164
- | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1165
- | `calls.safety.denyCategories` | string[] | `[]` | Categories of calls to deny (e.g., emergency numbers are always denied regardless of this setting). |
1166
- | `llm.callSites.callAgent.model` | string | _(unset — falls back to `llm.default.model`)_ | Optional override for the LLM model used in voice call conversations. |
1167
- | `calls.voice.language` | string | `'en-US'` | Language code for TTS and transcription. |
1168
- | `services.stt.provider` | enum | `'deepgram'` | STT provider for all boundaries including telephony. The daemon transcribes media-stream call audio with this provider (streaming when supported, batch otherwise). |
1169
- | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1170
- | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1117
+ | Field | Type | Default | Description |
1118
+ | --------------------------------- | -------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1119
+ | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1120
+ | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1121
+ | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1122
+ | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1123
+ | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1124
+ | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1125
+ | `calls.safety.denyCategories` | string[] | `[]` | Categories of calls to deny (e.g., emergency numbers are always denied regardless of this setting). |
1126
+ | `llm.callSites.callAgent.model` | string | _(unset — falls back to the resolved call-site default)_ | Optional override for the LLM model used in voice call conversations. |
1127
+ | `calls.voice.language` | string | `'en-US'` | Language code for TTS and transcription. |
1128
+ | `services.stt.provider` | enum | `'deepgram'` | STT provider for all boundaries including telephony. The daemon transcribes media-stream call audio with this provider (streaming when supported, batch otherwise). |
1129
+ | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1130
+ | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1171
1131
 
1172
1132
  ### Caller Identity Resolution
1173
1133
 
package/README.md CHANGED
@@ -145,6 +145,8 @@ This can be sent as an action-only payload (without `text` or `attachments`) whe
145
145
 
146
146
  The gateway serves as the single public ingress point for all external callbacks. The following routes are handled directly by the gateway before any proxy forwarding:
147
147
 
148
+ Control-plane routes are listed with their flat paths. Clients emit assistant-scoped URLs (`/v1/assistants/{id}/...`) because multi-tenant cloud routing needs the id; the deployment boundary strips the prefix before the request reaches the gateway — cloud's Django `RuntimeProxyView` strips it for every route, and the self-hosted web client's ingress rewrite (`rewriteForSelfHostedIngress` in `clients/web/src/lib/api-interceptors.ts`) flattens the contact family (`contacts`, `contact-channels`) while forwarding other segments scoped, verbatim. Contacts are therefore served flat-only, while gateway-global families that self-hosted clients still reach with scoped paths (trust-rules, channel-admission-policy, channel-permission-overrides, backups) also register assistant-scoped variants that match and discard the id. The flattening spans the whole `contacts`/`contact-channels` segment family: self-hosted invites requests land on the gateway's flat edge-scoped invite routes (the same invite engine the assistant relays to), and assistant-only subpaths (`contacts/search`, `contacts/prompt`) fall through the runtime-proxy catch-all to the assistant verbatim. Never remove a flat control-plane route — the flat family is the load-bearing cloud path.
149
+
148
150
  | Route | Method | Description |
149
151
  | ------------------------------------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
150
152
  | `/webhooks/telegram` | POST | Telegram bot webhook (validated via `TELEGRAM_WEBHOOK_SECRET`) |
@@ -161,10 +163,13 @@ The gateway serves as the single public ingress point for all external callbacks
161
163
  | `/v1/integrations/telegram/config` | GET/POST/DELETE | Authenticated control-plane proxy for Telegram integration config |
162
164
  | `/v1/integrations/telegram/commands` | POST | Authenticated control-plane proxy for Telegram command registration |
163
165
  | `/v1/integrations/telegram/setup` | POST | Authenticated control-plane proxy for Telegram setup orchestration |
164
- | `/v1/contacts` | GET/POST | Authenticated control-plane proxy for listing/searching and creating/updating contacts |
165
- | `/v1/contacts/:id` | GET | Authenticated control-plane proxy for retrieving a contact by ID |
166
- | `/v1/contacts/merge` | POST | Authenticated control-plane proxy for merging two contacts |
167
- | `/v1/contact-channels/:contactChannelId` | PATCH | Authenticated control-plane proxy for updating a contact channel's status/policy |
166
+ | `/v1/contacts` | GET/POST | Gateway-native contact list and upsert (search-filtered lists relay through the assistant runtime with a gateway ACL overlay) |
167
+ | `/v1/contacts/:id` | GET/DELETE | Gateway-native contact retrieval and deletion |
168
+ | `/v1/contacts/merge` | POST | Gateway-native transactional contact merge |
169
+ | `/v1/contacts/prompt/submit` | POST | Contact info submitted in response to a `contact_request` prompt — gateway-first upsert, then IPC to unblock the waiting flow |
170
+ | `/v1/contacts/guardian/channel` | POST | Guardian-authenticated guardian channel creation (platform auto-verify) |
171
+ | `/v1/contact-channels/:contactChannelId` | PATCH | Gateway-native contact-channel status/policy update |
172
+ | `/v1/contact-channels/:contactChannelId/verify` | POST | Guardian-authenticated manual channel verification |
168
173
  | `/v1/contacts/invites` | GET/POST | Gateway-native invite list/create against the gateway DB's `ingress_invites` table |
169
174
  | `/v1/contacts/invites/:id` | DELETE | Gateway-native invite revoke |
170
175
  | `/v1/contacts/invites/:id/call` | POST | Gateway-native invite-call relay — validates the invite row, then delegates the provider call to the assistant |
@@ -15,6 +15,7 @@
15
15
  "./handles": "./src/handles.ts",
16
16
  "./rpc": "./src/rpc.ts",
17
17
  "./attachment-naming": "./src/attachment-naming.ts",
18
+ "./redacted-credential": "./src/redacted-credential.ts",
18
19
  "./error": "./src/error.ts"
19
20
  },
20
21
  "scripts": {