toga-ai 1.0.497 → 1.0.499

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)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-01
9
+ updated: 2026-08-03
10
10
  owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -87,6 +87,28 @@ produced a different hash on every request — dedup never actually worked.
87
87
  cannot orphan a curated Issue: an admin repoints the new fingerprint at the existing Issue
88
88
  from the Tools console to merge them.
89
89
 
90
+ ### Where each exception class routes (the class you throw IS the routing decision)
91
+
92
+ | Thrown class | HTTP | Sentry | `Logs.Issue` | Fingerprint / routing |
93
+ |---|---|---|---|---|
94
+ | `_Exception_Validation` | **400** | no | **no** | n/a — bad client input. Still leaves a full `Logs.Api` row (see [api2 request logging](../../api2/features/request-logging.md)) |
95
+ | `_Exception_Business` | **500** | yes | **yes** | stable `issueKey` (refactor-immune) → business recipients configured in the Tools console |
96
+ | plain global `Exception` | **500** | yes | **yes** | **trace**-based fingerprint (moves when code moves) → developer ClickUp task |
97
+ | `_Exception` | — | — | — | **never throw it deliberately** (below) |
98
+
99
+ Verified against `api2/Controller/Index.php` (≈L205-245, L369-377) and `_Error.php`
100
+ (`captureException` ≈L222-265, `buildFingerprint` ≈L455-490) on **2026-08-03**, while TRUE-78188
101
+ was still in flight — **re-verify against `Controller/Index.php`** rather than trusting the table
102
+ blindly.
103
+
104
+ **`_Exception` must never appear at a deliberate throw site.** Its constructor takes **five**
105
+ required args (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because it is an
106
+ *error-handler shape* populated by `_Error` from `set_error_handler` context — not a general
107
+ exception. `throw new _Exception('msg')` raises an `ArgumentCountError` **before** the intended
108
+ exception is ever constructed, so the real signal is destroyed. Jeff states this rationale in the
109
+ `Exception/Business.php` docblock. Census in `_underscore`: 173 plain `throw new Exception` vs 14
110
+ `_Exception`.
111
+
90
112
  ### `_Exception_Business` — identity immune to refactoring
91
113
 
92
114
  `_underscore/Exception/Business.php` carries a stable `issueKey` plus a `minimumUrgency` floor.
@@ -267,6 +289,12 @@ clientUserId). **Neither was built.** As built instead:
267
289
 
268
290
  ## Change history
269
291
 
292
+ - 2026-08-03 — Added the **exception-class routing table** (`_Exception_Validation` → 400/no
293
+ Issue; `_Exception_Business` → 500 + `issueKey`-fingerprinted Issue to business recipients;
294
+ plain `Exception` → 500 + trace-fingerprinted Issue to a developer task) and recorded that
295
+ **`_Exception` must never be thrown deliberately** — its 5-arg error-handler constructor raises
296
+ `ArgumentCountError` first. Verified against `Controller/Index.php` / `_Error.php` while
297
+ TRUE-78188 was still in flight. (dfranks)
270
298
  - 2026-08-01 — **Decided: `Logs` cluster tables use SINGULAR names.** Renamed all six
271
299
  post-deployment (`Issues`→`Issue`, `Events`→`Event`, `IssueFingerprints`→`IssueFingerprint`,
272
300
  `IssueClickupTasks`→`IssueClickupTask`, `IssueEmailAddresses`→`IssueEmailAddress`,
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-28
10
- owners: [jcardinal, bala, mhammontree]
9
+ updated: 2026-08-03
10
+ owners: [jcardinal, bala, mhammontree, dfranks]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
@@ -102,6 +102,12 @@ One ~2,000-line `execute()` then `processRoutePairs()`:
102
102
  `public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s,
103
103
  refresh 30d); signing secret **rotated** with current+previous accepted across the
104
104
  boundary. The JWT `id` claim carries the full identity used for ACL.
105
+ **`/v2/auth/oauth` credential transport:** OAuth 2.0 `client_credentials`. `client_id` +
106
+ `client_secret` may arrive as **HTTP Basic** *or* as POST body fields — **Basic takes
107
+ priority** when both are present. `scope` is **required**; `grant_type` is **optional** and
108
+ defaults to `client_credentials` (accepted: `client_credentials`, `password`,
109
+ `refresh_token`). The call mints a JWT; subsequent requests carry `Authorization: Bearer`.
110
+ (`api2/Component/Api/V2/V2.php` L414-460.)
105
111
  4. **Response envelope** — `{transactionId, timestamp, authority, audience, isSuccess,
106
112
  status, error, messages[], meta{}, data{}}`; `isSuccess` = 2xx status.
107
113
  5. **Transaction logging** — every request logged (to client/core Logs DB, or as a JSONL
@@ -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
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
9
+ updated: 2026-08-03
10
10
  owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -46,6 +46,21 @@ In `api2/Component/Api/V2/V2.php` (~L2168) every request is logged and routed:
46
46
  - There is also a **config-gated branch** (~L2166): when `_Config::api('log_filepath')` is set,
47
47
  the log entry is written to a **file** on the host instead of the DB.
48
48
 
49
+ ### A rejected request (HTTP 400) is NOT unlogged
50
+
51
+ `api2/Controller/Index.php` (L369-377) handles a non-success response by **committing** the
52
+ `DB_LOGS` and `DB_CLIENT_LOGS` transactions and rolling back **only** the business-data
53
+ transaction. A rejection therefore still writes a **full `Client_Logs.Api` / `Core_Logs.Api`
54
+ row** — request payload, response payload, `apiId`, `sourceIp`, `transactionId` — while the
55
+ business row is discarded.
56
+
57
+ So a 400 **is** SQL-queryable and attributable to the calling credential. What a 400 lacks is a
58
+ `Logs.Issue` row: **no alerting, no aggregation, no owner** (see
59
+ [Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md)).
60
+ That is the deciding factor when choosing between `_Exception_Validation` (400, logged as a
61
+ request, no Issue) and `_Exception_Business` (500, Sentry + a routed Issue). Do **not** reach for
62
+ `_Exception_Business` merely to "make sure the rejection is recorded" — it already is.
63
+
49
64
  ## Blind spots when auditing a client's API traffic
50
65
 
51
66
  Looking only at `Logs_<Client>.Api` misses four sources:
@@ -95,6 +110,11 @@ re-send.
95
110
 
96
111
  ## Change history
97
112
 
113
+ - 2026-08-03 — Corrected a common wrong assumption: an **HTTP 400 leaves a full log row**.
114
+ `Controller/Index.php` L369-377 commits `DB_LOGS`/`DB_CLIENT_LOGS` and rolls back only the
115
+ business transaction, so rejections are queryable and attributable to the credential; what they
116
+ lack is a `Logs.Issue` (no alerting/owner) — the deciding factor between `_Exception_Validation`
117
+ and `_Exception_Business`. (dfranks)
98
118
  - 2026-07-29 — Documented (TRUE-80179) that the core/writer `Logs.Api` retains the **full original
99
119
  `requestPayload`** on failed writes, making it a **replayable backlog source** and a scope
100
120
  diagnostic: dedupe to one-per-contract with a window function on
@@ -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)
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-01
9
- owners: [jcardinal, mhammontree]
8
+ updated: 2026-08-03
9
+ owners: [jcardinal, mhammontree, dfranks]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
@@ -573,15 +573,42 @@ $c = 'Hello ' . $world;
573
573
 
574
574
  * Always use exceptions to handle unexpected conditions.
575
575
 
576
- ### Interceptor / model client-error rejections → throw `_Exception_Validation`, not `_Exception`
577
-
578
- A hard-block raised inside a pre/post interceptor (or model op) to reject a **bad client request**
579
- must throw **`_Exception_Validation`** (`_underscore/Exception/Validation.php`), which
580
- `api2/Controller/Index.php` (≈lines 204–222) maps to `DEFINED_MESSAGE_ERROR_BAD_REQUEST` →
581
- **HTTP 400** with the message carried in the response envelope (so the front end can drive a
582
- toaster). Throwing a plain `_Exception` yields an **HTTP 500 + stack trace** — wrong for a
583
- client-input rejection and an information leak. First applied: the three Rate WH hard-blocks
584
- (missing / invalid / duplicate service address), TRUE-79533.
576
+ ### Choosing an exception class is a ROUTING decision, not style
577
+
578
+ At a deliberate throw site the class you pick determines the HTTP status, whether Sentry sees
579
+ it, whether a `Logs.Issue` is created, how that Issue is fingerprinted, and **who gets
580
+ notified**. Pick deliberately:
581
+
582
+ | Throw this | When | HTTP | Sentry | `Logs.Issue` | Goes to |
583
+ |---|---|---|---|---|---|
584
+ | `_Exception_Validation` | bad **client input** — a hard-block in a pre/post interceptor or model op | **400** | no | no | the caller, via the response envelope (front end can toast it) |
585
+ | `_Exception_Business` | a **business condition a business user can act on and a developer cannot fix** | 500 | yes | yes, keyed by a stable `issueKey` | business recipients configured in the Tools `/errors` console |
586
+ | plain global `Exception` | a genuine **code defect / unexpected state** | 500 | yes | yes, fingerprinted by **trace** | a developer ClickUp task |
587
+
588
+ `_Exception_Business`'s constructor is `(issueKey, message, minimumUrgency)`. The `issueKey` is
589
+ the fingerprint, so the Issue's identity survives refactoring; a plain `Exception`'s
590
+ trace-based fingerprint changes when the code moves.
591
+
592
+ **A 400 is not "unlogged."** `api2/Controller/Index.php` commits the log transactions and rolls
593
+ back only the business transaction, so a rejection still writes a full `Logs.Api` row (request
594
+ + response payload, `apiId`, `sourceIp`, `transactionId`). What a 400 lacks is a `Logs.Issue` —
595
+ no alerting, no aggregation, no owner. Do not upgrade a client-input rejection to
596
+ `_Exception_Business` just to "make sure it's recorded."
597
+
598
+ ### NEVER `throw new _Exception(...)`
599
+
600
+ `_Exception` is an **error-handler shape**, not a general exception: its constructor requires
601
+ five arguments (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because
602
+ `_Error` populates it from `set_error_handler` context. `throw new _Exception('msg')` raises an
603
+ `ArgumentCountError` **before** your exception is ever constructed, destroying the real signal.
604
+ Throwing a plain `_Exception` also yields an HTTP 500 + stack trace — wrong for a client-input
605
+ rejection and an information leak. Census in `_underscore`: 173 plain `throw new Exception` vs
606
+ 14 `_Exception`.
607
+
608
+ Sources: `_underscore/Exception.php`, `Exception/Validation.php`, `Exception/Business.php`,
609
+ `_underscore/Error.php`, `api2/Controller/Index.php`. First applied: the three Rate WH
610
+ hard-blocks (TRUE-79533). Taxonomy verified 2026-08-03 **while TRUE-78188 was still in flight** —
611
+ re-verify against `Controller/Index.php` before relying on it.
585
612
 
586
613
  ### Comprehensive Error Handling
587
614
 
@@ -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
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-13
10
- owners: ["jcardinal", "rgirish"]
9
+ updated: 2026-08-03
10
+ owners: ["jcardinal", "rgirish", "dfranks"]
11
11
  files:
12
12
  - _underscore/Model/Compass/PurchaseOrder.php
13
13
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
@@ -29,6 +29,56 @@ production data-integrity bug.
29
29
  - USA/Canada subclasses (`Model/Compass/Usa|Canada/PurchaseOrder.php`) are empty and inherit
30
30
  this behavior — edit the parent.
31
31
 
32
+ ## Who actually calls `POST /v2/purchase-orders` (MITS is NOT the only caller)
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:**
37
+
38
+ | Identity | Channel | SO field sent | Values | `purchaseOrderItems` |
39
+ |---|---|---|---|---|
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** |
43
+
44
+ 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.
52
+
53
+ **Consequences for any validation added to this endpoint:**
54
+ - **The unprefixed `mitsSalesOrder` is the MITS marker** — not the identity id, and not the
55
+ `SA`/`MR` prefix. Supporting evidence: `prePost` reads `$payload->mitsSalesOrder`; the only
56
+ `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.
60
+ - 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.
63
+ - **`MA` orders never arrive from MITS.** Compass creates them manually; they go to ODP, reach us
64
+ over EDI, and land on an exception report for manual NetSuite entry. (An earlier belief that MA
65
+ is a third MITS prefix to filter on is wrong.)
66
+
67
+ > **⚠ Querying trap — a substring match on `mitsSalesOrder` conflates the two callers**, because
68
+ > `c_mitsSalesOrder` *contains* it. Anyone characterising this traffic from `Logs_Compass.Api`
69
+ > 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**
72
+ > (`"mitsSalesOrder":"MR…"`) **and pretty-printed** (`"mitsSalesOrder": "MR…"`, space after the
73
+ > colon), so a naive pattern under-counts a second way.
74
+
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.
81
+
32
82
  ## How it works
33
83
  - **SA orders (normal):** each inbound PO item carries `createdFromSalesOrderItem.lineNumber`
34
84
  (the MITS line, which includes configuration-line offsets). `prePost` remaps it to the real
@@ -50,6 +100,17 @@ production data-integrity bug.
50
100
  because SA/MR originate in Compass (their Compass SO+items pre-exist) whereas MA is
51
101
  ODP-originated, so nothing else would populate it.
52
102
 
103
+ ### MR and SA consume PO line items differently (asymmetry inside one method)
104
+ - **MR** — `handleMrOrder` (≈L54-57) **silently skips** any item whose
105
+ `vendorItem->item->partNumber` is empty while building `salesOrderItems`. So an MR PO whose
106
+ lines *all* lack a `partNumber` passes a naive `count($payload->purchaseOrderItems) > 0` check
107
+ and still creates an **empty Sales Order**. A "usable item" filter for MR must therefore test
108
+ `partNumber`, not array length.
109
+ - **SA** — items are linked via `createdFromSalesOrderItem` →
110
+ `salesOrderItemPurchaseOrderItems`, and the quantity cross-check (≈L318-324) explicitly
111
+ `continue`s past `partNumber`-less lines rather than discarding them. Applying the MR
112
+ `partNumber` filter to SA would **reject valid orders**.
113
+
53
114
  ## Data model
54
115
  - `SalesOrderItems_PurchaseOrderItems` (`Client_Compass`): `uuid`, `salesOrderItemId`,
55
116
  `purchaseOrderItemId`. One row per (SO item, PO item) the PO fulfills.
@@ -59,6 +120,15 @@ production data-integrity bug.
59
120
  USA and Canada share the parent handler unchanged.
60
121
 
61
122
  ## Gotchas / known issues
123
+ - **SQL injection in `prePost` (OPEN as of 2026-08-03).** `_underscore/Model/Compass/PurchaseOrder.php`
124
+ **L232** interpolates the raw client payload straight into SQL:
125
+ `WHERE SalesOrders.number LIKE '{$payload->mitsSalesOrder}'` — no escape, no cast. The **same
126
+ value is escaped at L301**, so the file is internally inconsistent and the correct pattern is
127
+ already present. Fix with `_Database::escape()` per the 2.0 back-end standard. Reachable only by
128
+ a caller holding valid Compass API credentials (OAuth2 `client_credentials`), so it is **not**
129
+ anonymous or internet-facing — normal priority, not an emergency. Separately, five sites in this
130
+ file (L148, L301, L461, L462, L471) use **`addslashes()`** where the standard requires
131
+ `_Database::escape()`.
62
132
  - **Spurious cross-part links from the `postPost` lineNumber join (fixed 2026-06-11).** The
63
133
  `postPost` link pass matched `SalesOrderItems.lineNumber = PurchaseOrderItems.lineNumber`
64
134
  for *all* orders. For SA orders that is wrong: PO line numbers are PO-local, so whenever an
@@ -137,6 +207,16 @@ USA and Canada share the parent handler unchanged.
137
207
  need item backfill from the sibling ODP SO first).
138
208
 
139
209
  ## Change history
210
+ - 2026-08-03 — Documented that `/v2/purchase-orders` has **two** Compass inbound channels (MITS
211
+ `153531108` vs `COMPASS-CXML`; Canada is a third identity `409531`), told apart by **field
212
+ name**: unprefixed `mitsSalesOrder` = MITS, `c_mitsSalesOrder` = cXML, neither = the item-less
213
+ header-first EDI POs. So a zero-item rejection keyed on the unprefixed field is safe, while
214
+ gating on item count alone is not. Recorded the substring/pretty-print querying traps that
215
+ conflate the two callers, and left open whether the framework strips the `c_` prefix for
216
+ interceptor payloads. Also recorded the MR-vs-SA `partNumber` item-consumption
217
+ asymmetry (MR silently skips `partNumber`-less lines → empty SO despite `count() > 0`) and an
218
+ open SQL-injection at L232 plus five `addslashes()` sites. Corrects the earlier belief that
219
+ `MA` arrives from MITS. (dfranks)
140
220
  - 2026-07-13 — Fixed empty MA orders: the 3a ODP-PO-import cron's new-SO branch now builds
141
221
  `salesOrderItems` from the 850 PO items (the MA back-linking block excludes MA by design).
142
222
  Forward-only; 192 historical empties still need item backfill. (rgirish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.497",
3
+ "version": "1.0.499",
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",