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.
@@ -6,13 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
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/shareMailto.ts, 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/test/shareMailto.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
+ | [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-23
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/lib/shareMailto.ts
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
- - **Vitest** test suite: **131 tests, all green** (2026-07-21 added `togaEnrichment.test.ts`,
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 is scripted theater (~5s) while the
310
- cron could take 60s; fast dial makes the theater roughly honest.
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-visit guard (no lead reference disabled submit)
316
-
317
- With **no** lead reference (the page was opened without `?hsContactId=`), both submit screens now
318
- **disable the button** and show a content-driven notice ("We couldn't find your contact reference…
319
- open this page from the link in your email"), replacing the previous silent no-op that faked success
320
- (`src/flow/screens/CallNow.tsx`, `src/flow/screens/Schedule.tsx`, `src/content/schema.ts`,
321
- `src/content/default.ts`). This matches info's "Missing contact reference" behavior. Production links
322
- come from HubSpot personalization tokens —
323
- `?campaign=slug&hsContactId={{contact.hs_object_id}}&hsCampaignId=uuid` or, when the campaign
324
- bundle pins `togaCampaignUuid`, links need only the slug + contact token.
325
-
326
- ## Share button is a real mailto: (no server-send path)
327
-
328
- The CallSummary "Send" button now builds a **`mailto:` URL** (recipient, subject, the displayed
329
- summary as body) and opens the visitor's own mail client a real share with **no server-side send
330
- path**, which deliberately avoids the spam-relay surface of server-sending client-supplied text
331
- (`src/lib/shareMailto.ts` NEW, `src/flow/screens/CallSummary.tsx`, `src/content/default.ts`; test
332
- `test/shareMailto.test.ts`). Confirmation copy is honest ("Opening your email app").
333
-
334
- - **LIMITATION:** the summary **content** is still the **simulated rotation** — the real
335
- `c_callSummary` can't reach the frontend because the BDR API client gets **403 on
336
- `ContactAttempts`**.
337
- - **Pending backend ask (DB ACL config, no deploy):** a read-only grant for the BDR API client on
338
- `ContactAttempts` fields `uuid` / `contactId` / `dtStarted` / `dtEnded` / `c_callSummary` /
339
- `c_callOutcome`. **One grant unlocks three features:** the real summary in the card, the real
340
- summary in Copy/Share, and a live call-status indicator.
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 **anonymous-visit guard
406
- notice shows even with a valid `?hsContactId`** `GET /api/contact` returns
407
- `500 {"error":"Failed to fetch contact"}`. If you see the guard notice on a deployed URL that
408
- has a real entry link, suspect missing server-side env before anything else.
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 — "guard notice shows despite a valid entry link."** The
581
- anonymous-visit guard notice is always the **terminal** symptom of "no lead reference,"
582
- regardless of the underlying cause, so probe the layer directly:
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-21
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-24
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-28
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-23
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
@@ -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) — 19 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
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) — 8 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
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)
@@ -224,7 +224,8 @@
224
224
  "framework": "2.0",
225
225
  "role": "app",
226
226
  "dependsOn": [
227
- "api2"
227
+ "api2",
228
+ "worker2"
228
229
  ]
229
230
  }
230
231
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.476",
3
+ "version": "1.0.477",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",