toga-ai 1.0.476 → 1.0.477
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.
- package/knowledge/2.0/apps/_underscore/features/email-send-pipeline.md +15 -2
- package/knowledge/2.0/apps/ai-bdr/INDEX.md +2 -1
- package/knowledge/2.0/apps/ai-bdr/features/live-call-status.md +156 -0
- package/knowledge/2.0/apps/ai-bdr/features/web-funnel-app.md +191 -39
- package/knowledge/2.0/apps/ai-bdr/workflows/safe-call-loop-testing.md +43 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +32 -1
- package/knowledge/2.0/apps/api2/features/v2-rest-query-contract.md +138 -0
- package/knowledge/2.0/apps/worker2/INDEX.md +1 -1
- package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +27 -2
- package/knowledge/2.0/apps/worker2/features/vapi-webhook-handler.md +35 -1
- package/knowledge/INDEX.md +2 -2
- package/knowledge/registry.json +2 -1
- package/package.json +1 -1
|
@@ -6,13 +6,14 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: ["bala"]
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: ["bala", tcox]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Email.php
|
|
13
13
|
- worker2/Worker/Infrastructure/Email/Send.php
|
|
14
14
|
related:
|
|
15
15
|
- email-template-sending.md
|
|
16
|
+
- ../../ai-bdr/features/web-funnel-app.md
|
|
16
17
|
---
|
|
17
18
|
|
|
18
19
|
## Summary
|
|
@@ -60,8 +61,20 @@ SMTP** using PHPMailer, then flips each row to `SENT` or `FAILED`. So a 2.0 emai
|
|
|
60
61
|
copy the values. Remediation: treat as compromised, rotate in AWS SES, and move them into
|
|
61
62
|
`Config/[environment].ini` (read via `_Config`). Also tracked on
|
|
62
63
|
[`email-template-sending.md`](email-template-sending.md).
|
|
64
|
+
- **Rotation now has a NON-PHP consumer (2026-07-29).** The BDR web funnel (Node/Next, repo
|
|
65
|
+
`bdr`) sends its call-summary email with **nodemailer against the same SES SMTP settings**,
|
|
66
|
+
read from server-only env vars (`TOGA_SMTP_HOST/PORT/USER/PASS`, `TOGA_EMAIL_FROM`) in
|
|
67
|
+
`.env.local` and the Amplify console. Rotating the constants in `Email.php` **must** therefore
|
|
68
|
+
be paired with updating BDR's env, or BDR's Send button can only reach its *failed* state (it
|
|
69
|
+
has no mail-app fallback). Settings BDR mirrors: AWS SES **us-west-2**, port **587**, TLS;
|
|
70
|
+
`DevTeam@goagilant.com` is a known-good verified sender identity in worker2 config. See
|
|
71
|
+
[ai-bdr web-funnel-app.md](../../ai-bdr/features/web-funnel-app.md).
|
|
63
72
|
|
|
64
73
|
## Change history
|
|
74
|
+
- 2026-07-29 — Noted that the SES SMTP credentials/settings now have a **non-PHP consumer**: the
|
|
75
|
+
BDR funnel sends via nodemailer against the same SES host (us-west-2, port 587, TLS) using
|
|
76
|
+
`TOGA_SMTP_*` env vars, so a rotation must be coordinated with BDR's `.env.local` + Amplify
|
|
77
|
+
vars or BDR's summary email goes dark. (tcox)
|
|
65
78
|
- 2026-07-28 — Created: documented that `_Email::send()` **queues** (writes a `PENDING`
|
|
66
79
|
`Logs_[Client].Email` row + attachment BLOBs) rather than transmitting, and that the worker2
|
|
67
80
|
`Infrastructure/Email/Send` cron (`Core.CronJobs` `* * * * *`) does the actual SES SMTP send
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
| [AI-BDR Architecture](architecture.md) | **AI-BDR** is TOGA's outbound AI sales-development representative. | ai-bdr/README.md, ai-bdr/requirements.txt, ai-bdr/.env.example, ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-inbound-callback-assistant-request.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md, ai-bdr/vapi/templates/system-prompt.template.md, ai-bdr/vapi/templates/assistant.template.json, ai-bdr/prompts/archive/prompt-may-15.txt, ai-bdr/prompts/campaigns/healthcare-2026-03-18.md, ai-bdr/prompts/campaigns/healthcare-v2-2026-03-25.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py, ai-bdr/scripts/update_structured_output.py, ai-bdr/scripts/update_call_summary_context.py, ai-bdr/scripts/fix_booking_guardrails.py, ai-bdr/scripts/get_assistant.py |
|
|
6
6
|
| [BDR Web Funnel — Full Implementation Plan](features/bdr-web-funnel-plan.md) | > **Execution status (2026-07-16):** PLAN **Phases 0–5 are BUILT and QA'd**, Phase 6 > was **mostly already built** (the `info` contract was implemented at comm | bdr/PLAN.md, bdr/mockup/app.jsx, bdr/mockup/screens.jsx, bdr/mockup/components.jsx |
|
|
7
7
|
| [Call Orchestration — PHP Worker ↔ Vapi (the integration seam)](features/call-orchestration.md) | The **PHP worker** is the orchestrator; **Vapi** is the actor. | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-inbound-callback-assistant-request.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md, ai-bdr/scripts/update_structured_output.py |
|
|
8
|
+
| [Live Call Status — Vapi call API → funnel Success screen lifecycle](features/live-call-status.md) | The BDR funnel's Success screen shows the **real** state of the call it just requested — dialing / ringing / answered-and-talking / ended / not-answered — inste | bdr/src/server/vapiCall.ts, bdr/src/server/callStatus.ts, bdr/src/server/statusToken.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/lib/callStatus.ts, bdr/src/app/api/call-status/route.ts, bdr/src/app/api/call-now/route.ts, bdr/src/flow/useCallStatusViewModel.ts, bdr/src/flow/callStatusApi.ts, bdr/src/flow/screens/Success.tsx, bdr/src/content/schema.ts, bdr/src/content/default.ts, bdr/src/proxy.ts |
|
|
8
9
|
| [Vapi Integration — Assistants, Tools, Structured Output](features/vapi-integration.md) | Everything inside Vapi: the three assistants, the three shared tools, the shared structured-output schema, the Liquid-templated system prompt, and the Python sc | ai-bdr/vapi/templates/assistant.template.json, ai-bdr/vapi/templates/system-prompt.template.md, ai-bdr/prompts/archive/prompt-may-15.txt, ai-bdr/prompts/campaigns/healthcare-2026-03-18.md, ai-bdr/prompts/campaigns/healthcare-v2-2026-03-25.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py, ai-bdr/scripts/update_structured_output.py, ai-bdr/scripts/update_call_summary_context.py, ai-bdr/scripts/fix_booking_guardrails.py, ai-bdr/scripts/get_assistant.py, ai-bdr/docs/vapi-firstmessage-timing-fix.md, ai-bdr/docs/vapi-voicemail-iphone-screening.md |
|
|
9
|
-
| [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/src/server/hubspot.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/lib/
|
|
10
|
+
| [Web Funnel — Built Next.js App (structure, stack, how to run)](features/web-funnel-app.md) | The **built** state of the BDR web funnel — the Next.js UI / lead-capture front door of the existing AI-BDR product (see `../architecture.md`). | bdr/PLAN.md, bdr/.gitignore, bdr/src/server/hubspot.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/server/workerDialer.ts, bdr/src/server/mailer.ts, bdr/src/server/statusToken.ts, bdr/src/server/vapiCall.ts, bdr/src/server/callStatus.ts, bdr/src/lib/summaryText.ts, bdr/src/lib/emailFormat.ts, bdr/src/lib/callStatus.ts, bdr/src/app/api/share-summary/route.ts, bdr/src/app/api/call-status/route.ts, bdr/src/flow/shareSummaryApi.ts, bdr/src/flow/callStatusApi.ts, bdr/src/flow/useCallStatusViewModel.ts, bdr/src/flow/screens/Success.tsx, bdr/src/flow/screens/CallNow.tsx, bdr/src/flow/screens/Schedule.tsx, bdr/src/flow/screens/CallSummary.tsx, bdr/src/content/schema.ts, bdr/src/content/default.ts, bdr/test/togaEnrichment.test.ts, bdr/test/fastDial.test.ts, bdr/eslint.config.mjs, bdr/amplify.yml, bdr/src/app, bdr/src/app/layout.tsx, bdr/src/app/page.tsx, bdr/src/app/studio.css, bdr/src/lib/resolveCampaignId.ts, bdr/src/lib/scheduleSlots.ts, bdr/src/lib/formatPhone.ts, bdr/src/lib/analytics.ts, bdr/src/lib/rateLimit.ts, bdr/src/lib/clientIp.ts, bdr/src/proxy.ts, bdr/.env.example, bdr/src/components/GoogleAnalytics.tsx, bdr/src/components/RichText.tsx, bdr/src/content, bdr/src/components, bdr/src/flow, bdr/src/server, bdr/src/server/toga.ts, bdr/src/app/api, bdr/test, bdr/test/togaPrimaryPhone.test.ts, bdr/public, bdr/mockup/styles.css |
|
|
10
11
|
| [Web Funnel — Next.js UI + Config-Driven Campaign Content](features/web-funnel-content-model.md) | > **Status note (RESOLVED 2026-07-16):** the content **contract** below (schema, > provider seam, adapters) is BUILT and QA'd (PLAN Phases 0–5). | bdr/src/content/schema.ts, bdr/src/content/provider.ts, bdr/src/content/useCampaign.ts, bdr/src/content/default.ts, bdr/src/lib/resolveCampaignId.ts, bdr/src/server/leadSink.ts, bdr/src/server/callbackService.ts, bdr/src/app/api/call-now/route.ts, bdr/src/app/api/call-later/route.ts, bdr/src/app/api/contact/route.ts |
|
|
11
12
|
| [New-Campaign Onboarding (6-phase runbook)](workflows/new-campaign-onboarding.md) | The end-to-end procedure for launching a new outbound BDR campaign (industry, offer, target persona, geography). | ai-bdr/docs/client-onboarding-sop.md, ai-bdr/docs/calcom-new-campaign-event-guide.md, ai-bdr/prompts/templates/campaign-template.md, ai-bdr/scripts/update_assistant.py, ai-bdr/scripts/update_system_prompt.py |
|
|
12
13
|
| [Safe AI-BDR Call-Loop Testing (isolated test-campaign runbook)](workflows/safe-call-loop-testing.md) | How to prove the AI-BDR **dialer → Vapi → end-of-call webhook** loop end-to-end **without dialing real prospects**. | dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Worker/Ai/Bdr/ZoomInfo.php, worker2/Config/beta.ini |
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Live Call Status — Vapi call API → funnel Success screen lifecycle
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: ai-bdr
|
|
5
|
+
project: AI-BDR
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: [tcox]
|
|
11
|
+
files:
|
|
12
|
+
- bdr/src/server/vapiCall.ts
|
|
13
|
+
- bdr/src/server/callStatus.ts
|
|
14
|
+
- bdr/src/server/statusToken.ts
|
|
15
|
+
- bdr/src/server/callbackService.ts
|
|
16
|
+
- bdr/src/server/workerDialer.ts
|
|
17
|
+
- bdr/src/lib/callStatus.ts
|
|
18
|
+
- bdr/src/app/api/call-status/route.ts
|
|
19
|
+
- bdr/src/app/api/call-now/route.ts
|
|
20
|
+
- bdr/src/flow/useCallStatusViewModel.ts
|
|
21
|
+
- bdr/src/flow/callStatusApi.ts
|
|
22
|
+
- bdr/src/flow/screens/Success.tsx
|
|
23
|
+
- bdr/src/content/schema.ts
|
|
24
|
+
- bdr/src/content/default.ts
|
|
25
|
+
- bdr/src/proxy.ts
|
|
26
|
+
related:
|
|
27
|
+
- web-funnel-app.md
|
|
28
|
+
- vapi-integration.md
|
|
29
|
+
- call-orchestration.md
|
|
30
|
+
- ../../worker2/features/vapi-webhook-handler.md
|
|
31
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Summary
|
|
35
|
+
|
|
36
|
+
The BDR funnel's Success screen shows the **real** state of the call it just requested —
|
|
37
|
+
dialing / ringing / answered-and-talking / ended / not-answered — instead of the old ~5s
|
|
38
|
+
scripted "Connecting" theater. The status source is **Vapi's own call API**
|
|
39
|
+
(`GET https://api.vapi.ai/call/{id}`), **not** Toga `ContactAttempts`, because the BDR API
|
|
40
|
+
client is still hard-blocked (`EZ-1`) on `ContactAttempts` — and because Vapi is strictly
|
|
41
|
+
better data even after that ACL grant lands (see "Why Vapi, not ContactAttempts").
|
|
42
|
+
|
|
43
|
+
## How it works
|
|
44
|
+
|
|
45
|
+
1. **Call Now** writes the contact (`src/server/callbackService.ts`), then fast-dials the
|
|
46
|
+
worker action `Ai/Bdr/Vapi/InitiateOutboundCall` (`src/server/workerDialer.ts`). The
|
|
47
|
+
worker's reply carries the **Vapi call id** — see the response-prefix gotcha below and
|
|
48
|
+
[worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md) for the
|
|
49
|
+
full return shape.
|
|
50
|
+
2. `/api/call-now` mints a **purpose-scoped, signed status token** (`src/server/statusToken.ts`)
|
|
51
|
+
whose claims carry the Vapi call id. The call id itself **never crosses to the client and is
|
|
52
|
+
never accepted as a client parameter** (see Security).
|
|
53
|
+
3. The Success screen polls `/api/call-status` (`src/flow/callStatusApi.ts` →
|
|
54
|
+
`src/flow/useCallStatusViewModel.ts`), passing the token in the **`x-status-token` header**.
|
|
55
|
+
4. The route reads Vapi server-side (`src/server/vapiCall.ts`), normalizes to the funnel's own
|
|
56
|
+
state machine (`src/lib/callStatus.ts`), and returns it with `Cache-Control: no-store, private`.
|
|
57
|
+
|
|
58
|
+
### State mapping (`src/lib/callStatus.ts`)
|
|
59
|
+
|
|
60
|
+
| Vapi `status` | Condition | Funnel state | Talk timer |
|
|
61
|
+
|---|---|---|---|
|
|
62
|
+
| `queued`, `ringing` | — (or `startedAt` absent) | **dialing** | none |
|
|
63
|
+
| `in-progress`, `forwarding` | `startedAt` present | **active** | counts from the **answer** time, corrected by a server-clock offset |
|
|
64
|
+
| `ended` | `startedAt` present | **ended** | frozen at the true talk duration (`endedAt - startedAt`) |
|
|
65
|
+
| `ended` | no `startedAt`, **or** `endedReason` matches voicemail / no-answer / did-not-answer / busy | **not answered** | none; the recap card is **suppressed** |
|
|
66
|
+
|
|
67
|
+
The "not answered" state exists specifically so the screen never shows an authored sample
|
|
68
|
+
conversation to someone whose call nobody took.
|
|
69
|
+
|
|
70
|
+
- **Post-hangup grace polling:** after `ended`, the client keeps polling every **2s for a
|
|
71
|
+
bounded 30s** — purely because Vapi writes `summary` asynchronously (below).
|
|
72
|
+
- **Simulation is the graceful fallback**, not the default: it runs whenever status is
|
|
73
|
+
unavailable (no handle, anonymous visit, missing `TOGA_VAPI_TOKEN`, upstream failure).
|
|
74
|
+
- **Scheduled-mode submits never poll** — there is nothing live to watch.
|
|
75
|
+
|
|
76
|
+
## The Vapi call read (`GET https://api.vapi.ai/call/{id}`)
|
|
77
|
+
|
|
78
|
+
Authenticated with the **same Vapi bearer credential worker2 dials with** (BDR reads it from
|
|
79
|
+
the server-only env var `TOGA_VAPI_TOKEN`; see web-funnel-app.md for where the value lives).
|
|
80
|
+
Verified live response fields: `id`, `status`, `type`, `createdAt`, `startedAt`, `endedAt`,
|
|
81
|
+
`endedReason`, `summary`, `transcript`, `recordingUrl`, `customer`, `analysis`, `messages`,
|
|
82
|
+
`cost`, `twilioCallSid`.
|
|
83
|
+
|
|
84
|
+
**Status enum:** `queued` → `ringing` → `in-progress` (`forwarding`) → `ended`.
|
|
85
|
+
|
|
86
|
+
**Timestamp semantics — the load-bearing insight:**
|
|
87
|
+
|
|
88
|
+
- `createdAt` = the call was **placed**.
|
|
89
|
+
- `startedAt` = the call was **ANSWERED**.
|
|
90
|
+
- `endedAt` = hangup.
|
|
91
|
+
- `createdAt → startedAt` is therefore **ring time** (observed 10s on a real call). Any talk
|
|
92
|
+
timer must count from `startedAt`, never `createdAt`.
|
|
93
|
+
|
|
94
|
+
**Timestamps are ISO-8601 UTC with a `Z`** — parse them directly. Do **NOT** apply the
|
|
95
|
+
Central-time conversion that `ContactAttempts` datetimes need (api2 serializes with a Central
|
|
96
|
+
offset because it runs `America/Chicago`; Vapi does not).
|
|
97
|
+
|
|
98
|
+
## Why Vapi, not ContactAttempts
|
|
99
|
+
|
|
100
|
+
- **ContactAttempts is hard-blocked for the BDR API client (still open as of 2026-07-29).**
|
|
101
|
+
Re-verified live against `api.togahub.com`: `EZ-1` on **every** variant — nested
|
|
102
|
+
`/contacts/{uuid}/contact-attempts`, flat `/contact-attempts`, with and without an explicit
|
|
103
|
+
`fields=` list. It is a **record-level** block (`data.contactAttempts: null`), not
|
|
104
|
+
field-level. Separately `/contacts/{uuid}?fields=id` returns `EZ-2` denying `Contacts.id`.
|
|
105
|
+
The read-only ACL grant asked for on 2026-07-21 is **still pending**. This supersedes the
|
|
106
|
+
older "not tried yet" note.
|
|
107
|
+
- **Even after the grant lands, Vapi wins for live UI.** `ContactAttempts.dtStarted` is the
|
|
108
|
+
**dial** time — it cannot distinguish "ringing" from "talking," so it can never drive an
|
|
109
|
+
honest talk timer or an answered/not-answered distinction. Vapi's `startedAt` can.
|
|
110
|
+
- ContactAttempts remains the right source for **durable, post-call** reporting (it is what the
|
|
111
|
+
end-of-call webhook writes back to).
|
|
112
|
+
|
|
113
|
+
## Security
|
|
114
|
+
|
|
115
|
+
The full security model for these public unauthenticated endpoints is documented in
|
|
116
|
+
[web-funnel-app.md](web-funnel-app.md#public-unauthenticated-endpoint-security-model). The two
|
|
117
|
+
rules that are specific to this feature:
|
|
118
|
+
|
|
119
|
+
- **The Vapi call id travels only inside signed token claims.** It is never accepted as a
|
|
120
|
+
client parameter — otherwise anyone could read **any** call in the org's Vapi account,
|
|
121
|
+
including transcripts and recordings.
|
|
122
|
+
- **Tokens are purpose-scoped** (`status` vs `share`) so a status-poll capability is not
|
|
123
|
+
silently also a send-an-email capability.
|
|
124
|
+
|
|
125
|
+
## Gotchas / known issues
|
|
126
|
+
|
|
127
|
+
- **`summary` is written ASYNCHRONOUSLY, a few seconds AFTER hangup.** It is commonly `null`
|
|
128
|
+
on the first read that shows `status: "ended"`. `endedReason`, by contrast, is set
|
|
129
|
+
immediately. This is the entire reason for the bounded 30s post-hangup grace polling, and
|
|
130
|
+
why the share-summary send **re-reads Vapi at send time** rather than trusting what the
|
|
131
|
+
screen had.
|
|
132
|
+
- **Never `JSON.parse` a worker dispatcher reply directly.** The worker wraps **every** reply
|
|
133
|
+
as `[<requestId>] Successfully Executed (<n> seconds): <json>`, so `response.json()` always
|
|
134
|
+
throws. BDR previously logged "immediate dial not placed" on every **successful** dial and
|
|
135
|
+
silently discarded the response body (which is where the Vapi call id lives). Strip the
|
|
136
|
+
prefix with a regex before parsing — see
|
|
137
|
+
[creating-worker-actions.md](../../worker2/features/creating-worker-actions.md).
|
|
138
|
+
- **`vapiCallId` may be the literal string `'unknown'`.** Treat it as "no handle" and fall
|
|
139
|
+
back to the simulation rather than polling for a call that cannot be read.
|
|
140
|
+
- **Do not convert Vapi timestamps to Central.** They are already UTC `Z`. The Central
|
|
141
|
+
conversion belongs only on ContactAttempts/api2 datetimes.
|
|
142
|
+
- **Without `TOGA_VAPI_TOKEN` the whole feature silently degrades to the simulation.** That is
|
|
143
|
+
deliberate (the funnel must never fail on a missing credential) — but it means "the timer
|
|
144
|
+
looks fake in this environment" is an **env** symptom, not a code bug.
|
|
145
|
+
|
|
146
|
+
## Change history
|
|
147
|
+
- 2026-07-29 — Created. BUILT the live call-status pipeline: `src/server/vapiCall.ts` reads
|
|
148
|
+
`GET https://api.vapi.ai/call/{id}` with the worker's Vapi bearer credential and drives a real
|
|
149
|
+
dialing / active / ended / not-answered lifecycle on the Success screen (replacing the ~5s
|
|
150
|
+
scripted simulation, which is now only the fallback). DECIDED Vapi is the live-status source
|
|
151
|
+
instead of Toga `ContactAttempts` — re-verified 2026-07-29 that ContactAttempts is still
|
|
152
|
+
`EZ-1` record-level blocked on every route variant (grant still pending), **and** that
|
|
153
|
+
`dtStarted` is dial time so Vapi's `startedAt` (= ANSWER time) is strictly better for live UI
|
|
154
|
+
regardless. Recorded the timestamp semantics (`createdAt` placed → `startedAt` answered →
|
|
155
|
+
`endedAt` hangup; the gap is ring time, 10s observed), the UTC-`Z` no-conversion rule, and the
|
|
156
|
+
asynchronous `summary` write that forces the bounded 30s post-hangup grace poll. (tcox)
|
|
@@ -6,7 +6,7 @@ project: AI-BDR
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-29
|
|
10
10
|
owners: [tcox]
|
|
11
11
|
files:
|
|
12
12
|
- bdr/PLAN.md
|
|
@@ -15,7 +15,19 @@ files:
|
|
|
15
15
|
- bdr/src/server/leadSink.ts
|
|
16
16
|
- bdr/src/server/callbackService.ts
|
|
17
17
|
- bdr/src/server/workerDialer.ts
|
|
18
|
-
- bdr/src/
|
|
18
|
+
- bdr/src/server/mailer.ts
|
|
19
|
+
- bdr/src/server/statusToken.ts
|
|
20
|
+
- bdr/src/server/vapiCall.ts
|
|
21
|
+
- bdr/src/server/callStatus.ts
|
|
22
|
+
- bdr/src/lib/summaryText.ts
|
|
23
|
+
- bdr/src/lib/emailFormat.ts
|
|
24
|
+
- bdr/src/lib/callStatus.ts
|
|
25
|
+
- bdr/src/app/api/share-summary/route.ts
|
|
26
|
+
- bdr/src/app/api/call-status/route.ts
|
|
27
|
+
- bdr/src/flow/shareSummaryApi.ts
|
|
28
|
+
- bdr/src/flow/callStatusApi.ts
|
|
29
|
+
- bdr/src/flow/useCallStatusViewModel.ts
|
|
30
|
+
- bdr/src/flow/screens/Success.tsx
|
|
19
31
|
- bdr/src/flow/screens/CallNow.tsx
|
|
20
32
|
- bdr/src/flow/screens/Schedule.tsx
|
|
21
33
|
- bdr/src/flow/screens/CallSummary.tsx
|
|
@@ -23,7 +35,6 @@ files:
|
|
|
23
35
|
- bdr/src/content/default.ts
|
|
24
36
|
- bdr/test/togaEnrichment.test.ts
|
|
25
37
|
- bdr/test/fastDial.test.ts
|
|
26
|
-
- bdr/test/shareMailto.test.ts
|
|
27
38
|
- bdr/eslint.config.mjs
|
|
28
39
|
- bdr/amplify.yml
|
|
29
40
|
- bdr/src/app
|
|
@@ -53,10 +64,14 @@ files:
|
|
|
53
64
|
related:
|
|
54
65
|
- web-funnel-content-model.md
|
|
55
66
|
- bdr-web-funnel-plan.md
|
|
67
|
+
- live-call-status.md
|
|
56
68
|
- ../architecture.md
|
|
57
69
|
- ../workflows/safe-call-loop-testing.md
|
|
58
70
|
- ../../worker2/features/vapi-webhook-handler.md
|
|
71
|
+
- ../../worker2/features/creating-worker-actions.md
|
|
59
72
|
- ../../api2/features/nested-relationship-writes.md
|
|
73
|
+
- ../../api2/features/v2-rest-query-contract.md
|
|
74
|
+
- ../../_underscore/features/email-send-pipeline.md
|
|
60
75
|
---
|
|
61
76
|
|
|
62
77
|
## What this is
|
|
@@ -109,7 +124,12 @@ to its ship state (trusted-IP position for X-Forwarded-For; O(1) LRU-capped limi
|
|
|
109
124
|
- **ESLint flat config** carries a **no-em-dash rule** on shipped strings
|
|
110
125
|
(`Literal` / `TemplateElement` / `JSXText` selectors) — enforces the mockup copy
|
|
111
126
|
rule on in-repo strings (`eslint.config.mjs`).
|
|
112
|
-
- **
|
|
127
|
+
- **Runtime dep added 2026-07-29: `nodemailer`** (+ `@types/nodemailer` dev) — the first
|
|
128
|
+
outbound-transport dependency in the repo, for the server-side summary send.
|
|
129
|
+
- **Vitest** test suite: **306 tests across 20 files, all green** (2026-07-29 — the live
|
|
130
|
+
call-status, status/share token, mailer, summary-renderer and anonymous-submit suites;
|
|
131
|
+
`shareMailto.test.ts` was **deleted** with the mailto: path). Historical detail:
|
|
132
|
+
**131 tests** (2026-07-21 added `togaEnrichment.test.ts`,
|
|
113
133
|
`fastDial.test.ts`, `shareMailto.test.ts` for the enrichment / fast-dial / share features below;
|
|
114
134
|
10 added 2026-07-20 for the primary-phone
|
|
115
135
|
self-heal — null-pointer re-link, changed-number re-link, create-as-last-resort, strict
|
|
@@ -306,38 +326,112 @@ so the call fires in **seconds** instead of waiting up to 60s for the dialer cro
|
|
|
306
326
|
- **Strictly best-effort:** 5s timeout, all failures absorbed + logged; the cron remains the
|
|
307
327
|
fallback, and the dialer's own in-progress check prevents a double-dial.
|
|
308
328
|
- **Scheduled calls are deliberately excluded** — they wait for their slot.
|
|
309
|
-
- **Motivation:** the Success screen's "Connecting" animation
|
|
310
|
-
cron could take 60s; fast dial
|
|
329
|
+
- **Motivation:** the Success screen's "Connecting" animation was scripted theater (~5s) while
|
|
330
|
+
the cron could take 60s; fast dial made the theater roughly honest. As of 2026-07-29 the
|
|
331
|
+
screen shows the **real** lifecycle instead (see [live-call-status.md](live-call-status.md)),
|
|
332
|
+
and fast dial is what makes the dial happen soon enough to be worth watching.
|
|
333
|
+
- **FIXED 2026-07-29 — the reply was never parseable.** The worker dispatcher wraps **every**
|
|
334
|
+
reply as `[<requestId>] Successfully Executed (<n> seconds): <json>`, so BDR's
|
|
335
|
+
`response.json()` **always** threw: it logged "immediate dial not placed" on every
|
|
336
|
+
**successful** dial and silently discarded the response body. `workerDialer.ts` now strips the
|
|
337
|
+
prefix with a regex before parsing and returns a typed `DialResult`. That body matters — it
|
|
338
|
+
carries `vapiCallId`, the handle the live-status feature needs (full return shape in
|
|
339
|
+
[worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md); the general
|
|
340
|
+
rule for any HTTP consumer of the worker endpoint is in
|
|
341
|
+
[creating-worker-actions.md](../../worker2/features/creating-worker-actions.md)).
|
|
311
342
|
- **SECURITY note / ticket:** the worker's direct-action endpoint accepts **unauthenticated** POSTs
|
|
312
343
|
(a pre-existing exposure — now load-bearing for a product feature). Flag for hardening; it is not
|
|
313
344
|
introduced by BDR but BDR now depends on it.
|
|
314
345
|
|
|
315
|
-
## Anonymous
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
346
|
+
## Anonymous phone entry (the contact-reference gate was REMOVED 2026-07-29)
|
|
347
|
+
|
|
348
|
+
**Anyone can now enter a phone number and submit**, with or without a lead reference. The
|
|
349
|
+
2026-07-16 "Missing contact reference" behavior — the `hasLead` submit-block plus the
|
|
350
|
+
`phone.missingContactNotice` copy key — was **removed entirely** (`src/flow/screens/CallNow.tsx`,
|
|
351
|
+
`src/flow/screens/Schedule.tsx`, `src/content/schema.ts`, `src/content/default.ts`). The notice
|
|
352
|
+
key is gone from the schema, so do not re-reference it.
|
|
353
|
+
|
|
354
|
+
- **The backend was deliberately left unchanged.** An anonymous submit is still a **client-side
|
|
355
|
+
no-op** — no POST is made without a `togaUuid` — so a **real call still only ever fires for a
|
|
356
|
+
campaign-linked contact**. The gate's protective effect is intact; only the dead-end UX was
|
|
357
|
+
removed.
|
|
358
|
+
- **Analytics submit events now carry `simulated: true/false`** so funnel metrics can separate
|
|
359
|
+
real submits from anonymous no-ops. Read that flag before trusting a submit count.
|
|
360
|
+
- Production links still come from HubSpot personalization tokens —
|
|
361
|
+
`?campaign=slug&hsContactId={{contact.hs_object_id}}&hsCampaignId=uuid` — or, when the campaign
|
|
362
|
+
bundle pins `togaCampaignUuid`, just the slug + contact token.
|
|
363
|
+
- **Diagnostic consequence:** the guard notice is no longer the terminal symptom of "no lead
|
|
364
|
+
reference" (see the deployed-site triage gotcha, which now has one fewer signal). Probe
|
|
365
|
+
`GET /api/contact?hsContactId=<id>` directly instead.
|
|
366
|
+
|
|
367
|
+
## Live call status on the Success screen
|
|
368
|
+
|
|
369
|
+
The Success screen shows the **real** call lifecycle (dialing → answered/talking → ended, or a
|
|
370
|
+
distinct "not answered" state) driven by **Vapi's own call API**, replacing the ~5s scripted
|
|
371
|
+
simulation — which survives only as the graceful fallback. Full detail, including the
|
|
372
|
+
`createdAt`/`startedAt`/`endedAt` semantics and why Vapi beats `ContactAttempts`, is in
|
|
373
|
+
**[live-call-status.md](live-call-status.md)**.
|
|
374
|
+
|
|
375
|
+
## Share summary = a real server-side email (the mailto: fallback was REMOVED)
|
|
376
|
+
|
|
377
|
+
The CallSummary "Send" button now **actually sends the email** from the server
|
|
378
|
+
(`src/app/api/share-summary/route.ts` NEW, `src/server/mailer.ts` NEW — nodemailer,
|
|
379
|
+
`src/lib/summaryText.ts` NEW, `src/lib/emailFormat.ts` NEW, `src/flow/shareSummaryApi.ts` NEW,
|
|
380
|
+
`src/flow/screens/CallSummary.tsx`). **`src/lib/shareMailto.ts` and its test were DELETED** —
|
|
381
|
+
the 2026-07-21 mailto: approach is gone; do not reintroduce it.
|
|
382
|
+
|
|
383
|
+
- **The body is built 100% server-side and rendered through the SAME shared renderer the Copy
|
|
384
|
+
button uses**, so Copy and the emailed text are byte-identical *by construction* rather than by
|
|
385
|
+
two implementations agreeing. Change the renderer, both change together.
|
|
386
|
+
- **The client may only supply**: a recipient, a campaign id, a bounded variant index, and a
|
|
387
|
+
signed token. **Never body or subject text** — that is what keeps this from being an open
|
|
388
|
+
spam relay (see the security model below).
|
|
389
|
+
- **The send re-reads Vapi at send time**, so it can pick up a `summary` that landed *after* the
|
|
390
|
+
on-screen 30s grace window closed — bounded by the share token's **30-minute TTL**.
|
|
391
|
+
- **CONSEQUENCE of dropping the mail-app fallback:** the button can only ever reach
|
|
392
|
+
*sending → sent* or *failed-with-retry*. **Without SMTP configured it can only reach `failed`**,
|
|
393
|
+
so credential provisioning is now **required** for the feature to do anything at all (it is no
|
|
394
|
+
longer a nice-to-have that degrades to opening a mail client).
|
|
395
|
+
|
|
396
|
+
## Public unauthenticated endpoint security model
|
|
397
|
+
|
|
398
|
+
`/api/call-status` and `/api/share-summary` are **public and unauthenticated**. The initial
|
|
399
|
+
design (an unauthenticated arbitrary-recipient mailer sending from the client's DKIM-signed
|
|
400
|
+
identity) was **BLOCKed by a `cso` audit**; these are the fixes that were applied, and they are
|
|
401
|
+
load-bearing — do not relax one without re-reviewing:
|
|
402
|
+
|
|
403
|
+
- **Purpose-scoped HMAC-SHA256 tokens** (`status` vs `share`), so one capability is never
|
|
404
|
+
silently also the other. **Future-dated token issuance is rejected.**
|
|
405
|
+
- **The token travels in an `x-status-token` HEADER, not the URL query string.** A 30-minute
|
|
406
|
+
bearer capability over a named individual's call summary must not land in access logs.
|
|
407
|
+
- **A valid share token is REQUIRED for every send** — the mailer is provably never invoked
|
|
408
|
+
without one.
|
|
409
|
+
- **Content-Type and `sec-fetch-site` checks** to blunt cross-origin IP-spreading.
|
|
410
|
+
- **Strict recipient validation:** rejects display-name / angle-bracket form, commas, and all
|
|
411
|
+
non-ASCII (header injection and bidi tricks), and the address is passed to nodemailer in
|
|
412
|
+
**object form**, never as a raw string.
|
|
413
|
+
- **SMTP `requireTLS` + TLS 1.2 minimum + connection timeouts**; upstream reads carry
|
|
414
|
+
`AbortSignal` timeouts.
|
|
415
|
+
- **`Cache-Control: no-store, private`** on every API envelope.
|
|
416
|
+
- **EXACT ACL error-code matching that fails CLOSED on an unknown 403** — a substring match would
|
|
417
|
+
classify `EZ-25` as `EZ-2` and read data out of a 403 (see
|
|
418
|
+
[api2 v2-api-error-codes.md](../../api2/features/v2-api-error-codes.md)).
|
|
419
|
+
- **The Vapi call id is never a client parameter** — it travels only inside signed token claims.
|
|
420
|
+
Otherwise anyone could read **any** call in the org's Vapi account, transcripts and recordings
|
|
421
|
+
included.
|
|
422
|
+
- **Residual ACCEPTED risk:** a token holder can email the summary to any address they type
|
|
423
|
+
(that *is* the share-with-a-colleague use case). The stricter alternative — binding the
|
|
424
|
+
recipient to the contact's on-file address — was considered and not taken.
|
|
425
|
+
|
|
426
|
+
## ContactAttempts is still 403-blocked (re-verified 2026-07-29)
|
|
427
|
+
|
|
428
|
+
The read-only ACL grant asked for on 2026-07-21 is **still pending**, and the block is
|
|
429
|
+
**record-level `EZ-1`** on every route variant tried — see
|
|
430
|
+
[live-call-status.md](live-call-status.md) for the full verification and for why the funnel no
|
|
431
|
+
longer needs it for live status. The still-open ask (DB ACL config, no deploy): a read-only grant
|
|
432
|
+
for the BDR API client on `ContactAttempts` fields `uuid` / `contactId` / `dtStarted` / `dtEnded`
|
|
433
|
+
/ `c_callSummary` / `c_callOutcome` — note `c_*` fields are granted via
|
|
434
|
+
**`AclCustomFieldPermissions`**, not `AclFieldPermissions`.
|
|
341
435
|
|
|
342
436
|
## Parity audit — `info` (original) vs `bdr` (new) (§ closed 2026-07-21)
|
|
343
437
|
|
|
@@ -374,6 +468,24 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
|
|
|
374
468
|
- **Restart the dev server after creating `.env.local`** — Next reads env files only at
|
|
375
469
|
server startup. `.env.*` is gitignored in BDR.
|
|
376
470
|
|
|
471
|
+
### Email + Vapi credentials (2026-07-29) — WHERE they live, never the values
|
|
472
|
+
|
|
473
|
+
The two new server-only credential groups BDR needs, and where the platform keeps them:
|
|
474
|
+
|
|
475
|
+
| BDR env var(s) | Source of truth in the platform |
|
|
476
|
+
|---|---|
|
|
477
|
+
| `TOGA_SMTP_HOST` / `PORT` / `USER` / `PASS`, `TOGA_EMAIL_FROM` | The 2.0 framework's SMTP settings are **hardcoded as class constants in `_underscore/Email.php`** (AWS SES, `us-west-2`, port 587, TLS; PHPMailer under the hood) — see [email-send-pipeline.md](../../_underscore/features/email-send-pipeline.md) |
|
|
478
|
+
| `TOGA_VAPI_TOKEN` | The Vapi bearer is **hardcoded as a class constant in `worker2/Worker/Ai/Bdr/Vapi.php`** — see [vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md) |
|
|
479
|
+
|
|
480
|
+
- All are declared in `.env.example`, with real values only in the gitignored `.env.local` and
|
|
481
|
+
in the **Amplify console** for the deployed site (same build-time recipe as the other vars —
|
|
482
|
+
the `env | grep -E '^(HUBSPOT_|TOGA_|NEXT_PUBLIC_|RATE_LIMIT_)'` line already covers `TOGA_*`).
|
|
483
|
+
- **`DevTeam@goagilant.com` is a known-good sender identity** in worker2 config — use it rather
|
|
484
|
+
than inventing a From address that SES has not verified.
|
|
485
|
+
- **SECURITY FLAG:** both platform credentials are **committed literals in repo source**, which
|
|
486
|
+
the team's own security rules treat as **compromised → rotate**. See the rotation-coupling
|
|
487
|
+
gotcha below.
|
|
488
|
+
|
|
377
489
|
## Deploy status (AWS Amplify — LIVE + verified 2026-07-23)
|
|
378
490
|
|
|
379
491
|
**The BDR site is LIVE and verified (2026-07-23):** branch `_dev-sandbox` (Amplify default
|
|
@@ -402,10 +514,11 @@ up — confirm the real owner before assuming.)
|
|
|
402
514
|
miss), and secrets stay in the Amplify console, never in git. The same broken `cp` pattern would
|
|
403
515
|
have bitten the `_production` launch too.
|
|
404
516
|
- **Symptom chain when the env vars are missing (what "no vars" looks like end-to-end):**
|
|
405
|
-
page-prime's HubSpot fetch throws → lead ref comes back empty → the
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
517
|
+
page-prime's HubSpot fetch throws → lead ref comes back empty → the visit behaves as
|
|
518
|
+
**anonymous even with a valid `?hsContactId`** (before 2026-07-29 this surfaced as the
|
|
519
|
+
guard notice; now it is silent) → `GET /api/contact` returns
|
|
520
|
+
`500 {"error":"Failed to fetch contact"}`. If a real entry link behaves anonymously on a
|
|
521
|
+
deployed URL, suspect missing server-side env before anything else.
|
|
409
522
|
- **OPERATIONAL — env vars saved AFTER a build starts do NOT reach that build.** They are baked in
|
|
410
523
|
at build time, so after adding/changing console vars you must **"Redeploy this version"** for
|
|
411
524
|
them to take effect — a fresh save alone does nothing for an in-flight or already-built deploy.
|
|
@@ -472,6 +585,19 @@ up — confirm the real owner before assuming.)
|
|
|
472
585
|
|
|
473
586
|
## Gotchas
|
|
474
587
|
|
|
588
|
+
- **HIGH (security + availability) — rotating the platform credentials takes BDR dark unless
|
|
589
|
+
BDR's env is updated in the SAME change.** BDR consumes two credentials that are **committed
|
|
590
|
+
literals** elsewhere in the platform: the SES SMTP settings in `_underscore/Email.php` and the
|
|
591
|
+
Vapi bearer in `worker2/Worker/Ai/Bdr/Vapi.php`. Both should be rotated on the team's own
|
|
592
|
+
security rules — but BDR reads copies of them as `TOGA_SMTP_*` / `TOGA_VAPI_TOKEN`, in
|
|
593
|
+
`.env.local` **and** the Amplify console. Rotate without updating those and BDR's email send
|
|
594
|
+
starts failing and live call status silently degrades to the simulation. Coordinate the two.
|
|
595
|
+
- **Never `JSON.parse` a worker dispatcher reply.** Every reply is prefixed
|
|
596
|
+
`[<requestId>] Successfully Executed (<n> seconds): ` — strip it first. See the fast-dial
|
|
597
|
+
section; this cost a full session of "the dial isn't firing" when it always was.
|
|
598
|
+
- **The Send button has no non-SMTP path any more.** With the mailto: fallback deleted, an
|
|
599
|
+
unprovisioned environment can only produce the *failed* state — that is an env symptom, not a
|
|
600
|
+
bug in the share flow.
|
|
475
601
|
- **CRITICAL — GA4 leaks the entry identifiers unless you scrub `page_location`.** `gtag`
|
|
476
602
|
stamps `page_location = window.location.href` (which **includes** `?hsContactId=` &
|
|
477
603
|
`?hsCampaignId=`) on the auto `page_view` **and on every subsequent event** — so the
|
|
@@ -577,9 +703,10 @@ up — confirm the real owner before assuming.)
|
|
|
577
703
|
lookup carries the email in the query. Do not reintroduce value logging.
|
|
578
704
|
- **No Tailwind** — see Stack. The mockup CSS is the styling system; adding Tailwind
|
|
579
705
|
risks pixel parity.
|
|
580
|
-
- **Deployed-site triage — "
|
|
581
|
-
|
|
582
|
-
|
|
706
|
+
- **Deployed-site triage — "a known lead is being treated as anonymous."** Since the guard
|
|
707
|
+
notice was removed (2026-07-29) there is **no longer a visible terminal symptom** for "no lead
|
|
708
|
+
reference" — the funnel just quietly behaves anonymously (submit becomes a no-op; analytics
|
|
709
|
+
marks it `simulated: true`). So probe the layer directly:
|
|
583
710
|
`GET /api/contact?hsContactId=<id>` on the deployed URL. **`500 "Failed to fetch contact"`**
|
|
584
711
|
⇒ HubSpot env missing server-side (see the Deploy status env-var bug). **`400`** ⇒ the id
|
|
585
712
|
value/format. **Contact JSON returned** ⇒ env is fine — suspect the rate-limit proxy's
|
|
@@ -589,6 +716,31 @@ up — confirm the real owner before assuming.)
|
|
|
589
716
|
harness; pull it with `npx toga-ai`.
|
|
590
717
|
|
|
591
718
|
## Change history
|
|
719
|
+
- 2026-07-29 — Four behavior changes plus a security model. (1) **Anonymous phone entry** — the
|
|
720
|
+
`hasLead` submit-block and the `phone.missingContactNotice` copy key were **removed entirely**,
|
|
721
|
+
reversing the 2026-07-16 "Missing contact reference" behavior; the backend is deliberately
|
|
722
|
+
unchanged, so an anonymous submit stays a client-side no-op and real calls still fire only for
|
|
723
|
+
campaign-linked contacts. Analytics submit events now carry `simulated: true/false`.
|
|
724
|
+
(2) **Live call status** replaces the ~5s simulation on Success, sourced from Vapi's call API —
|
|
725
|
+
new doc [live-call-status.md](live-call-status.md). (3) **FIXED fast dial's response parsing** —
|
|
726
|
+
the worker prefixes every reply `[<requestId>] Successfully Executed (<n> seconds): `, so
|
|
727
|
+
`response.json()` always threw and BDR logged "immediate dial not placed" on every **successful**
|
|
728
|
+
dial while discarding the body that carries `vapiCallId`; now stripped with a regex and returned
|
|
729
|
+
as a typed `DialResult`. (4) **Real server-side summary email** (`nodemailer`) replaces the
|
|
730
|
+
`mailto:` link — `src/lib/shareMailto.ts` **DELETED**; the body is rendered server-side through
|
|
731
|
+
the same renderer Copy uses (byte-identical by construction), the client may supply only
|
|
732
|
+
recipient/campaign/variant/token, and the send re-reads Vapi so a late `summary` still lands
|
|
733
|
+
(30-min token TTL). Consequence: **no mail-app fallback**, so SMTP provisioning is now required.
|
|
734
|
+
Recorded the **public-endpoint security model** applied after a `cso` audit returned BLOCK
|
|
735
|
+
(purpose-scoped HMAC tokens, token in the `x-status-token` header not the URL, share token
|
|
736
|
+
required for every send, Content-Type + `sec-fetch-site` checks, strict ASCII recipient
|
|
737
|
+
validation in object form, `requireTLS`/TLS1.2/timeouts, `no-store, private`, exact ACL-code
|
|
738
|
+
matching that fails closed, call id only inside token claims; accepted residual: a token holder
|
|
739
|
+
can email any address they type). Recorded **where the SMTP and Vapi credentials live**
|
|
740
|
+
(`_underscore/Email.php` and `worker2/Worker/Ai/Bdr/Vapi.php` class constants; BDR reads copies
|
|
741
|
+
as `TOGA_SMTP_*` / `TOGA_VAPI_TOKEN`) with the rotation-coupling gotcha. Re-verified
|
|
742
|
+
`ContactAttempts` is still `EZ-1` blocked. Suite **306/306 across 20 files**; tsc + lint clean.
|
|
743
|
+
All work uncommitted on branch `TRUE-80435` at capture. (tcox)
|
|
592
744
|
- 2026-07-23 — **BDR site is LIVE + verified** (day 3 of go-live). RESOLVED the deployed 500
|
|
593
745
|
"Failed to fetch contact" (missing server env at runtime): confirmed the full root cause with git
|
|
594
746
|
evidence — the inherited `amplify.yml` (from the old `info` repo, which COMMITS its real `.env`
|
|
@@ -6,7 +6,7 @@ project: AI-BDR
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: workflow
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-29
|
|
10
10
|
owners: [tcox]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql
|
|
@@ -16,6 +16,7 @@ files:
|
|
|
16
16
|
related:
|
|
17
17
|
- new-campaign-onboarding.md
|
|
18
18
|
- ../architecture.md
|
|
19
|
+
- ../features/live-call-status.md
|
|
19
20
|
- ../../worker2/features/vapi-webhook-handler.md
|
|
20
21
|
---
|
|
21
22
|
|
|
@@ -109,6 +110,38 @@ Reference implementation: `dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCam
|
|
|
109
110
|
(idempotent `NOT EXISTS` guards keyed to the real UNIQUE constraints; passed `sql-reviewer`). This
|
|
110
111
|
proves the loop with **zero prospect exposure** instead of mass-writing flags on real contacts.
|
|
111
112
|
|
|
113
|
+
## "No calls are happening" — check the campaign's `dateEnd` FIRST (recurring fuse)
|
|
114
|
+
|
|
115
|
+
The recurring "the AI-BDR loop stopped working" symptom traces, every time so far, to campaign
|
|
116
|
+
**2 — `26.05 - AI BDR - Ryan Nitti`** (uuid `15d0c02f-501e-e081-2ead-61b78c854223`) having a
|
|
117
|
+
**lapsed `dateEnd`**. It was set to **2026-07-28** during the 2026-07-21 go-live test and simply
|
|
118
|
+
expired, which **silently stops all dialing** (`validateCampaignReady` rejects the campaign every
|
|
119
|
+
run with "Campaign has ended" — no funnel-side symptom at all; the submit still returns 200).
|
|
120
|
+
|
|
121
|
+
**Treat `dateEnd` as a fuse to extend, not a bug to re-debug.** Re-activation triage order:
|
|
122
|
+
|
|
123
|
+
1. **Wedge-check the tester FIRST.** Look for an open `ContactAttempts` row on the tester
|
|
124
|
+
(`dtEnded IS NULL`) — an open attempt excludes the contact from dialing **forever**. Unwedge:
|
|
125
|
+
`UPDATE ContactAttempts SET dtEnded = NOW() WHERE contactId = ? AND dtEnded IS NULL`.
|
|
126
|
+
2. Then follow **Approach A order exactly**: flip every campaign-2 contact **except the tester
|
|
127
|
+
(contact 248)** to `isOkayToCall = 0` **first**, *then* extend `dateEnd`, *then* have the
|
|
128
|
+
tester re-arm through the funnel's Call Now.
|
|
129
|
+
3. **The 5 pre-existing genuine DNC contact ids — 120, 132, 134, 146, 155 — must never be
|
|
130
|
+
restored.** (Same non-negotiable as Rule 2 step 1.)
|
|
131
|
+
|
|
132
|
+
## Environment constraint — you cannot reach the client DB from a dev laptop
|
|
133
|
+
|
|
134
|
+
The production client database is **not reachable without VPN**: TCP 3306 to the
|
|
135
|
+
`*.client.database.togahub.com` host times out from a stock dev machine, and that machine may
|
|
136
|
+
also have **no `mysql` client, no PHP, and no Python**. Consequences for anyone (or any agent)
|
|
137
|
+
running this runbook:
|
|
138
|
+
|
|
139
|
+
- **Do not assume direct DB access.** Either the developer runs the SQL in their own DB session,
|
|
140
|
+
or the change goes through **api2**.
|
|
141
|
+
- **Hand over ready-to-run SQL** — pre-flight `SELECT`s first, then the writes, with explicit
|
|
142
|
+
**stop conditions** ("if this returns more than 1 row, stop"). A blind `UPDATE` handed to
|
|
143
|
+
someone else to paste is how a DNC contact gets re-enabled.
|
|
144
|
+
|
|
112
145
|
## Rule 3 — NEVER dry-run against production
|
|
113
146
|
|
|
114
147
|
`dryRun` in the dialer is **not** write-free: `createContactAttempt` runs **before** the dryRun
|
|
@@ -148,6 +181,15 @@ Sequence the cleanup into a later, separately-executed window. (See dbchanges2 a
|
|
|
148
181
|
`YYYY-MM-DD<letter>` ordering contract.)
|
|
149
182
|
|
|
150
183
|
## Change history
|
|
184
|
+
- 2026-07-29 — Added the **"no calls are happening" triage section**: campaign 2's `dateEnd`
|
|
185
|
+
(set to 2026-07-28 during the go-live test) is a **recurring fuse** whose lapse silently stops
|
|
186
|
+
all dialing with no funnel-side symptom — extend it rather than re-debugging, after
|
|
187
|
+
wedge-checking the tester for an open `ContactAttempts` row (`dtEnded IS NULL`) and following
|
|
188
|
+
the Approach A order (zero everyone but contact 248 → extend `dateEnd` → re-arm via Call Now;
|
|
189
|
+
never restore DNC ids 120/132/134/146/155). Also recorded the **environment constraint**: the
|
|
190
|
+
production client DB is unreachable from a dev laptop without VPN (3306 times out) and a stock
|
|
191
|
+
machine may have no mysql/PHP/Python, so DB changes must be run by the developer or go through
|
|
192
|
+
api2 — hand over ready-to-run SQL with pre-flight reads and stop conditions. (tcox)
|
|
151
193
|
- 2026-07-21 — Added **Approach A** (now preferred for go-live), used to prove campaign 2
|
|
152
194
|
(`26.05 - AI BDR - Ryan Nitti`) live end-to-end: reuse an existing campaign by recording its
|
|
153
195
|
pre-existing DNC set first (campaign 2 = contact ids 120/132/134/146/155, which the restore must
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
| [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql |
|
|
19
19
|
| [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
|
|
20
20
|
| [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, _underscore/Model/Client/TrackingNumber.php |
|
|
21
|
+
| [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
|
|
21
22
|
| [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
|
|
22
23
|
| [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
|
|
23
24
|
| [New Environment Configuration & Provisioning (api2)](workflows/environment-configuration-and-provisioning.md) | What it takes for a 2.0 API environment (e.g. | api2/Config/<environment>.ini, api2/Controller/Index.php, dbchanges2/Core/2026-06-16a - DatabaseHosts for new QA QC stage demo environments.sql, dbchanges2/Logs/, _underscore/Route.php |
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-29
|
|
10
10
|
owners: [mhammontree, tcox, jcardinal]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -14,6 +14,7 @@ files:
|
|
|
14
14
|
related:
|
|
15
15
|
- ../../_underscore/features/acl-permission-chain.md
|
|
16
16
|
- scripted-api-post-body-args.md
|
|
17
|
+
- v2-rest-query-contract.md
|
|
17
18
|
- ../architecture.md
|
|
18
19
|
---
|
|
19
20
|
|
|
@@ -39,6 +40,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
39
40
|
| **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
|
|
40
41
|
| **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
|
|
41
42
|
| **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
|
|
43
|
+
| **EV-13** | Validation — page size too large | `recordsPerPage` exceeds the **10000** max | Lower `recordsPerPage` and paginate with `page` |
|
|
42
44
|
| **EV-12** | Record's parent not synced | (Fulfill & Ship) POST `/item-fulfillments` when the Sales Order isn't synced into Toga yet | `GET /sales-orders/syncNetsuiteSalesOrder?netsuiteInternalSalesOrderId=<id>` first |
|
|
43
45
|
|
|
44
46
|
## How to diagnose
|
|
@@ -64,6 +66,23 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
64
66
|
**before** the interceptor/logging layer, and initialize loop-locals so no branch leaves a
|
|
65
67
|
variable undefined.
|
|
66
68
|
|
|
69
|
+
6. **What a denial looks like from the CLIENT side (EZ-1 vs EZ-2, and the silent case).**
|
|
70
|
+
Severity differs by request shape, and one shape hides the denial entirely:
|
|
71
|
+
- **EZ-1 is a record-level block** — `data.<route>` comes back **`null`**; nothing is usable.
|
|
72
|
+
Note a bare `allowRead = 1` grant row is **not** sufficient: without a logic group behind it
|
|
73
|
+
the route still returns `EZ-1`.
|
|
74
|
+
- **EZ-2 is field-level** — on a **LIST** it is *degraded but usable* (the permitted fields
|
|
75
|
+
still populate alongside the 403), but on a **by-uuid READ** it is **fatal**.
|
|
76
|
+
- **Omit `fields=` and a denied field is SILENTLY omitted** from the JSON: HTTP 200, no
|
|
77
|
+
warning, undetectable from the envelope. Name the field in `fields=` and you instead get
|
|
78
|
+
`EZ-2` with a machine-readable **`identifiers.fields`** list. **Always send an explicit
|
|
79
|
+
`fields=` list** so denials are visible.
|
|
80
|
+
- **`meta.prohibitedRecordCount`** (populated only when `totalRecordCount` is 0) tells you
|
|
81
|
+
"ACL hid every row" vs. "there genuinely are none."
|
|
82
|
+
- **Match these codes EXACTLY when branching on them in a client.** A substring match
|
|
83
|
+
classifies a future `EZ-25` as `EZ-2` — i.e. it would read data out of a 403. Fail **closed**
|
|
84
|
+
on an unrecognized 403 code.
|
|
85
|
+
|
|
67
86
|
4. **EO-1 ("no field called 'X' in the model") is NOT a DB problem.** EV-8 means the Core
|
|
68
87
|
`RecordFields` registration is missing; EO-1 means the DB column and RecordFields are both
|
|
69
88
|
present but the **generated PHP model class** lacks the field. Before touching migrations,
|
|
@@ -77,10 +96,22 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
|
|
|
77
96
|
- **Add a writable field to a V2 record (3-file migration)** and **expose a scripted API**
|
|
78
97
|
(RecordScript + AclRecordScripts): see
|
|
79
98
|
[ACL Permission Chain](../../_underscore/features/acl-permission-chain.md).
|
|
99
|
+
- **Query-side contract an HTTP client must obey** (recognized params, `where` grammar,
|
|
100
|
+
encoding, pagination, and the unrecognized-param LIST→READ flip that 404s a valid route):
|
|
101
|
+
see [V2 REST query contract](v2-rest-query-contract.md).
|
|
80
102
|
- The response-envelope shape and code families: see [api2 architecture](../architecture.md).
|
|
81
103
|
|
|
82
104
|
## Change history
|
|
83
105
|
|
|
106
|
+
- 2026-07-29 — Added **EV-13** (`recordsPerPage` over the 10000 max) and **diagnosis note 6**:
|
|
107
|
+
what a denial looks like from the client side — `EZ-1` = record-level (`data.<route>` null,
|
|
108
|
+
and a bare `allowRead=1` with no logic group still returns it); `EZ-2` = field-level,
|
|
109
|
+
degraded-but-usable on a LIST and fatal on a by-uuid READ; **omitting `fields=` makes a denial
|
|
110
|
+
SILENT** (200 with the field quietly absent) while naming it yields `EZ-2` +
|
|
111
|
+
`identifiers.fields`; `meta.prohibitedRecordCount` distinguishes ACL-hidden from genuinely
|
|
112
|
+
none; and **match codes exactly / fail closed** (a substring match reads `EZ-25` as `EZ-2` and
|
|
113
|
+
would parse data out of a 403). Surfaced re-verifying the BDR API client's ContactAttempts
|
|
114
|
+
block. Linked the new [V2 REST query contract](v2-rest-query-contract.md). (tcox)
|
|
84
115
|
- 2026-07-27 — TRUE-79533: noted the **`id`-field** case of `EZ-2` — fetching a record by its numeric
|
|
85
116
|
`id` (`GET /v2/addresses?fields=id,...`) 403s `EZ-2` when `id` has no `AclFieldPermissions` read
|
|
86
117
|
grant; the `id` field is ACL-gated like any field. Found fixing Rate's service-card address fetch
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: V2 REST query contract (params, where grammar, encoding, ACL behavior)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: [tcox]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- v2-api-error-codes.md
|
|
16
|
+
- tableview-apiwhereclause-row-filtering.md
|
|
17
|
+
- nested-fk-acl-embedding.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are
|
|
23
|
+
recognized, the exact `where` grammar, how the query string is (not) decoded, how pagination
|
|
24
|
+
works, and — the part that costs the most time — how ACL denials actually manifest per request
|
|
25
|
+
shape. Source-verified against `api2/Component/Api/V2/V2.php`. The tableview-side view of the
|
|
26
|
+
same `where` parser is in
|
|
27
|
+
[tableview-apiwhereclause-row-filtering.md](tableview-apiwhereclause-row-filtering.md); the
|
|
28
|
+
code-by-code troubleshooting map is in [v2-api-error-codes.md](v2-api-error-codes.md).
|
|
29
|
+
|
|
30
|
+
## Routes are DATA, not code
|
|
31
|
+
|
|
32
|
+
Routes are rows in `Core.Records`, keyed by the **exact kebab route string**, lowercased before
|
|
33
|
+
lookup. The envelope key is the kebab route converted to camel:
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
GET /v2/contact-attempts → data.contactAttempts
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
So "the endpoint doesn't exist" is usually a missing/misnamed `Core.Records` row, not a missing
|
|
40
|
+
controller.
|
|
41
|
+
|
|
42
|
+
## Recognized query params
|
|
43
|
+
|
|
44
|
+
`fields`, `where`, `join` / `ojoin`, `sort`, `group`, `distinct`, `page`, `recordsPerPage`,
|
|
45
|
+
`depth`, `calcDepth` (or one base64(JSON) `_` blob).
|
|
46
|
+
|
|
47
|
+
- **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** Defaults:
|
|
48
|
+
`recordsPerPage` **25**, `depth` **3**. Max `recordsPerPage` **10000** — exceeding it returns
|
|
49
|
+
**`EV-13`** (HTTP 400).
|
|
50
|
+
- **Sort:** `-field` = DESC, bare `field` = ASC. **Do not send `+`** for ascending.
|
|
51
|
+
|
|
52
|
+
### CRITICAL — an unrecognized param silently flips LIST → READ
|
|
53
|
+
|
|
54
|
+
Any query param the engine does not recognize switches the request onto a **different code
|
|
55
|
+
path** — the by-uuid READ path, which expects a uuid. The symptom is a confusing **HTTP 404**
|
|
56
|
+
on a route that plainly exists. This is the likely cause of the previously recorded
|
|
57
|
+
`with=...` → 404. **Send only recognized params — no cache-busters, no ad-hoc flags.**
|
|
58
|
+
|
|
59
|
+
## `where` grammar
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- Conditions are comma-separated; **parentheses are REQUIRED to switch between AND and OR**.
|
|
66
|
+
- Operators: `eq`, `ne`, `gt`, **`ge`**, `lt`, **`le`**, `not`, `like`, `contains`, `starts`,
|
|
67
|
+
`ends`, `excludes`, `in`, `notin`, `between`. **It is `ge`/`le`, not `gte`/`lte`.**
|
|
68
|
+
- `in` / `notin` / `between` take **colon-separated** values.
|
|
69
|
+
- **NULL has no operator.** Use the literal value `null`: `field:eq:null` → `IS NULL`,
|
|
70
|
+
`field:ne:null` → `IS NOT NULL`.
|
|
71
|
+
|
|
72
|
+
## Encoding rules (the query string is never urldecoded at parse time)
|
|
73
|
+
|
|
74
|
+
- Encode a literal `%` in a LIKE pattern as **`%25`**.
|
|
75
|
+
- Encode the space in a datetime as **`%20`**.
|
|
76
|
+
- Use **MySQL datetime** `YYYY-MM-DD HH:MM:SS` — **not** ISO-8601 with a `T`.
|
|
77
|
+
- Values must not contain **raw commas or parentheses** (they are grammar).
|
|
78
|
+
|
|
79
|
+
## ACL behavior (what a denial actually looks like)
|
|
80
|
+
|
|
81
|
+
- **`where` and `join` are NOT ACL-checked — only `fields` is.** You can therefore **filter** on
|
|
82
|
+
`Contacts.uuid` with no read grant on `Contacts`, as long as you never **select** a
|
|
83
|
+
`Contacts.*` field. Useful when you have a child grant but not the parent's fields.
|
|
84
|
+
- **Referencing a table in `where` that you did not `join`** produces invalid SQL, which is
|
|
85
|
+
uncaught → **`EO-1` 500**.
|
|
86
|
+
- **Nested route `/contacts/{uuid}/contact-attempts`** auto-filters by the child's FK
|
|
87
|
+
(discovered by **model class**, not field name) and requires **record-level READ on the
|
|
88
|
+
parent** but **no field grant** on it.
|
|
89
|
+
- **Field-denial behavior differs by request shape:**
|
|
90
|
+
- **Omit `fields=`** → denied fields are **silently omitted** from the JSON. HTTP 200, no
|
|
91
|
+
warning, and the omission is **undetectable from the envelope**.
|
|
92
|
+
- **Name them in `fields=`** → **`EZ-2` 403** with a machine-readable `identifiers.fields`
|
|
93
|
+
list, **and the permitted data still populated**.
|
|
94
|
+
- **Recommendation: always send an explicit `fields=` list.** Silent omission is how a
|
|
95
|
+
consumer ends up "working" against data it never actually received.
|
|
96
|
+
- **`EZ-1` vs `EZ-2` for a consumer:** `EZ-1` = record-level block (`data.<route>` is `null`) —
|
|
97
|
+
hard blocked, nothing usable. `EZ-2` = field-level — on a **LIST** it is
|
|
98
|
+
*degraded-but-usable*; on a **by-uuid READ** it is **fatal**.
|
|
99
|
+
- **A bare `allowRead = 1` grant row is not enough** — it needs a logic group behind it or the
|
|
100
|
+
route still returns `EZ-1`.
|
|
101
|
+
- **`meta.prohibitedRecordCount`** (set only when `totalRecordCount` is 0) distinguishes
|
|
102
|
+
"ACL hid every row" from "there genuinely are none."
|
|
103
|
+
|
|
104
|
+
## Serialization quirks
|
|
105
|
+
|
|
106
|
+
- **Datetimes on a primary table** serialize as ISO-8601 with a **Central offset** (no
|
|
107
|
+
conversion is applied — api2 runs `America/Chicago`).
|
|
108
|
+
- **Datetimes pulled through a `join`** come back as **raw, offset-less** strings. A client that
|
|
109
|
+
parses both paths with one function will be wrong on one of them.
|
|
110
|
+
- **Custom `c_` fields are returned FLAT** alongside standard fields (sorted last, `c_` prefix
|
|
111
|
+
retained) and are filterable like normal columns — but they are granted through a **separate**
|
|
112
|
+
table, **`AclCustomFieldPermissions`**, not `AclFieldPermissions`. State that explicitly when
|
|
113
|
+
requesting a grant, or the grant gets written to the wrong table and changes nothing.
|
|
114
|
+
|
|
115
|
+
## Gotchas / known issues
|
|
116
|
+
|
|
117
|
+
- **A 404 on a route you know exists ⇒ suspect an unrecognized query param** (LIST → READ flip)
|
|
118
|
+
before suspecting the route.
|
|
119
|
+
- **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
|
|
120
|
+
- **`gte`/`lte` silently are not operators** — use `ge`/`le`.
|
|
121
|
+
- **Do not trust a 200 with missing fields.** Without an explicit `fields=` list, ACL removal is
|
|
122
|
+
invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
|
|
123
|
+
|
|
124
|
+
## Change history
|
|
125
|
+
- 2026-07-29 — Created from a source read of `api2/Component/Api/V2/V2.php` while building the
|
|
126
|
+
BDR funnel's call-status reads. Recorded: routes-as-data (`Core.Records`, kebab → camel
|
|
127
|
+
envelope key); the recognized-param list and the **unrecognized-param LIST→READ flip** that
|
|
128
|
+
surfaces as a bogus 404 (explains the old `with=...` → 404); the full `where` grammar
|
|
129
|
+
(parens required to switch AND/OR, `ge`/`le` not `gte`/`lte`, `null` as a literal value);
|
|
130
|
+
`page`/`recordsPerPage` only (max 10000 → `EV-13`) and `-`/bare sort prefixes; the
|
|
131
|
+
no-urldecode encoding rules (`%25`, `%20`, MySQL datetime, no raw commas/parens); the ACL
|
|
132
|
+
subtleties (`where`/`join` unchecked but `fields` checked; unjoined table in `where` →
|
|
133
|
+
`EO-1` 500; nested-route parent record grant with no field grant; **silent field omission
|
|
134
|
+
without `fields=` vs. `EZ-2` + `identifiers.fields` with it**; `EZ-1` hard vs `EZ-2`
|
|
135
|
+
degraded-on-LIST/fatal-on-READ; bare `allowRead=1` still `EZ-1`;
|
|
136
|
+
`meta.prohibitedRecordCount`); and the serialization quirks (Central offset on primary-table
|
|
137
|
+
datetimes vs. raw offset-less through a join; flat `c_` fields granted via
|
|
138
|
+
`AclCustomFieldPermissions`). (tcox)
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
| [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | > **DB-DRIVEN AI-MODEL ROUTING (2026-07-29).** Which knowledge base a transcript is cleaned > into is now decided by the **meeting organizer's "home" AI model** | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Client_True/2026-07-27a - TranscriptAiModelRoutingColumns.sql, dbchanges2/Client_True/2026-07-27b - TranscriptAiModelRoutingData.sql, dbchanges2/Team/2026-07-27a - TranscriptVocabularyAiModelScope.sql, dbchanges2/Team/2026-07-29a - TranscriptVocabularyBackfillAllModels.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a, dbchanges2/Team/2026-07-28a - TranscriptProcessingRetryAttempts.sql, dbchanges2/Team/2026-07-28b - TranscriptPromptTemplateConverseModel.sql, dbchanges2/Core/2026-07-28a - TeamsTranscriptRetryCron.sql |
|
|
33
33
|
| [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php, _underscore/Model/Team/Sprint.php, dbchanges2/Core/CronJobs (SprintLockScheduled seed) |
|
|
34
34
|
| [Teams Meeting Transcript Export](features/teams-transcript-export.md) | > **SUPERSEDED (2026-07-09) — the S3-staging model below is history.** `Export` is now a thin > **GRAPH-DIRECT** cron poller: it no longer archives raw VTT to ` | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
|
|
35
|
-
| [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php |
|
|
35
|
+
| [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
|
|
36
36
|
| [WJE Freshservice Sync (worker2)](features/wje-freshservice-sync.md) | WJE ("WJE IT", helpdesk `wje.freshservice.com`) is a **Freshservice**-based help-desk client whose tickets, contacts, assets, groups, categories, and canned res | worker2/Worker/Wje.php, _underscore/Component/Api/Wje/Wje.php, _underscore/Model/Wje/Ticket.php, _underscore/Model/Wje/TicketNote.php, _underscore/Model/Wje/Contact.php, _underscore/Model/Wje/Unit.php, _underscore/Model/Wje/TicketTeam.php, _underscore/Model/Wje/TicketCategory.php, _underscore/Model/Wje/AssetType.php, _underscore/Model/Wje/PredefinedReply.php, library/app/api/wje.php, worker/crons/toga2/wje/import_supporting_records.php, worker/crons/toga2/wje/sync_togasupply_wje.php, worker/crons/notifications/reports/wje/wje_common.php, library/app/systemmonitor/wje.php, dbchanges2/Client_Wje/2024-10-04 - WjeOnboarding.sql |
|
|
37
37
|
| [PHP Runtime Upgrade on Elastic Beanstalk (worker2 8.3 → 8.5 + PhpSpreadsheet 1.x → 3.x)](workflows/php-runtime-upgrade-dependency-audit.md) | The procedure used to move worker2 from **PHP 8.3 to PHP 8.5** on Elastic Beanstalk, and the dependency work that had to land first. | worker2/composer.json, worker2/composer.lock, worker2/Worker/Team/Sprint.php, worker2/Worker/Client/TowFoundation/ProcessReceipts.php, worker2/Worker/Forecast/Import.php |
|
|
38
38
|
| [Ticket → ClickUp Pseudocode Planning (Talos-grounded)](workflows/ticket-to-pseudocode-planning.md) | A repeatable procedure for turning a ClickUp ticket into a reviewed, formatted implementation plan posted back to the ticket's `📝 Pseudocode` custom field. | test/@dave/clickup_md2delta.js |
|
|
@@ -6,8 +6,8 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
10
|
-
owners: [jcardinal, dfranks, mhammontree]
|
|
9
|
+
updated: 2026-07-29
|
|
10
|
+
owners: [jcardinal, dfranks, mhammontree, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/
|
|
13
13
|
- worker2/Controller/Index.php
|
|
@@ -147,6 +147,25 @@ curl -X POST https://worker.togahub.com/ \
|
|
|
147
147
|
|
|
148
148
|
Use realistic placeholder values, not empty strings/nulls.
|
|
149
149
|
|
|
150
|
+
### The HTTP reply is PREFIXED — never `JSON.parse` it raw
|
|
151
|
+
|
|
152
|
+
The dispatcher returns your action's return value **wrapped in the same composed success
|
|
153
|
+
string it writes to `WorkerJobs.output`**, over HTTP too:
|
|
154
|
+
|
|
155
|
+
```
|
|
156
|
+
[<requestId>] Successfully Executed (<n> seconds): <your json>
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
So any HTTP consumer that calls `response.json()` (or `json_decode()`) on the raw body
|
|
160
|
+
**always** fails, even on a completely successful run — and typically logs a misleading
|
|
161
|
+
"the job didn't run" while silently discarding a useful response body. **Strip the prefix
|
|
162
|
+
before parsing** (a regex up to the first `: ` after the `Successfully Executed (…)` segment).
|
|
163
|
+
|
|
164
|
+
This bit BDR's fast-dial caller (`bdr/src/server/workerDialer.ts`): it logged "immediate dial
|
|
165
|
+
not placed" on **every successful dial** and threw away `InitiateOutboundCall`'s response —
|
|
166
|
+
which is where the Vapi call id lives. If you write an action whose return value matters to
|
|
167
|
+
an HTTP caller, say so in its doc, because the caller has to unwrap it.
|
|
168
|
+
|
|
150
169
|
## How exceptions & retries work
|
|
151
170
|
|
|
152
171
|
The EB worker dispatcher (`Controller/Index.php::worker()`) wraps your action call in
|
|
@@ -207,6 +226,12 @@ be reattempted.
|
|
|
207
226
|
commit-before-SQS transaction pattern that the worker relies on.
|
|
208
227
|
|
|
209
228
|
## Change history
|
|
229
|
+
- 2026-07-29 — Documented that the dispatcher's **HTTP reply carries the same
|
|
230
|
+
`[<requestId>] Successfully Executed (<n> seconds): <json>` prefix** it writes to
|
|
231
|
+
`WorkerJobs.output`, so a direct-POST consumer must strip it before parsing — calling
|
|
232
|
+
`response.json()` raw always throws on a successful run and silently discards the body.
|
|
233
|
+
Surfaced fixing BDR's fast-dial caller, which logged "immediate dial not placed" on every
|
|
234
|
+
successful dial and lost the Vapi call id. (tcox)
|
|
210
235
|
- 2026-07-28 — Added the **`_ApiRequest` logging / `DB_CLIENT_LOGS` gotcha**: api-log rows are
|
|
211
236
|
written via `_Model_Client_Logs_Api` (`DATABASE = DB_CLIENT_LOGS`), so a worker must either
|
|
212
237
|
register the Logs DB under that alias in `initialize()` or pass `setLogging(false)`
|
|
@@ -6,16 +6,19 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-07-
|
|
9
|
+
updated: 2026-07-29
|
|
10
10
|
owners: [snaredla, tcox]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Vapi.php
|
|
13
13
|
- worker2/Worker/Ai/Bdr/Vapi.php
|
|
14
|
+
- worker2/Controller/Index.php
|
|
14
15
|
related:
|
|
15
16
|
- ../../ai-bdr/features/call-orchestration.md
|
|
16
17
|
- ../../ai-bdr/features/vapi-integration.md
|
|
18
|
+
- ../../ai-bdr/features/live-call-status.md
|
|
17
19
|
- ../../ai-bdr/workflows/safe-call-loop-testing.md
|
|
18
20
|
- ../../api2/features/nested-relationship-writes.md
|
|
21
|
+
- creating-worker-actions.md
|
|
19
22
|
---
|
|
20
23
|
|
|
21
24
|
## Summary
|
|
@@ -154,6 +157,24 @@ truth for **"why isn't my contact being called."** Confirmed against source
|
|
|
154
157
|
`initiateCallForContact(…, CALL_TYPE__IMMEDIATE)`. It errors **"not assigned to any active
|
|
155
158
|
campaign"** if no ACTIVE/PENDING campaign-contact link exists.
|
|
156
159
|
|
|
160
|
+
**`InitiateOutboundCall` return shape (a real contract — BDR's fast dial depends on it).** On
|
|
161
|
+
success it returns:
|
|
162
|
+
|
|
163
|
+
```json
|
|
164
|
+
{"success": true, "contactId": 0, "contactUuid": "", "campaignId": 0, "campaignUuid": "",
|
|
165
|
+
"phoneNumber": "", "attemptNumber": 1, "attemptId": 0, "vapiCallId": "", "remainingSlots": 0}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
- **`vapiCallId` may be the literal string `'unknown'`** — a caller using it as a handle must
|
|
169
|
+
treat that as "no handle," not as an id.
|
|
170
|
+
- **An HTTP caller must strip the dispatcher's reply prefix first.** The direct-POST endpoint
|
|
171
|
+
wraps every reply as `[<requestId>] Successfully Executed (<n> seconds): <json>`
|
|
172
|
+
(`Controller/Index.php` ~L498, action at `Worker/Ai/Bdr/Vapi.php` ~L503), so
|
|
173
|
+
`response.json()` on the raw body always throws — see
|
|
174
|
+
[creating-worker-actions.md](creating-worker-actions.md). BDR consumes `vapiCallId` from this
|
|
175
|
+
body to drive its live call-status UI
|
|
176
|
+
([live-call-status.md](../../ai-bdr/features/live-call-status.md)).
|
|
177
|
+
|
|
157
178
|
### Finer detail (source-verified 2026-07-20)
|
|
158
179
|
|
|
159
180
|
Where each gate actually lives in `Worker/Ai/Bdr/Vapi.php`, and where the eligibility summary
|
|
@@ -243,6 +264,10 @@ the home for this credential. (Token value is deliberately not recorded here —
|
|
|
243
264
|
**HubSpot access token** and **Toga API client secret** that are committed to git in the old `info`
|
|
244
265
|
repo's tracked `.env.*` files — see the security gotcha in
|
|
245
266
|
[ai-bdr web-funnel-app.md](../../ai-bdr/features/web-funnel-app.md).
|
|
267
|
+
**Rotation now has a second consumer (2026-07-29):** BDR reads the **same** Vapi bearer as the
|
|
268
|
+
server-only env var `TOGA_VAPI_TOKEN` to drive its live call-status UI. Rotating this token
|
|
269
|
+
without updating BDR's `.env.local` **and** the Amplify console vars takes BDR's live status
|
|
270
|
+
dark (it silently degrades to the scripted simulation) — coordinate the two changes.
|
|
246
271
|
|
|
247
272
|
## Data model
|
|
248
273
|
|
|
@@ -287,6 +312,15 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
|
|
|
287
312
|
attempt 183 / contact 248; fix both the Vapi schema and `convertDateTime`); (2) SECURITY — the Vapi
|
|
288
313
|
API bearer token is hardcoded as a `const` in `Worker/Ai/Bdr/Vapi.php` line 6 (committed to git) —
|
|
289
314
|
rotate it and move it to the `[vapi]` config section. (tcox)
|
|
315
|
+
- 2026-07-29 — Recorded **`InitiateOutboundCall`'s success return shape** (`success`,
|
|
316
|
+
`contactId`, `contactUuid`, `campaignId`, `campaignUuid`, `phoneNumber`, `attemptNumber`,
|
|
317
|
+
`attemptId`, `vapiCallId`, `remainingSlots`) — now a real contract because BDR's fast dial
|
|
318
|
+
reads `vapiCallId` from it to drive live call status. Two caveats: `vapiCallId` can be the
|
|
319
|
+
literal `'unknown'`, and an HTTP caller must **strip the dispatcher's
|
|
320
|
+
`[<requestId>] Successfully Executed (…): ` prefix** before parsing (BDR was calling
|
|
321
|
+
`response.json()` raw and logging a false "dial not placed" on every success). Also noted that
|
|
322
|
+
rotating the hardcoded Vapi bearer now requires updating BDR's env + Amplify vars in the same
|
|
323
|
+
change or BDR's live status goes dark. (tcox)
|
|
290
324
|
- 2026-07-20 — Source-verified corrections/extensions to the dialer eligibility rules: the campaign
|
|
291
325
|
gate is split (`getActiveCampaignsForDialing` L177–193 = isActive+assistant, NO date; date range +
|
|
292
326
|
non-empty `assistantIdentifier` in `validateCampaignReady` L759–789); `Campaigns_Contacts.status
|
package/knowledge/INDEX.md
CHANGED
|
@@ -20,7 +20,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
20
20
|
|
|
21
21
|
- **_underscore** (_Underscore) _(framework core)_ — 40 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
22
|
- **worker2** (Worker) — 35 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
|
-
- **api2** (API) —
|
|
23
|
+
- **api2** (API) — 20 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
24
|
- **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
25
25
|
- **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
26
26
|
- **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
|
@@ -28,7 +28,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
28
28
|
- **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
|
|
29
29
|
- **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
|
|
30
30
|
- **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
|
|
31
|
-
- **ai-bdr** (AI-BDR) —
|
|
31
|
+
- **ai-bdr** (AI-BDR) — 9 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
|
|
32
32
|
- **toga2-commerce** (TOGa Commerce) — 11 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
|
|
33
33
|
- **toga25-supply** (TOGa 2.5 Supply) — 11 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
|
|
34
34
|
- **toga-blox** (TOGa Blox) — 8 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
|
package/knowledge/registry.json
CHANGED
package/package.json
CHANGED