toga-ai 1.0.499 → 1.0.501

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.
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-08-03
10
- owners: ["mhammontree"]
10
+ owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/ItemFulfillment.php
@@ -46,6 +46,42 @@ interceptor mechanism: the symptom looks exactly like a code bug.
46
46
  Because the registration is a **data** row, it is per-environment: the code ships with a deploy, the
47
47
  hook only becomes live when the migration lands.
48
48
 
49
+ ## Registration is also **per-API** — the scoping is DB config, not code
50
+
51
+ The interceptor row carries an **`apiId`** (plus `isActive`) alongside `recordId`,
52
+ `prePostProcessing` and `httpMethod`, and both a **`Core.ApiPayloadInterceptors`** and a
53
+ **`Client_<X>.ApiPayloadInterceptors`** table are consulted. So a hook runs only for the APIs that
54
+ have a row.
55
+
56
+ **Consequence worth internalising: a guard you place in an interceptor is implicitly scoped to
57
+ whichever APIs have interceptor rows, and that scoping lives in data, not in the PHP.** Reading
58
+ the model tells you nothing about who it applies to, and **adding a row for another API silently
59
+ widens the blast radius** of every check in that hook. Worked example: Compass `recordId 17`
60
+ (`purchase-orders`) has exactly two rows, both `apiId = 2` (MITS Service Hub), identical in local
61
+ and prod — so a validation added to `_Model_Compass_PurchaseOrder::prePost` cannot fire for the
62
+ tenant's other APIs at all. See
63
+ [Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
64
+
65
+ ## The interceptor sees payload keys **verbatim** (no `c_` stripping, no rename)
66
+
67
+ **Verified empirically** by a live HTTP probe against local dev
68
+ (`test/@dave/Junk Drawer/probe_c_prefix_payload_mapping.php`):
69
+
70
+ - `$payload` carries the keys **exactly as the client sent them**. Sending `mitsSalesOrder` yields
71
+ `$payload->mitsSalesOrder`; sending `c_mitsSalesOrder` yields `$payload->c_mitsSalesOrder`.
72
+ **Column mapping happens AFTER the interceptor runs.**
73
+ - The rename in **`Client_<X>.Apis_CustomRecordFields.overrideFieldName`** governs only which
74
+ field names the API **accepts on input**. Without a rename row the renamed name is rejected with
75
+ *"a field specified in your request does not exist"*; the **raw model field name is always
76
+ accepted**. Rename rows are themselves `apiId`-scoped, so two APIs writing the same record can
77
+ legitimately use different names for one field.
78
+
79
+ > **⚠ Do not re-derive this from the source.** Static reading of `api2/Component/Api/V2/V2.php`
80
+ > around **L7384** — where `$modelVarsToSet` is cast to an object after a `$flippedRenamedFields`
81
+ > flip — *looks* like the interceptor receives MODEL-level field names. It does not. The probe
82
+ > result above is authoritative; the code path is misleading. Anyone tempted to reason it out
83
+ > from V2.php will reach the wrong answer.
84
+
49
85
  ## Worked example — the EV-10 that was not a code bug
50
86
 
51
87
  `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
@@ -78,6 +114,13 @@ and the failing environment**. It is a small table, and the drift is usually exa
78
114
 
79
115
  ## Change history
80
116
 
117
+ - 2026-08-03 — Added two verified behaviours from a live dev probe: (1) interceptor registration
118
+ is **per-API** (`apiId` on the row, in both the Core and `Client_<X>` tables), so a guard in a
119
+ hook is scoped by **DB config rather than code** and a new row widens its blast radius; (2) the
120
+ interceptor receives payload keys **verbatim** — no `c_` stripping, no rename — with
121
+ `Apis_CustomRecordFields.overrideFieldName` governing only which names the API *accepts* on
122
+ input, and column mapping happening after the hook. Flagged the misleading `V2.php` ~L7384
123
+ `$flippedRenamedFields` path that suggests the opposite. (dfranks)
81
124
  - 2026-08-03 — TRUE-80494: documented the mechanism after an `EV-10`
82
125
  (`itemFulfillmentStageId cannot be null`) in dev-sandbox turned out to be a **missing
83
126
  `ApiPayloadInterceptors` `(28, PRE, POST)` row**, not a code fault — `prePost` is dispatched only
@@ -31,35 +31,53 @@ production data-integrity bug.
31
31
 
32
32
  ## Who actually calls `POST /v2/purchase-orders` (MITS is NOT the only caller)
33
33
 
34
- `/v2/purchase-orders` for Compass is a **shared endpoint with two unrelated inbound channels**,
35
- distinguished in `Core.ClientApiIdentities` (clientId 2). **The channels are told apart by the
36
- FIELD NAME, not by whether a MITS SO is present at all:**
34
+ `/v2/purchase-orders` for Compass is a **shared endpoint with two inbound callers**. Identify the
35
+ caller from `Logs_<Client>.Api.apiId`, which is a FK to **`_Model_Client_Api` →
36
+ `Client_<X>.Apis`** — *not* to `Core.ClientApiIdentities` (`_underscore/Model/Client/Logs/Api.php:17`
37
+ declares `FIELDOPT_FOREIGNKEY_MODEL => '\_Model_Client_Api'`, and `_Model_Client_Api::TABLE =
38
+ 'Apis'`). `Client_Compass.Apis` is: **1 = Agilant** (internal), **2 = MITS Service Hub**,
39
+ 3 = Compass Group, 4 = Office Depot (Cxml).
37
40
 
38
- | Identity | Channel | SO field sent | Values | `purchaseOrderItems` |
41
+ **The two callers are told apart by the FIELD NAME, not by whether a MITS SO is present at all:**
42
+
43
+ | `apiId` | Caller | SO field sent | Values | `purchaseOrderItems` |
39
44
  |---|---|---|---|---|
40
- | `153531108` | MITS | **`mitsSalesOrder`** (unprefixed) | only `SA…` or `MR…` | always present |
41
- | `COMPASS-CXML` | cXML / EDI | **`c_mitsSalesOrder`** (`c_`-prefixed) | numeric, e.g. `472981379001` | present |
42
- | `COMPASS-CXML` | cXML / EDI | **neither field** | — | often **absent entirely** |
45
+ | 2 | **MITS Service Hub** | **`mitsSalesOrder`** (unprefixed) | only `SA…` or `MR…` | always present |
46
+ | 1 | **Agilant** (internal) | **`c_mitsSalesOrder`** (`c_`-prefixed) | numeric, e.g. `472981379001` | present |
47
+ | 1 | **Agilant** (internal) | **neither field** | — | **absent entirely** |
43
48
 
44
49
  Measured over 60 days of inbound `POST /v2/purchase-orders` (`Logs_Compass.Api`):
45
- - **MITS — 4,547 posts, 100% carry the unprefixed `mitsSalesOrder`**; `SA` 2,928, `MR` 1,619.
46
- Never `MA`, never any other prefix. **No `SA` value has ever been sent by a non-MITS caller.**
47
- - **COMPASS-CXML — 2,697 posts**: 2,455 carry `c_mitsSalesOrder`, and **242 carry neither field**.
48
- In a 30-day slice: 1,508 `c_mitsSalesOrder` posts all had items; **136 posts had neither field
49
- and no `purchaseOrderItems` key** — the legitimate header-first EDI POs.
50
- - Compass **Canada** posts under a **separate identity (`409531`)**, so never hardcode an
51
- identity id.
50
+ - **MITS (apiId 2) — 4,547 posts, 100% carry the unprefixed `mitsSalesOrder`**; `SA` 2,928,
51
+ `MR` 1,619. Never `MA`, never any other prefix. **No `SA` value has ever been sent by any other
52
+ api.**
53
+ - **Agilant (apiId 1) — 2,697 posts**: 2,455 carry `c_mitsSalesOrder`, and **242 carry neither
54
+ field**. In a 30-day slice: 1,508 `c_mitsSalesOrder` posts all had items; **136 posts had
55
+ neither field and no `purchaseOrderItems` key**. What upstream process creates those item-less
56
+ header-first POs is **not established** — we know only which api posts them.
57
+ - Compass **Canada** is a separate tenant with its own `Client_CompassCanada.Apis` ids — resolve
58
+ the caller per tenant; never hardcode one.
59
+
60
+ **Why the two field names exist:** `Client_Compass.Apis_CustomRecordFields` holds exactly **one**
61
+ row for `c_mitsSalesOrder` — recordId 17 → `overrideFieldName` `mitsSalesOrder`, scoped to
62
+ **apiId 2 only**. So MITS is *permitted* to send the unprefixed name and every other api must use
63
+ the raw model field name. `Client_CompassCanada` is configured identically. See
64
+ [API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md) for how
65
+ per-api renames and interceptor scoping interact.
52
66
 
53
67
  **Consequences for any validation added to this endpoint:**
54
- - **The unprefixed `mitsSalesOrder` is the MITS marker** — not the identity id, and not the
68
+ - **The unprefixed `mitsSalesOrder` is the MITS marker** — not an identity string, and not the
55
69
  `SA`/`MR` prefix. Supporting evidence: `prePost` reads `$payload->mitsSalesOrder`; the only
56
70
  `c_mitsSalesOrder` occurrence in the codebase is the model field declaration at
57
- `_underscore/Model/Compass/PurchaseOrder.php:18`; and `Core.ApiPayloadInterceptors` has **no
58
- rows for `recordId = 17`** (the `purchase-orders` record), so nothing rewrites field names in
59
- flight.
71
+ `_underscore/Model/Compass/PurchaseOrder.php:18`; and an interceptor receives payload keys
72
+ **verbatim** — there is no `c_` stripping and no rename applied to `$payload` (verified by live
73
+ probe; see the interceptor doc).
74
+ - **The interceptor itself is api-scoped by DB config.** `Client_Compass.ApiPayloadInterceptors`
75
+ has exactly two rows for recordId 17 (`purchase-orders`), **both `apiId = 2`**, identical in
76
+ local and prod. So `prePost`/`postPost` — and therefore any guard placed in them — **never
77
+ execute for apiId 1 at all**. Adding a row for another api silently widens the blast radius.
60
78
  - A "reject purchase orders with zero line items" rule keyed on the **unprefixed** field cannot
61
- touch the 136 item-less EDI posts — they carry neither field. Gating on **item count alone**
62
- would reject all of them.
79
+ touch the 136 item-less posts: they carry neither field *and* their api has no interceptor row.
80
+ Gating on **item count alone** at a shared layer would reject all of them.
63
81
  - **`MA` orders never arrive from MITS.** Compass creates them manually; they go to ODP, reach us
64
82
  over EDI, and land on an exception report for manual NetSuite entry. (An earlier belief that MA
65
83
  is a third MITS prefix to filter on is wrong.)
@@ -67,17 +85,16 @@ Measured over 60 days of inbound `POST /v2/purchase-orders` (`Logs_Compass.Api`)
67
85
  > **⚠ Querying trap — a substring match on `mitsSalesOrder` conflates the two callers**, because
68
86
  > `c_mitsSalesOrder` *contains* it. Anyone characterising this traffic from `Logs_Compass.Api`
69
87
  > must match the **exact key**; a pattern like `"mitsSalesOrder":"` also silently misses the
70
- > prefixed form, which is how this session first reached the wrong conclusion that cXML never
71
- > sends an SO reference. Second trap: payloads appear **both minified**
88
+ > prefixed form, which is how this session first reached the wrong conclusion that the second
89
+ > caller never sends an SO reference. Second trap: payloads appear **both minified**
72
90
  > (`"mitsSalesOrder":"MR…"`) **and pretty-printed** (`"mitsSalesOrder": "MR…"`, space after the
73
- > colon), so a naive pattern under-counts a second way.
91
+ > colon), so a naive pattern under-counts a second way. Third trap: `Logs_<Client>.Api.apiId`
92
+ > resolves against **`Client_<X>.Apis`**, not `Core.ClientApiIdentities` — reading it in the wrong
93
+ > id space mislabels every caller.
74
94
 
75
- > **OPEN / UNVERIFIED:** it is **not** confirmed whether the 2.0 framework strips the `c_` prefix
76
- > when populating `$payload` for an interceptor. If it does, cXML requests carrying
77
- > `c_mitsSalesOrder` would also enter a guard keyed on `$payload->mitsSalesOrder`. Harmless while
78
- > those posts all carry items, but a future item-less cXML post carrying `c_mitsSalesOrder` would
79
- > then be wrongly rejected. This **cannot be settled from logs** — it needs a dev-environment
80
- > check.
95
+ > **RESOLVED (was open):** the framework does **not** strip the `c_` prefix when populating
96
+ > `$payload` for an interceptor — keys arrive verbatim. A guard keyed on `$payload->mitsSalesOrder`
97
+ > therefore cannot be entered by a request that sent `c_mitsSalesOrder`.
81
98
 
82
99
  ## How it works
83
100
  - **SA orders (normal):** each inbound PO item carries `createdFromSalesOrderItem.lineNumber`
@@ -207,6 +224,15 @@ USA and Canada share the parent handler unchanged.
207
224
  need item backfill from the sibling ODP SO first).
208
225
 
209
226
  ## Change history
227
+ - 2026-08-03 — **Corrected the caller identification** recorded earlier the same day:
228
+ `Logs_<Client>.Api.apiId` is a FK to `Client_<X>.Apis`, **not** `Core.ClientApiIdentities`, so
229
+ the second caller is **Agilant (apiId 1, internal)** — not a cXML/EDI channel. Traffic counts
230
+ are unchanged; what creates the item-less header-first POs is **not** established. Added the
231
+ `Apis_CustomRecordFields` per-api rename (recordId 17 `c_mitsSalesOrder` → `mitsSalesOrder`,
232
+ apiId 2 only; Canada identical) as the reason two field names exist, and the fact that the
233
+ recordId-17 interceptor rows are **both apiId 2**, so a guard in `prePost` never runs for
234
+ apiId 1. **Resolved** the open `c_`-prefix question: payload keys reach an interceptor verbatim.
235
+ (dfranks)
210
236
  - 2026-08-03 — Documented that `/v2/purchase-orders` has **two** Compass inbound channels (MITS
211
237
  `153531108` vs `COMPASS-CXML`; Canada is a third identity `409531`), told apart by **field
212
238
  name**: unprefixed `mitsSalesOrder` = MITS, `c_mitsSalesOrder` = cXML, neither = the item-less
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  type: session
3
3
  slug: true-79401-multi-email-resolution
4
- title: TRUE-79401 — closed the multi-email payload-shape question (no api2 work needed)
4
+ title: TRUE-79401 — payload shape closed (no api2 work), static harness built, branch merged up
5
5
  author: mhammontree
6
- repos: [library, api2, _underscore, worker2]
6
+ repos: [library, test, api2, _underscore, worker2, dbchanges2]
7
7
  framework: "both"
8
8
  client: aig
9
9
  status: active
@@ -13,70 +13,93 @@ updated: 2026-08-03
13
13
 
14
14
  # Session: true-79401-multi-email-resolution
15
15
  **Date:** 2026-08-03
16
- **Project/Repo:** library (1.0 core) — with 2.0 findings in api2 / _underscore / worker2
17
- **Task:** Review the one open question left on TRUE-79401 (multiple email addresses for AIG in the 1.0 `library` bridge) — namely "do we need an api2 intake mapping?" — and determine whether it was already answered in prior sessions or meetings.
16
+ **Project/Repo:** library (1.0 core) — with 2.0 findings in api2 / _underscore / worker2 / dbchanges2
17
+ **Task:** Resolve the last open question on TRUE-79401 (multiple email addresses for AIG in the 1.0 `library` bridge) — "do we need an api2 intake mapping?" — then build a static regression harness for the transform and bring the branch up to date with `_production`.
18
18
 
19
19
  ---
20
20
 
21
21
  ## What WORKED
22
22
 
23
- - **Found the prior session's record immediately** via the knowledge base, not by code archaeology. `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` held the full TRUE-79401 history from the 2026-07-28 capture, including the open question and the "owner Paulina" note.
24
- - **Confirmed the library implementation is present in the working tree** — grep for `CUSTOMER_EMAIL_DELIMITER|contactEmailAddresses` in `C:\WWW\library` returned `toga2.php:112` (the `'; '` constant), `:8424` (the `/contacts` fetch `fields` whitelist entry), and `:8499–8505` (the `array_column` → trim/filter/unique → implode → `sqlEscape` chain).
25
- - **Proved `UserDefined3` is not a real field anywhere.** Grep across all of `C:\WWW` returned exactly two hits: the comment at `library/app/api/toga2.php:8424` and the knowledge doc itself. Zero hits in `api2` or `_underscore`. This was the key evidence that the flat-field branch was never implemented and never intended.
26
- - **Proved `c_businessUnit` was never built.** Grep for `businessUnit|c_businessUnit` (case-insensitive) across `C:\WWW` returned only unrelated `BusinessUnits` table hits in the `forecast` repo — nothing in `_underscore`, `api2`, or `dbchanges2`. So there is no dead custom field to clean up.
27
- - **Extracted the Teams transcript from the .docx successfully** after two failures (see below). Working method: `[System.IO.Compression.ZipFile]::ExtractToDirectory()` on the `.docx`, then on `word/document.xml` replace `</w:p>` → newline and `<w:br/>` → newline, strip `<[^>]+>`, and hand-replace the five XML entities. Produced 21,829 chars / 199 lines of readable transcript at `<scratchpad>/aig-transcript/transcript.txt`.
28
- - **The transcript answered the question**, plus three things nobody asked: purpose is verification-only, the ~15-client-DB column approach was rejected, and the origin of the "VARCHAR(55)" fear.
29
- - **AIG's mapping spreadsheet (shared as an image by Paulina) closed it definitively** — both `UserDefined3` and `UserDefined4` map to `Entitlements.contact.contactEmailAddresses[].emailAddress`, and the `UserDefined2` (Business Unit) target column is conspicuously **empty**, corroborating that it was dropped.
30
- - **`/capture` published 4 doc UPDATEs and pushed** — validate OK (305 docs / 30 repos), mirrored to `C:\WWW\.claude\knowledge`, PUSHED.
23
+ - **Found the prior session's record immediately** via the knowledge base, not code archaeology. `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` held the full TRUE-79401 history from the 2026-07-28 capture, including the open question and the "owner Paulina" note.
24
+ - **Confirmed the library implementation is present** — grep for `CUSTOMER_EMAIL_DELIMITER|contactEmailAddresses` in `C:\WWW\library` returned `toga2.php:112` (the `'; '` constant), `:8424` (the `/contacts` fetch `fields` whitelist entry), and `:8499–8505` (the `array_column` → trim/filter/unique → implode → `sqlEscape` chain).
25
+ - **Proved `UserDefined3` is not a real field anywhere.** Grep across all of `C:\WWW` returned exactly two hits: the comment at `library/app/api/toga2.php:8424` and the knowledge doc itself. Zero hits in `api2` or `_underscore`. Key evidence that the flat-field branch was never implemented and never intended.
26
+ - **Proved `c_businessUnit` was never built.** Grep for `businessUnit|c_businessUnit` (case-insensitive) across `C:\WWW` returned only unrelated `BusinessUnits` hits in the `forecast` repo — nothing in `_underscore`, `api2`, or `dbchanges2`.
27
+ - **Extracted the 2026-05-01 Teams transcript from a `.docx`** after two failures (below). Working method: `[System.IO.Compression.ZipFile]::ExtractToDirectory()` on the `.docx`, then on `word/document.xml` replace `</w:p>` → newline and `<w:br/>` → newline, strip `<[^>]+>`, and hand-replace the five XML entities. Produced 21,829 chars / 199 lines at `<scratchpad>/aig-transcript/transcript.txt`.
28
+ - **The transcript answered the question**, plus three unasked things: purpose is verification-only, the ~15-client-DB column approach was rejected, and the origin of the "VARCHAR(55)" fear.
29
+ - **AIG's mapping spreadsheet (image from Paulina) closed it definitively** — both `UserDefined3` and `UserDefined4` map to `Entitlements.contact.contactEmailAddresses[].emailAddress`, and the `UserDefined2` (Business Unit) target column is conspicuously **empty**.
30
+ - **Built a working static regression harness** at `test/@Mark/AIG/test_multi_email.php` — **12/12 cases pass** on PHP 7.2.33, `php -l` clean, no database required. Covers: 2 and 3 addresses, primary omitted from the collection, primary de-duplication, empty collection → primary fallback, absent collection property, no primary at all, bare-string array, whitespace trimming, empty-string filtering, mixed records/strings, and apostrophe escaping.
31
+ - **Discovered `App_Database::sqlEscape()` needs NO database connection** — it delegates to `App_Database::mysqlRealEscapeString()`, a pure `str_replace` over `\ \x00 \n \r ' " \x1a` (the `mysqli_real_escape_string` version is commented out). `abstract class App_Database` has no `extends` and no parse-time deps, so `require_once 'C:/WWW/library/app/database.php'` alone is enough. **This is what makes static 1.0 tests possible.**
32
+ - **The drift-guard technique works.** The transform is 3 lines inlined in a `private static` method that also writes SQL, so it can't be called without a DB and the test must mirror it. The harness reads `toga2.php`, normalizes whitespace, and asserts the 5 production statements still appear verbatim — printing DRIFT DETECTED and refusing to report results otherwise. It also parses `CUSTOMER_EMAIL_DELIMITER` out of the source instead of hardcoding `'; '`. Verified still OK after both merges.
33
+ - **Brought the branch fully up to date with `_production`.** `TRUE-79401` is now at `5aca31ea`; `git merge-base --is-ancestor _production HEAD` → YES; `git log HEAD.._production` empty; working tree clean; local `_production` == `origin/_production` as of the last fetch.
34
+ - **Confirmed the merge did not disturb the change.** The patch is **still +17 / −2** in `app/api/toga2.php` against `_production`. The two merge commits (`90b4178b`, `5aca31ea`) pulled in ~404 insertions of OTHER tickets' work and **zero** of them touched `syncContactToToga1Customer`, the `/contacts` fetch `fields` list, or any TRUE-79401 identifier (all merge hunks are below ~line 8000). `php -l` clean; harness 12/12 after merging.
35
+ - **`git diff _production...HEAD -- <file>` is the technique** that isolates a ticket's real contribution from merge noise when an old branch is finally synced.
36
+ - **Verified the variable split is complete.** Every remaining use of `$emailAddress` in `syncContactToToga1Customer` is a **storage** use (INSERT ~L8558, UPDATE ~L8584–8585); the only **matching** use is the dedupe `WHERE` now on `$primaryEmailAddress` (~L8532). No third site still matches against the joined list.
37
+ - **Measured prod vs local MySQL** — prod Core **8.0.39** `collation_server=utf8mb4_0900_ai_ci`; local Core_2 **8.0.44** `collation_server=utf8mb4_unicode_ci` (explicit `my.ini` override). Same engine, same major version.
38
+ - **Two `/capture` passes published and pushed** — 4 docs in the first, 6 in the second (including the ELEVATED `dbchanges2/architecture.md` after approval). Validate OK, index 307 docs / 30 repos, mirrored to `C:\WWW\.claude\knowledge`.
31
39
 
32
40
  ## What did NOT work — DO NOT RETRY THESE
33
41
 
34
- - **Searching the production logs for a multi-email payload.** This was my suggestion and it is a **dead end by construction**: AIG currently sends only one email address and will not enable the extra fields until we tell them the feature is live. Therefore every payload in `Logs_Aig.Api` and in the core/writer `Logs` contains exactly one `primaryContactEmailAddress` and nothing else. There is no multi-email payload to find. **Do not propose a log audit to determine the future payload shape** — the shape is a *specification* question, not a discovery question.
35
- - **Searching our own transcript store for the 2026-05-01 meeting.** `Team/Transcripts/Export` first ran successfully in production on **2026-06-12** (PR #78) and runs off an incremental watermark with a bounded `lookbackDays`; no multi-week backfill appears in its change history. A May 1 meeting is **not** in `Team.TranscriptExports`, `Team.TranscriptProcessing`, or the `togaiq` KBs despite having been recorded. Recover pre-June meetings from Microsoft Graph or a manual export instead.
36
- - **Reading the `.docx` with the `Read` tool.** Failed with: `This tool cannot read binary files. The file appears to be a binary .docx file.` Must unzip and strip XML (method above).
37
- - **`[System.Web.HttpUtility]::HtmlDecode($t)` in the extraction script.** Failed with: `Unable to find type [System.Web.HttpUtility].` — Windows PowerShell 5.1 does not load `System.Web` by default. Either `Add-Type -AssemblyName System.Web` first, or (what I did) hand-replace `&amp; &lt; &gt; &quot; &apos;`. Note the failure was non-terminating, so the script *appeared* to continue and wrote a partially-processed file — the first extraction produced only 6 lines and looked plausible.
38
- - **Assuming the delimited string was the payload format.** The comma/semicolon discussion in the 2026-05-01 meeting is about **1.0 storage** (`TOGA_AIG.Customers.emailAddress`, a legacy `varchar(255)` that can only hold a string), *not* the inbound payload. The payload is an array of objects. Three layers: nested records inbound → one row per address in `Client_Aig.ContactEmailAddresses` → `'; '`-joined string in 1.0. Easy conflation because the same meeting covers both within minutes.
42
+ - **Searching the production logs for a multi-email payload.** Dead end **by construction**: AIG sends only one address today and will not enable the extra fields until we tell them the feature is live. Every payload in `Logs_Aig.Api` and the core/writer `Logs` has exactly one `primaryContactEmailAddress`. There is nothing to find. The payload shape is a **specification** question, not a discovery question.
43
+ - **Searching our own transcript store for the 2026-05-01 meeting.** `Team/Transcripts/Export` first ran in production **2026-06-12** (PR #78), works off an incremental watermark with a bounded `lookbackDays`, and no multi-week backfill appears in its history. Pre-June meetings are absent from `Team.TranscriptExports`, `Team.TranscriptProcessing`, and the `togaiq` KBs despite being recorded. Recover them from Microsoft Graph or a manual export.
44
+ - **Reading the `.docx` with the `Read` tool.** Exact failure: `This tool cannot read binary files. The file appears to be a binary .docx file.`
45
+ - **`[System.Web.HttpUtility]::HtmlDecode($t)` in the extraction script.** Exact failure: `Unable to find type [System.Web.HttpUtility].` — Windows PowerShell 5.1 does not load `System.Web` by default. **The failure is NON-terminating**, so the script continued and wrote a partially-processed file that looked plausible (6 lines). Either `Add-Type -AssemblyName System.Web` first, or hand-replace `&amp; &lt; &gt; &quot; &apos;`.
46
+ - **`C:\xampp7\php\php.exe` does not exist on this machine.** Exact failure: `Exit code 127 — C:/xampp7/php/php.exe: No such file or directory`. The knowledge doc said to lint with that path; it has since been genericized. The real binaries are `C:\xampp_7_2_33\php\php.exe` (PHP 7.2, for 1.0) and `C:\xampp_8x\php\php.exe` (PHP 8, for 2.0). Only the **8x MySQL** runs and it serves both.
47
+ - **My predicted behavior for a bare-string array was WRONG — and the truth is worse.** I expected `"contactEmailAddresses": ["email2@domain.com"]` to yield an empty stored value. The harness proved it yields **the primary address**: `array_column()` finds no `emailAddress` key → empty list → **the primary fallback fires**. The result is **byte-identical to today's correct single-email behavior**, so there is NO observable difference between "AIG sent the wrong shape" and "AIG hasn't enabled the feature yet." **Do not expect to detect a wrong payload shape by inspecting `TOGA_AIG.Customers.emailAddress`** — only a `Client_Aig.ContactEmailAddresses` row count distinguishes them. (An earlier `/capture` recorded the wrong version; corrected in a later pass.)
48
+ - **Counting the `varchar(255)` capacity overflow as a test failure.** It made the harness exit 1 on every run, which would train everyone to ignore it. Capacity is now tracked as an informational warning; only real regressions set the exit code.
49
+ - **Assuming `_production` was an ancestor after the first merge.** It was not — `git merge-base --is-ancestor _production HEAD` returned NO and merge-base was `871bc416`, because the local `_production` ref had advanced *after* that merge. Always re-check ancestry rather than trusting that a merge commit means "caught up."
39
50
 
40
51
  ## Not tried yet (candidates for next session)
41
52
 
42
- - **The beta nested-write test.** POST a synthetic AIG entitlement to beta/QA with two nested `contactEmailAddresses` entries and **assert the actual `Client_Aig.ContactEmailAddresses` row count** — not the HTTP status. Must cover **both** a brand-new contact (CREATE) and a **second entitlement for an existing contact** (UPDATE). The UPDATE path is the suspect one: per `2.0/apps/api2/features/nested-relationship-writes.md` (2026-07-20), the reverse back-reference injection at `V2.php:4985–4996` breaks single-key forced-MATCH and explicitly names `primaryContactEmailAddress` as likely affected.
43
- - **Verifying the library change is merged and deployed to the prod worker.** Git/deploy state of `C:\WWW\library` was **never checked** this session. The code is confirmed present in the working tree only.
44
- - **The note to Paulina / AIG** — two confirmations (did the mapping document actually reach AIG after the 2026-05-04 email; has their dev acknowledged the `contactEmailAddresses[]` mapping) plus the concrete JSON example.
45
- - **Hardening the dedupe lookup** — `LIKE '%$primaryEmailAddress%'` instead of the current wildcard-less `LIKE`, or dropping the email fallback in favour of `c_togaCustomerId` alone.
46
- - **Guarding the unrelated-but-open `prePost` empty-`$itemId` defect** in `_Model_Aig_Entitlement` (a missing sale-item code still 500s instead of returning a clean "Missing AIG item ID"). Pre-existing, documented, out of TRUE-79401 scope.
53
+ - **The beta nested-write test.** POST a synthetic AIG entitlement to beta/QA with two nested `contactEmailAddresses` entries and **assert the actual `Client_Aig.ContactEmailAddresses` row count** — not the HTTP status, and (per the correction above) not the 1.0 stored string either. Must cover **both** a new contact (CREATE) and a **second entitlement for an existing contact** (UPDATE); the UPDATE path is the suspect one per `2.0/apps/api2/features/nested-relationship-writes.md` (`V2.php:4985–4996` reverse back-reference breaking single-key forced-MATCH, explicitly naming `primaryContactEmailAddress`).
54
+ - **Verify the library change is merged and DEPLOYED to the prod worker.** Still unverified — code confirmed in the working tree and on branch `TRUE-79401` only.
55
+ - **The note to Paulina / AIG** — retract the "only if necessary" ambiguity, include the concrete JSON example (not the `contactEmailAddresses[].emailAddress` path expression), and ask the two confirmations (did the mapping doc reach AIG after 2026-05-04; has their dev acknowledged the mapping).
56
+ - **Land the branch + harness** — `TRUE-79401` is 3 commits ahead of `_production` and the harness is unstaged. Nothing has been committed or pushed on the code side.
57
+ - **Harden the dedupe lookup** — `LIKE '%$primaryEmailAddress%'`, or drop the email fallback and rely on `c_togaCustomerId` alone.
58
+ - **Optionally extract the transform** into a `public static function buildCustomerEmailAddressList()` so the harness calls production directly and the mirror + drift guard become unnecessary. Deliberately NOT done — it changes production code.
59
+ - **`git fetch`** to confirm `origin/_production` hasn't moved since the last fetch (not run — network).
60
+ - **Guard the unrelated open `prePost` empty-`$itemId` defect** in `_Model_Aig_Entitlement` (a missing sale-item code still 500s instead of returning "Missing AIG item ID"). Pre-existing, out of TRUE-79401 scope.
47
61
 
48
62
  ## Current file state
49
63
 
50
64
  | File | Status | Notes |
51
65
  |------|--------|-------|
52
- | `library/app/api/toga2.php` | **Unchanged this session** | TRUE-79401 code from 2026-07-28 confirmed present at `:112`, `:8424`, `:8499–8505`. No edits made. |
53
- | `_underscore/Model/Aig/Entitlement.php` | Unchanged (read only) | `postPost` single-recipient email confirmed **correct by design**, not a gap. |
54
- | `_underscore/Model/Client/ContactEmailAddress.php` | Unchanged (read only) | Confirms the collection is one row per address; FK `contactId` → `_Model_Client_Contact`. |
55
- | `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` | **Updated + pushed** | "Payload shape OPEN" gotcha **replaced** with a RESOLVED section; new Open items section; 4 new gotchas. |
56
- | `knowledge/clients/aig/features/entitlement-intake.md` | **Updated + pushed** | New "Business Unit (UserDefined2) — answered and DROPPED" section + 2 gotchas. |
57
- | `knowledge/2.0/apps/api2/features/nested-relationship-writes.md` | **Updated + pushed** | New framework-level section: a wrong-shaped nested collection returns 201 and silently drops data. |
58
- | `knowledge/2.0/apps/worker2/features/teams-transcript-export.md` | **Updated + pushed** | New gotcha: no transcript coverage before ~2026-06-12. |
59
- | `<scratchpad>/aig-transcript/transcript.txt` | Created (temp) | Extracted 2026-05-01 meeting transcript, 199 lines. Scratchpad — not committed. |
60
-
61
- **No source code was written or changed this session.** It was review + resolution + knowledge capture.
66
+ | `library/app/api/toga2.php` | **Unchanged by me; branch merged up** | TRUE-79401 code from `a5e9b844` intact at `:112`, `:8424`, `:8499–8505`. Branch `TRUE-79401` @ `5aca31ea`, clean, `_production` merged in, patch still +17/−2, `php -l` clean. **Not pushed by me.** |
67
+ | `test/@Mark/AIG/test_multi_email.php` | **Created, unstaged** | 12/12 pass on PHP 7.2.33. Mirrors the transform + drift guard + capacity report. |
68
+ | `_underscore/Model/Aig/Entitlement.php` | Read only | `postPost` single-recipient email confirmed correct by design. |
69
+ | `_underscore/Model/Client/ContactEmailAddress.php` | Read only | One row per address; FK `contactId` → `_Model_Client_Contact`. |
70
+ | `C:\xampp_8x\mysql\bin\my.ini` | Read only — **deliberately unchanged** | Diverges from prod (`collation-server`, `sql_mode=''`, `lower_case_table_names=2`); developer chose to leave it. |
71
+ | `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` | **Updated + pushed** (both passes) | Payload shape RESOLVED, open items, corrections, `varchar(255)` ceiling, redundant-UPDATE gotcha, genericized lint path, merge verification. |
72
+ | `knowledge/clients/aig/features/entitlement-intake.md` | **Updated + pushed** (both passes) | Business Unit dropped; collation trap reframed as per-machine. |
73
+ | `knowledge/2.0/apps/api2/features/nested-relationship-writes.md` | **Updated + pushed** (both passes) | 201-with-silent-drop; "addresses vanish" corrected to degradation-to-primary. |
74
+ | `knowledge/2.0/apps/worker2/features/teams-transcript-export.md` | **Updated + pushed** | No transcript coverage before ~2026-06-12. |
75
+ | `knowledge/1.0/apps/test/features/static-no-db-regression-harness.md` | **Created + pushed** | The harness technique, `sqlEscape()` enabler, drift guard. |
76
+ | `knowledge/2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md` | **Created + pushed** | Parity query + the four divergence classes. |
77
+ | `knowledge/2.0/apps/dbchanges2/architecture.md` | **Updated + pushed (⚠ ELEVATED, approved)** | Mandatory staging-column collation pin; per-machine reframing. |
78
+
79
+ **No production source code was written or changed this session** — the only new code is the test harness.
62
80
 
63
81
  ## Decisions made
64
82
 
65
- - **No api2 intake mapping will be built.** Rationale: AIG's own field-mapping document maps both `UserDefined3` and `UserDefined4` to `Entitlements.contact.contactEmailAddresses[].emailAddress`, so AIG restructures on their side and the V2 metadata-driven engine writes the collection generically. *Rejected alternative:* an AIG `prePost` interceptor converting flat `c_secondaryEmailAddress`/`c_tertiaryEmailAddress` custom fields into `ContactEmailAddresses` rows — rejected because it caps the address count, requires custom-field definitions plus a release, and the mapping document already specifies the nested form.
66
- - **Keep the separately-escaped `$primaryEmailAddress` for the dedupe lookup**, despite the 2026-05-01 meeting saying to "get rid of the primary contact email address" entirely. Rationale: the primary-email `LIKE` is the legacy fallback for matching pre-existing 1.0 customers that have no `c_togaCustomerId`; removing it would break that matching outright. Accepted consequence: a stored `'; '`-joined list can no longer satisfy the wildcard-less `LIKE`, which could duplicate a customer if the `c_togaCustomerId` write-back ever fails after the insert.
67
- - **Multi-recipient notification stays out of scope.** Rationale: the meeting explicitly framed this as verification/lookup for support agents ("it's just for visual, this is for verification, it's a string") and specifically rejected CC/BCC-ing all addresses onto TogaDesk tickets. *Rejected alternative:* widening `postPost`'s registration email to all addresses.
68
- - **Change 4 (201-with-silent-drop) was recorded on the existing api2 `nested-relationship-writes` feature doc rather than promoted to a `standard`.** Rationale: it is the behavior of one already-documented capability, not a new cross-cutting rule — and it avoided an ELEVATED approval gate.
69
- - **`worker2` was NOT added to the `aig` client app-scope.** Rationale: the transcript-coverage finding is a shared/internal worker2 fact, not AIG-specific.
83
+ - **No api2 intake mapping will be built.** AIG's mapping document targets `Entitlements.contact.contactEmailAddresses[].emailAddress`, so AIG restructures and the V2 metadata engine writes the collection generically. *Rejected:* an AIG `prePost` interceptor converting flat `c_secondaryEmailAddress`/`c_tertiaryEmailAddress` custom fields — caps the address count and needs custom-field definitions plus a release.
84
+ - **Keep the separately-escaped `$primaryEmailAddress` for the dedupe lookup**, against the meeting's "get rid of the primary contact email address." The primary-email `LIKE` is the legacy fallback for 1.0 customers with no `c_togaCustomerId`; removing it breaks that matching outright. Accepted consequence: a stored joined list can't satisfy the wildcard-less `LIKE`.
85
+ - **Multi-recipient notification stays out of scope** — the meeting scoped this to verification/lookup and rejected CC/BCC on TogaDesk tickets.
86
+ - **Test the transform with a mirror + drift guard rather than refactoring production.** A mirror is the only option while the logic is inlined in a DB-writing private method; the guard makes it self-invalidating so it can't silently pass on stale logic. *Rejected for now:* extracting to a `public static` helper (cleaner, but a production change on a ticket that is otherwise code-complete).
87
+ - **The nested array is NOT unbounded** — `varchar(255)` caps `Customers.emailAddress` at roughly 6–7 business-length addresses (2 = 96 chars, 3 = 145, 5 = 243 fit; 8 = 390 and 10 = 489 overflow). The committed scope of 3 addresses (~145 chars) is safe, but don't pitch "send as many as you like" without that caveat.
88
+ - **The `ERROR 1267` collation trap is a per-machine CONFIG property, not prod-only and not a version difference.** Both servers are MySQL 8.0. Mitigation unchanged but better justified: always pin `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` on staging columns — a local pass is not evidence.
89
+ - **Local MySQL left diverging from prod, deliberately.** Aligning to 2.0 prod would diverge from 1.0 prod, since one server backs both platforms; `lower_case_table_names` can't be changed after datadir init anyway. Consequence: local cannot validate collation, strict-mode truncation, or table-name casing.
90
+ - **`worker2` was NOT added to the `aig` client app-scope** — the transcript-coverage finding is a shared worker2 fact. **`test` was NOT added either** — the harness is a technique artifact in a personal folder.
91
+ - **The collation rule landed in `dbchanges2/architecture.md`** (elevated, approved) rather than only in the workflow doc, so authors trip over it where they work. The one generalization was softened to "tenant tables are *typically* `utf8mb4_unicode_ci` (verified for `Client_Aig.Items.partNumber`)."
70
92
 
71
93
  ## Blockers
72
94
 
73
- - **Communication deadlock with AIG, not a code blocker.** AIG will not enable the secondary/tertiary email fields until we tell them the feature is live; we have not told them, and our last message (2026-05-04) asked them to send the email fields "only if they are necessary for the process" — ambiguous phrasing delivered in the same breath as dropping Business Unit, which may have read as a de-scope. **There is no AIG reply to that message.** Both sides are waiting on each other.
74
- - **Unknown whether the mapping document actually reached AIG.** In the 2026-05-01 meeting Paulina was holding the whole document back until the Business Unit / invoice-file question was answered. That question *was* answered on 2026-05-04, but nothing confirms the document went out afterward.
75
- - **Deploy state of the library change is unverified** — cannot honestly tell AIG "it's live" until confirmed.
95
+ - **Communication deadlock with AIG — not a code blocker.** AIG will not enable the secondary/tertiary email fields until we tell them the feature is live; our 2026-05-04 email asked them to send the fields "only if they are necessary for the process" — ambiguous phrasing delivered in the same breath as dropping Business Unit, which may have read as a de-scope. **No AIG reply to that message.** Both sides are waiting on each other.
96
+ - **Unknown whether the mapping document ever reached AIG.** Paulina was holding it until the Business Unit question was answered; that was answered 2026-05-04, but nothing confirms the document went out afterward.
97
+ - **Deploy state of the library change is unverified** — can't honestly tell AIG "it's live" until confirmed.
98
+ - **`origin/_production` freshness unconfirmed** — local matches origin as of the last fetch, but no `git fetch` was run this session.
76
99
 
77
100
  ## Exact next step
78
101
 
79
- > Draft the note to Paulina for AIG's dev team. It must (1) retract the "only if necessary" ambiguity and state plainly that the secondary/tertiary addresses **are** wanted — unlike Business Unit; (2) include the concrete JSON example, not just the `contactEmailAddresses[].emailAddress` path expression, because a bare-string array `["email2@domain.com"]` returns HTTP 201 and silently drops the addresses (`array_column($list, 'emailAddress')` yields empty); (3) ask the two confirmations — did the mapping document reach AIG after the 2026-05-04 email, and has their dev acknowledged the `contactEmailAddresses[]` mapping. The exact payload fragment to paste is in `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` → the `TRUE-79401 payload shape — RESOLVED` section. Immediately after: check the git/deploy state of `C:\WWW\library` for the TRUE-79401 commit before signalling "live".
102
+ > Draft the note to Paulina for AIG's dev team. It must (1) retract the "only if necessary" ambiguity and state plainly that the secondary/tertiary addresses **are** wanted — unlike Business Unit; (2) include the concrete JSON example, not just the `contactEmailAddresses[].emailAddress` path expression, because a bare-string array returns HTTP 201 and stores **only the primary** — indistinguishable from the feature being off; (3) ask the two confirmations — did the mapping document reach AIG after the 2026-05-04 email, and has their dev acknowledged the `contactEmailAddresses[]` mapping. Mention that 3 addresses is safe but the 1.0 column caps out around 6–7. The payload fragment to paste is in `knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md` → the `TRUE-79401 payload shape — RESOLVED` section. Immediately after: check the git/deploy state of `C:\WWW\library` for the TRUE-79401 commit before signalling "live".
80
103
 
81
104
  ---
82
105
  _Saved by /session-save on 2026-08-03_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.499",
3
+ "version": "1.0.501",
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",