toga-ai 1.0.498 → 1.0.500

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.
@@ -14,4 +14,4 @@
14
14
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
15
15
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
16
16
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
17
- | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php |
17
+ | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php, test/@Mark/AIG/test_multi_email.php |
@@ -12,9 +12,12 @@ files:
12
12
  - library/app/api/toga2.php
13
13
  - worker/crons/toga2/aig/sync_togasupply_aig.php
14
14
  - worker/crons/toga2/wje/sync_togasupply_wje.php
15
+ - test/@Mark/AIG/test_multi_email.php
15
16
  related:
16
17
  - ../../../2.0/apps/api2/features/nested-relationship-writes.md
17
18
  - ../../../clients/aig/features/entitlement-intake.md
19
+ - ../../test/features/static-no-db-regression-harness.md
20
+ - ../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md
18
21
  - netsuite-suiteql-api-reference.md
19
22
  - netsuite-suiteql-rest-shim.md
20
23
  - ../../worker/features/netsuite-togasupply-per-client-sync.md
@@ -138,7 +141,28 @@ Correct inbound shape:
138
141
  ```
139
142
 
140
143
  The primary **may** be repeated inside the collection — `syncContactToToga1Customer` de-dupes with
141
- `array_unique`.
144
+ `array_unique` — and AIG **should** repeat it: if the collection carries extras but **omits** the
145
+ primary, the primary is **not** re-added (the fallback fires only on a *fully empty* list), so the
146
+ 1.0 field ends up holding the secondary/tertiary **without** the main address.
147
+
148
+ **A wrong shape degrades silently to primary-only — it does NOT blank the field (corrected
149
+ 2026-08-03, proven by the static harness).** With an array of bare strings, `array_column()` finds
150
+ no `emailAddress` key → empty list → **the primary-email fallback fires** → the stored value is
151
+ exactly the primary address, **byte-identical to correct single-email behavior.** So there is **no
152
+ observable difference between "AIG sent the wrong shape" and "AIG hasn't enabled the feature yet"**
153
+ — strictly worse for diagnosis than a blanked field. The **only** reliable verification signal is
154
+ the child row count in `Client_Aig.ContactEmailAddresses`: not the HTTP status, and **not** the 1.0
155
+ `Customers.emailAddress` value.
156
+
157
+ **Storage is bounded — "send as many as you like" is FALSE at the 1.0 layer.** With `'; '` joining
158
+ and ~47-char business addresses, measured lengths against the `varchar(255)` column: 2 addr = 96,
159
+ 3 = 145, 5 = 243 chars (all fit); **8 = 390 and 10 = 489 chars → OVERFLOW.** So the practical cap
160
+ is roughly **6–7 business-length addresses.** The committed scope (secondary + tertiary ⇒ 3
161
+ addresses ≈ 145 chars) is comfortably safe, but the 2.0 nested collection is structurally
162
+ **unbounded** while the 1.0 column is not — the "1, 2, 3 or 10 all work with no change on our side"
163
+ pitch is true of 2.0 storage only, and must be qualified for 1.0. Overflow behavior is itself
164
+ machine-dependent (silent truncation under an empty `sql_mode`, `ERROR 1406` under strict mode) —
165
+ see [local vs prod MySQL config parity](../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md).
142
166
 
143
167
  **Three distinct layers — only the last one is delimited.** Conflating them is the easy mistake:
144
168
 
@@ -164,8 +188,18 @@ tickets) was **explicitly rejected** — see the gotcha about the single-recipie
164
188
 
165
189
  ### Open items (TRUE-79401)
166
190
 
167
- 1. Confirm the library change is **merged and deployed to the prod worker** (git/deploy state not
168
- verified as of 2026-08-03).
191
+ 1. **Merged: YES. Deployed: still unverified.** Verified 2026-08-03: branch `TRUE-79401`
192
+ (`5aca31ea`) is **fully merged up with `_production`** (`_production` is an ancestor of HEAD,
193
+ working tree clean, local `_production` == `origin/_production` as of the last fetch — no network
194
+ fetch was run). The ticket's own patch is **still +17 / −2 in `app/api/toga2.php`** against
195
+ `_production`: the two merge commits (`90b4178b`, `5aca31ea`) pulled in ~404 insertions of
196
+ **other** tickets' work (TRUE-80408/80374, plus the error-capture and ServiceNow-OAuth commits)
197
+ and **none** of them touched `syncContactToToga1Customer`, the `/contacts` fetch `fields` list, or
198
+ any TRUE-79401 identifier. `php -l` clean on PHP 7.2.33; harness drift guard OK; 12/12 cases pass
199
+ after the merges. **A reviewer should not be alarmed by the file's churn** — the small patch is
200
+ intact under a large merge. **Still open: whether it is deployed to the prod worker.**
201
+ Review technique worth reusing: `git diff _production...HEAD -- <file>` isolates a ticket's real
202
+ contribution from merge noise when an old branch is finally synced.
169
203
  2. **Beta verification of the nested write is outstanding.** Assert real
170
204
  `Client_Aig.ContactEmailAddresses` **row counts** (never the HTTP status), covering **both** a
171
205
  brand-new contact (CREATE) **and** a second entitlement for an **existing** contact (UPDATE).
@@ -250,7 +284,9 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
250
284
  framework-level fact captured separately.)
251
285
  - **`TOGA_AIG.Customers.emailAddress` is `varchar(255)`** (V1 legacy, latin1_swedish_ci) — wide
252
286
  enough to hold several `'; '`-joined emails, so TRUE-79401 needed **no** schema/dbchanges migration
253
- (the earlier fear that it was ~VARCHAR(55) was wrong). `TOGA_AIG` is the 1.0 legacy DB;
287
+ (the earlier fear that it was ~VARCHAR(55) was wrong) — but **it is a real ceiling: ~6–7
288
+ business-length addresses** (measured: 3 = 145 chars, 5 = 243 fit; 8 = 390, 10 = 489 overflow).
289
+ The 2.0 collection is unbounded; this column is not. See *Storage is bounded* above. `TOGA_AIG` is the 1.0 legacy DB;
254
290
  `Client_Aig` is the 2.0 prod tenant.
255
291
  - **The multi-email path is not yet exercised in prod data (TRUE-79401)** — as of 2026-07-28 all 20
256
292
  `Client_Aig.ContactEmailAddresses` rows are one-per-contact. The *payload-shape* question that
@@ -280,10 +316,48 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
280
316
  was ~VARCHAR(55)" traces to a garbled transcript line — "needs to fifty-five… maybe we should
281
317
  change it to a long"; the column is `varchar(255)`. Closed.)
282
318
  - **PHP 7.2** target (prod worker/library) — no arrow functions, typed properties, `??=`, `match`.
283
- Lint with `C:\xampp7\php\php.exe -l`.
319
+ Lint with your **PHP 7.2 CLI** (`php -l <file>`); do not lint 1.0 code with a PHP 8 binary. (The
320
+ per-machine location of each PHP CLI is a developer-local detail and is deliberately not recorded
321
+ here.)
322
+ - **This logic IS testable without a database — and there is a harness.**
323
+ `App_Database::sqlEscape()` needs **no DB connection** (pure `str_replace`), so the email
324
+ transform can be exercised statically. The TRUE-79401 transform is inlined in the DB-writing
325
+ `private static syncContactToToga1Customer()`, so the harness **mirrors** it and guards against
326
+ drift by asserting the production statements verbatim — see
327
+ [static no-DB regression harness](../../test/features/static-no-db-regression-harness.md). **If
328
+ you edit those 5 statements, the harness will refuse to report until it is updated.** The clean
329
+ fix if you are already in there: extract the transform to a `public static` helper so the mirror
330
+ disappears.
331
+ - **Pre-existing (low severity, out of scope): a raw-vs-escaped comparison causes a redundant
332
+ UPDATE every sync.** At the update path (~line 8584) `syncContactToToga1Customer` compares
333
+ `$existingCustomer['emailAddress'] !== $emailAddress` — the DB value from
334
+ `App_Database::fetchRow()` is **unescaped**, while `$emailAddress` has already been through
335
+ `App_Database::sqlEscape()`. For any value containing `'`, `"` or `\` the two can **never** compare
336
+ equal, so that column is rewritten on **every** run. Same pattern applies to `businessName` and
337
+ `phoneNumber` just above. **Predates TRUE-79401**, is a redundant write (not data loss or
338
+ corruption), and was deliberately left out of that ticket's scope — recorded here so a future
339
+ reader knows it is known.
284
340
 
285
341
  ## Change history
286
342
 
343
+ - 2026-08-03 (later pass) — TRUE-79401 **static regression harness + one correction.** BUILT a
344
+ no-DB PHP harness (`test/@Mark/AIG/test_multi_email.php`, 12 cases, PHP 7.2.33, `php -l` clean)
345
+ that feeds mocked `/contacts` fragments through the email transform and asserts the delimited
346
+ string; it **mirrors** the transform (inlined in the DB-writing private method) and **guards
347
+ against drift** by asserting the 5 production statements verbatim, plus parses
348
+ `CUSTOMER_EMAIL_DELIMITER` from source — technique documented in
349
+ [static no-DB regression harness](../../test/features/static-no-db-regression-harness.md).
350
+ **CORRECTION (supersedes the earlier same-day entry):** a bare-string collection does **not**
351
+ blank/vanish the addresses — it **silently degrades to primary-only**, byte-identical to correct
352
+ single-email output, so the wrong shape is indistinguishable from the feature being disabled;
353
+ child row counts in `Client_Aig.ContactEmailAddresses` are the only signal. Also recorded: the
354
+ omitted-primary sub-case (repeat the primary in the array); the **`varchar(255)` ceiling of ~6–7
355
+ business-length addresses** (so "send as many as you like" is false at the 1.0 layer, qualifying
356
+ the earlier pitch); the pre-existing raw-vs-escaped comparison causing a redundant UPDATE every
357
+ sync (~line 8584, out of scope); genericized the lint instruction (was a non-existent absolute
358
+ `C:\xampp7\...` path); and **verified the TRUE-79401 branch is fully merged with `_production`
359
+ with its patch intact at +17/−2 under ~404 insertions of other tickets' merge noise** (deploy to
360
+ the prod worker still unverified). (mhammontree)
287
361
  - 2026-08-03 — TRUE-79401 **payload shape RESOLVED** (review/no-code session; sources: AIG's
288
362
  field-mapping spreadsheet, the 2026-05-01 Teams meeting, the 2026-05-04 AIG email). Both
289
363
  `UserDefined3` and `UserDefined4` map to the **same** nested
@@ -9,6 +9,7 @@
9
9
  | [Developer Generators (password, UUID)](features/dev-generators.md) | Two tiny **1.0 `App_` framework** convenience scripts for everyday developer needs. | test/team/generate_password.php, test/team/uuid.php |
10
10
  | [Forecast vs NetSuite Discrepancy Analysis](features/forecast-netsuite-discrepancy-analysis.md) | `team/forecast-netsuite/discrepancy_analysis.php` detects discrepancies between our **Forecast database** and **NetSuite** (the source of truth for all sales da | test/team/forecast-netsuite/discrepancy_analysis.php |
11
11
  | [@goagilant.com → @togatech.com Email-Domain Migration (1.0 + 2.0)](features/goagilant-to-togatech-email-migration.md) | Reference + technique for migrating the company email domain `@goagilant.com` → `@togatech.com` across **both** platforms. | migrate_goagilant_to_togatech_2026-06-26.sql, migrate_goagilant_to_togatech_LEGACY_2026-06-26.sql |
12
+ | [Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard](features/static-no-db-regression-harness.md) | 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL, so "just call it" means standing up a client database. | test/@Mark/AIG/test_multi_email.php, library/app/database.php, library/app/api/toga2.php |
12
13
  | [TableView Builder (2.0 TableViews SQL generator)](features/tableview-builder.md) | `team/tableViewBuilder/` generates SQL `INSERT` statements for the **2.0 `TableViews`**, `TableViewFields`, and `TableViewJoins` tables from a plain SQL `SELECT | test/team/tableViewBuilder/TableViewGenerator.php, test/team/tableViewBuilder/index.php, test/team/tableViewBuilder/Instructions.md |
13
14
  | [Talos Knowledge Base Pipeline (Uploader + Processor)](features/talos-kb-pipeline.md) | `team/talos/` holds the two-script web tooling that feeds the **TOGa Talos** (TOGa IQ) AI knowledge bases. | test/team/talos/kb_uploader.php, test/team/talos/kb_processor.php, test/team/talos/kb_processor.ini |
14
15
  | [TOGa 2.0 Client Onboarding SQL Generator](features/toga2-client-onboarding-sql.md) | > **Superseded by the browser wizard.** The generation logic here was extracted into the reusable > `OnboardingSqlGenerator` class and wrapped in a local browse | test/team/generate_toga2_onboarding_sql.php |
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Static (no-DB) Regression Harness for 1.0 Logic + Source Drift Guard
3
+ framework: "1.0"
4
+ repo: test
5
+ project: Test
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-03
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - test/@Mark/AIG/test_multi_email.php
13
+ - library/app/database.php
14
+ - library/app/api/toga2.php
15
+ related:
16
+ - ../architecture.md
17
+ - ../../library/features/toga2-api-client-and-bridge.md
18
+ - ../../library/architecture.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ 1.0 has **no PHPUnit**, and most of its business logic sits inside methods that also write SQL,
24
+ so "just call it" means standing up a client database. This doc records the technique that makes
25
+ a large class of 1.0 logic testable **statically — no DB connection, no framework bootstrap** —
26
+ plus the **source drift guard** that keeps such a test honest when the logic under test cannot
27
+ actually be invoked.
28
+
29
+ First built for TRUE-79401 (`test/@Mark/AIG/test_multi_email.php`): 12 cases feeding mocked 2.0
30
+ `/contacts` payload fragments through the multi-email transform and asserting the delimited
31
+ string that would be written to `TOGA_AIG.Customers.emailAddress`. Runs on PHP 7.2.33, `php -l`
32
+ clean, no database.
33
+
34
+ ## Key enabler: `App_Database::sqlEscape()` needs NO database connection
35
+
36
+ This is the fact that unlocks static 1.0 testing and it is documented nowhere else:
37
+
38
+ - `App_Database::sqlEscape()` delegates to `App_Database::mysqlRealEscapeString()`, which is a
39
+ **pure `str_replace`** over `\`, `\x00`, `\n`, `\r`, `'`, `"`, `\x1a`. The
40
+ `mysqli_real_escape_string` implementation is **commented out** in the source.
41
+ - So it needs **no mysqli link and no `App_Registry` lookup**.
42
+ - `library/app/database.php` declares `abstract class App_Database` with **no `extends`** and no
43
+ parse-time dependencies, so a test can `require_once` that one file alone.
44
+
45
+ Consequence: **any 1.0 logic whose only framework dependency is escaping is unit-testable
46
+ statically.** Consequence for correctness testing too — because escaping is a pure string
47
+ transform, a test can assert the *exact* escaped output.
48
+
49
+ ## How the harness is structured
50
+
51
+ 1. `require_once` `library/app/database.php` only (no bootstrap, no config, no DB).
52
+ 2. Build mocked payload fragments as plain `stdClass`/arrays shaped like the 2.0 API response.
53
+ 3. Run the transform (see drift guard below) and assert the resulting string.
54
+ 4. Cases cover: single address, multiple, duplicates, blank/whitespace entries, empty
55
+ collection (primary fallback), and wrong payload shapes.
56
+ 5. Parse `CUSTOMER_EMAIL_DELIMITER` **out of the production source** rather than hardcoding
57
+ `'; '`, so changing the constant does not silently invalidate the expectations.
58
+
59
+ ## Drift guard — for logic that cannot be called
60
+
61
+ The TRUE-79401 transform is 3 lines **inlined inside `private static
62
+ App_Api_Toga2::syncContactToToga1Customer()`**, which also writes SQL. It cannot be invoked
63
+ without a live client DB, so the harness must **mirror** it — and a mirror silently rots.
64
+
65
+ The guard: the harness **reads `library/app/api/toga2.php`**, normalizes whitespace, and asserts
66
+ that the **5 production statements it mirrors still appear verbatim**. On any mismatch it prints
67
+ `DRIFT DETECTED` and **refuses to report results at all** (it does not just warn — a passing
68
+ mirror of stale code is worse than no test).
69
+
70
+ This pattern is reusable for **any 1.0 pure-logic fragment buried in a DB-writing private
71
+ method**, which is a large share of `library`.
72
+
73
+ **Cleaner long-term alternative (deliberately NOT done here):** extract the transform into a
74
+ `public static` helper so the test calls production code directly and the mirror disappears
75
+ entirely. That was out of scope because it changes production code; prefer it when you are
76
+ already editing the method.
77
+
78
+ ## Gotchas / known issues
79
+
80
+ - **A mirrored test proves the mirror, not production.** The drift guard is what makes it
81
+ trustworthy — never ship a mirror without one.
82
+ - **Static tests cannot validate anything the server decides.** Column truncation, collation
83
+ behavior, strict mode and table-name casing are all server-config dependent — a no-DB pass says
84
+ nothing about them. See
85
+ [local vs prod MySQL config parity](../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md).
86
+ - **`@Mark/` (and any `@<name>/`) folders in `test` are per-developer.** `test` is not branched;
87
+ write only in your own folder and ask before touching another developer's.
88
+ - Lint 1.0 code with your **PHP 7.2** CLI (`php -l <file>`) — the prod worker/library target is
89
+ 7.2, so 7.4+/8.x syntax must not appear.
90
+
91
+ ## Change history
92
+
93
+ - 2026-08-03 — Documented the static no-DB 1.0 harness technique, built for TRUE-79401
94
+ (`test/@Mark/AIG/test_multi_email.php`, 12 cases, PHP 7.2.33). Key enabler recorded:
95
+ `App_Database::sqlEscape()` is a pure `str_replace` (the `mysqli_real_escape_string` path is
96
+ commented out) needing **no DB link or registry**, and `library/app/database.php` can be
97
+ `require_once`d alone. Also recorded the **source drift guard** for logic inlined in a
98
+ DB-writing private method (assert the mirrored production statements appear verbatim; refuse to
99
+ report results on mismatch) and parsing constants out of the source instead of hardcoding them.
100
+ (mhammontree)
@@ -135,9 +135,20 @@ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `L
135
135
  `UserDefined3`) instead of the nested collection. The key is dropped; no child row is created.
136
136
  2. **Array of bare strings instead of array of objects.** `"contactEmailAddresses":
137
137
  ["email2@domain.com"]` instead of `[{"emailAddress": "email2@domain.com"}]`. Nothing is
138
- created, and any consumer doing `array_column($list, 'emailAddress')` gets an **empty** list —
139
- so a downstream sync silently falls back to the primary email and the extra addresses vanish
140
- with no trace.
138
+ created, and any consumer doing `array_column($list, 'emailAddress')` gets an **empty** list.
139
+
140
+ **Corrected 2026-08-03 (proven by harness):** the downstream effect is **not** an empty/blanked
141
+ field. The empty list makes the consumer's **primary-email fallback fire**, so it stores
142
+ **exactly the primary address** — output that is **byte-identical to correct single-email
143
+ behavior**. There is therefore **no observable difference between "the partner sent the wrong
144
+ shape" and "the partner hasn't enabled the extra addresses yet."** That is strictly *worse* for
145
+ diagnosis than a blanked field would be, and it is why the child ROW COUNT is the only reliable
146
+ signal (see below) — the consumer's stored value cannot distinguish the two.
147
+
148
+ Related shape trap in the same family: if the partner sends extras but **omits the primary from
149
+ the collection**, the primary is **not re-added** — the fallback only fires on a *fully empty*
150
+ list — so the consumer stores the secondary/tertiary but **not** the main address. Tell partners
151
+ to **repeat the primary inside the array**.
141
152
 
142
153
  **Operational rules that follow:**
143
154
 
@@ -163,6 +174,15 @@ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `L
163
174
 
164
175
  ## Change history
165
176
 
177
+ - 2026-08-03 (later pass) — **Correction, supersedes the entry below on one point.** The bare-string
178
+ collection shape does **not** make the addresses "vanish"/blank the consumer's field: the empty
179
+ `array_column()` result makes the consumer's primary-email fallback fire, so the stored value is
180
+ **byte-identical to correct single-email output** and the wrong shape is **indistinguishable from
181
+ the feature simply not being enabled** — worse for diagnosis, and confirmation that the child row
182
+ count is the only reliable signal. Also added the omitted-primary sub-case (the fallback fires
183
+ only on a *fully empty* list, so a collection lacking the primary stores the extras **without**
184
+ the main address — partners should repeat the primary inside the array). Proven by the TRUE-79401
185
+ static harness. (mhammontree)
166
186
  - 2026-08-03 — Documented that V2 **silently ignores unrecognized payload keys and does not
167
187
  validate nested-collection element shape**: both a flat/unknown key and an array of bare strings
168
188
  (instead of an array of objects) return **HTTP 201** with the data dropped and no error logged
@@ -5,4 +5,5 @@
5
5
  | [Database Changes (dbchanges2) Repository Architecture](architecture.md) | `dbchanges2` is the **schema-migration / SQL change-set repository** for the entire 2.0 platform. | Core/, Client/, Client_<Tenant>/, Logs/, Logs_Client/, _modules/ |
6
6
  | [Surface Layer Schema (UI presentation/config tables)](features/surface-layer-schema.md) | The persistent schema for the platform-wide **Surface** UI presentation/configuration layer (see the `_underscore` [surface-resolver](../../_underscore/features | dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client_Compass/2026-06-25d - SalesOrderSurfaceClientSeed.sql, _underscore/Model/Client/ThemeToken.php, toga25-supply/src/themeConfig.json, dbchanges2/Core/2026-06-25a - SurfaceCoreTables.sql, dbchanges2/Core/2026-06-25b - SurfaceRecordsAndFields.sql, dbchanges2/Core/2026-06-25c - SalesOrderLoginSurfaceSeed.sql, dbchanges2/Core/2026-06-29a - ItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29b - SurfaceMetaPublicReadAcl.sql, dbchanges2/Client/2026-06-29c - SurfaceRecordScriptAcl.sql, dbchanges2/Core/2026-06-29c - SurfaceDebugPhpMethodFix.sql, dbchanges2/Core/2026-06-29d - VendorItemsSurfaceSeed.sql, dbchanges2/Core/2026-06-29e - InventorySurfaceSeed.sql, dbchanges2/Core/2026-06-30a - SurfaceMetaGroupAndSalesOrderSections.sql, dbchanges2/Client/2026-06-30a - SurfaceMetaGroupAcl.sql, dbchanges2/Client_Compass/2026-06-30a - SalesOrderDisplaySectionManagerOverrides.sql, dbchanges2/Client_CompassCanada/2026-06-30a - SalesOrderSurfaceManagerOverrides.sql, dbchanges2/Client_Quad/2026-06-30a - SalesOrderSurfaceClientOverrides.sql, dbchanges2/Client/2026-06-25a - SurfaceClientTables.sql, dbchanges2/Client/2026-06-25b - SurfaceClientSeed.sql, dbchanges2/Client/2026-06-25c - SurfaceClientAcl.sql, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Core/2026-07-17h - Update - ClearApprovalsFilterButtonConfig.sql, dbchanges2/Client_Compass/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Compass/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Client_Quad/2026-07-17a - SalesOrderApprovalActionsOverride.sql, dbchanges2/Client_Quad/2026-07-17b - SalesOrderApprovalsFilterButtonOverride.sql, dbchanges2/Core/2026-07-20a - Update - HideAdminNotesSectionByDefault.sql, dbchanges2/Core/2026-07-20b - Update - NotesSectionFieldElements.sql, dbchanges2/Client_Compass/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_Compass/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20a - AdminNotesSectionVisibilityOverride.sql, dbchanges2/Client_CompassCanada/2026-07-20b - NotesSectionFieldsOverride.sql, dbchanges2/Client_Quad/2026-07-20a - NotesSectionFieldsOverride.sql, dbchanges2/Core/2026-07-20c - Update - VendorItemsToggleSurfaceSeed.sql, dbchanges2/Client_Compass/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_Compass/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20c - ItemRecordEditButtonEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20d - ItemRecordVendorItemsEnable.sql, dbchanges2/Core/2026-07-20e - RestoreApproveDenyRowActions.sql, dbchanges2/Client_Compass/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Client_CompassCanada/2026-07-20e - RowActionsApprovalWorkflowAdminEnable.sql, dbchanges2/Core/2026-07-17 - README - RUN ORDER.md, dbchanges2/Client_Compass/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Compass/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_CompassCanada/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_CompassCanada/2026-07-21b - SalesOrderApprovalsGateEnable.sql, dbchanges2/Client_Quad/2026-07-21a - SalesOrderApproveEnabledRuleOverride.sql, dbchanges2/Client_Quad/2026-07-21b - SalesOrderApproveDisabledTooltipTranslation.sql, dbchanges2/Core/2026-07-21a - SalesOrderDecisionSummarySurfaceSeed.sql, dbchanges2/Core/2026-07-21b - SalesOrderDecisionActionSurfaceSeed.sql, dbchanges2/Client_Quad/2026-07-21c - SalesOrderDecisionSummaryOverride.sql, dbchanges2/Client_Compass/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Client_CompassCanada/2026-07-21c - SalesOrderDecisionSummaryTotalConcat.sql, dbchanges2/Core/2026-07-23a - PoNumberDetailFieldValueKey.sql |
7
7
  | [2.0 New-Client Onboarding (manual process)](workflows/client-onboarding.md) | > **A local browser wizard now automates this.** Steps 2–9 below (create DBs, generate Core/API > inserts, append to `Clients_Db.txt`) — plus the dbchanges2 bla | Client/, Client_<Tenant>/, Core/, Logs_Client/ |
8
+ | [Local vs prod MySQL config parity — why “it passed locally” is not evidence](workflows/local-vs-prod-mysql-config-parity.md) | Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL server-configuration differences**. | |
8
9
  | [Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)](workflows/nonprod-metadata-drift-repair.md) | Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`, `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` row | dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql, dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql, api2/Config/beta.ini |
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-31
9
+ updated: 2026-08-03
10
10
  owners: [jcardinal, mhammontree, bala, ajean]
11
11
  files:
12
12
  - Core/
@@ -417,6 +417,42 @@ under the 64 MB `max_allowed_packet` default. First hit in
417
417
  `Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql` (2,200 AIG SaleItem codes); see
418
418
  `clients/aig/features/entitlement-intake.md` → *Uploading new codes*.
419
419
 
420
+ ### Staging tables MUST pin the collation of the column they join against
421
+
422
+ A `CREATE TEMPORARY TABLE` that omits an explicit `COLLATE` inherits the **connection/database
423
+ default**, which is **not** guaranteed to match the target column. The moment the staging column
424
+ is joined to a real column with a different collation, MySQL raises:
425
+
426
+ ERROR 1267 (HY000): Illegal mix of collations (utf8mb4_general_ci,IMPLICIT) and
427
+ (utf8mb4_unicode_ci,IMPLICIT) for operation '='
428
+
429
+ The connection default is commonly `utf8mb4_general_ci`, while 2.0 tenant tables are **typically**
430
+ `utf8mb4_unicode_ci` (verified for `Client_Aig.Items.partNumber`) — so the mismatch is the default
431
+ outcome, not the exception. **Always declare the collation explicitly on every staging column that
432
+ participates in a join**, copying it from the column being matched:
433
+
434
+ ```sql
435
+ CREATE TEMPORARY TABLE _stage (
436
+ uuid VARCHAR(36) NOT NULL,
437
+ keyColumn VARCHAR(255) COLLATE utf8mb4_unicode_ci NOT NULL, -- pinned to TargetTable.keyColumn
438
+ otherColumn TEXT NULL
439
+ );
440
+ ```
441
+
442
+ **This is a per-machine failure, not a per-query one.** Whether it fires depends on the server's
443
+ own defaults, so the *same file* passes on one developer's machine and dies on another's — and on
444
+ the production server. Measured: it ran clean on MySQL **8.0.39** and failed with 1267 on
445
+ **8.0.44** with no change to the SQL. The same class of per-machine divergence applies to
446
+ **`sql_mode`** (e.g. `ONLY_FULL_GROUP_BY`/strict-mode rejecting a statement that passed elsewhere),
447
+ **`lower_case_table_names`** (table-name casing resolving locally and 404-ing on Linux), and
448
+ **`thread_stack`** (the `UNION ALL` parse limit above triggering at different sizes).
449
+
450
+ **Rule: a local pass is NOT evidence that a migration is portable.** Anything that depends on a
451
+ server variable must be pinned **in the SQL file itself** — explicit `COLLATE`, explicit column
452
+ casing, no reliance on a permissive `sql_mode`. Verify server-setting parity before trusting a
453
+ local run; see the environment-parity workflow in
454
+ `2.0/apps/_underscore/architecture.md` → *Database architecture*.
455
+
420
456
  ## Self-referencing DELETE — wrap the subquery in a derived table
421
457
 
422
458
  MySQL forbids a `DELETE` (or `UPDATE`) whose `WHERE` subquery reads **the same table being
@@ -511,6 +547,15 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
511
547
  tables that `_Model_*` classes map to.
512
548
 
513
549
  ## Change history
550
+ - 2026-08-03 — **Added *Staging tables MUST pin the collation of the column they join against*.**
551
+ A `CREATE TEMPORARY TABLE` without an explicit `COLLATE` inherits the connection default
552
+ (commonly `utf8mb4_general_ci`) and dies with **error 1267 illegal mix of collations** when
553
+ joined to a tenant column that is typically `utf8mb4_unicode_ci` (verified for
554
+ `Client_Aig.Items.partNumber`). Pin the collation on every staging column used in a join.
555
+ Reframed as the general rule that **a local pass is not evidence of portability**: the same file
556
+ ran clean on MySQL 8.0.39 and failed on 8.0.44 unchanged, and the same per-machine divergence
557
+ applies to `sql_mode`, `lower_case_table_names`, and `thread_stack`. Anything depending on a
558
+ server variable must be pinned in the SQL file itself. (mhammontree)
514
559
  - 2026-07-31 — **Added *`Core.Records` / `Core.RecordFields` — the only hardcoded `id`s on the
515
560
  platform* + rule #9.** These two tables are the **only** ones platform-wide whose `id` is a
516
561
  team-maintained constant: the next available value is tracked **in the developer chat**, so a
@@ -0,0 +1,113 @@
1
+ ---
2
+ title: "Local vs prod MySQL config parity — why “it passed locally” is not evidence"
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-03
10
+ owners: ["mhammontree"]
11
+ related:
12
+ - ../architecture.md
13
+ - ../workflows/nonprod-metadata-drift-repair.md
14
+ - ../../../clients/aig/features/entitlement-intake.md
15
+ - ../../../1.0/apps/test/features/static-no-db-regression-harness.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ Several migration failures that look like "prod-only bugs" are actually **per-machine MySQL
21
+ server-configuration differences**. They are not version differences and not environment
22
+ properties — prod and local are both **MySQL 8.0** — so the same SQL can pass on one developer's
23
+ machine, fail on another's, and fail on deploy, with no code change involved.
24
+
25
+ Measured 2026-08-03: **prod Core = 8.0.39**, **local Core_2 = 8.0.44**. Same engine, same major
26
+ version.
27
+
28
+ **Operating rule: a local pass is NOT evidence that a migration is safe.** Run the parity query
29
+ below before trusting a local result, and pin anything the server would otherwise decide for you.
30
+
31
+ ## Parity check — run this on BOTH the target environment and locally
32
+
33
+ ```sql
34
+ SELECT VERSION(), @@collation_server, @@character_set_server, @@sql_mode,
35
+ @@lower_case_table_names, @@thread_stack, @@max_allowed_packet;
36
+ ```
37
+
38
+ Compare the two outputs before concluding anything from a local run. (Machine-specific config
39
+ file locations and one developer's overrides are deliberately **not** recorded here — they belong
40
+ in per-developer notes, not the shared KB.)
41
+
42
+ ## 1. `collation_server` — the ERROR 1267 trap is per-MACHINE, not prod-only
43
+
44
+ Measured: **prod `collation_server = utf8mb4_0900_ai_ci`** (the MySQL 8 *default*); **local
45
+ `utf8mb4_unicode_ci`** (an explicit config override on that machine).
46
+
47
+ So the difference is a **config property of whichever server was left at the MySQL 8 default** —
48
+ *not* a property of "production". Consequences:
49
+
50
+ - A developer on a **stock MySQL 8** install **will** reproduce `ERROR 1267 (Illegal mix of
51
+ collations)` locally, and is actively misled by any doc claiming it only happens on prod.
52
+ - A developer with the `utf8mb4_unicode_ci` override will **never** reproduce it.
53
+ - It is **fragile**: a MySQL reinstall or a reset config file silently flips the behavior with no
54
+ code change.
55
+
56
+ **Mitigation (unchanged, now correctly justified):** always pin
57
+ `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` **explicitly** on temp/staging columns that
58
+ will be compared to a real table column. Because the server default is per-machine, **a local
59
+ pass proves nothing** about the deploy target. First hit on the AIG SaleItem load — see
60
+ [AIG entitlement intake](../../../clients/aig/features/entitlement-intake.md) → *Uploading new
61
+ codes*.
62
+
63
+ ## 2. `sql_mode` — an empty local mode truncates where prod throws ERROR 1406
64
+
65
+ MySQL 8's default `sql_mode` includes **`STRICT_TRANS_TABLES`** and **`ONLY_FULL_GROUP_BY`**. A
66
+ local instance configured with an **empty `sql_mode`**:
67
+
68
+ - **Silently TRUNCATES** an over-length write (e.g. a >255-char value into a `varchar(255)`)
69
+ where a strict server raises **`ERROR 1406 Data too long`**.
70
+ - Accepts `GROUP BY` queries that a strict (`ONLY_FULL_GROUP_BY`) server **rejects**.
71
+
72
+ Directly relevant to the 1.0 multi-email work: overflow of `Customers.emailAddress` behaves
73
+ **differently per machine** — truncate here, hard error there. See
74
+ [the library bridge doc](../../../1.0/apps/library/features/toga2-api-client-and-bridge.md).
75
+
76
+ ## 3. `lower_case_table_names` — a PERMANENT Windows-local vs Linux-prod difference
77
+
78
+ - Windows dev installs typically run **`lower_case_table_names=2`**; Linux prod runs **`0`**
79
+ (case-**sensitive** table names).
80
+ - It **cannot be changed after the data directory is initialized** — MySQL 8 refuses to start on a
81
+ data-dictionary mismatch — and `0` is **not permitted on Windows at all**. This one is not
82
+ fixable, only worked around.
83
+ - Consequence: SQL referencing a table with the wrong casing (e.g. `contactemailaddresses` instead
84
+ of `ContactEmailAddresses`) **passes locally and fails on prod.**
85
+
86
+ 2.0 table names are **PascalCase**, so treat exact table-name casing as something local testing
87
+ can **never** validate. Review it by eye.
88
+
89
+ ## 4. `thread_stack` — a bigger local stack hides ERROR 1436 until deploy
90
+
91
+ `thread_stack` is what produced the **`ERROR 1436 Thread stack overrun`** on the ~2,200-row
92
+ `UNION ALL` in TRUE-79534. A larger local stack means a big migration **parses locally and dies on
93
+ deploy** — the same shape as the collation trap. The fix is still structural (flat multi-row
94
+ `VALUES` into a temp table, never a long `UNION ALL` chain); see
95
+ [dbchanges2 architecture](../architecture.md) → *Bulk data loads*. Do not raise `thread_stack` for
96
+ a one-off load.
97
+
98
+ ## Gotchas / known issues
99
+
100
+ - **The common failure mode is a false negative, not a false positive.** Every item above lets bad
101
+ SQL pass locally. Nothing here makes good SQL fail on prod.
102
+ - **Do not "fix" a parity gap by changing the server.** Pin collations in the SQL, keep casing
103
+ exact, size values to the column, and structure bulk loads to avoid parser recursion.
104
+
105
+ ## Change history
106
+
107
+ - 2026-08-03 — Created. Established that the `ERROR 1267` temp-table collation trap is a
108
+ **per-machine config property, not a prod-only / version difference** (measured prod 8.0.39 with
109
+ `utf8mb4_0900_ai_ci`, local 8.0.44 with an explicit `utf8mb4_unicode_ci` override) — a developer
110
+ on a stock MySQL 8 install reproduces it locally. Added the `sql_mode` finding (empty local mode
111
+ silently truncates where strict prod raises `ERROR 1406`, and disables `ONLY_FULL_GROUP_BY`), the
112
+ permanent `lower_case_table_names` Windows-vs-Linux table-name-casing gap, the `thread_stack`
113
+ connection to `ERROR 1436`, and the cross-environment parity query. (mhammontree)
@@ -12,7 +12,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
12
12
  - **togaview** (TOGa View) — 6 doc(s) → [1.0/apps/togaview/INDEX.md](1.0/apps/togaview/INDEX.md)
13
13
  - **webhook** (Webhook) — 1 doc(s) → [1.0/apps/webhook/INDEX.md](1.0/apps/webhook/INDEX.md)
14
14
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
15
- - **test** (Test) — 13 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
15
+ - **test** (Test) — 14 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 2 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
17
  - **tools** (Tools) — 13 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
18
18
 
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
21
21
  - **_underscore** (_Underscore) _(framework core)_ — 42 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 38 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 21 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
- - **dbchanges2** (Database Changes) _(framework core)_ — 4 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 5 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)
27
27
  - **toga2-view** (TOGa View Frontend) — 7 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
@@ -227,12 +227,15 @@ When AIG sends a new "Active SaleItemID" spreadsheet (columns `SaleItemID`, `Des
227
227
  The `TEMPORARY` table is session-scoped (safe on production, auto-dropped on disconnect
228
228
  even if the run aborts). Do **not** raise `thread_stack` on the server for a one-off load.
229
229
  5. **Pin the staging `partNumber` collation explicitly to `utf8mb4_unicode_ci`** (as shown
230
- above). If you omit it, the temp column inherits the **server default**, which on the MySQL 8
231
- **prod** server is `utf8mb4_0900_ai_ci` — while `Client_Aig.Items.partNumber` is
232
- `utf8mb4_unicode_ci`. The anti-join's `existing.partNumber = src.partNumber` then mixes
233
- collations and dies with **ERROR 1267 (Illegal mix of collations)**. It does **not** fail
234
- locally (the local server default is already `utf8mb4_unicode_ci`), so this is a **prod-only**
235
- failure — see the gotcha.
230
+ above). If you omit it, the temp column inherits the **server default**. On any server left at
231
+ the MySQL 8 default that is `utf8mb4_0900_ai_ci` — including **prod** — while
232
+ `Client_Aig.Items.partNumber` is `utf8mb4_unicode_ci`. The anti-join's
233
+ `existing.partNumber = src.partNumber` then mixes collations and dies with **ERROR 1267
234
+ (Illegal mix of collations)**. This is a **per-machine config** property, **not** a prod-only or
235
+ version difference (prod and local are both MySQL 8.0) — a developer on a stock MySQL 8 install
236
+ reproduces it locally, and a developer with a `utf8mb4_unicode_ci` override never does. So **a
237
+ local pass is not evidence.** See the gotcha and
238
+ [local vs prod MySQL config parity](../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md).
236
239
 
237
240
  ## Client variations
238
241
 
@@ -252,7 +255,12 @@ this interceptor or use this dual-purpose Items pattern.
252
255
  Both a flat/unknown key (a literal `UserDefined3`) and an array of bare strings
253
256
  (`"contactEmailAddresses": ["a@b.com"]` instead of `[{"emailAddress": "a@b.com"}]`) are accepted
254
257
  with **no error anywhere**. When verifying AIG's multi-email feed, assert **actual
255
- `Client_Aig.ContactEmailAddresses` row counts** — never the HTTP status. See
258
+ `Client_Aig.ContactEmailAddresses` row counts** — never the HTTP status, **and (corrected
259
+ 2026-08-03) never the 1.0 `TOGA_AIG.Customers.emailAddress` value either.** Proven by harness:
260
+ the bare-string shape does **not** blank the 1.0 field — `array_column()` returns an empty list,
261
+ the primary-email fallback fires, and the stored value is **byte-identical to correct
262
+ single-email output**, so "AIG sent the wrong shape" and "AIG hasn't enabled the feature yet"
263
+ are indistinguishable. The child row count is the **only** reliable signal. See
256
264
  [api2 nested-relationship writes](../../../2.0/apps/api2/features/nested-relationship-writes.md).
257
265
  - **A sale-item code missing from `Client_Aig.Items` currently produces an HTTP 500, not a clean
258
266
  "Missing AIG item ID."** In prod (TRUE-79534) a valid-but-unknown code made `$itemId` empty and
@@ -295,17 +303,34 @@ this interceptor or use this dual-purpose Items pattern.
295
303
  type. **Both stem from the same unguarded lookup** — `prePost` must guard an empty/failed lookup
296
304
  and return a clean "Missing AIG item ID" **before** running the fulfillment query or injecting
297
305
  anything. Not yet fixed.
298
- - **Staging a temp table for a `Client_Aig` load can hit ERROR 1267 on prod only.** A
299
- `CREATE TEMPORARY TABLE` column with no explicit collation inherits the server default —
300
- `utf8mb4_0900_ai_ci` on the MySQL 8 prod server — while `Client_Aig.Items.partNumber` is
301
- `utf8mb4_unicode_ci`; the anti-join `=` then mixes collations → **ERROR 1267 (Illegal mix of
302
- collations)**. It passes locally (local default already `utf8mb4_unicode_ci`), so it is a
303
- **prod-only** trap. Always pin the staging `partNumber` column
304
- `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` (see *Uploading new codes* step 5). This is a
305
- general dbchanges2 temp-table-staging rule, not AIG-specific.
306
+ - **Staging a temp table for a `Client_Aig` load can hit ERROR 1267 — on whichever server is left
307
+ at the MySQL 8 default collation (a PER-MACHINE config trap, not "prod-only").** A
308
+ `CREATE TEMPORARY TABLE` column with no explicit collation inherits `@@collation_server`, which
309
+ is `utf8mb4_0900_ai_ci` by default on MySQL 8 (prod is exactly this) — while
310
+ `Client_Aig.Items.partNumber` is `utf8mb4_unicode_ci`; the anti-join `=` then mixes collations →
311
+ **ERROR 1267 (Illegal mix of collations)**. Measured 2026-08-03: prod Core **8.0.39 /
312
+ `utf8mb4_0900_ai_ci`**, local Core_2 **8.0.44 / `utf8mb4_unicode_ci` (an explicit override)** —
313
+ same engine, same major version, so this was **never** a version or environment difference. A
314
+ developer on a stock MySQL 8 install **will** reproduce it locally; one with the override never
315
+ will; a MySQL reinstall silently flips it. Always pin the staging `partNumber` column
316
+ `CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci` (see *Uploading new codes* step 5) **and treat
317
+ a local pass as no evidence at all** — see
318
+ [local vs prod MySQL config parity](../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md).
319
+ This is a general dbchanges2 temp-table-staging rule, not AIG-specific.
306
320
 
307
321
  ## Change history
308
322
 
323
+ - 2026-08-03 (later pass) — **Two corrections.** (1) The `ERROR 1267` temp-table collation trap is a
324
+ **per-machine server-config property, not a "prod-only" / version difference**: measured prod Core
325
+ 8.0.39 with the MySQL 8 default `utf8mb4_0900_ai_ci` vs local Core_2 8.0.44 with an explicit
326
+ `utf8mb4_unicode_ci` override. The earlier reasoning ("the local server default is already
327
+ `utf8mb4_unicode_ci`") was wrong even though the mitigation was right — a developer on a stock
328
+ MySQL 8 install reproduces it locally, so **a local pass is not evidence.** New doc:
329
+ [local vs prod MySQL config parity](../../../2.0/apps/dbchanges2/workflows/local-vs-prod-mysql-config-parity.md).
330
+ (2) The wrong-shaped-payload gotcha overstated the symptom: an array of **bare strings** does
331
+ **not** blank the 1.0 email field — it **silently degrades to primary-only**, producing output
332
+ byte-identical to correct single-email behavior, so it is undetectable from the stored value.
333
+ `Client_Aig.ContactEmailAddresses` row counts remain the only reliable signal. (mhammontree)
309
334
  - 2026-08-03 — Review-only session (no code changed). **Business Unit (`UserDefined2`) is answered
310
335
  and dropped**: AIG (2026-05-04) needs nothing returned — it is theirs, from the sale, for **sales**
311
336
  invoicing (not claims invoicing); we asked them to omit it from the API call and **no invoice-file
@@ -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.498",
3
+ "version": "1.0.500",
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",