toga-ai 1.0.382 → 1.0.384

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,6 +6,7 @@
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
8
  | [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/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/app/api, bdr/test, bdr/public, bdr/mockup/styles.css |
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/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
10
  | [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
11
  | [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
+ | [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/Config/beta.ini |
@@ -6,7 +6,7 @@ project: AI-BDR
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-16
9
+ updated: 2026-07-20
10
10
  owners: [tcox]
11
11
  files:
12
12
  - bdr/PLAN.md
@@ -31,14 +31,19 @@ files:
31
31
  - bdr/src/components
32
32
  - bdr/src/flow
33
33
  - bdr/src/server
34
+ - bdr/src/server/toga.ts
34
35
  - bdr/src/app/api
35
36
  - bdr/test
37
+ - bdr/test/togaPrimaryPhone.test.ts
36
38
  - bdr/public
37
39
  - bdr/mockup/styles.css
38
40
  related:
39
41
  - web-funnel-content-model.md
40
42
  - bdr-web-funnel-plan.md
41
43
  - ../architecture.md
44
+ - ../workflows/safe-call-loop-testing.md
45
+ - ../../worker2/features/vapi-webhook-handler.md
46
+ - ../../api2/features/nested-relationship-writes.md
42
47
  ---
43
48
 
44
49
  ## What this is
@@ -88,7 +93,9 @@ to its ship state (trusted-IP position for X-Forwarded-For; O(1) LRU-capped limi
88
93
  - **ESLint flat config** carries a **no-em-dash rule** on shipped strings
89
94
  (`Literal` / `TemplateElement` / `JSXText` selectors) — enforces the mockup copy
90
95
  rule on in-repo strings (`eslint.config.mjs`).
91
- - **Vitest** test suite: **104 tests, all green** (13 added in the first 2026-07-16 session:
96
+ - **Vitest** test suite: **114 tests, all green** (10 added 2026-07-20 for the primary-phone
97
+ self-heal — null-pointer re-link, changed-number re-link, create-as-last-resort, strict
98
+ `requestCall` vs. absorbing `upsertContactByEmail`; 13 added in the first 2026-07-16 session:
92
99
  phone format/prefill/mask, live-anchor consent links, `submitOk` race; the later 2026-07-16
93
100
  session added the rate-limiter + trusted-IP + LRU-eviction + `hsCampaignId` validation tests
94
101
  to reach 104). Every phase was verified with `tsc` + lint + build + a live SSR `curl` before
@@ -212,19 +219,33 @@ source of truth for "why isn't my contact being called." Full rules are document
212
219
  call windows and weekly cadence limits, but still requires an active campaign **with an
213
220
  assigned assistant**, `isActive=1`, `isOkayToCall=1`, a live **primary** phone, and
214
221
  `attemptCount < maxAttemptsPerContact`.
215
- - **BUG — a phone-less HubSpot contact can never receive a call (silent).** When the HubSpot
216
- contact has no phone, BDR creates the Toga contact **without** a phone at page-prime; the
222
+ - **BUG (funnel side FIXED — self-heal shipped; api2 platform fix still deferred).** When the
223
+ HubSpot contact has no phone, BDR creates the Toga contact **without** a phone at page-prime; the
217
224
  phone the visitor types arrives later via the Call Now `PUT`. api2 creates a
218
225
  `ContactPhoneNumber` row on that update but does **not** set
219
- `Contacts.primaryContactPhoneNumberId`. The dialer's eligibility SQL `INNER JOIN`s on
220
- `primaryContactPhoneNumberId`, so the contact stays invisible to the dialer. **Re-submitting
221
- does not fix it** (verified in production: the phone row exists but the primary pointer
222
- stays null). The funnel still shows success and returns `200` — a silent failure. The old
223
- `info` site never hit this because CRM-sourced contacts already had a phone at CREATE time
224
- (api2 links the primary on create, not on a later update). Fix candidates: api2-side (set
225
- the primary when a phone row is created during an update) or a BDR workaround (a follow-up
226
- `PUT` linking the existing phone-row uuid as primary). Affects **any** phone-less HubSpot
227
- contact and blocks live call testing.
226
+ `Contacts.primaryContactPhoneNumberId` — the exact api2 root cause (the UPDATE path injects a
227
+ reverse back-reference into the nested child object, defeating the single-key-uuid forced-MATCH
228
+ fast-path) is now documented in
229
+ [api2 nested-relationship-writes.md](../../api2/features/nested-relationship-writes.md). The
230
+ dialer's eligibility SQL `INNER JOIN`s on `primaryContactPhoneNumberId`, so the contact stays
231
+ invisible to the dialer, the funnel still shows success and returns `200` — a silent failure. The
232
+ old `info` site never hit this because CRM-sourced contacts had a phone at CREATE time (api2 links
233
+ the primary on create, not on a later update).
234
+ - **Self-heal (SHIPPED, `src/server/toga.ts`, commit `4ac579e`):** after any contact write that
235
+ carried a phone, if the response echoes `primaryContactPhoneNumber` **null** OR a number whose
236
+ last-10 digits **don't match** what was sent (stale-pointer / changed-number case), the code
237
+ finds the orphaned `ContactPhoneNumber` row uuid (from the echoed contact's `contactPhoneNumbers`
238
+ → fresh `GET /contacts/{uuid}` → creates one via `POST /contact-phone-numbers` as a last resort)
239
+ and re-links it with an **identifier-only** `PUT {primaryContactPhoneNumber:{uuid}}` (a single-key
240
+ identifier object hits api2's forced-MATCH path). **`requestCall` is strict** (throws → the route
241
+ returns `500`, so the UI never reports success for an undial-able submit); **`upsertContactByEmail`
242
+ absorbs + warns** (lead priming must never break the funnel — call-now re-heals). Covered by 10
243
+ regression tests (written first, failed on old code; 114-test suite green; an independent review
244
+ found + fixed the changed-number case). Resolves the funnel-side bug **once `_dev-sandbox` deploys**
245
+ (deploy still pending — see the deploy note below).
246
+ - **api2 platform fix is still deferred** to its own PR/ticket (gate the reverse-back-reference
247
+ injection to true reverse/has-many relations). The self-heal covers the funnel meanwhile; the
248
+ same api2 bug likely affects `primaryContactEmailAddress` / `primaryContactAddress` on update.
228
249
 
229
250
  ## Local dev setup (BDR)
230
251
 
@@ -244,6 +265,30 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
244
265
  - **Restart the dev server after creating `.env.local`** — Next reads env files only at
245
266
  server startup. `.env.*` is gitignored in BDR.
246
267
 
268
+ ## Deploy status (AWS Amplify — NOT yet deployed)
269
+
270
+ The BDR site is **not deployed anywhere yet** (as of 2026-07-20). Go-live is blocked on the
271
+ Amplify setup, tracked by ClickUp **868kdf4tj "BDR - create amplify and sandbox route"** (urgent,
272
+ Sprint 82) — still `to do`. (ClickUp shows Alex Peterson as assignee; the team says Jeff picked it
273
+ up — ClickUp is stale, confirm the real owner before assuming.)
274
+
275
+ - **Deploy branch is `_dev-sandbox`** (underscore-prefix convention), **not** `BDR-Development` /
276
+ `BDR-Phase-2`. The primary-phone self-heal (commit `4ac579e`, see the phone-less bug above) is
277
+ already merged into `_dev-sandbox`, so **Amplify's first build ships the fix**.
278
+ - **Amplify must be configured for SSR hosting (NOT static export)** — the app is server-rendered
279
+ (server components, route handlers, and the `proxy.ts` edge proxy all require the SSR/compute
280
+ runtime). Point the app at branch `_dev-sandbox`.
281
+ - **5 server-side env vars are required** (same set as local dev): `TOGA_API_BASE_URL`,
282
+ `TOGA_CLIENT_ID`, `TOGA_CLIENT_API_UUID`, `TOGA_CLIENT_API_SECRET`, `HUBSPOT_ACCESS_TOKEN`.
283
+ **Where the values live (never the values):** the `info` repo's `.env.local` (production
284
+ credential set). Add them in the Amplify console environment config — never commit them.
285
+ - **Phase 8 WAF pairing.** `BDR/PLAN.md` Phase 8 specifies a **per-IP rate rule on the CloudFront
286
+ distribution / WAF** that pairs with the in-app token-bucket limiter (the app limiter is
287
+ defense-in-depth only; the edge WAF rule is the authoritative control — see the enumeration
288
+ gotcha). Configure it as part of the Amplify/CloudFront setup, along with the non-gating `cso`
289
+ follow-ups (lock the Amplify origin to CloudFront; verify `CloudFront-Viewer-Address` inject+strip
290
+ before trusting it).
291
+
247
292
  ## What is parked / not built
248
293
 
249
294
  - **Creator screen + `TweaksPanel` — deliberately NOT ported** (parked per plan
@@ -371,6 +416,19 @@ PRODUCTION credential set (`TOGA_API_BASE_URL = https://api.togahub.com/v2`).
371
416
  harness; pull it with `npx toga-ai`.
372
417
 
373
418
  ## Change history
419
+ - 2026-07-20 — FIXED the phone-less-contact dialer bug on the funnel side: shipped a primary-phone
420
+ **self-heal** in `src/server/toga.ts` (commit `4ac579e`) — after any contact write carrying a
421
+ phone, if the echoed `primaryContactPhoneNumber` is null or its last-10 digits mismatch, find/create
422
+ the `ContactPhoneNumber` row uuid and re-link via an identifier-only `PUT {primaryContactPhoneNumber:
423
+ {uuid}}` (single-key forced-MATCH). `requestCall` is strict (throws → route 500, UI never falsely
424
+ reports success); `upsertContactByEmail` absorbs+warns (priming never breaks the funnel; call-now
425
+ re-heals). 10 regression tests (114 suite green; independent review found+fixed the changed-number
426
+ case). The **exact api2 root cause** was also located and documented (UPDATE-path reverse-back-ref
427
+ injection defeats the uuid forced-MATCH — see api2 `nested-relationship-writes.md`); the api2 platform
428
+ fix is deferred. Added the **Deploy status** section: BDR is not deployed yet (ClickUp 868kdf4tj still
429
+ to-do); deploy branch is `_dev-sandbox` (now carrying the self-heal); Amplify needs SSR (not static),
430
+ the 5 server env vars (values sourced from `info`'s `.env.local`), and the Phase 8 per-IP WAF rate
431
+ rule. (tcox)
374
432
  - 2026-07-16 — Rate-limit mitigation HARDENED to ship state after an independent `cso` security
375
433
  review returned **SHIP after two rounds** (supersedes the mid-state recorded in the entry
376
434
  below: test count is **104**, not 89, and the review is complete, not "still running"). Added
@@ -0,0 +1,112 @@
1
+ ---
2
+ title: Safe AI-BDR Call-Loop Testing (isolated test-campaign runbook)
3
+ framework: "2.0"
4
+ repo: ai-bdr
5
+ project: AI-BDR
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-07-20
10
+ owners: [tcox]
11
+ files:
12
+ - dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql
13
+ - worker2/Worker/Ai/Bdr/Vapi.php
14
+ - worker2/Config/beta.ini
15
+ related:
16
+ - new-campaign-onboarding.md
17
+ - ../architecture.md
18
+ - ../../worker2/features/vapi-webhook-handler.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ How to prove the AI-BDR **dialer → Vapi → end-of-call webhook** loop end-to-end **without
24
+ dialing real prospects**. The production dialer cron (`Core.CronJobs` "AI BDR",
25
+ `ProcessAllActiveCampaigns`, schedule `* * * * *`) fires **every minute**, so any campaign that
26
+ becomes eligible starts dialing live prospects **within 60 seconds** — a test must be built so
27
+ that the *only* contact that can ever be dialed is a deliberate test contact. The safe technique
28
+ is an **isolated one-contact test campaign with no call windows**. Eligibility mechanics that make
29
+ this work are in [worker2 vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md).
30
+
31
+ ## Rule 1 — NEVER revive an ended campaign that has linked prospects
32
+
33
+ An ended campaign (`dateEnd` in the past) is rejected every run by `validateCampaignReady`
34
+ ("Campaign has ended"). Flipping `dateEnd`/`isActive` to revive it makes **all** its
35
+ contact-level-dialable contacts eligible at once, and the every-minute cron dials them within
36
+ 60s. (Observed live: campaign `26.05 - AI BDR - Ryan Nitti` was blocked **solely** by
37
+ `dateEnd`; it had 132 linked contacts, ~86 contact-level dialable — reviving it would have
38
+ dialed real prospects in one cron tick.) Likewise, do **not** blanket-flip `isOkayToCall=1` on a
39
+ real list: it is two mass writes plus a restore burden, and it silently un-blocks any contact
40
+ that was intentionally set `isOkayToCall=0`.
41
+
42
+ ## Rule 2 — Use an isolated one-contact test campaign with NO call windows
43
+
44
+ The safe posture exploits the eligibility split (see vapi-webhook-handler.md): the normal
45
+ (non-requested) dial path requires a `CampaignCallWindows` row for the current day/time, but the
46
+ **requested** path (`dtNextContactRequested <= NOW()`) bypasses windows/cadence. So:
47
+
48
+ 1. **Deactivate any ended campaign** that shares the environment (also silences the every-minute
49
+ "Campaign has ended" cron error).
50
+ 2. **Create a dedicated test campaign** copied from a known-good campaign's config (same assistant
51
+ / Cal.com event / timezone, a short window e.g. 7 days, the normal attempt cap), with a
52
+ **hardcoded UUID** so the migration is idempotent and re-runnable.
53
+ 3. Create **NO `CampaignCallWindows` rows** for it. With no call windows, the campaign can **only
54
+ ever dial a contact that has a pending `dtNextContactRequested`** — never a normal-cadence
55
+ contact.
56
+ 4. **Link exactly ONE test contact** (by uuid, `INNER JOIN`-guarded so the migration no-ops on any
57
+ environment that lacks that contact). That single linked contact is the *only* thing the
58
+ campaign can dial, and only when you set its `dtNextContactRequested`.
59
+
60
+ Reference implementation: `dbchanges2/Client_True/2026-07-20a - AiBdrTcoxTestCampaign.sql`
61
+ (idempotent `NOT EXISTS` guards keyed to the real UNIQUE constraints; passed `sql-reviewer`). This
62
+ proves the loop with **zero prospect exposure** instead of mass-writing flags on real contacts.
63
+
64
+ ## Rule 3 — NEVER dry-run against production
65
+
66
+ `dryRun` in the dialer is **not** write-free: `createContactAttempt` runs **before** the dryRun
67
+ early-return, writing a real `ContactAttempts` row (`dtStarted` set, `dtEnded NULL`) that counts
68
+ toward `maxAttemptsPerContact` **and** wedges the contact as in-progress (excluded from all future
69
+ dialing). See the dryRun gotcha in vapi-webhook-handler.md. Unwedge SQL:
70
+ `UPDATE ContactAttempts SET dtEnded=NOW() WHERE contactId=? AND dtEnded IS NULL`.
71
+
72
+ ## Rule 4 — Validate the assistant identifier before assigning it
73
+
74
+ `validateCampaignReady` only checks `assistantIdentifier` is **non-empty**, not that it is a
75
+ well-formed Vapi UUID. A corrupt value (e.g. a 37-char uuid-plus-trailing-char) passes the gate
76
+ and then **400s at Vapi** at call time. Confirm the assistant's identifier shape before wiring it
77
+ to any campaign, test or real.
78
+
79
+ ## Why a true beta end-to-end CALL is impossible as currently wired
80
+
81
+ Do **not** expect to place a real beta call and see its webhook land in the beta DB:
82
+
83
+ - Vapi is **one production account**. Its assistants' `serverUrl` targets the **production**
84
+ webhook (`webhook.togahub.com`). A beta-placed call's `end-of-call-report` therefore posts to
85
+ **production**, where the `contactAttemptUuid` (created in the **beta** DB) has no matching row
86
+ → the write-back can never find its attempt.
87
+ - **Beta is good only for rehearsing the SQL / eligibility logic** via a **local worker2 run**
88
+ (not a real call). Note `worker2/Config/beta.ini`'s `aws_worker_queue_url` still points at
89
+ `WorkerProductionQueue` — another reason not to trust beta for anything that enqueues work.
90
+
91
+ A real end-to-end call test must run against production, which is exactly why the isolated
92
+ one-contact test campaign (Rule 2) is the safe vehicle.
93
+
94
+ ## dbchanges2 execution caveat — don't ship create + cleanup in the same window
95
+
96
+ The dbchanges2 external executor runs a folder's files **alphabetically in one pass**. Never place
97
+ a create-migration and its cleanup/teardown migration in the **same** run window (same day/folder
98
+ batch) — they will apply together in one pass and the cleanup will immediately undo the create.
99
+ Sequence the cleanup into a later, separately-executed window. (See dbchanges2 architecture for the
100
+ `YYYY-MM-DD<letter>` ordering contract.)
101
+
102
+ ## Change history
103
+ - 2026-07-20 — Initial runbook. Captured the safe AI-BDR call-loop test procedure: never revive an
104
+ ended campaign with linked prospects (every-minute cron dials within 60s); use an isolated
105
+ one-contact test campaign with NO `CampaignCallWindows` (only requested `dtNextContactRequested`
106
+ calls can fire) as built in `dbchanges2/Client_True/2026-07-20a`; never dry-run in prod (writes a
107
+ wedging ContactAttempts row); validate the assistant identifier shape (non-empty check lets a
108
+ corrupt uuid 400 at Vapi); a true beta end-to-end call is impossible (single prod Vapi account →
109
+ webhook posts to prod, beta contactAttemptUuid unmatchable); and the dbchanges2 same-window
110
+ create+cleanup execution caveat. (tcox)
111
+ </content>
112
+ </invoke>
@@ -6,13 +6,15 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-09
10
- owners: ["bala", "mhammontree"]
9
+ updated: 2026-07-20
10
+ owners: ["bala", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  related:
14
14
  - ../architecture.md
15
15
  - ../../../clients/aig/features/entitlement-intake.md
16
+ - ../../ai-bdr/features/web-funnel-app.md
17
+ - ../../worker2/features/vapi-webhook-handler.md
16
18
  ---
17
19
 
18
20
  ## Summary
@@ -58,6 +60,39 @@ rows on every write.
58
60
  If you need to link by a business key (like an employee XID), **resolve that key to a uuid in your
59
61
  caller first** (e.g. build a `key → uuid` map), then send `{uuid}`.
60
62
 
63
+ ## UPDATE-path back-reference injection defeats the uuid forced-MATCH (primary-pointer bug)
64
+
65
+ A **single-key identifier object** (`{uuid: <x>}` and nothing else) is supposed to hit the
66
+ engine's **forced-MATCH fast-path** (`V2.php:6952`) — link the existing child, never create,
67
+ and let the caller re-point a parent's forward FK to it. On the **CREATE** path this works: the
68
+ set-loop (`V2.php:4601–4616`) applies the child object and the parent FK is assigned at
69
+ `V2.php:4647` with no interference.
70
+
71
+ On the **UPDATE** path it does **not**. When a child model carries a `contactId` FK *back* to
72
+ the parent (as `ContactPhoneNumbers` → `Contacts` does), the update path **injects a reverse
73
+ back-reference** (sets the child's `contactId = parent id`) into the nested child object at
74
+ `V2.php:4985–4996` — added by commit `91d803f` "guard child FK set". That injection turns the
75
+ single-key object into a **multi-key** object, so it no longer qualifies for the forced-MATCH
76
+ fast-path, and it diverts what `getForeignKeyValue()` returns **before** the parent's forward FK
77
+ is assigned at `V2.php:5047`. Net effect: a `PUT {primaryContactPhoneNumber: {uuid}}` does **not**
78
+ set `Contacts.primaryContactPhoneNumberId`, and the `PUT` response **echoes
79
+ `primaryContactPhoneNumber: null`** on the miss — which callers can detect.
80
+
81
+ **Scope.** Any **forward singular FK** whose child model has a back-FK to the parent is exposed
82
+ on update — `primaryContactEmailAddress` / `primaryContactAddress` on `Contacts` are likely
83
+ affected the same way. This is the exact root cause of the **BDR web-funnel "phone-less contact
84
+ is never dialed"** bug (a phone added by a later `PUT` links a `ContactPhoneNumber` row but never
85
+ becomes the primary): see
86
+ [web-funnel-app.md](../../ai-bdr/features/web-funnel-app.md) and
87
+ [vapi-webhook-handler.md](../../worker2/features/vapi-webhook-handler.md).
88
+
89
+ **Suggested platform fix (deferred to its own PR/ticket):** gate the `4985–4996` injection to
90
+ **true reverse / has-many** relations only, so a forward singular-FK child keeps its single-key
91
+ forced-MATCH; confirm `RecordFields.childPolicy` for `Contacts.primaryContactPhoneNumberId` when
92
+ fixing. Until then the caller-side workaround is a follow-up identifier-only `PUT` re-link with a
93
+ self-heal that detects the null/mismatched echo (shipped on the BDR funnel side — see
94
+ web-funnel-app.md).
95
+
61
96
  ## Per-API overrides (`Apis_RecordFields`)
62
97
 
63
98
  The base identifier flags above (`Core.RecordFields.isIdentifier`) can be **overridden per API**
@@ -100,6 +135,15 @@ beta/QA threw EV-12 on the injected `entitlementFulfillmentType` — see
100
135
 
101
136
  ## Change history
102
137
 
138
+ - 2026-07-20 — Root-caused the **UPDATE-path** failure of the single-key-uuid forced-MATCH: the
139
+ update path injects a reverse back-reference (`contactId = parent id`) into nested child objects
140
+ at `V2.php:4985–4996` (commit `91d803f`) when the child has a back-FK to the parent, turning the
141
+ single-key object multi-key and diverting `getForeignKeyValue()` before the forward FK assignment
142
+ at `:5047` — so `PUT {primaryContactPhoneNumber:{uuid}}` never sets the pointer and the response
143
+ echoes `null`. CREATE path (`:4601–4616` / `:4647`) is unaffected. Scope: any forward singular FK
144
+ whose child has a back-FK to the parent (likely `primaryContactEmailAddress`/`primaryContactAddress`
145
+ too). This is the root cause of the BDR web-funnel phone-less-contact dialer bug; platform fix
146
+ deferred to its own PR (gate the injection to true reverse/has-many relations). (tcox)
103
147
  - 2026-07-09 — Documented the per-API `Apis_RecordFields` override layer (`overrideIsIdentifier`,
104
148
  `overrideChildPolicy`) and the trap that a `NULL` `overrideIsIdentifier` is read as "not an
105
149
  identifier" (`!$row->overrideIsIdentifier`) — an override row set only for a child policy
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-16
9
+ updated: 2026-07-20
10
10
  owners: [snaredla, tcox]
11
11
  files:
12
12
  - worker2/Worker/Vapi.php
@@ -14,6 +14,8 @@ files:
14
14
  related:
15
15
  - ../../ai-bdr/features/call-orchestration.md
16
16
  - ../../ai-bdr/features/vapi-integration.md
17
+ - ../../ai-bdr/workflows/safe-call-loop-testing.md
18
+ - ../../api2/features/nested-relationship-writes.md
17
19
  ---
18
20
 
19
21
  ## Summary
@@ -152,6 +154,34 @@ truth for **"why isn't my contact being called."** Confirmed against source
152
154
  `initiateCallForContact(…, CALL_TYPE__IMMEDIATE)`. It errors **"not assigned to any active
153
155
  campaign"** if no ACTIVE/PENDING campaign-contact link exists.
154
156
 
157
+ ### Finer detail (source-verified 2026-07-20)
158
+
159
+ Where each gate actually lives in `Worker/Ai/Bdr/Vapi.php`, and where the eligibility summary
160
+ above is easy to over-read:
161
+
162
+ - **The campaign gate is split across two methods.** `getActiveCampaignsForDialing` (L177–193)
163
+ filters **only** `Campaigns.isActive = 1` + `INNER JOIN CampaignAssistants` — there is **no
164
+ date check there**. The date-range gate lives in `validateCampaignReady` (L759–789), which
165
+ throws **"Campaign has ended / not started"** and separately requires the `campaignAssistantId`
166
+ to resolve to a **NON-EMPTY `assistantIdentifier`**. So an active campaign with a valid assistant
167
+ can still be rejected every run purely because `dateEnd`/`dateStart` is out of range.
168
+ - **`Campaigns_Contacts.status IN ('PENDING','ACTIVE')` is required ONLY when
169
+ `dtNextContactRequested IS NULL`** (L237). On a requested (web-funnel call-now/scheduled) contact
170
+ the status is not checked. **`PENDING` is a NORMAL dialable state** — the webhook resets links to
171
+ `PENDING` for retries, so "stuck on PENDING" is **not** a blocker signal.
172
+ - **The prior-DNC / terminal-outcome exclusion is BYPASSED when `dtNextContactRequested` is set.**
173
+ The exclusion (L314–331: `c_dncRequested`, `DO_NOT_CALL`, `MEETING_BOOKED` / `DNC_REQUESTED`
174
+ outcomes) applies only on the non-requested path; a customer who actively re-engages
175
+ (`dtNextContactRequested`) is dialed regardless of a prior terminal outcome.
176
+ - **The normal (non-requested) path requires a `CampaignCallWindows` row to EXIST for the current
177
+ day/time.** A campaign with **no** call-window rows can therefore **only ever dial contacts with a
178
+ pending `dtNextContactRequested`** — this is the **safest launch posture for a web-funnel-only
179
+ campaign** (see the safe call-loop testing workflow in
180
+ `../../ai-bdr/workflows/safe-call-loop-testing.md`).
181
+ - **Per-run cap + in-progress exclusion.** Results are `LIMIT MAX_CALLS_PER_MINUTE` per run, and the
182
+ in-progress check excludes any contact with an **open attempt** (`dtStarted` set, `dtEnded NULL`)
183
+ for that campaign — see the dryRun gotcha below for how a stray open attempt wedges a contact.
184
+
155
185
  ### Gotcha — a phone-less contact is silently never dialed (BDR web funnel)
156
186
 
157
187
  Criterion 4's `INNER JOIN` on `primaryContactPhoneNumberId` is why a contact whose phone was
@@ -160,10 +190,33 @@ added **after** creation can be invisible to the dialer: **api2 creates the
160
190
  (it only links the primary on CREATE). The BDR web funnel hits this for any phone-less HubSpot
161
191
  contact — the visitor's typed phone arrives via a later `PUT /contacts/{uuid}`, so the row
162
192
  exists but the primary pointer stays null and the eligibility join excludes it. Re-submitting
163
- does not fix it (verified in production). See
164
- `../../ai-bdr/features/web-funnel-app.md` for the funnel side. Fix candidates: api2 sets the
165
- primary on phone-row create-during-update, or a caller-side `PUT` linking the existing
166
- phone-row uuid as primary.
193
+ does not fix it (verified in production). The **exact api2 root cause** is now documented — the
194
+ UPDATE path injects a reverse back-reference into the nested child object, defeating the
195
+ single-key-uuid forced-MATCH fast-path (see
196
+ [api2 nested-relationship-writes.md](../../api2/features/nested-relationship-writes.md)). The
197
+ platform fix is deferred; a **funnel-side self-heal has SHIPPED** (`bdr/src/server/toga.ts`):
198
+ after any contact write that carried a phone, if the echoed `primaryContactPhoneNumber` is null or
199
+ its last-10 digits don't match what was sent, BDR finds/creates the `ContactPhoneNumber` row uuid
200
+ and re-links it with an identifier-only `PUT {primaryContactPhoneNumber:{uuid}}`. So on the funnel
201
+ side this is resolved once the fix deploys — see `../../ai-bdr/features/web-funnel-app.md`.
202
+
203
+ ### Gotcha — `dryRun` is NOT write-free; it wedges contacts (never dry-run in prod)
204
+
205
+ `createContactAttempt` (which sets `dtStarted = NOW()`, `dtEnded NULL`) executes **BEFORE** the
206
+ `dryRun` early-return (L438–466 / L685–708). So a "dry run" writes a **real `ContactAttempts` row
207
+ for every eligible contact** — it counts toward `maxAttemptsPerContact` **and** wedges the contact
208
+ as "in progress" (the open-attempt check above then excludes it from **all** future dialing) until
209
+ someone sets `dtEnded`. **Never dry-run against production.** Fix candidate: move
210
+ `createContactAttempt` below the `dryRun` return (build the payload with a placeholder uuid).
211
+ Unwedge SQL: `UPDATE ContactAttempts SET dtEnded=NOW() WHERE contactId=? AND dtEnded IS NULL`.
212
+
213
+ ### Gotcha — a corrupt `assistantIdentifier` passes validation but 400s at Vapi
214
+
215
+ `validateCampaignReady` only checks that `assistantIdentifier` is **non-empty**, not that it is a
216
+ well-formed Vapi assistant UUID. A corrupt identifier (e.g. a valid uuid with an extra trailing
217
+ character — a 37-char value) **passes** the readiness gate and the campaign is dialed, then the
218
+ Vapi `POST /call/phone` **400s** at call time. Validate the assistant identifier's shape before
219
+ assigning an assistant to any campaign.
167
220
 
168
221
  ## Data model
169
222
 
@@ -192,6 +245,18 @@ status. See the `ai-bdr` skill / `_Model_True_ContactAttempt` for the full field
192
245
  - [VAPI Integration — assistants, tools, structured output](../../ai-bdr/features/vapi-integration.md)
193
246
 
194
247
  ## Change history
248
+ - 2026-07-20 — Source-verified corrections/extensions to the dialer eligibility rules: the campaign
249
+ gate is split (`getActiveCampaignsForDialing` L177–193 = isActive+assistant, NO date; date range +
250
+ non-empty `assistantIdentifier` in `validateCampaignReady` L759–789); `Campaigns_Contacts.status
251
+ IN (PENDING,ACTIVE)` required ONLY when `dtNextContactRequested IS NULL` (PENDING is normal/dialable);
252
+ prior-DNC/terminal-outcome exclusion is bypassed on the requested path; a campaign with NO
253
+ `CampaignCallWindows` rows can only dial requested contacts (safest web-funnel launch posture);
254
+ per-run `LIMIT MAX_CALLS_PER_MINUTE` + open-attempt exclusion. Added gotchas: (1) `dryRun` is NOT
255
+ write-free — `createContactAttempt` runs before the dryRun return, writing a real ContactAttempts
256
+ row that counts toward the attempt cap AND wedges the contact as in-progress (never dry-run in
257
+ prod; unwedge SQL provided); (2) a corrupt `assistantIdentifier` passes the non-empty check but
258
+ 400s at Vapi. Updated the phone-less-contact gotcha: exact api2 root cause now documented and a
259
+ funnel-side self-heal (`bdr/src/server/toga.ts`) has shipped. (tcox)
195
260
  - 2026-07-16 — Documented the outbound dialer eligibility rules from source
196
261
  `getEligibleContactsForVapi()` (the 6 ALL-of criteria, the `dtNextContactRequested <= NOW()`
197
262
  window/cadence bypass = the website call-now/scheduled path, cron entry chain, and the single
@@ -27,7 +27,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
27
27
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
28
28
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
29
29
  - **voice-to-voice** (TOGa Voice) — 4 doc(s) → [2.0/apps/voice-to-voice/INDEX.md](2.0/apps/voice-to-voice/INDEX.md)
30
- - **ai-bdr** (AI-BDR) — 7 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
30
+ - **ai-bdr** (AI-BDR) — 8 doc(s) → [2.0/apps/ai-bdr/INDEX.md](2.0/apps/ai-bdr/INDEX.md)
31
31
  - **toga2-commerce** (TOGa Commerce) — 9 doc(s) → [2.0/apps/toga2-commerce/INDEX.md](2.0/apps/toga2-commerce/INDEX.md)
32
32
  - **toga25-supply** (TOGa 2.5 Supply) — 10 doc(s) → [2.0/apps/toga25-supply/INDEX.md](2.0/apps/toga25-supply/INDEX.md)
33
33
  - **toga-blox** (TOGa Blox) — 7 doc(s) → [2.0/apps/toga-blox/INDEX.md](2.0/apps/toga-blox/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.382",
3
+ "version": "1.0.384",
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",