toga-ai 1.0.540 → 1.0.542

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.
@@ -9,6 +9,7 @@
9
9
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
10
10
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
11
11
  | [Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)](features/error-capture-1-0.md) | The 1.0 side of the platform error-reporting pipeline (TRUE-78188). | library/app/error/capture.php, library/app/error.php, library/app/exception/business.php, library/app/api/toga2.php, library/app/cloud.php, worker/config.worker.ini, worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php |
12
+ | [HTTP 500 Error Monitor (App_SystemMonitor_500Error) — and why its \"Error Type\" is not a diagnosis](features/http-500-error-monitor.md) | `App_SystemMonitor_500Error` (`library/app/systemmonitor/500error.php`, title **"HTTP 500 Error Alert"**) is the 1.0 system monitor that watches **`Logs.Api` fo | library/app/systemmonitor/500error.php, worker/crons/infrastructure/system_monitors.php, api2/Controller/Index.php, _underscore/Error.php |
12
13
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
13
14
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
14
15
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
@@ -0,0 +1,169 @@
1
+ ---
2
+ title: "HTTP 500 Error Monitor (App_SystemMonitor_500Error) — and why its \"Error Type\" is not a diagnosis"
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-10
10
+ owners: ["bala"]
11
+ files:
12
+ - library/app/systemmonitor/500error.php
13
+ - worker/crons/infrastructure/system_monitors.php
14
+ - api2/Controller/Index.php
15
+ - _underscore/Error.php
16
+ related:
17
+ - ./error-capture-1-0.md
18
+ - ./netsuite-sync-alert-monitor.md
19
+ - ./cron-execution-monitoring.md
20
+ - ../../../../2.0/apps/_underscore/features/error-reporting-issue-event.md
21
+ - ../../../../2.0/apps/api2/features/v2-api-error-codes.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ `App_SystemMonitor_500Error` (`library/app/systemmonitor/500error.php`, title
27
+ **"HTTP 500 Error Alert"**) is the 1.0 system monitor that watches **`Logs.Api` for
28
+ `responseCode = 500`** and raises a ClickUp task + a `devteam@togatech.com` email when it finds
29
+ any. It is the alert most api2 production incidents arrive through.
30
+
31
+ > **⚠ The "Error Type" line in the alert is almost always the literal string
32
+ > `No message found in response payload`, and that string carries ZERO diagnostic signal.** It is
33
+ > generated **by this monitor**, not by the API, and it is the *expected* value for every genuine
34
+ > production api2 500. Do not spend time on it, do not search for it, and do not treat two alerts
35
+ > that share it as the same bug. **The real diagnostic is `error.id` in the logged response
36
+ > payload** — see the lookup recipe below.
37
+
38
+ ## How it works
39
+
40
+ - **Schedule.** `minuteFrequency = 5`, `minHourToRun = 8`, `maxHourToRun = 17`,
41
+ `alertRemindFrequency = 240`. Registered in the shared dispatcher
42
+ `worker/crons/infrastructure/system_monitors.php` alongside the other `App_SystemMonitor`
43
+ classes (same 300s shared budget — see
44
+ [NetSuite Sync Alert Monitor](./netsuite-sync-alert-monitor.md) for how a monitor can silently
45
+ die inside that budget).
46
+ - **Query.** `getStatus()` selects `id, requestHeaders, instanceId, route, method, dtStamp,
47
+ queryString, responsePayload` from **`Logs.Api`** where `responseCode = 500` and
48
+ `dtStamp >= NOW() - INTERVAL 5 MINUTE`, on the **`db_toga2_logs`** connection. Zero rows = OK.
49
+ **The window is 5 minutes and the monitor keeps no cursor**, so its view of an incident is only
50
+ ever the last 5 minutes of it.
51
+ - **Non-production rows are filtered by a referer heuristic** — a `referer:` header containing
52
+ `beta`/`gamma`, or lacking `https://` or `.com`, marks the row non-prod and skips it.
53
+ - **Grouping.** `groupErrors()` keys each group on `referer . ' HTTP 500 Error: ' . $errorMessage`
54
+ and creates/updates one ClickUp task per key in list `901110669877` (or the current dev-sprint
55
+ list when it resolves one). Two ClickUp custom fields are set from substring matches on the
56
+ request headers: a **stakeholder** map (`compass`, `prudential`, `quad`, `spglobal`, `nychh`,
57
+ `growrk`) and a **platform** map (`togasupply`, `togacommerce`, `togadesk`), each falling back
58
+ to a default uuid.
59
+
60
+ ### Where the "Error Type" string comes from
61
+
62
+ `groupErrors()` reads `$respPayload->messages[0]->identifiers->message` out of the logged
63
+ `responsePayload` and falls back to the literal `'No message found in response payload'` when that
64
+ path is absent (`500error.php:93-99`).
65
+
66
+ **api2 leaves those identifiers empty on every production 500 by design.**
67
+ `api2/Controller/Index.php` (~L271-285) populates `message` and `trace` **only** when
68
+ `_Environment::isDebugMode()` is true, because `getTrace()` returns frame **arguments** verbatim
69
+ and any stack with `_Database::register()` on it would hand the database username and password to
70
+ anyone who could force a 500. See the api2
71
+ [error-codes doc](../../../../2.0/apps/api2/features/v2-api-error-codes.md) for that decision.
72
+
73
+ So the fallback is not a failure — it is the **normal** state in production. Its only real meaning
74
+ is "this 500 came from production api2."
75
+
76
+ ## The real diagnostic: `error.id` (verified lookup recipe)
77
+
78
+ The api2 envelope carries `error: { code, id }`. **`id` is the quotable Core Logs reference**, in
79
+ the form `<Logs.Issue.reference>-<Logs.Event.eventNumber>` (e.g. `78-859`), produced by
80
+ `_Error::captureException()` (`_underscore/Error.php:334+`), which resolves/creates a `Logs.Issue`
81
+ by fingerprint, writes a `Logs.Event`, and returns the reference. Three queries get you from an
82
+ alert to the actual exception:
83
+
84
+ ```sql
85
+ # 1. Read error.id out of the logged envelope (ids come from the alert body)
86
+ SELECT id, route, method, dtStamp, responsePayload
87
+ FROM Logs.Api
88
+ WHERE id IN (<ids from the alert>);
89
+
90
+ # 2. The exception itself + its full framework stack (reference = the part before the dash)
91
+ SELECT id, reference, status, errorMessage, trace, totalOccurrences, dtLastOccurred
92
+ FROM Logs.Issue
93
+ WHERE reference = '78';
94
+
95
+ # 3. The specific occurrence — and WHICH TENANT it belongs to
96
+ SELECT clientId, dtCreated, errorMessage
97
+ FROM Logs.Event
98
+ WHERE issueId = <issue.id> AND eventNumber = <n>;
99
+ ```
100
+
101
+ - `Logs.Issue.trace` is the full framework stack — this is the thing the alert deliberately does
102
+ not contain.
103
+ - `Logs.Event.clientId` is how you find out **which client** was affected; the monitor's
104
+ stakeholder guess is a header substring match and is not authoritative.
105
+ - **api2's own catch block is the ONLY capture point for the inbound-API 500 surface.** api2
106
+ handles `Throwable` in its front controller, so `_Error::exceptionHandler` never fires for API
107
+ traffic. If api2's catch did not capture it, nothing did.
108
+
109
+ Full mechanics of the Issue/Event model, the reference encoding, and its failure modes:
110
+ [error reporting](../../../../2.0/apps/_underscore/features/error-reporting-issue-event.md).
111
+
112
+ ## ⚠ Known defect (2026-08-10, NOT fixed) — all unrelated 500s collapse into ONE ClickUp task
113
+
114
+ Because the grouping key is `referer + errorMessage` and `$errorMessage` is the **constant**
115
+ `No message found in response payload` for every production 500, the key degenerates to the
116
+ referer alone. Consequences:
117
+
118
+ - Distinct, unrelated defects sharing a referer are **merged into one ClickUp task**, whose
119
+ description accumulates mixed routes and mixed incidents.
120
+ - The responder cannot tell from the task whether it is one bug or five, and the task's occurrence
121
+ count is meaningless as an impact signal.
122
+
123
+ **Proposed fix (recorded, not implemented):** parse the `Logs.Issue` reference out of `error.id`
124
+ and **group on that** — it is exactly the platform's own deduplicated identity for a distinct
125
+ problem — and surface `error.id` in the task description so the responder can skip the manual
126
+ lookup entirely. Falling back to the current key only when `error.id` is null keeps graceful
127
+ rejections working.
128
+
129
+ ## Gotchas / known issues
130
+
131
+ - **A 500 whose request has NO `referer:` header passes the production filter and groups under an
132
+ empty referer.** The `$isProd = false` assignment only happens *inside* the referer branch
133
+ (`500error.php:55-74`), so a header set with no referer line at all keeps `$isProd = true` and
134
+ `$referer = ''`. Server-to-server inbound API traffic has no referer, so all of it lands in one
135
+ unnamed group — compounding the collapse defect above.
136
+ - **Repeat count in the alert is NOT user impact.** apiproxy retries a 5xx across regions, so one
137
+ user action routinely produces several near-identical `Logs.Api` 500 rows within seconds, on
138
+ different `instanceId`s, **sharing one `transactionId`**. Check the transaction id before
139
+ reading a burst as a burst of users.
140
+ - **The 5-minute window means the alert is a sample, not the incident.** Always go to `Logs.Api` /
141
+ `Logs.Issue` for the full extent and the first occurrence — `Issue.dtLastOccurred` +
142
+ `totalOccurrences` are the real timeline.
143
+ - **`Logs.Issue.status = RESOLVED` does not mean fixed** — it means *stopped firing* (the worker2
144
+ frequency-decay cron auto-resolves on consecutive quiet windows). A live defect on a low-traffic
145
+ tenant reads as RESOLVED the moment that tenant goes quiet. See
146
+ [error-issue auto-resolution](../../../../2.0/apps/worker2/features/error-issue-auto-resolution.md).
147
+ - **Hardcoded ClickUp API token in source.** `CLICK_UP_API_ACCESS_TOKEN` is a literal class
148
+ constant in this file at line 14 (pre-existing, same pattern as the NetSuite Sync Alert monitor).
149
+ It belongs in config/secrets; treat the in-repo value as compromised. (Location only — the value
150
+ is never copied into the KB.)
151
+ - **Stakeholder/platform attribution is a substring guess.** `str_contains($headers, 'growrk')`
152
+ style matching mis-attributes any request whose headers happen to mention another tenant, and
153
+ silently defaults when nothing matches. Use `Logs.Event.clientId` for the real tenant.
154
+
155
+ ## Change history
156
+
157
+ - 2026-08-10 — Created while investigating a production api2 500 alert for GroWrk. Recorded that
158
+ the alert's "Error Type" is the monitor's **own fallback literal** (`500error.php:93-99`) and is
159
+ the expected value for **every** production 500 — because api2 gates `message`/`trace` behind
160
+ `_Environment::isDebugMode()` to avoid returning `getTrace()` frame arguments (DB credentials) to
161
+ external callers — so it carries no diagnostic signal at all. Documented the **`error.id`
162
+ three-query lookup recipe** (`Logs.Api.responsePayload` → `Logs.Issue.reference` →
163
+ `Logs.Event.eventNumber`/`clientId`) as the actual diagnostic path, and that api2's catch block is
164
+ the only capture point for the inbound-API 500 surface. Recorded the **open grouping defect**
165
+ (constant `errorMessage` collapses every unrelated 500 sharing a referer into one ClickUp task;
166
+ proposed grouping on the `Logs.Issue` reference from `error.id` instead — not implemented), the
167
+ no-referer rows that pass the prod filter into an empty group, that a burst of near-identical 500s
168
+ sharing one `transactionId` is apiproxy's cross-region retry rather than user impact, and that
169
+ `Issue.status = RESOLVED` means "stopped firing", not "fixed". (bala)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["jcardinal", "mhammontree", "tcox"]
9
+ updated: 2026-08-10
10
+ owners: ["jcardinal", "mhammontree", "tcox", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Core/Page.php
@@ -135,6 +135,29 @@ its `CustomRecordFields` row was deleted, so the pending `AclCustomFieldPermissi
135
135
  (never hardcode), and clone a sibling field's per-role rows — e.g. `Client/2026-07-02c -
136
136
  TrackingNumberNeedsReturnLabelFieldPermission.sql` is the precedent shape.
137
137
 
138
+ ### A custom (`c_`) field has THREE parts, and each missing part fails DIFFERENTLY
139
+
140
+ The custom-field counterpart of the four-requirement standard-field recipe below. Knowing which
141
+ symptom maps to which missing part is what turns a 500 into a one-line diagnosis:
142
+
143
+ | Missing piece | Symptom |
144
+ |---|---|
145
+ | the **physical column** on the client table | MySQL **1054 "Unknown column"** → **HTTP 500 / `EO-1`** on **every** load of that record |
146
+ | the **`CustomRecordFields`** row (client DB) | the field is **not requestable** through the v2 API at all |
147
+ | the **`AclCustomFieldPermissions`** grant | **`EZ-2` / HTTP 403 for the WHOLE request** when the field is named in `fields=` — one unreadable field fails the entire GET |
148
+
149
+ The fourth part is the **declaration**: a `c_` property on the client model **or on a trait it
150
+ `use`s** (e.g. `_Model_Growrk_Unit` is nothing but `extends _Model_Client_Unit` +
151
+ `use _Trait_Netsuite_Unit`, and the trait declares all seven `c_` fields). That declaration is what
152
+ makes a missing column **fatal**: `_Model` builds its SELECT from every declared property, so it
153
+ asks for the column whether or not the caller requested it.
154
+
155
+ **Practical consequence for an audit:** "which columns must this client have?" cannot be answered by
156
+ reading the model class — follow its `use` statements. And a `CustomRecordFields` row that exists
157
+ with **no** grant is not automatically a defect; compare against a healthy client on the same module
158
+ first (see
159
+ [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md)).
160
+
138
161
  ## Where ACL rows live: Core vs. Client database
139
162
 
140
163
  `Core.Records.aclDatabase` decides which database holds the ACL rows for that record:
@@ -335,6 +358,15 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
335
358
 
336
359
  ## Change history
337
360
 
361
+ - **2026-08-10** — Added the **custom (`c_`) field THREE-part anatomy** as the counterpart of the
362
+ standard-field recipe — physical column (missing → MySQL 1054 → 500/`EO-1` on every load),
363
+ `CustomRecordFields` row (missing → not requestable), `AclCustomFieldPermissions` grant (missing →
364
+ `EZ-2`/403 for the **whole** request) — plus the fourth, code-side part: the `c_` declaration may
365
+ live on a **trait**, not the model class (`_Model_Growrk_Unit` declares nothing; all seven `c_`
366
+ fields come from `_Trait_Netsuite_Unit`), which is why `_Model` demands the column regardless of the
367
+ requested `fields=`. Noted that a `CustomRecordFields` row with no grant can be the normal
368
+ cross-client state, so compare with a healthy same-module client before repairing. Surfaced by the
369
+ production `Client_Growrk.Units` `c_lastModified` outage. (bala)
338
370
  - **2026-08-06** — TRUE-80282 testing: recorded that **`appId` + `indirectRecordId` both NULL is a
339
371
  grant with no scoping at all**, that **`Base` is not a low-privilege role** (every portal customer
340
372
  and API service user carries it), and that the dangerous combination to audit for is *unscoped
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-06
10
- owners: ["dfranks", "jcardinal", "mhammontree", "ajean"]
9
+ updated: 2026-08-10
10
+ owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala"]
11
11
  files:
12
12
  - _underscore/Error.php
13
13
  - _underscore/Database.php
@@ -34,6 +34,7 @@ related:
34
34
  - ../../../1.0/apps/library/features/error-capture-1-0.md
35
35
  - ../../api2/features/v2-api-error-codes.md
36
36
  - ../../dbchanges2/architecture.md
37
+ - ../../../../1.0/apps/library/features/http-500-error-monitor.md
37
38
  ---
38
39
 
39
40
  ## Summary
@@ -330,6 +331,42 @@ work side by side. Accepted cost: 10 of the 68 existing issues keep ambiguous re
330
331
  > `IssueFingerprint`, `IssueEmailAddress`, `IssueClickupTask` and `Event` all reference
331
332
  > `Issue.id` — they must be cleared in the **same** operation or they orphan onto reused ids.
332
333
 
334
+ ### Looking up an `error.id` — from an alert to the actual exception (3 queries)
335
+
336
+ `error.id` in the api2 envelope is **`<Issue.reference>-<Event.eventNumber>`** (e.g. `78-859`).
337
+ That string is the *only* handle a production 500 gives you: api2 returns **no message and no
338
+ trace** outside debug mode (`api2/Controller/Index.php` ~L271-285) because `getTrace()` would leak
339
+ `_Database::register()`'s frame arguments. So an alert that says nothing useful is working as
340
+ designed — resolve the reference instead:
341
+
342
+ ```sql
343
+ # 1. error.id lives in the logged response envelope
344
+ SELECT id, route, method, dtStamp, responsePayload
345
+ FROM Logs.Api
346
+ WHERE id IN (<ids from the alert>);
347
+
348
+ # 2. the exception + full framework stack (reference = the part BEFORE the dash)
349
+ SELECT id, reference, status, errorMessage, trace, totalOccurrences, dtLastOccurred
350
+ FROM Logs.Issue
351
+ WHERE reference = '78';
352
+
353
+ # 3. the specific occurrence, and WHICH TENANT it was
354
+ SELECT clientId, dtCreated, errorMessage
355
+ FROM Logs.Event
356
+ WHERE issueId = <issue.id> AND eventNumber = <n>;
357
+ ```
358
+
359
+ - `Issue.trace` is the full stack — the thing the response deliberately omits.
360
+ - `Event.clientId` is the authoritative tenant, not whatever a monitor guessed from headers.
361
+ - **`Issue.status = RESOLVED` does NOT mean fixed** — it means *stopped firing*. A live defect on a
362
+ tenant that has gone quiet auto-resolves; check `dtLastOccurred` against the tenant's traffic
363
+ before believing it (see
364
+ [error-issue auto-resolution](../../worker2/features/error-issue-auto-resolution.md)).
365
+ - The **1.0 alert surface** that fires on these is
366
+ [App_SystemMonitor_500Error](../../../../1.0/apps/library/features/http-500-error-monitor.md) — its
367
+ "Error Type" is a fallback literal with **zero** diagnostic value, which is why this recipe
368
+ exists.
369
+
333
370
  ### Shutdown handler
334
371
 
335
372
  `register_shutdown_function` is registered with a **pre-allocated memory reserve**. Before this,
@@ -580,6 +617,14 @@ clientUserId). **Neither was built.** As built instead:
580
617
 
581
618
  ## Change history
582
619
 
620
+ - 2026-08-10 — Added the **`error.id` lookup recipe** (`Logs.Api.responsePayload` → `Logs.Issue`
621
+ by `reference` → `Logs.Event` by `eventNumber` for the tenant), because in production `error.id`
622
+ is the **only** handle an api2 500 gives you — `message`/`trace` are debug-mode-gated so the
623
+ `Logs.Issue.trace` row is the only stack. Recorded that `Issue.status = RESOLVED` means *stopped
624
+ firing*, not *fixed* (a live defect auto-resolves once its tenant goes quiet — a prod GroWrk
625
+ defect read RESOLVED with `dtLastOccurred` 4 days stale while still broken), and linked the 1.0
626
+ alert surface [App_SystemMonitor_500Error](../../../../1.0/apps/library/features/http-500-error-monitor.md),
627
+ whose "Error Type" is a fallback literal with no diagnostic value. (bala)
583
628
  - 2026-08-06 — Added two debugging/security notes found while unjamming the Quad PO/ASN importer:
584
629
  (1) a `varchar(255)`-truncated `errorMessage` severs a dumped api2 envelope exactly at
585
630
  `status`/`error.code`/`messages[]`, so escalate to the **client-scoped** `Logs_<Client>.Api`
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
9
+ updated: 2026-08-10
10
10
  owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -106,6 +106,28 @@ fact AIG **does** post live entitlements — the writes were **failing (HTTP 500
106
106
  core `Logs`**, not `Logs_Aig`. See
107
107
  [AIG entitlement intake → Intake is a live API feed](../../../../clients/aig/features/entitlement-intake.md).
108
108
 
109
+ ## Querying prod `Logs.Api` without timing out (narrow first, fetch payloads last)
110
+
111
+ Prod `Logs.Api` is large enough that **any `LIKE` over `requestPayload` / `responsePayload`
112
+ (both `mediumtext`) times out the `toga_query` MCP call at 300 s — even scoped to a single day.**
113
+ There is no usable index on those columns. Do not start from a payload search.
114
+
115
+ The working pattern, in order:
116
+
117
+ 1. **Narrow with indexed / non-text columns only** — a `dtStamp` range plus `route`,
118
+ `method`, `responseCode`, `hostname`. A
119
+ `GROUP BY hostname, route, method, responseCode` over one day is cheap and is the fastest way
120
+ to spot a failing route (this is what surfaced the AIG entitlement 500s).
121
+ 2. **Collect the `id`s** from that narrowed set.
122
+ 3. **Then fetch payloads by `id`**, a few rows at a time, using `LEFT(...)` / `SUBSTRING(...)`
123
+ rather than selecting the whole `mediumtext`.
124
+
125
+ Volume reference for sizing a query: **~8,151 rows in `Logs.Api` for a single day** (2026-05-27).
126
+
127
+ Note this is in tension with the payload-based techniques in the next section — the
128
+ `JSON_EXTRACT` dedupe/vendor-split works because it runs over an **already-narrowed** row set
129
+ (one route, one response code, one date range), not over the raw table.
130
+
109
131
  ## Recovering an intake backlog from logged payloads
110
132
 
111
133
  Because the core/writer `Logs.Api` stores **every** inbound request — including failed writes —
@@ -134,6 +156,11 @@ re-send.
134
156
 
135
157
  ## Change history
136
158
 
159
+ - 2026-08-10 — Added **Querying prod `Logs.Api` without timing out**: any `LIKE` over the
160
+ `mediumtext` `requestPayload`/`responsePayload` times out the `toga_query` MCP call at 300 s even
161
+ for one day. Narrow first on indexed/non-text columns (`dtStamp` + `route` + `responseCode`, or a
162
+ `GROUP BY hostname, route, method, responseCode` to spot the failing route), collect `id`s, then
163
+ fetch payloads by `id` with `LEFT()`/`SUBSTRING()`. Volume reference ~8,151 rows/day. (mhammontree)
137
164
  - 2026-08-04 — Added the limit of the commit-logs contract: an **uncaught PHP fatal** (a
138
165
  `Call to undefined method` from an interceptor dispatch) produced **zero rows in
139
166
  `Logs_Compass.Api`** — no 5xx row for the whole day and no row for the failing request — while
@@ -7,7 +7,7 @@ client: shared
7
7
  type: feature
8
8
  status: active
9
9
  updated: 2026-08-10
10
- owners: [mhammontree, tcox, jcardinal, ajean]
10
+ owners: [mhammontree, tcox, jcardinal, ajean, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Component/Api/V2/Response/Response.php
@@ -19,6 +19,8 @@ files:
19
19
  related:
20
20
  - ../../_underscore/features/error-reporting-issue-event.md
21
21
  - ../../_underscore/features/acl-permission-chain.md
22
+ - ../../dbchanges2/workflows/client-schema-drift-audit.md
23
+ - ../../../../1.0/apps/library/features/http-500-error-monitor.md
22
24
  - scripted-api-post-body-args.md
23
25
  - v2-rest-query-contract.md
24
26
  - ../architecture.md
@@ -43,6 +45,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
43
45
  | **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
44
46
  | **EO-1** | Operation failed — identifier "There is no field called 'X' in the '_Model_Client_Y' model" | The DB column **and** `Core.RecordFields` exist, but the **generated model class** `_underscore/Model/Client/<Name>.php` doesn't declare the field. This is the **4th** requirement beyond the 3-file migration — and most often it's a **cross-repo git branch mismatch** (`_underscore` on a branch whose generated model lacks a field the DB/RecordFields already carry) | Declare the field in the generated model class (`public $field = self::FIELD_*`) and put all related repos (`_underscore`, `api2`, `dbchanges2`, `toga2-supply`) on the **same** feature branch — see the ACL doc's writable-field recipe |
45
47
  | **EO-1** | Operation failed — **surfaced from a PHP warning/notice, not a real op error** (e.g. "Attempt to read property 'id' on bool", undefined variable) | A latent PHP warning escalates to a 500 because **Sentry's `ErrorHandler` in api2 promotes warnings/notices into thrown exceptions** (see diagnosis note 5). The known instance: in `V2.php::processRoutePairs()` an **unresolved route** leaves the local `$record = false`, and the post-processing payload interceptor layer then dereferenced `$record->id` → warning → 500 | Guard before dereferencing an unresolved record. The fix added a guard clause `if (!$record) return [$rawRequestedRouteName => $outData];` **before** the interceptor/logging layer (so a bad route returns a clean envelope, not a fatal), and initializes `$record = false;` at the top of the `foreach ($lookupByRouteNames ...)` loop so the invalid-HTTP-method / null-`$action` branch can't leave `$record` undefined (an undefined-variable warning would itself escalate to a 500) |
48
+ | **EO-1** | Operation failed — MySQL **1054 "Unknown column 'X' in 'field list'"** | The **exact reverse** of the row above: the generated model (or a **trait** it `use`s) *declares* the field but the client table has **no such column**. `_Model` builds its SELECT from **every declared property**, so this fires on **any** load of that record on **any** route — including when the caller never asked for that field. Usual cause: a client DB whose module migration only **partially** applied | Add the missing column to that client DB — plus its `CustomRecordFields` row and `AclCustomFieldPermissions` grant if it is a `c_` field. Audit the rest of the drift first: [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md) |
46
49
  | **EZ-1** | Unauthorized record/script dispatch | Missing **`AclRecordScripts`** (scripted APIs) or the **`AclRecordPermissions`** four-table chain (records) for the caller's role | Grant `AclRecordScripts` (scripts) or complete the record-CRUD chain |
47
50
  | **EZ-2** | Field-level authorization denied (READ) | The field is registered (`Core.RecordFields` present → no `EV-8`) but the caller's role has no **`AclFieldPermissions`** grant to **read** it. The read-side counterpart of `EV-9` (write). **This applies to the `id` field too:** fetching a record by its numeric `id` (`GET /v2/<record>?fields=id,...`) 403s `EZ-2` if `id` has no read grant — the `id` field is ACL-gated like any other, not implicitly readable | Add the `AclFieldPermissions` row for the role (`isWritable=0` if the field is server-written). For a **custom** `c_` field use `AclCustomFieldPermissions` instead — see the ACL doc's standard-vs-custom table |
48
51
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
@@ -137,6 +140,24 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
137
140
  a payload server-side will **create a duplicate** rather than match the existing record.
138
141
  When replaying or backfilling, rewrite every nested reference to use `uuid`.
139
142
 
143
+ 12. **Why a production 500 can only be diagnosed through note 9 — and what NOT to conclude.**
144
+ `message` and `trace` are gated behind `_Environment::isDebugMode()` (see the security fix
145
+ below), so a production envelope carries `error.code` + `error.id` and nothing else. Two
146
+ consequences worth knowing before you start:
147
+ - The 1.0 [HTTP 500 monitor](../../../../1.0/apps/library/features/http-500-error-monitor.md)
148
+ alert's "Error Type" is therefore **always** *"No message found in response payload"* — the
149
+ monitor's own fallback literal, identical for every unrelated production 500. It is not a
150
+ diagnosis and two alerts sharing it are not the same bug.
151
+ - `error.id` is `<Issue.reference>-<Event.eventNumber>`: note 9 resolves the `Issue` for the
152
+ stack; the **`Event`** half also gives you `Logs.Event.clientId` — the authoritative **tenant**
153
+ (full recipe:
154
+ [error reporting](../../_underscore/features/error-reporting-issue-event.md)).
155
+
156
+ **Never conclude "timeout" / "deep traversal" / "memory" from the request shape.** Read the
157
+ logged `executionTime` on the `Logs.Api` rows first: 0.19-2.3s 500s on a `depth=6` request were a
158
+ **missing DB column**. And several near-identical 500s sharing **one `transactionId`** are
159
+ apiproxy's cross-region retry, not repeated user impact.
160
+
140
161
  ## BREAKING (2026-07-30): `error` is now an object, not a bare string
141
162
 
142
163
  The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
@@ -222,6 +243,17 @@ Full mechanics:
222
243
 
223
244
  ## Change history
224
245
 
246
+ - 2026-08-10 — Added a **third `EO-1` case — MySQL 1054 "Unknown column"**: the reverse of the
247
+ known model-declaration gap. When the model (or a **trait** it `use`s) declares a field the client
248
+ table lacks, `_Model` still SELECTs it — it builds its SELECT from **every declared property**,
249
+ ignoring the caller's `fields=` — so **every** load of that record 500s on **every** route. Root
250
+ cause in the observed case was a **partially applied** module migration on one client DB
251
+ (`Client_Growrk.Units` missing `c_lastModified` from `_Trait_Netsuite_Unit`). Added diagnosis note
252
+ 12: in production the envelope carries only `error.code` + `error.id`, so the 1.0 HTTP 500 monitor
253
+ alert always reads *"No message found in response payload"* (its own fallback literal) — resolve
254
+ `error.id` against `Logs.Issue`/`Logs.Event` instead (note 9's lookup, plus `Logs.Event.clientId`
255
+ for the tenant), and check the logged `executionTime` before believing a timeout theory (a
256
+ 0.19-2.3s 500 on `depth=6` was a missing column). (bala)
225
257
  - 2026-08-10 — TRUE-80575: added diagnosis notes 9-11 from debugging a Rate entitlement
226
258
  replay — (9) `error.id`'s prefix before the dash **is** `Logs.Issue.reference`, so an `EO-1`
227
259
  500 becomes a real stack trace via `SELECT * FROM Logs.Issue WHERE reference = '…'`, and
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
10
- owners: [tcox]
9
+ updated: 2026-08-10
10
+ owners: [tcox, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  related:
@@ -114,6 +114,13 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
114
114
 
115
115
  ## Gotchas / known issues
116
116
 
117
+ - **⚠ `fields=` narrows the RESPONSE, never the SQL SELECT.** `_Model` builds its SELECT from
118
+ **every property declared on the model class and on the traits it `use`s**, so a column that is
119
+ declared but physically missing from that client's table raises MySQL **1054 → HTTP 500 (`EO-1`)**
120
+ even when the caller asked only for one unrelated field. Two consequences: (a) a 500 naming a field
121
+ you never requested is expected, not a red herring; (b) **every** route whose traversal reaches that
122
+ record fails identically, so narrowing `fields=` cannot work around it. The fix is schema-side —
123
+ see [client schema-drift audit](../../dbchanges2/workflows/client-schema-drift-audit.md).
117
124
  - **A 404 on a route you know exists ⇒ suspect an unrecognized query param** (LIST → READ flip)
118
125
  before suspecting the route.
119
126
  - **A 500 (`EO-1`) on a filter ⇒ suspect a `where` reference to an unjoined table.**
@@ -122,6 +129,11 @@ where=(field:op:value,LOGIC,field:op:value,(nested,OR,nested))
122
129
  invisible; re-request with `fields=` to force `EZ-2` and see the denied list.
123
130
 
124
131
  ## Change history
132
+ - 2026-08-10 — Recorded that **`fields=` narrows the response but not the SQL SELECT**: `_Model`
133
+ selects every property declared on the class **and its traits**, so a declared-but-missing column
134
+ 500s (`EO-1`, MySQL 1054) any request touching that record — including one that named only an
135
+ unrelated field — and no `fields=` list can avoid it. Found via the production `Client_Growrk.Units`
136
+ `c_lastModified` outage. (bala)
125
137
  - 2026-07-29 — Created from a source read of `api2/Component/Api/V2/V2.php` while building the
126
138
  BDR funnel's call-status reads. Recorded: routes-as-data (`Core.Records`, kebab → camel
127
139
  envelope key); the recognized-param list and the **unrecognized-param LIST→READ flip** that
@@ -5,5 +5,6 @@
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
+ | [Auditing a client DB that drifted from its models (partially applied module migration)](workflows/client-schema-drift-audit.md) | A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**, usually because a `_modules/<module>/` migration was applied to | dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
8
9
  | [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**. | |
9
10
  | [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 |
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: Auditing a client DB that drifted from its models (partially applied module migration)
3
+ framework: "2.0"
4
+ repo: dbchanges2
5
+ project: Database Changes
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-10
10
+ owners: ["bala"]
11
+ files:
12
+ - dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql
13
+ - dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql
14
+ - dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql
15
+ - dbchanges2/Client_Growrk/_modules.txt
16
+ related:
17
+ - ../architecture.md
18
+ - ./nonprod-metadata-drift-repair.md
19
+ - ../../_underscore/features/acl-permission-chain.md
20
+ - ../../api2/features/v2-api-error-codes.md
21
+ - ../../../../clients/growrk/features/units-netsuite-custom-fields.md
22
+ ---
23
+
24
+ ## Summary
25
+
26
+ A recurring 2.0 failure mode: **one client's database drifts from what the PHP models declare**,
27
+ usually because a `_modules/<module>/` migration was applied to that client only **partially**.
28
+ The `_Model` layer then SELECTs a column that does not exist and the API 500s on every route that
29
+ touches the record.
30
+
31
+ > **This is the mirror image of
32
+ > [non-prod metadata drift](./nonprod-metadata-drift-repair.md).** There the environment is behind
33
+ > (works in prod, broken in beta). Here **production is the outlier** and beta/dev-sandbox is
34
+ > correct — so "it works on beta" is not evidence the schema is fine. Check *which* side is behind
35
+ > before assuming.
36
+
37
+ Written from repairing `Client_Growrk.Units` (missing `c_lastModified`, 6 of 7 columns and 1 of 6
38
+ metadata rows applied) — see
39
+ [GroWrk Units custom fields](../../../../clients/growrk/features/units-netsuite-custom-fields.md).
40
+
41
+ ## Why it 500s: `_Model` ignores the caller's `fields=`
42
+
43
+ `_Model` builds its SELECT from **every declared property on the class and its traits**, not from
44
+ the fields the API request asked for. So a single missing column fails **any** traversal that
45
+ reaches that record, on **any** route, even when the request only asked for one unrelated field.
46
+ MySQL raises **1054 "Unknown column 'X' in 'field list'"**, api2 answers **HTTP 500 / `EO-1`**.
47
+
48
+ Corollary: **the field named in the error has nothing to do with the field the request wanted.**
49
+ Do not narrow the investigation to the requested field list.
50
+
51
+ ## The three parts of a 2.0 custom (`c_`) field — each fails differently
52
+
53
+ | Missing piece | Symptom |
54
+ |---|---|
55
+ | the **physical column** on the client table | MySQL **1054** → **HTTP 500 / `EO-1`** on every load of the record |
56
+ | the **`CustomRecordFields`** row (client DB) | the field is **not requestable** through the v2 API at all |
57
+ | the **`AclCustomFieldPermissions`** grant | **`EZ-2` / HTTP 403 for the WHOLE request** — one unreadable field fails the entire GET; it is *not* silently omitted when named in `fields=` |
58
+
59
+ (The declaration side — the `c_` property on the client model or a trait it `use`s — is the fourth
60
+ part, and it is what makes a missing column fatal. Standard, non-`c_` fields use
61
+ `Core.RecordFields` + `AclFieldPermissions` instead; see
62
+ [ACL permission chain](../../_underscore/features/acl-permission-chain.md).)
63
+
64
+ ## Steps
65
+
66
+ 1. **Get the real exception first.** Resolve `error.id` from the response envelope against
67
+ `Logs.Issue` / `Logs.Event` — the stack tells you which model's `load()` failed and which column
68
+ MySQL rejected. Recipe:
69
+ [error reporting](../../_underscore/features/error-reporting-issue-event.md). Also read
70
+ `Logs.Api.executionTime`: sub-second 500s **disprove** the timeout / deep-traversal / memory
71
+ theories a high `depth=` invites.
72
+ 2. **Find the client's module membership** — `dbchanges2/Client_<Name>/_modules.txt` (one module per
73
+ line; e.g. Growrk = `netsuite`). That tells you which `_modules/<module>/` change-sets *should*
74
+ have been applied to it.
75
+ 3. **Diff `information_schema.COLUMNS` against a HEALTHY client on the SAME module**, scoped to the
76
+ tables in the failing request's traversal path. This is the cheap, complete check — it answers
77
+ "is there a second missing column?" in one query instead of guessing field by field.
78
+ `Client_Aig` served as the healthy `netsuite` reference for `Client_Growrk`; the diff returned
79
+ exactly one row before the fix and zero after.
80
+ 4. **Corroborate from the code side.** Extract every declared **stored** field from the client's
81
+ model classes (`_Model_Client_*` and `_Model_<Client>_*`) **plus every trait they `use`**, and
82
+ test each against `information_schema`. **Exclude `FIELD_SQL` and underscore-prefixed calculated
83
+ fields — those are computed, not columns**, and treating them as missing columns produces false
84
+ positives.
85
+ 5. **Check the metadata rows, not just the columns.** For the record in question, count
86
+ `CustomRecordFields` rows and their `AclCustomFieldPermissions` grants, and compare the counts
87
+ with the healthy client. A partial run leaves an arbitrary subset of both.
88
+ 6. **Confirm which environment is the outlier** by running the same audit against dev-sandbox
89
+ (beta). If beta already has the correct state, the missing work is a **production-only** catch-up
90
+ — do not re-run the whole module change-set.
91
+ 7. **Write the catch-up migration in `dbchanges2/Client_<Name>/`** (naming contract:
92
+ `YYYY-MM-DD<letter> - <Description>.sql`, letter mandatory). Two rules:
93
+ - **Reuse the module migration's fixed UUIDs** for the metadata rows it failed to insert, so the
94
+ repaired client ends up **identical** to every other client on that module.
95
+ - **Grant ACL for the rows that already existed too.** An insert-then-grant migration typically
96
+ grants only what *it* inserted, so any `CustomRecordFields` row that survived the partial run
97
+ keeps its missing grant and needs its own follow-up file.
98
+ 8. **Verify the end state explicitly:** every custom field on that `recordId` has **exactly one**
99
+ ACL grant, and the healthy-client column diff across all tables in the request path is **empty**.
100
+ Then re-drive the failing request.
101
+
102
+ ## Gotchas / known issues
103
+
104
+ - **Partial means arbitrary — never assume all-or-nothing.** The observed run landed **6 of 7**
105
+ columns and **1 of 6** metadata rows from one file. "The migration ran" is not a fact about any
106
+ individual statement in it.
107
+ - **A `CustomRecordFields` row with no ACL grant is not automatically a defect.** Compare with the
108
+ healthy client before "fixing" it — `Client_Growrk` recordId 312
109
+ (`c_netsuiteInternalSalesOrderId`, `TransferOrders`) has no grant, and `Client_Aig` is identical,
110
+ so that is the normal cross-client state. Repairing it would be a change, not a fix.
111
+ - **A client can be fine for months and then break on onboarding.** Before a client joins the
112
+ module, its records resolve to the plain `_Model_Client_*` class with none of the `c_` fields, so
113
+ the missing columns are unreachable. The 500s begin the day the client is switched onto the
114
+ module-specific model — which is why the "it has always worked" evidence is misleading. Prove it
115
+ with `Logs.Api`: successful 200s for the exact same request shape right up to the onboarding
116
+ timestamp, 500s immediately after.
117
+ - **`ALTER TABLE … ADD COLUMN` was INSTANT here** (MySQL 8.0.39, ~5.4k rows) — a small tenant table
118
+ needs no maintenance window, but confirm the server version and row count rather than assuming.
119
+ - **Re-running the whole module change-set is the wrong repair** when only one environment drifted:
120
+ the other statements are already applied and some are not idempotent. Write a scoped catch-up
121
+ file instead.
122
+
123
+ ## Change history
124
+
125
+ - 2026-08-10 — Created from the production `Client_Growrk.Units` incident: `_Trait_Netsuite_Unit`
126
+ declared `c_lastModified` but the column had never been added, so **every** traversal reaching a
127
+ Growrk Unit 500'd (`EO-1`, MySQL 1054) regardless of the requested `fields=`. Recorded the
128
+ audit playbook (diff `information_schema.COLUMNS` against a healthy same-module client scoped to
129
+ the failing traversal path; corroborate from the model + trait declarations, excluding `FIELD_SQL`
130
+ and underscore-prefixed calculated fields), the **three-part anatomy** of a 2.0 custom field and
131
+ how each part fails differently, and the catch-up-migration rules (reuse the module's fixed UUIDs;
132
+ grant ACL for pre-existing rows too). Noted that this is the **inverse** of non-prod metadata
133
+ drift — here prod was the outlier and beta was already correct — and that a `CustomRecordFields`
134
+ row with no ACL grant can be the normal cross-client state (recordId 312, identical in
135
+ `Client_Aig`). (bala)
@@ -6,8 +6,8 @@ project: Database Changes
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-03
10
- owners: ["mhammontree"]
9
+ updated: 2026-08-10
10
+ owners: ["mhammontree", "bala"]
11
11
  files:
12
12
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
13
13
  - dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql
@@ -15,6 +15,7 @@ files:
15
15
  - api2/Config/beta.ini
16
16
  related:
17
17
  - ../architecture.md
18
+ - ./client-schema-drift-audit.md
18
19
  - ../../api2/features/api-payload-interceptors.md
19
20
  - ../../api2/workflows/environment-configuration-and-provisioning.md
20
21
  - ../../_underscore/features/acl-permission-chain.md
@@ -32,6 +33,10 @@ environments receive those rows by someone remembering to run a migration there,
32
33
  This workflow is the repair procedure, written from bringing dev-sandbox up to date so beta could
33
34
  exercise TOGa Supply Fulfill & Ship (TRUE-80494).
34
35
 
36
+ > **Wrong doc?** When the drift runs the other way — **production** is the outlier and beta is
37
+ > correct, or one **client's** DB is behind the models (a partially applied `_modules/` migration) —
38
+ > use [client schema-drift audit](./client-schema-drift-audit.md) instead.
39
+
35
40
  ## Steps
36
41
 
37
42
  1. **Identify which cluster the environment actually reads.** `api.beta.togahub.com` reads the
@@ -140,6 +145,10 @@ blocked by the id-divergence hazard in item 2 above, which is why both go to jca
140
145
 
141
146
  ## Change history
142
147
 
148
+ - 2026-08-10 — Added a disambiguation pointer to the new
149
+ [client schema-drift audit](./client-schema-drift-audit.md) for the **inverse** case (production or
150
+ a single client's DB is the outlier, e.g. a partially applied `_modules/` migration). No change to
151
+ this procedure. (bala)
143
152
  - 2026-08-03 — TRUE-80494: created from repairing dev-sandbox so beta could exercise Fulfill & Ship —
144
153
  completed `Client_Growrk` measure grants (2476/2477/2478, role 1), applied the missing
145
154
  `ApiPayloadInterceptors` PRE/POST row for recordId 28, registered
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-05
10
- owners: ["jcardinal"]
9
+ updated: 2026-08-10
10
+ owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - worker2/Worker/Infrastructure/Errors.php
13
13
  - worker2/Worker/Clickup/ErrorTask.php
@@ -171,6 +171,13 @@ console's own conventions.)
171
171
 
172
172
  ## Gotchas / known issues
173
173
 
174
+ - **⚠ For a DEBUGGER, `status = RESOLVED` means "stopped firing" — NOT "fixed".** Resolution keys on
175
+ quiet time alone, so a still-broken defect on a low-traffic tenant auto-resolves as soon as that
176
+ tenant goes quiet. Observed 2026-08-10: a production GroWrk 500 (missing DB column, entirely
177
+ unfixed) presented as a RESOLVED Issue with `dtLastOccurred` **4 days** stale, purely because
178
+ GroWrk traffic had stopped. **Never close an investigation on Issue status** — read
179
+ `dtLastOccurred` against that tenant's traffic, and confirm the root cause is gone in the code or
180
+ schema.
174
181
  - **Only the frequency-decay cron may write `Issue.status = RESOLVED`.** The ClickUp webhook
175
182
  (`ErrorTask.php`) closes episodes and resets counters but must never touch `Issue.status`.
176
183
  - **`autoResolveIssue` must stay DB-first.** Closing the episode and committing `RESOLVED` before
@@ -190,6 +197,10 @@ console's own conventions.)
190
197
 
191
198
  ## Change history
192
199
 
200
+ - 2026-08-10 — Added the reader-facing warning that **RESOLVED means "stopped firing", not
201
+ "fixed"** — a live production GroWrk 500 (missing DB column) read as RESOLVED with
202
+ `dtLastOccurred` 4 days stale simply because that tenant's traffic had gone quiet. No behaviour
203
+ change; this is a diagnosis trap for anyone reading Issue status during an incident. (bala)
193
204
  - 2026-08-05 — **Built:** Issue-level auto-resolution and reopen by frequency decay. New
194
205
  `Issue.status enum('OPEN','RESOLVED')` + `dtAutoResolved`, a durable cron-maintained firing
195
206
  baseline (`baselineGapSeconds` EWMA + `baselineSampleCount`/`dtBaselineAnchor`/
@@ -4,7 +4,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 15 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 16 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
8
  - **worker** (Worker) — 21 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
@@ -18,10 +18,10 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 47 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 48 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 42 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
- - **dbchanges2** (Database Changes) _(framework core)_ — 7 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
+ - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 5 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)
@@ -3,5 +3,5 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [AIG Contract Reconciliation & Dealer Programs (STS_001 / SA_001)](features/contract-reconciliation.md) | 2.0 | How to reconcile an **AIG contract sales sheet** against the `Client_Aig` tenant, and the durable finding that the **Staples Advantage.com (`SA_001`) program do | _underscore/Model/Client/Entitlement.php, _underscore/Model/Aig/Entitlement.php, _underscore/Model/Client/Contact.php, _underscore/Model/Aig/Contact.php |
6
- | [AIG Entitlement Intake & SaleItem Code Resolution](features/entitlement-intake.md) | 2.0 | The `_Model_Aig_Entitlement::prePost` interceptor below is the code path for AIG protection-plan entitlements arriving as `api2` V2 JSON POSTs — and that path * | _underscore/Model/Aig/Entitlement.php, dbchanges2/Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql |
6
+ | [AIG Entitlement Intake & SaleItem Code Resolution](features/entitlement-intake.md) | 2.0 | The `_Model_Aig_Entitlement::prePost` interceptor below is the code path for AIG protection-plan entitlements arriving as `api2` V2 JSON POSTs — and that path * | _underscore/Model/Aig/Entitlement.php, api2/Component/Api/V2/V2.php, dbchanges2/Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql |
7
7
  | [AIG (Staples Protection Plan)](profile.md) | 2.0 | AIG is the warranty underwriter behind the **Staples Protection Plan** retail program. | |
@@ -5,7 +5,7 @@ project: API
5
5
  client: aig
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-07-29
8
+ updated: 2026-08-10
9
9
  owners: ["mhammontree"]
10
10
  files:
11
11
  - _underscore/Model/Client/Entitlement.php
@@ -27,7 +27,7 @@ durable finding that the **Staples Advantage.com (`SA_001`) program does not rou
27
27
  | DealerId | Program | Status |
28
28
  |---|---|---|
29
29
  | `STS_001` | **Staples Technology Solutions** | live api2 feed; intake gap **RESOLVED 2026-07-29** (self-healed) |
30
- | `SA_001` | **Staples Advantage.com** | **never reaches api2** — separate feed; scope decision is the one open item |
30
+ | `SA_001` | **Staples Advantage.com** | **never reaches api2** — SA.com is not sending them; **AIG owns and is working it**, not a TOGa defect (2026-08-10) |
31
31
 
32
32
  `STS_001` arrives as a **live API feed**; its 156-contract gap from the now-fixed intake bug
33
33
  **self-healed** (the feed re-sent and all 156 succeeded — see below). `SA_001` never reaches our
@@ -106,6 +106,28 @@ because their `SA_001` contract was never ingested.
106
106
  into `Client_Aig`, and if so via what feed/integration? That is a scope/integration decision for
107
107
  AIG / the team — not a back-fill of a dropped load. Until decided, `SA_001` stays out-of-pipeline.
108
108
 
109
+ #### CLOSED (2026-08-10): missing `SA_001` entitlements are AIG's issue, not a TOGa defect
110
+
111
+ **SA.com is not sending the new entitlements to us. AIG owns this and is working it.** Stop
112
+ working "missing SA_001 entitlement" escalations as a TOGa bug — the correct response is to route
113
+ them to AIG.
114
+
115
+ Confirmed on a concrete case from AIG's own Warranty Administration Portal: contract
116
+ **CN 1000046237886 / CCN 7680760752-2** (Staples Advantage.com program, plan *"2YR Printer Onsite
117
+ Next Day: ($500-$999.99)"*, purchased 2026-05-27) exists in AIG's portal and is **completely absent
118
+ on our side**:
119
+
120
+ - zero rows in `Client_Aig.Entitlements` — searched by `number`, `vendorEntitlementNumber`,
121
+ `datePurchase`, **and** contract-holder name;
122
+ - zero rows in `Client_Aig.Contacts`;
123
+ - **zero trace in `Logs_Aig.Api` or the core/writer `Logs.Api`.**
124
+
125
+ No log row anywhere means the contract was **never delivered** — nothing was rejected, nothing was
126
+ partially created, and there is no dropped payload to replay. That is the distinguishing signal:
127
+ a TOGa-side intake defect always leaves a failed-write row in the core `Logs` (see
128
+ [request logging](../../../2.0/apps/api2/features/request-logging.md)); **total absence points
129
+ upstream.**
130
+
109
131
  ## How to load a missing program / sheet (back-fill)
110
132
 
111
133
  If `SA_001` is ever confirmed in-scope for `Client_Aig` and AIG cannot route it through api2, it
@@ -118,6 +140,13 @@ feed re-send over a manual load whenever the contracts do reach api2.
118
140
 
119
141
  ## Change history
120
142
 
143
+ - 2026-08-10 — Read-only prod investigation. **Closed the `SA_001` question as NOT a TOGa defect:**
144
+ SA.com is not sending the entitlements; **AIG owns and is working it.** Evidenced by portal
145
+ contract CN 1000046237886 / CCN 7680760752-2 (purchased 2026-05-27) being absent from
146
+ `Client_Aig.Entitlements` *and* `Contacts` *and* from both `Logs_Aig.Api` and core `Logs.Api` —
147
+ never delivered, so nothing was rejected or partially created. Recorded the general
148
+ discriminator: an intake defect leaves a failed-write row in core `Logs`; **total log absence
149
+ points upstream.** Future "missing SA_001 entitlement" escalations route to AIG. (mhammontree)
121
150
  - 2026-07-29 — TRUE-80179 / TRUE-79534: **156 active `STS_001` RESOLVED (self-healed).** The next
122
151
  scheduled batch re-sent all 156 outstanding contracts and every one succeeded (201 × 156, zero
123
152
  failures; `Client_Aig.Entitlements` +156) — no manual back-fill needed, because the feed re-sends
@@ -5,10 +5,11 @@ project: API
5
5
  client: aig
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-08-03
8
+ updated: 2026-08-10
9
9
  owners: ["mhammontree"]
10
10
  files:
11
11
  - _underscore/Model/Aig/Entitlement.php
12
+ - api2/Component/Api/V2/V2.php
12
13
  - dbchanges2/Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql
13
14
  related:
14
15
  - clients/aig/features/contract-reconciliation.md
@@ -122,6 +123,32 @@ Loading the catalog fixed intake, and because the feed re-sends outstanding cont
122
123
  **backlog of dropped contracts self-healed on the next scheduled run** — no manual back-fill was
123
124
  needed (see [contract reconciliation](contract-reconciliation.md)).
124
125
 
126
+ ## Verified prod state (2026-08-10) — the catalog IS loaded; do NOT re-diagnose as "missing item ID"
127
+
128
+ Re-verified read-only against prod. **The TRUE-79534 catalog load did run and is present**, so a
129
+ 1064/500 seen from here on is **not** the missing-catalog problem:
130
+
131
+ - **`Client_Aig.Items` = 2,212 rows** — 1,110 `ASI-*`, 558 `SM-*`, 37 legacy `SP-*`.
132
+ - The exact part number in the historically-failing payloads, **`ASI-2YG2`, IS present.**
133
+ - **`Client_Aig.Entitlements` = 621 rows**, latest `dtCreated` **2026-08-04**, latest
134
+ `datePurchase` **2026-07-31** — entitlements are still being created.
135
+
136
+ So the remaining exposure at `Entitlement.php:48` is the **input-empty** variant (an *empty*
137
+ `itemId` interpolated), not an unknown-code variant. Anyone re-opening this should read the actual
138
+ interpolated value before assuming a missing AIG item ID — the earlier ticket already ruled that
139
+ out.
140
+
141
+ **Open question (not answered this session):** the historical failure window in the core/writer
142
+ `Logs.Api` shows **exactly 1,200 `POST /v2/entitlements` requests per day, all HTTP 500, zero
143
+ successes on that route**, every day from at least **2026-05-20 through 2026-07-28**, with the
144
+ *same* stale batch replayed daily — log id 996641 (05-27) and id 1877726 (07-28) carry
145
+ **byte-identical** payloads (`number: 1000045980287`), as do ids 998804 / 1881349
146
+ (`number: 1000045972141`). No entitlement POSTs are logged in core `Logs` after 07-28. Because
147
+ **successful** writes log to `Logs_Aig`, not core `Logs`, the most likely reading is that the route
148
+ recovered on 07-29 exactly as the resolution above describes. But the creating path for the newest
149
+ rows (through 2026-08-04) was **not positively identified** — confirm it in `Logs_Aig.Api` before
150
+ asserting either "intake is entirely down" or "intake is healthy."
151
+
125
152
  ## Business Unit (`UserDefined2`) — answered and DROPPED (nothing to build)
126
153
 
127
154
  AIG (Alissa, 2026-05-04) confirmed the **Business Unit / Value** field needs **nothing returned to
@@ -294,7 +321,17 @@ this interceptor or use this dual-purpose Items pattern.
294
321
  `Apis_RecordFields` override row that stripped `name` as an identifier — see
295
322
  [api2 nested-relationship writes → Per-API overrides](../../../2.0/apps/api2/features/nested-relationship-writes.md).
296
323
  - **A lookup miss yields an empty `$itemId` that crashes the next query (confirmed in prod,
297
- TRUE-79534).** When `saleItem.partNumber` is **valid but absent** from `Client_Aig.Items`, the
324
+ TRUE-79534).** The unguarded line is **`_underscore/Model/Aig/Entitlement.php:48`** in `prePost`,
325
+ reached from **`api2/Component/Api/V2/V2.php:2601`**. The malformed statement it emits is:
326
+ ```sql
327
+ SELECT EntitlementFulfillmentTypes.name
328
+ FROM Items_EntitlementFulfillmentTypes
329
+ INNER JOIN EntitlementFulfillmentTypes ON ...
330
+ WHERE Items_EntitlementFulfillmentTypes.itemId = -- nothing after the '='
331
+ ```
332
+ → MySQL **ERROR 1064** → HTTP 500. The fix is a **guard clause / early return** on an
333
+ empty-or-failed lookup *before* this query runs. When `saleItem.partNumber` is
334
+ **valid but absent** from `Client_Aig.Items`, the
298
335
  `SELECT id … WHERE partNumber = '{$partNumber}' LIMIT 1` returns nothing → `$itemId` is empty →
299
336
  the **fulfillment query interpolates it unquoted** → `WHERE …itemId = ` → **ERROR 1064** → HTTP
300
337
  500 (the mechanism behind the whole intake outage). A separate **input-empty** variant also
@@ -320,6 +357,16 @@ this interceptor or use this dual-purpose Items pattern.
320
357
 
321
358
  ## Change history
322
359
 
360
+ - 2026-08-10 — Read-only prod investigation (no code/SQL changed). Added **Verified prod state**:
361
+ the TRUE-79534 catalog **is** loaded (`Client_Aig.Items` = 2,212 rows — 1,110 `ASI-*` / 558
362
+ `SM-*` / 37 `SP-*`; `ASI-2YG2` present), so a future 1064 here is the **empty-input** variant,
363
+ **not** a missing AIG item ID — do not re-diagnose it as TRUE-79534. Pinned the unguarded lookup
364
+ to **`_underscore/Model/Aig/Entitlement.php:48`** (from `api2/Component/Api/V2/V2.php:2601`) and
365
+ recorded the exact malformed fulfillment query, so the fix is a plain guard clause. Quantified
366
+ the historical outage from core `Logs.Api` (**1,200 × HTTP 500/day, zero successes on the route,
367
+ 2026-05-20 → 2026-07-28**, the same batch replayed byte-identically — corroborates the
368
+ scheduled-re-send model). Flagged the **open question**: 621 entitlements exist with `dtCreated`
369
+ up to 2026-08-04 and the creating path was not positively identified. (mhammontree)
323
370
  - 2026-08-03 (later pass) — **Two corrections.** (1) The `ERROR 1267` temp-table collation trap is a
324
371
  **per-machine server-config property, not a "prod-only" / version difference**: measured prod Core
325
372
  8.0.39 with the MySQL 8 default `utf8mb4_0900_ai_ci` vs local Core_2 8.0.44 with an explicit
@@ -3,4 +3,5 @@
3
3
  | Doc | Framework | Summary | Files |
4
4
  |-----|-----------|---------|-------|
5
5
  | [Transfer Order Flow (NetSuite SalesOrder workaround → dedicated TransferOrders)](features/transfer-order-flow.md) | 2.0 | GroWrk moves inventory between their own locations (warehouses, distribution points). | _underscore/Model/Client/TransferOrder.php, _underscore/Model/Client/TransferOrderItem.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemReceipt.php, _underscore/Model/Client/ItemReceiptItem.php, _underscore/Trait/Netsuite/TransferOrder.php, dbchanges2/Client_Growrk/, dbchanges2/Client/ |
6
+ | [GroWrk Units — NetSuite-module custom fields (the c_lastModified 500 incident)](features/units-netsuite-custom-fields.md) | 2.0 | GroWrk is on the dbchanges2 **`netsuite`** module (`dbchanges2/Client_Growrk/_modules.txt`), so its Units carry the module's seven `c_` custom fields. | _underscore/Trait/Netsuite/Unit.php, _underscore/Model/Growrk/Unit.php, dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql, dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql, dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql, dbchanges2/Client_Growrk/_modules.txt |
6
7
  | [GroWrk](profile.md) | 2.0 | GroWrk is a 2.0 client using TOGA for inventory and order management. | |
@@ -0,0 +1,122 @@
1
+ ---
2
+ title: "GroWrk Units — NetSuite-module custom fields (the c_lastModified 500 incident)"
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: growrk
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-08-10
10
+ owners: ["bala", "mhammontree", "rgirish"]
11
+ files:
12
+ - _underscore/Trait/Netsuite/Unit.php
13
+ - _underscore/Model/Growrk/Unit.php
14
+ - dbchanges2/_modules/netsuite/2026-07-10a - UnitInventoryFields.sql
15
+ - dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql
16
+ - dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql
17
+ - dbchanges2/Client_Growrk/_modules.txt
18
+ related:
19
+ - ../profile.md
20
+ - ../../../2.0/apps/_underscore/features/units-for-items-for-purchase-orders.md
21
+ - ../../../2.0/apps/_underscore/features/acl-permission-chain.md
22
+ - ../../../2.0/apps/api2/features/v2-api-error-codes.md
23
+ - ../../../2.0/apps/dbchanges2/workflows/client-schema-drift-audit.md
24
+ - ../../../1.0/apps/library/features/http-500-error-monitor.md
25
+ ---
26
+
27
+ ## Summary
28
+
29
+ GroWrk is on the dbchanges2 **`netsuite`** module (`dbchanges2/Client_Growrk/_modules.txt`), so its
30
+ Units carry the module's seven `c_` custom fields. `_Model_Growrk_Unit` is *only*
31
+ `extends _Model_Client_Unit` + `use _Trait_Netsuite_Unit` — **every one of those `c_` fields is
32
+ declared by the trait**, and each must exist as a physical column in `Client_Growrk.Units`, as a
33
+ `CustomRecordFields` row (recordId **31** = Units), and as an `AclCustomFieldPermissions` grant.
34
+
35
+ On **2026-08-10** production GroWrk 500'd on `GET /v2/item-fulfillments` because one of those
36
+ columns — `c_lastModified` — had never been created: the module migration
37
+ `_modules/netsuite/2026-07-10a - UnitInventoryFields.sql` had applied to `Client_Growrk` only
38
+ **partially**. Two catch-up migrations fixed it in production; the general playbook lives in
39
+ [client schema-drift audit](../../../2.0/apps/dbchanges2/workflows/client-schema-drift-audit.md).
40
+
41
+ ## The seven fields (all from `_Trait_Netsuite_Unit`)
42
+
43
+ `c_netsuiteInternalInventoryAssignmentId` (integer), `c_itemDescription`, `c_lastModified`,
44
+ `c_chargingBrick`, `c_deviceStatus`, `c_chargingCable`, `c_grade` (char).
45
+
46
+ Because they come from a **trait**, grepping `Model/Growrk/Unit.php` alone shows nothing — the model
47
+ body is empty. Any audit of "what columns must this client have?" has to follow the `use` statements.
48
+
49
+ ## What happened (2026-08-10 production incident)
50
+
51
+ **Symptom.** 6 logged 500s in ~26 seconds across 5 EC2 instances (`Logs.Api` ids 2052548-2052569),
52
+ all `GET /v2/item-fulfillments`, `depth=6`, one fulfillment uuid — and **all sharing one
53
+ `transactionId`**.
54
+
55
+ **Root cause.** `Client_Growrk.Units` had no `c_lastModified` column. `_Model` builds its SELECT from
56
+ **every** declared property, so MySQL threw **1054 "Unknown column 'c_lastModified' in 'field
57
+ list'"** and api2 answered `EO-1` / HTTP 500. The trace bottomed out at `_Model->load()` from
58
+ `getChildOptionRecords()` — the FK hop to Unit.
59
+
60
+ **Trigger.** GroWrk was onboarded to NetSuite on **2026-08-05**, which matches the `Logs.Issue`
61
+ first-seen timestamp of 18:03:22 exactly. Before onboarding, Units resolved through the plain
62
+ `_Model_Client_Unit` (no `c_` fields at all), which is why the same request shape had **1,613
63
+ successful 200s** right up to 2026-08-05 15:37:51.
64
+
65
+ **Extent of the drift.** From `_modules/netsuite/2026-07-10a - UnitInventoryFields.sql`:
66
+ **6 of its 7 Units columns** landed (`c_lastModified` did not) and **5 of its 6
67
+ `CustomRecordFields` rows** never inserted.
68
+
69
+ ## Remediation (applied to production and verified)
70
+
71
+ | Migration | What it does |
72
+ |---|---|
73
+ | `dbchanges2/Client_Growrk/2026-08-10 - GrowrkUnitInventoryFieldsCatchUp.sql` | `ALTER TABLE Units ADD c_lastModified VARCHAR(255) NULL AFTER c_itemDescription`; the 5 missing `CustomRecordFields` rows (recordId 31) **reusing the module migration's fixed UUIDs** so every client stays identical; and their 5 `AclCustomFieldPermissions` grants (roleId 1 = Base, `isWritable = 1`) |
74
+ | `dbchanges2/Client_Growrk/2026-08-10b - GrowrkUnitItemDescriptionAcl.sql` | The `c_itemDescription` ACL grant — missed by the first file because that `CustomRecordFields` row already existed from the partial run, and the first file only granted ACL for rows **it** inserted |
75
+
76
+ **Verified end state:** all 7 recordId-31 custom fields have exactly one ACL grant; the
77
+ `Client_Aig`-vs-`Client_Growrk` column diff across all 20 tables in the request path is empty; no
78
+ further 500s. The `ALTER` was an **instant** operation (MySQL 8.0.39, 5,415 `Units` rows).
79
+
80
+ > **⚠ Two follow-ups on these files.** (1) The first file is named `2026-08-10 - …` with **no letter
81
+ > suffix** — the dbchanges2 contract requires `YYYY-MM-DD<letter>` (it should be `2026-08-10a`), and
82
+ > that rule is hook-enforced. (2) At capture time **neither file was present in the `dbchanges2`
83
+ > checkout or on any branch**, while both are applied to production — i.e. production is ahead of the
84
+ > repo. Commit them, or the next client/environment provisioned from `dbchanges2` re-creates exactly
85
+ > this drift.
86
+
87
+ ## Gotchas / known issues
88
+
89
+ - **The failing field is unrelated to the requested field.** The request asked only for
90
+ `…itemFulfillmentItemUnits.unit.serialNumber`, yet failed on `c_lastModified`, because `_Model`
91
+ loads the whole row. **Any** traversal reaching a Growrk Unit failed, on **any** route — not just
92
+ this field list.
93
+ - **Execution times were 0.19s-2.3s, which DISPROVES the timeout / deep-traversal /
94
+ memory-exhaustion theories** that a `depth=6` request invites. Read `Logs.Api.executionTime`
95
+ before theorising about depth.
96
+ - **6 near-identical hits sharing ONE `transactionId` is apiproxy's cross-region 5xx retry**, not 6
97
+ user actions. Do not read the repeat count as user impact.
98
+ - **`Logs.Issue.status` said RESOLVED with `dtLastOccurred` 4 days earlier while the defect was
99
+ still live.** Issues auto-resolve on consecutive quiet windows, so RESOLVED means "stopped
100
+ firing" — GroWrk traffic had simply gone silent. Never close an investigation on Issue status.
101
+ - **A missing `AclCustomFieldPermissions` grant fails the WHOLE request** (`EZ-2` / 403) when the
102
+ field is named in `fields=` — it is not silently dropped. So an ACL gap and a column gap present
103
+ very differently: 403 vs 500.
104
+ - **`Client_Growrk` recordId 312 (`c_netsuiteInternalSalesOrderId`, `TransferOrders`) has a
105
+ `CustomRecordFields` row with no ACL grant — and that is NORMAL**, not a defect: `Client_Aig` is
106
+ identical. Do not "repair" it on a future audit.
107
+
108
+ ## Change history
109
+
110
+ - 2026-08-10 — **Fixed** the production GroWrk 500s on `GET /v2/item-fulfillments`: `Units` was
111
+ missing the `c_lastModified` column declared by `_Trait_Netsuite_Unit`, so `_Model`'s
112
+ SELECT-all-declared-fields behaviour raised MySQL 1054 on **every** load of a Growrk Unit
113
+ (`EO-1`/500) — triggered by the 2026-08-05 NetSuite onboarding, which switched Units from
114
+ `_Model_Client_Unit` onto the module model. Root cause was a **partially applied**
115
+ `_modules/netsuite/2026-07-10a - UnitInventoryFields.sql` (6 of 7 columns, 1 of 6
116
+ `CustomRecordFields` rows). Applied two catch-up migrations in `Client_Growrk/` (column + the 5
117
+ missing metadata rows with the module's fixed UUIDs + 5 role-1 grants; then the `c_itemDescription`
118
+ grant that the insert-then-grant pattern had skipped) and verified the end state against
119
+ `Client_Aig`. Recorded the diagnostic traps: the failing column is unrelated to the requested
120
+ field, sub-second execution times disprove the timeout theory, one shared `transactionId` means
121
+ apiproxy retried, `Issue.status = RESOLVED` only means the tenant went quiet, and recordId 312's
122
+ grant-less row is the normal cross-client state. (bala)
@@ -7,15 +7,17 @@ apps:
7
7
  - worker2
8
8
  - toga2-supply
9
9
  - dbchanges2
10
+ - library
10
11
  project: _Underscore
11
12
  client: growrk
12
13
  type: profile
13
14
  status: active
14
- updated: 2026-07-14
15
- owners: ["rgirish", "mhammontree"]
15
+ updated: 2026-08-10
16
+ owners: ["rgirish", "mhammontree", "bala"]
16
17
  files: []
17
18
  related:
18
19
  - clients/growrk/features/transfer-order-flow.md
20
+ - clients/growrk/features/units-netsuite-custom-fields.md
19
21
  ---
20
22
 
21
23
  ## Summary
@@ -24,6 +26,23 @@ GroWrk is a 2.0 client using TOGA for inventory and order management. Their prim
24
26
 
25
27
  GroWrk is (as far as told) the **only client on TOGa Supply's Fulfill & Ship** flow — it is the client used to drive that feature and its return-label increment (TRUE-79191) green. See the [Fulfill & Ship feature](../../2.0/apps/toga2-supply/features/fulfill-and-ship.md) and [tracking-number bridges](../../2.0/apps/_underscore/features/tracking-number-bridges.md).
26
28
 
27
- ## Local dev gotchas
29
+ ## Identity
28
30
 
29
- - **`_Model_Growrk_Unit` declares runtime custom-field columns that must physically exist on `Client_Growrk.Units`.** The model declares `c_` fields (`c_itemDescription`, `c_chargingBrick`, `c_deviceStatus`, `c_chargingCable`, `c_grade`); these are runtime custom-field columns, so a locally-drifted `Client_Growrk` DB missing them **1054s (unknown column) on any Unit load**. Add the columns to bring a local DB current.
31
+ `Core.Clients` id **33**, `clientIdentifier` **`Growrk`**, row title **"Growrks"**; databases
32
+ `Client_Growrk` / `Logs_Growrk`; site `growrk.togasupply.com`. Written **GroWrk** in prose and
33
+ `growrk` in slugs/config — all four spellings refer to this one client.
34
+
35
+ GroWrk was **onboarded to NetSuite on 2026-08-05**, and it is on the dbchanges2 **`netsuite`**
36
+ module (`dbchanges2/Client_Growrk/_modules.txt`).
37
+
38
+ ## Gotchas
39
+
40
+ - **`_Model_Growrk_Unit`'s `c_` columns must physically exist on `Client_Growrk.Units` — in EVERY
41
+ environment, production included.** The model body is empty: it is only
42
+ `extends _Model_Client_Unit` + `use _Trait_Netsuite_Unit`, and **the trait** declares all seven
43
+ custom fields (`c_netsuiteInternalInventoryAssignmentId`, `c_itemDescription`, `c_lastModified`,
44
+ `c_chargingBrick`, `c_deviceStatus`, `c_chargingCable`, `c_grade`). `_Model` SELECTs every
45
+ declared field regardless of the API's requested `fields=`, so a DB missing any one of them
46
+ **1054s (unknown column) on any Unit load, on any route**. This is not only a local-drift
47
+ problem — it took production down for GroWrk on 2026-08-10; see
48
+ [Units — NetSuite-module custom fields](features/units-netsuite-custom-fields.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.540",
3
+ "version": "1.0.542",
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",