toga-ai 1.0.492 → 1.0.494

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,13 +6,15 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-08-03
10
10
  owners: [jcardinal, mhammontree]
11
11
  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
15
  related:
16
+ - ../../../2.0/apps/api2/features/nested-relationship-writes.md
17
+ - ../../../clients/aig/features/entitlement-intake.md
16
18
  - netsuite-suiteql-api-reference.md
17
19
  - netsuite-suiteql-rest-shim.md
18
20
  - ../../worker/features/netsuite-togasupply-per-client-sync.md
@@ -106,6 +108,80 @@ bool $enabled2to1, bool $enabled1to2)`. When `enabled2to1`:
106
108
  - The 1→2 direction (`enabled1to2`) is a stub (not yet implemented).
107
109
  - DB target is selected with `App_Model_Client::changeDbLinkToClientDatabase(getClientDatabaseNames()[$togaClientId])`.
108
110
 
111
+ ### TRUE-79401 payload shape — RESOLVED 2026-08-03 (nested; NO api2 work needed)
112
+
113
+ **The multi-email payload shape is settled, and the 2026-07-28 library change is the COMPLETE
114
+ implementation — zero api2/`_underscore` work is required.** (This supersedes the previous "open
115
+ question, owner Paulina".) Sources: AIG's post-entitlements API field-mapping spreadsheet, the
116
+ 2026-05-01 Teams meeting, and the 2026-05-04 AIG email exchange.
117
+
118
+ AIG's mapping document maps **both** `UserDefined3` (secondary email) **and** `UserDefined4` (third
119
+ email) to the **same** nested collection — `Entitlements.contact.contactEmailAddresses[].emailAddress`.
120
+ In the meeting the team looked up our own record/field names specifically to write them into that
121
+ document ("it is called contact email addresses and the field is called email address").
122
+
123
+ > **`UserDefined3` / `UserDefined4` are AIG's SOURCE-SIDE column labels only.** They are not, and
124
+ > never were, fields to implement on our side. Verified: `UserDefined3` appears **nowhere** in
125
+ > `api2` or `_underscore` — its only occurrences in the tree are a comment in
126
+ > `library/app/api/toga2.php` and this doc.
127
+
128
+ Correct inbound shape:
129
+
130
+ ```json
131
+ "contact": {
132
+ "primaryContactEmailAddress": { "emailAddress": "primary@example.com" },
133
+ "contactEmailAddresses": [
134
+ { "emailAddress": "primary@example.com" },
135
+ { "emailAddress": "second@example.com" }
136
+ ]
137
+ }
138
+ ```
139
+
140
+ The primary **may** be repeated inside the collection — `syncContactToToga1Customer` de-dupes with
141
+ `array_unique`.
142
+
143
+ **Three distinct layers — only the last one is delimited.** Conflating them is the easy mistake:
144
+
145
+ | Layer | Shape |
146
+ |---|---|
147
+ | 1. AIG → us (V2 payload) | nested `contactEmailAddresses[]` **array of objects** |
148
+ | 2. `Client_Aig.ContactEmailAddresses` (2.0) | one **ROW** per address |
149
+ | 3. `TOGA_AIG.Customers.emailAddress` (1.0) | a single `'; '`-joined **STRING** |
150
+
151
+ The 2026-05-01 meeting's comma-vs-semicolon debate was entirely about **layer 3** (a legacy
152
+ `varchar(255)` can only hold a string) and says nothing about the inbound payload.
153
+
154
+ **Structural trap:** `contactEmailAddresses` is a **direct child collection**, so it is a **flat**
155
+ array of objects — *not* the double-wrapped bridge-table form used by its siblings in the very same
156
+ AIG payload (`entitlementEntitlementCoverageTypes: [{ entitlementCoverageType: {…} }]`). And a
157
+ wrong shape does **not** error: V2 returns **201** and drops the data — see
158
+ [api2 nested-relationship writes](../../../2.0/apps/api2/features/nested-relationship-writes.md).
159
+
160
+ **Purpose is verification/lookup ONLY.** Settled 2026-05-01: "it's just for visual, this is for
161
+ verification, it's a string." The use case is a support agent looking up a caller by **any** of
162
+ their addresses after pulling the contract number. Multi-recipient notification (CC/BCC on TogaDesk
163
+ tickets) was **explicitly rejected** — see the gotcha about the single-recipient registration email.
164
+
165
+ ### Open items (TRUE-79401)
166
+
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).
169
+ 2. **Beta verification of the nested write is outstanding.** Assert real
170
+ `Client_Aig.ContactEmailAddresses` **row counts** (never the HTTP status), covering **both** a
171
+ brand-new contact (CREATE) **and** a second entitlement for an **existing** contact (UPDATE).
172
+ The UPDATE path is the suspect one: the reverse back-reference injection at `V2.php:4985–4996`
173
+ breaks single-key forced-MATCH and explicitly names `primaryContactEmailAddress` as likely
174
+ affected (see the api2 doc, 2026-07-20).
175
+ 3. Give AIG's dev team a **concrete JSON example** (above), not just the
176
+ `contactEmailAddresses[].emailAddress` path expression, so they don't emit an array of bare
177
+ strings.
178
+ 4. Confirm with **Paulina** that the mapping document actually reached AIG. Risk: our 2026-05-04
179
+ email told AIG to send the email fields "only if they are necessary for the process" — ambiguous
180
+ phrasing, sent in the same breath as dropping Business Unit, which may have read as a de-scope.
181
+ No AIG reply. Meanwhile AIG won't enable the extra fields until we tell them the feature is live
182
+ → **both sides are waiting on each other.** The feature has been functionally complete since
183
+ 2026-07-28 and is gated on **communication + verification, not development.**
184
+
109
185
  ### `syncWithTogadesk()` — 2.0 ⇄ 1.0 (TOGaDesk)
110
186
 
111
187
  A large bi-directional sync gated by **two checkpoint parameters** stored in 2.0 via
@@ -176,18 +252,51 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
176
252
  enough to hold several `'; '`-joined emails, so TRUE-79401 needed **no** schema/dbchanges migration
177
253
  (the earlier fear that it was ~VARCHAR(55) was wrong). `TOGA_AIG` is the 1.0 legacy DB;
178
254
  `Client_Aig` is the 2.0 prod tenant.
179
- - **The multi-email path is not yet exercised (open, TRUE-79401).** As of 2026-07-28 all 20
180
- `Client_Aig.ContactEmailAddresses` rows are one-per-contact, and **no** api2/`_underscore` code
181
- references `UserDefined3`/`UserDefined4` or creates `ContactEmailAddresses` rows. Whether an api2
182
- intake mapping is still needed depends on how Staples / SA.com sends the extra emails: if they POST
183
- nested `contactEmailAddresses` records to the V2 API the library change is complete; if they send
184
- flat `UserDefined3`/`UserDefined4` fields expecting us to map them into email rows, an api2 intake
185
- mapping must be built. Open question owned by **Paulina**.
255
+ - **The multi-email path is not yet exercised in prod data (TRUE-79401)** — as of 2026-07-28 all 20
256
+ `Client_Aig.ContactEmailAddresses` rows are one-per-contact. The *payload-shape* question that
257
+ used to sit here is **RESOLVED** — see *TRUE-79401 payload shape* above. Verification (real row
258
+ counts on beta, CREATE **and** UPDATE paths) is the remaining open item.
259
+ - **Do not "fix" the single-recipient registration email.**
260
+ `_Model_Aig_Entitlement::postPost` sends the Staples Protection Plan registration email to
261
+ `addTo($payload->contact->primaryContactEmailAddress->emailAddress)` only. That is **correct by
262
+ design**: TRUE-79401's scope is *verification/lookup*, and the team explicitly rejected
263
+ CC/BCC-ing every address onto TogaDesk tickets. Multi-recipient notification is out of scope.
264
+ - **The dedupe `LIKE` is an equality match a stored list can no longer satisfy (latent).**
265
+ `syncContactToToga1Customer`'s fallback lookup is
266
+ `WHERE Customers.emailAddress LIKE '$primaryEmailAddress'` — **no wildcards**, so it is an
267
+ equality match. Now that the column can hold a `'; '`-joined list, a 1.0 row that already holds a
268
+ list will **not** match a primary-only lookup. Shielded in practice because dedupe is
269
+ authoritatively keyed on `c_togaCustomerId` first and a successful sync writes that id back to
270
+ 2.0, dropping the contact out of the `c_togaCustomerId IS NULL` query forever. It only bites if
271
+ the **write-back fails after the insert succeeds** — a retry then inserts a **duplicate**
272
+ customer instead of matching. Hardening if it ever fires: `LIKE '%$primaryEmailAddress%'`. Note
273
+ this is a consequence of a deliberate deviation: the 2026-05-01 meeting said to drop the primary
274
+ email entirely, but the implementation retained a separately-escaped primary for this lookup (the
275
+ right call — dropping it would have broken legacy record matching outright).
276
+ - **No `emailAddress2`/`emailAddress3` columns — deliberate.** The 2026-05-01 meeting explicitly
277
+ rejected adding extra email columns across the ~15 Toga Classic client DBs and chose the
278
+ delimited single field instead. That is **why TRUE-79401 has no `dbchanges` migration** — an
279
+ intentional decision, not an oversight. (The doc's earlier "fear that `Customers.emailAddress`
280
+ was ~VARCHAR(55)" traces to a garbled transcript line — "needs to fifty-five… maybe we should
281
+ change it to a long"; the column is `varchar(255)`. Closed.)
186
282
  - **PHP 7.2** target (prod worker/library) — no arrow functions, typed properties, `??=`, `match`.
187
283
  Lint with `C:\xampp7\php\php.exe -l`.
188
284
 
189
285
  ## Change history
190
286
 
287
+ - 2026-08-03 — TRUE-79401 **payload shape RESOLVED** (review/no-code session; sources: AIG's
288
+ field-mapping spreadsheet, the 2026-05-01 Teams meeting, the 2026-05-04 AIG email). Both
289
+ `UserDefined3` and `UserDefined4` map to the **same** nested
290
+ `contact.contactEmailAddresses[].emailAddress` collection; `UserDefined3/4` are **AIG's
291
+ source-side labels only** and exist nowhere in `api2`/`_underscore` — so **no api2 intake mapping
292
+ is needed** and the 2026-07-28 library change is the complete implementation. Replaced the
293
+ "open question, owner Paulina" gotcha with the resolved shape + a concrete JSON example. Also
294
+ recorded: purpose is **verification/lookup only** (multi-recipient notification explicitly
295
+ rejected, so `postPost`'s single-recipient email is correct by design); the three-layer
296
+ payload/row/delimited-string distinction; the extra-email-columns rejection that explains the
297
+ absent `dbchanges` migration; and the latent wildcard-less dedupe `LIKE` vs. a stored list.
298
+ Remaining: deploy confirmation, beta row-count verification (CREATE **and** UPDATE), sending AIG
299
+ a concrete JSON example, and confirming the mapping doc reached AIG. (mhammontree)
191
300
  - 2026-07-28 — TRUE-79401: `syncContactToToga1Customer` now stores a contact's **full** email list in
192
301
  1.0 `TOGA_AIG.Customers.emailAddress` as a `CUSTOMER_EMAIL_DELIMITER` (`'; '`)-joined, deduped,
193
302
  escaped string (was primary-only) for AIG/Staples support lookup. Added
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-08-03
10
10
  owners: ["jcardinal", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -214,6 +214,23 @@ read fresh per request) — no Apache restart needed. Concrete case: `TrackingNu
214
214
  threw EO-1 purely because `_underscore` was on a branch whose generated `TrackingNumber.php` lacked
215
215
  the field.
216
216
 
217
+ ### ⚠ Verify the mirror sibling's grants EXIST in the target DB — or the clone inserts nothing
218
+
219
+ A "clone a sibling's grants" migration reads the **target database's own** `AclFieldPermissions`
220
+ rows, so on a client where the sibling itself has no grants the `INSERT … SELECT` matches zero rows
221
+ and **succeeds with 0 inserted and no error**. Always confirm the sibling has rows in that DB first.
222
+
223
+ **Reference siblings that are safe to mirror** (verified present + complete per client):
224
+ `shippingMethodId` (338), `measures.slug` (723), `containsBattery` (962), `shipToAddressId` (991).
225
+ **Do NOT mirror `needsReturnLabel`** — its grants are absent in some clients.
226
+
227
+ Reconfirmed 2026-08-03 (TRUE-80494): `measures.measureType` had **zero** grant rows in dev-sandbox
228
+ `Client_Growrk`, so it was omitted from `GET /v2/measures`; the frontend's strict
229
+ `m.measureType === "LENGTH"` split then produced empty arrays and **both unit dropdowns rendered
230
+ empty with no error anywhere**. The same audit found `lengthMeasureId`/`weightMeasureId` granted to
231
+ **role 3 only, not role 1** — grant fan-out is per-client and must be verified per client, not
232
+ assumed from one.
233
+
217
234
  ## Checklist (so the chain is never half-built)
218
235
 
219
236
  - [ ] `AclRecordPermissions` row for the role × record with the right CRUD flags.
@@ -227,6 +244,14 @@ the field.
227
244
 
228
245
  ## Change history
229
246
 
247
+ - **2026-08-03** — TRUE-80494: reconfirmed the **silent-omission** read rule in a second environment
248
+ (`measures.measureType` ungranted in dev-sandbox `Client_Growrk` → absent from `GET /v2/measures` →
249
+ empty unit dropdowns, no error) and added the **verify-the-mirror-sibling** rule: a clone migration
250
+ reads the target DB's own rows, so a sibling with no grants there inserts **zero rows without
251
+ erroring**. Listed safe reference siblings (`shippingMethodId` 338, `measures.slug` 723,
252
+ `containsBattery` 962, `shipToAddressId` 991) and flagged `needsReturnLabel` as unsafe to mirror.
253
+ Also noted `lengthMeasureId`/`weightMeasureId` were granted to role 3 but not role 1 — verify the
254
+ role set per client. (mhammontree)
230
255
  - **2026-07-22** — TRUE-79191: documented that the V2 GET **OUTPUT** field list is itself built from
231
256
  `AclFieldPermissions` (`getAclFieldPermissions` + the output `foreach` loops in
232
257
  `api2/.../V2.php`), so an **ungranted field is SILENTLY OMITTED** from the response (200, no
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-14
9
+ updated: 2026-08-03
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Client/ItemFulfillment.php
@@ -27,6 +27,8 @@ related:
27
27
  - ../architecture.md
28
28
  - recursive-item-fulfillments.md
29
29
  - ../../toga2-supply/features/fulfill-and-ship.md
30
+ - ../../api2/features/record-scripts.md
31
+ - ../../dbchanges2/workflows/nonprod-metadata-drift-repair.md
30
32
  ---
31
33
 
32
34
  ## Summary
@@ -168,6 +170,65 @@ outbound label:
168
170
  falls back to Signature Required (`2`).** None Specified is **omitted** (UPS default, no
169
171
  confirmation).
170
172
 
173
+ ## Reference numbers printed on the label (Reference 1 / Reference 2 — implemented 2026-07-31)
174
+
175
+ Two user-entered reference values now reach the carrier and **print on the shipping label**. They
176
+ are **transient**: not sourced from NetSuite, not persisted anywhere, no DB columns, no
177
+ `RecordFields`, **zero `dbchanges2` work**. They ride as **query-string params on the existing
178
+ carrier RecordScript calls** — see
179
+ [Record Scripts → named-argument binding](../../api2/features/record-scripts.md), including the
180
+ **mandatory backend-before-frontend deploy order** that pattern imposes.
181
+
182
+ - `Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php` gained **`$referenceString2`**
183
+ (alongside the existing `$referenceString`).
184
+ - `upsShipmentApi` / `fedexShipmentApi` (`Model/Client/ItemFulfillment.php`) gained optional
185
+ **`$reference1` / `$reference2`** params, and the two **OUTBOUND** shipment builders now use them
186
+ **instead of `SalesOrders.customerPurchaseOrder`**.
187
+ - Semantics (decided; the field-config placeholders were authoritative over the initial verbal
188
+ description, and confirmed in testing when `281144` = `SalesOrders.number` was entered into
189
+ Reference 1): **Reference 1 = NetSuite SALES order #, Reference 2 = NetSuite PURCHASE order #.**
190
+ - Length is capped at **35 characters** (carrier limit), enforced in the form config
191
+ (`characterLimit: 35`).
192
+
193
+ ### ⚠ CRITICAL — UPS `TransactionReference.CustomerContext` NEVER prints on a label
194
+
195
+ The code previously sent the reference into
196
+ `ShipmentRequest.Request.TransactionReference.CustomerContext`. That field is UPS's **request/response
197
+ correlation echo**: UPS accepts it, returns **HTTP 200**, and **prints nothing**. This is why a
198
+ reference never appeared on a GroWrk label — with no error to point at it.
199
+
200
+ > **A carrier accepting a value with a 200 is not evidence that it printed. Only the rendered label
201
+ > is.** Verify every label-content change against the actual label image.
202
+
203
+ **Real label references require PACKAGE-LEVEL `ReferenceNumber` entries** (`Code` + `Value`):
204
+
205
+ - **Shipment-level `ReferenceNumber` is REJECTED by UPS on US/PR → US/PR domestic shipments** — which
206
+ is most GroWrk volume — so **package level is mandatory**, not merely preferred.
207
+ - **UPS prints the code's MEANING next to the value**, so the code choice is user-visible. Verified
208
+ code table: **`PO`** = Purchase Order Number, **`TN`** = Transaction Reference Number, **`IK`** =
209
+ Invoice Number, **`ON`** = Dealer Order Number, **`RZ`** = Return Authorization, plus `AJ`, `AT`,
210
+ `BM`, `9V`, `DP`, `3Q`, `MK`, `MJ`, `PM`, `PC`, `RQ`, `SA`, `SE`, `ST`, `EI`, `TJ`, `SY`.
211
+ **UPS has NO sales-order code.**
212
+ - **Limits:** max **2** reference numbers per shipment, **35 chars** each; only **one** can be
213
+ barcoded per shipment (barcoding additionally requires ≤14 alphanumeric / ≤24 numeric and **no
214
+ spaces**).
215
+
216
+ **FedEx** (`requestedPackageLineItems[].customerReferences[]`) **does** print. Enum values:
217
+ `CUSTOMER_REFERENCE`, `P_O_NUMBER`, `INVOICE_NUMBER`, `DEPARTMENT_NUMBER`, `BILL_OF_LADING`,
218
+ `RMA_ASSOCIATION`, `STORE_NUMBER`, `ELECTRONIC_PRODUCT_CODE`, `SHIPMENT_INTEGRITY`,
219
+ `INTRACOUNTRY_REGULATORY_REFERENCE`. Max **3 per package**, and FedEx **rejects two entries of the
220
+ same type**.
221
+
222
+ **Chosen mapping (kept identical across carriers so a label reads the same either way):**
223
+
224
+ | Field | UPS code | FedEx type |
225
+ |---|---|---|
226
+ | Reference 1 (NetSuite Sales Order #) | `TN` (Transaction Reference Number) | `CUSTOMER_REFERENCE` |
227
+ | Reference 2 (NetSuite Purchase Order #) | `PO` (Purchase Order Number) | `P_O_NUMBER` |
228
+
229
+ `TN` was chosen over `ON` (Dealer Order Number) precisely **because UPS prints the code's meaning** —
230
+ "Dealer Order Number" would be misleading next to a sales-order number.
231
+
171
232
  ## `FIELD_STORAGE` mechanics (reference)
172
233
 
173
234
  `Model.php` handles storage fields separately from regular columns (write loop ~line
@@ -204,6 +265,30 @@ still discards the unsaved in-memory value.
204
265
 
205
266
  ## Gotchas / known issues
206
267
 
268
+ - **⚠ `[ups] label_format` must be set in EVERY environment's api2 config.** Without it **production
269
+ UPS returns ZPL**, while the Fulfill & Ship return-label path expects a **raster PNG** for FPDF.
270
+ `beta.ini` already carried `label_format = "PNG"` and `production.ini` did **not** — which is
271
+ exactly why the flow worked on beta and failed in production (api2 `851c227` added it). When a
272
+ label-format bug reproduces in only one environment, diff the `[ups]` config block first.
273
+ - **⚠ NO carrier can be tested on ANY non-production environment.** Beta (`api.beta.togahub.com`,
274
+ which reads the **dev-sandbox** cluster) runs `debug_mode = 1` → UPS `wwwcie.ups.com` (CIE) and
275
+ FedEx `apis-sandbox.fedex.com`; production uses `apis.fedex.com`. But the carrier **account
276
+ numbers** in non-prod data are **copied from production**, and neither test environment accepts a
277
+ production account: UPS → **"Missing or invalid shipper number" (120100)**, FedEx → **HTTP 400**.
278
+ **Label generation is therefore verifiable only in production** until CIE/sandbox-valid test
279
+ accounts are seeded into non-prod `ShippingCarrierAccountNumbers` (the stated remedy — not done).
280
+ Also: **beta and production point at the SAME NetSuite account (1095849)**, so a beta fulfillment
281
+ writes to production NetSuite. See the
282
+ [non-prod metadata drift workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
283
+ - ~~FedEx shipment failures collapse to a generic "temporarily unavailable" message.~~ **Fixed
284
+ 2026-07-31.** `Fedex.php` did
285
+ `$errorData?->errors[0]?->message ?? 'Shipment service is temporarily unavailable (HTTP n)'`, so any
286
+ unexpected response shape lost the real cause — and because the ship call runs with
287
+ `setLogging(false)`, **that message is the only diagnostic there is**. A new private static
288
+ **`describeShipmentError()`** (with `MAX_ERROR_BODY_LENGTH = 300`) now reports **every** `errors[]`
289
+ entry as `"CODE: message"`, echoes a **truncated raw body** when the shape is unrecognized, and
290
+ distinguishes an **empty** body. General rule for this codebase: a `??` fallback string on an error
291
+ path destroys the only evidence — report the unexpected shape instead of masking it.
207
292
  - UPS **test** endpoint (`wwwcie.ups.com`, debug mode) returns a constant tracking
208
293
  number `1ZXXXXXXXXXXXXXXXX` + SAMPLE labels → `TrackingNumbers.number` UNIQUE
209
294
  collision and File Cabinet duplicate-filename failures on repeat tests (see the
@@ -282,6 +367,22 @@ still discards the unsaved in-memory value.
282
367
  bill) — expect a hard error rather than a silently-wrong bill in those cases.
283
368
 
284
369
  ## Change history
370
+ - 2026-08-03 — TRUE-80494: **wired Reference 1 / Reference 2 through to the carrier so they PRINT on
371
+ the label** — `ShipmentRequest` gained `$referenceString2`, `upsShipmentApi`/`fedexShipmentApi`
372
+ gained optional `$reference1`/`$reference2` (transient query-string params, **zero dbchanges2
373
+ work**), and the outbound builders now use them instead of `SalesOrders.customerPurchaseOrder`.
374
+ **Critical carrier discovery:** UPS `TransactionReference.CustomerContext` is a correlation echo
375
+ that **never prints** (200, no error) — label references need **package-level `ReferenceNumber`**
376
+ (`Code`+`Value`), since shipment-level is **rejected** on US/PR→US/PR domestic; UPS prints the
377
+ code's *meaning*, max 2 refs × 35 chars, one barcodable; FedEx
378
+ `requestedPackageLineItems[].customerReferences[]` does print (max 3/package, no duplicate types).
379
+ Mapping: Ref1 (NS Sales Order) → UPS `TN` / FedEx `CUSTOMER_REFERENCE`; Ref2 (NS Purchase Order) →
380
+ UPS `PO` / FedEx `P_O_NUMBER`. Fixed **FedEx error swallowing** (`describeShipmentError()` reports
381
+ every `errors[]` entry + a truncated raw body; the ship call logs nothing, so that string was the
382
+ only diagnostic). Recorded that **no carrier is testable outside production** (non-prod holds
383
+ production account numbers → UPS 120100 / FedEx 400; beta shares prod NetSuite 1095849) and that
384
+ **`[ups] label_format = "PNG"` was missing from `production.ini`** (prod returned ZPL where FPDF
385
+ needs PNG — beta had it, which is why only prod failed). (mhammontree)
285
386
  - 2026-07-17 — TRUE-79191: applied **signature type** to outbound labels for the first time (carrier
286
387
  libs previously ignored it). Added a generic `$signatureType` on `ShipmentRequest`;
287
388
  `fedexShipmentApi`/`upsShipmentApi` now SELECT + pass `TrackingNumbers.signatureType`; FedEx maps to
@@ -3,13 +3,14 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
+ | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/ItemFulfillment.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql |
6
7
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
7
8
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
8
9
  | [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
9
10
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
10
11
  | [Nested FK object embedding is gated by the CHILD record's own ACL](features/nested-fk-acl-embedding.md) | When the V2 JSON engine serializes a foreign-key field into a **nested object** (in `getFullModelData()`, ~V2.php L6016-6060), it re-checks the **child** record | api2/Component/Api/V2/V2.php, dbchanges2/Client_Compass/2026-07-23b - PurchaseOrdersRecordReadAcl.sql |
11
- | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
12
- | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php, _underscore/Query.php |
12
+ | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php, _underscore/Model/Client/ContactEmailAddress.php |
13
+ | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Query.php |
13
14
  | [V2 Request Logging & Where Requests Land (client vs core log DB)](features/request-logging.md) | The V2 engine logs **every inbound request** — success *and* failure, with response code and payload — and routes each log entry to the **client** log or the ** | api2/Component/Api/V2/V2.php, _underscore/Model/Client/Logs/Api.php, _underscore/Model/Core/Logs/Api.php |
14
15
  | [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
15
16
  | [TOGa IQ Sprint Dashboard API (Record Scripts)](features/sprint-dashboard-api.md) | The internal **TOGa IQ sprint dashboard** is served in production by **six api2 Record Scripts** on `_Model_Team_Sprint` (`_underscore/Model/Team/Sprint.php`), | _underscore/Model/Team/Sprint.php, api2/Component/Api/V2/V2.php, dbchanges2/Core/2026-07-24a - SprintDashboardRecordScripts.sql, dbchanges2/Client_True/2026-07-24b - SprintDashboardScriptAcl.sql |
@@ -0,0 +1,86 @@
1
+ ---
2
+ title: API Payload Interceptors (metadata-registered prePost/postPut model hooks)
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-03
10
+ owners: ["mhammontree"]
11
+ files:
12
+ - api2/Component/Api/V2/V2.php
13
+ - _underscore/Model/Client/ItemFulfillment.php
14
+ - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
15
+ related:
16
+ - ./record-scripts.md
17
+ - ../architecture.md
18
+ - ../../_underscore/features/acl-permission-chain.md
19
+ - ../../_underscore/features/recursive-item-fulfillments.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the
25
+ model. They are dispatched by the api2 **/v2 engine**, and **only if a metadata row exists** in
26
+ `Core.ApiPayloadInterceptors` (or the Client-level equivalent) for that record + processing phase +
27
+ HTTP method. **A missing registration row means the hook silently never runs** — the code is
28
+ present, correct, deployed, and dead. This is the single most misleading failure mode in the
29
+ interceptor mechanism: the symptom looks exactly like a code bug.
30
+
31
+ ## How it works
32
+
33
+ 1. On a write, `_Component_Api_V2` (`api2/Component/Api/V2/V2.php`) looks up
34
+ `ApiPayloadInterceptors` by **`(recordId, prePostProcessing, httpMethod)`** — e.g.
35
+ `(28, 'PRE', 'POST')`.
36
+ 2. The PHP method name is **derived from those two enum values**, not stored:
37
+ `strtolower(prePostProcessing) . ucfirst(strtolower(httpMethod))`. So:
38
+ - `PRE` + `POST` → **`prePost`**
39
+ - `POST` + `POST` → **`postPost`**
40
+ - `POST` + `PUT` → **`postPut`**
41
+ 3. If a row matches, the engine calls that static/instance method on the resolved model (the
42
+ **client subclass** when one exists — `_Model_Growrk_ItemFulfillment` before
43
+ `_Model_Client_ItemFulfillment`). If no row matches, nothing is called and there is **no error,
44
+ no message, and no log line**.
45
+
46
+ Because the registration is a **data** row, it is per-environment: the code ships with a deploy, the
47
+ hook only becomes live when the migration lands.
48
+
49
+ ## Worked example — the EV-10 that was not a code bug
50
+
51
+ `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
52
+ a `POST /v2/item-fulfillments`. In **dev-sandbox** the `(recordId 28, PRE, POST)` row was missing, so
53
+ `prePost` never ran, `itemFulfillmentStageId` stayed `NULL`, and the write failed with **`EV-10`
54
+ "Column 'itemFulfillmentStageId' cannot be null"** — while the identical code worked in production.
55
+ Fix was purely metadata: apply the existing
56
+ `dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql`.
57
+
58
+ **Diagnostic that works:** do a **full-table compare of `ApiPayloadInterceptors` between production
59
+ and the failing environment**. It is a small table, and the drift is usually exactly one row.
60
+
61
+ ## Gotchas / known issues
62
+
63
+ - **⚠ A hook that "isn't running" is a missing registration row before it is a code bug.** Check
64
+ `ApiPayloadInterceptors` for `(recordId, phase, method)` **first** — never start by re-reading the
65
+ PHP.
66
+ - **The method name is derived, so a typo'd enum silently misses.** A row with
67
+ `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine
68
+ will simply find no method and move on.
69
+ - **Registration is per-environment.** Like `RecordFields` / `RecordScripts` / ACL rows, this is
70
+ metadata — "works in prod, not in beta/dev-sandbox" is the signature of drift, not of a bad
71
+ deploy. See the
72
+ [non-prod metadata drift repair workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
73
+ - **Never emit output from an interceptor.** A stray `echo`/`print_r` corrupts the `/v2`
74
+ `{isSuccess,…,data}` envelope (this really happened in `ItemFulfillment.php` — see the
75
+ carrier-shipping-labels doc).
76
+ - **Interceptors are a client-behavior extension point.** Per api2's architecture doc, client
77
+ specifics belong in `_Model_<Slug>_X` overrides + interceptor hooks, **not** in `V2.php`.
78
+
79
+ ## Change history
80
+
81
+ - 2026-08-03 — TRUE-80494: documented the mechanism after an `EV-10`
82
+ (`itemFulfillmentStageId cannot be null`) in dev-sandbox turned out to be a **missing
83
+ `ApiPayloadInterceptors` `(28, PRE, POST)` row**, not a code fault — `prePost` is dispatched only
84
+ from that metadata, and the method name is derived as
85
+ `strtolower(phase) . ucfirst(strtolower(method))`. Added the prod-vs-env full-table compare as the
86
+ diagnostic. (mhammontree)
@@ -6,10 +6,11 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-08-03
10
10
  owners: ["bala", "mhammontree", "tcox"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
+ - _underscore/Model/Client/ContactEmailAddress.php
13
14
  related:
14
15
  - ../architecture.md
15
16
  - ../../../clients/aig/features/entitlement-intake.md
@@ -124,6 +125,33 @@ build — audit such rows before/with the deploy. First seen in AIG (TRUE-79978)
124
125
  beta/QA threw EV-12 on the injected `entitlementFulfillmentType` — see
125
126
  [AIG entitlement intake](../../../clients/aig/features/entitlement-intake.md).
126
127
 
128
+ ## A wrong-shaped nested collection returns 201 and silently drops the data
129
+
130
+ V2 **ignores payload keys it does not recognize** and does not validate the *element shape* of a
131
+ nested collection. Two distinct failure modes both return **HTTP 201 Created** with no error
132
+ anywhere — no `messages[]`, nothing in the client `Logs` or the core/writer `Logs`:
133
+
134
+ 1. **Unknown / flat key.** A partner sends a source-side column label (e.g. a literal
135
+ `UserDefined3`) instead of the nested collection. The key is dropped; no child row is created.
136
+ 2. **Array of bare strings instead of array of objects.** `"contactEmailAddresses":
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.
141
+
142
+ **Operational rules that follow:**
143
+
144
+ - **When verifying a nested-collection integration, assert actual child ROW COUNTS** (e.g.
145
+ `SELECT COUNT(*) FROM Client_Aig.ContactEmailAddresses …`). **Never** treat the HTTP status as
146
+ evidence — a 201 proves only that the parent was written.
147
+ - **Never hand a partner a path expression as the spec.** `contactEmailAddresses[].emailAddress`
148
+ does not pin element structure and reads as "an array of emails" to many developers. Always give
149
+ a **concrete JSON example** of the collection, objects included.
150
+ - **Know whether the collection is direct or bridged.** A direct child collection is a **flat
151
+ array of objects** (`contactEmailAddresses: [{emailAddress}]`); a bridge/join collection is
152
+ **double-wrapped** (`entitlementEntitlementCoverageTypes: [{entitlementCoverageType: {…}}]`).
153
+ Both shapes routinely appear in the *same* payload, so the sibling collections actively mislead.
154
+
127
155
  ## Gotcha
128
156
 
129
157
  - **Nested write with only a non-identifier field silently creates duplicates.** This is a
@@ -135,6 +163,13 @@ beta/QA threw EV-12 on the injected `entitlementFulfillmentType` — see
135
163
 
136
164
  ## Change history
137
165
 
166
+ - 2026-08-03 — Documented that V2 **silently ignores unrecognized payload keys and does not
167
+ validate nested-collection element shape**: both a flat/unknown key and an array of bare strings
168
+ (instead of an array of objects) return **HTTP 201** with the data dropped and no error logged
169
+ anywhere. Added the verification rule (assert child row counts, never the HTTP status), the rule
170
+ to give partners a concrete JSON example rather than a `collection[].field` path expression, and
171
+ the direct-vs-bridged collection shape distinction. Surfaced while resolving the AIG TRUE-79401
172
+ multi-email payload shape (`Client_Aig.ContactEmailAddresses`); no code changed. (mhammontree)
138
173
  - 2026-07-20 — Root-caused the **UPDATE-path** failure of the single-key-uuid forced-MATCH: the
139
174
  update path injects a reverse back-reference (`contactId = parent id`) into nested child objects
140
175
  at `V2.php:4985–4996` (commit `91d803f`) when the child has a back-FK to the parent, turning the
@@ -6,11 +6,12 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-24
10
- owners: ["kyalamarthi"]
9
+ updated: 2026-08-03
10
+ owners: ["kyalamarthi", "mhammontree"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Team/Sprint.php
14
+ - _underscore/Model/Client/ItemFulfillment.php
14
15
  - _underscore/Query.php
15
16
  related:
16
17
  - ./sprint-dashboard-api.md
@@ -67,6 +68,37 @@ Rules the engine enforces (see `getRecordScriptPhpMethod()` + the dispatch in
67
68
  method itself still returns the raw object/array — do not echo, and never build the
68
69
  success/error envelope yourself.
69
70
  - Invocation is effectively `$model::$phpMethod(...$args)`.
71
+
72
+ ### ⚠ Argument binding is PHP NAMED-ARGUMENT unpacking of the QUERY STRING — with two consequences
73
+
74
+ Verified in `api2/Component/Api/V2/V2.php` (~L4202 and ~L4468): the engine does
75
+ `parse_str($_SERVER['QUERY_STRING'], $args)`, **merges the JSON request body** into the same array
76
+ (**the query string wins** on a key collision — body support exists so a large value can avoid an
77
+ HTTP 414; see [scripted-api POST-body args](./scripted-api-post-body-args.md)), sets
78
+ `$args['api'] = $this`, and then calls
79
+ `$candidateClientModelName::$recordScriptPhpMethod(...$args)`. Because `$args` is a **string-keyed**
80
+ array, that spread is **PHP named-argument unpacking**: each query-string key must match a
81
+ **declared parameter name** on the method.
82
+
83
+ 1. **Free extension point — a transient value needs NO schema, RecordField, or ACL work.**
84
+ `Core.RecordScripts` has columns `recordId`, `method`, `route`, `phpMethod` and **no parameter
85
+ signature at all**, so adding a parameter to a script is a **pure code change** — no migration.
86
+ This is the sanctioned way to pass a per-request, **unpersisted** value through to an external
87
+ call (first use: TRUE-80494 passed the user-entered Reference 1 / Reference 2 through
88
+ `upsShipmentApi` / `fedexShipmentApi` to the carrier label — the values are never stored).
89
+ Declare the new params **optional** (`?string $reference1 = null`).
90
+ 2. **⚠ An unmatched query key THROWS — so backend MUST deploy before frontend.** A key with no
91
+ matching declared parameter raises `Unknown named parameter $x` and the call fails outright.
92
+ Two real hazards: (a) if several **sibling** scripts receive the same new query params, every
93
+ sibling must declare them — adding the param to only `upsShipmentApi` breaks
94
+ `fedexShipmentApi`; (b) a frontend deployed **ahead** of the backend sends a param the deployed
95
+ method doesn't declare and breaks **every** call to that script. Any change that adds a script
96
+ parameter therefore carries a **mandatory deploy order: `_underscore`/api2 first, frontend
97
+ second.**
98
+
99
+ > **`_underscore/Route.php` is NOT involved in this.** `Route.php` binds **path segments**
100
+ > (`explode('/', urldecode($route))`), not the query string. Don't go looking there when debugging
101
+ > record-script arguments.
70
102
  - **No current-sprint (or any per-request context) middleware.** Unlike a hand-built app, the
71
103
  `/v2` engine has no middleware that pre-resolves a "current" entity for a script. Each Record
72
104
  Script must resolve its own context (e.g. the current sprint) inside the method.
@@ -225,6 +257,14 @@ an `AclRecordScripts` grant in each client DB.
225
257
 
226
258
  ## Change history
227
259
 
260
+ - 2026-08-03 — TRUE-80494: documented that argument binding is **PHP named-argument unpacking of the
261
+ query string** (`parse_str` + merged JSON body, query wins, `$args['api']` last, then
262
+ `$model::$phpMethod(...$args)`; `V2.php` ~4202/~4468) and its two consequences: (a) a script
263
+ parameter is a **pure code change** — `Core.RecordScripts` stores no signature, so a transient,
264
+ unpersisted per-request value can be threaded to an external call with **no migration/ACL work**
265
+ (first use: Reference 1/2 → carrier label); (b) an **unmatched query key throws**, so every
266
+ sibling script must declare shared new params and the **backend must deploy before the frontend**.
267
+ Also noted `_underscore/Route.php` binds path segments, not query args. (mhammontree)
228
268
  - 2026-07-31 — TRUE-80487: added the **reusable client-side `CustomRecordScript` pattern** for a
229
269
  scripted API declared on a client model — `CustomRecordScripts` + `AclCustomRecordScripts` are
230
270
  **both** `DB_CLIENT`, so registration and grant land in **one** database and the grant resolves
@@ -5,3 +5,4 @@
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
+ | [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,153 @@
1
+ ---
2
+ title: Repairing non-prod metadata drift (works in prod, broken in beta/dev-sandbox)
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
+ files:
12
+ - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
13
+ - dbchanges2/Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql
14
+ - dbchanges2/Client/2026-07-22c - TrackingNumberMeasureIdsFieldPermission.sql
15
+ - api2/Config/beta.ini
16
+ related:
17
+ - ../architecture.md
18
+ - ../../api2/features/api-payload-interceptors.md
19
+ - ../../api2/workflows/environment-configuration-and-provisioning.md
20
+ - ../../_underscore/features/acl-permission-chain.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ Almost all 2.0 platform behavior is **metadata** — `Core.Records`/`RecordFields`,
26
+ `Core.RecordScripts`, `Core.ApiPayloadInterceptors`, and per-client `Acl*` rows. Non-prod
27
+ environments receive those rows by someone remembering to run a migration there, so they **drift**.
28
+
29
+ > **Default assumption: when a feature works in production but not in beta / dev-sandbox, the
30
+ > environment's metadata is behind — not the code.** Verify drift before touching PHP.
31
+
32
+ This workflow is the repair procedure, written from bringing dev-sandbox up to date so beta could
33
+ exercise TOGa Supply Fulfill & Ship (TRUE-80494).
34
+
35
+ ## Steps
36
+
37
+ 1. **Identify which cluster the environment actually reads.** `api.beta.togahub.com` reads the
38
+ **dev-sandbox** cluster (`api2/Config/beta.ini` `[database] hostname =
39
+ dev.sandbox.database.togahub.com`) — "beta" is not its own database. Fix the right DB.
40
+ 2. **Full-table compare the small metadata tables, prod vs. target.** `ApiPayloadInterceptors` in
41
+ particular is small enough to diff wholesale, and the drift is typically **exactly one row**
42
+ (dev-sandbox was missing `(recordId 28, PRE, POST)`, so `prePost` never defaulted
43
+ `itemFulfillmentStageId` → `EV-10 Column 'itemFulfillmentStageId' cannot be null`). Apply the
44
+ existing migration rather than writing a new one.
45
+ 3. **For a field that reads back empty/blank, check `AclFieldPermissions` before the selector.** An
46
+ ungranted field is **silently omitted** from the GET body (HTTP 200, no message). `Measures.measureType`
47
+ had zero grant rows in dev-sandbox `Client_Growrk` → omitted from `GET /v2/measures` → the frontend's
48
+ `measureType === "LENGTH"` split produced empty arrays → both unit dropdowns rendered empty with no
49
+ error anywhere. See the [ACL permission chain](../../_underscore/features/acl-permission-chain.md).
50
+ 4. **Before running a grant migration that clones a sibling, verify the sibling's own grants exist in
51
+ that DB.** The clone reads the target DB's own rows, so a sibling with no grants there inserts
52
+ **zero rows and does not error**. Safe reference siblings (present + complete per client):
53
+ `shippingMethodId` (338), `measures.slug` (723), `containsBattery` (962), `shipToAddressId` (991).
54
+ **Do NOT mirror `needsReturnLabel`** — its grants are absent in some clients.
55
+ 5. **Register any missing Core `RecordField` + client column.** In dev-sandbox
56
+ `tracking-numbers.signatureType` was unregistered; it was registered, the
57
+ `TrackingNumbers.signatureType VARCHAR(255)` utf8mb4 column added, and `containsBattery` (962)
58
+ mirrored for its grants. **⚠ `Core.RecordFields` ids are team-maintained constants — ask for the
59
+ next `id` per the [dbchanges2 architecture rule](../architecture.md) (2026-07-31, jcardinal).**
60
+ What was actually done here diverged from that rule; see *Open question for jcardinal* below
61
+ before copying it.
62
+ 6. **Re-test the feature end-to-end in that environment**, then record what is still missing so the
63
+ next person doesn't rediscover it.
64
+
65
+ ## Environment facts worth knowing before you test here
66
+
67
+ - **NO carrier can be tested on ANY non-production environment.** Beta runs
68
+ `[_underscore] debug_mode = 1`, which routes UPS to `wwwcie.ups.com` (CIE/test), and its
69
+ `[fedex] oauth` points at `https://apis-sandbox.fedex.com` (production uses `https://apis.fedex.com`).
70
+ But the carrier **account numbers** in non-prod data are copied from production, and neither test
71
+ environment accepts a production account — UPS returns **"Missing or invalid shipper number"
72
+ (120100)** and FedEx returns **HTTP 400**. **Label generation can only be verified in production**
73
+ until CIE/sandbox-valid test accounts are seeded into non-prod
74
+ `ShippingCarrierAccountNumbers`. (Remedy = obtain those test accounts; documented as the fix, not done.)
75
+ - **⚠ Beta and production point at the SAME NetSuite account (1095849).** Any beta fulfillment
76
+ writes to **production** NetSuite. Treat a beta fulfill run as a production side effect.
77
+ - **In dev-sandbox, `Core` and `Client_*` share one cluster**, so a `Core.`-qualified reference in a
78
+ `Client/` migration resolves there and looks fine. **Production splits them** (prod-core /
79
+ prod-client) — a clean dev-sandbox run is never proof a migration is cluster-safe (see the
80
+ architecture doc's hard isolation rule).
81
+
82
+ ## Open question for jcardinal — observed `Core.RecordFields` id divergence
83
+
84
+ > **This section records evidence only. It does NOT amend
85
+ > [`2.0/apps/dbchanges2/architecture.md`](../architecture.md).** The 2026-07-31 rule there
86
+ > (jcardinal, commit `0af489d`, enforced by the `dbchanges2-record-ids` hook) is **normative**:
87
+ > `Core.Records`/`Core.RecordFields` ids are team-maintained constants **so that** a client-database
88
+ > migration can hardcode a `recordFieldId` and never read `Core` — which is what makes the
89
+ > *Database isolation* hard rule workable, since production splits `Core` and `Client_*` across
90
+ > separate clusters. The observations below indicate the rule is being **violated in dev-sandbox**;
91
+ > they are not evidence the rule is wrong. Escalated to jcardinal, unresolved.
92
+
93
+ **1. Observed divergence** (verified 2026-08-03, read-only inspection):
94
+
95
+ - `items.isFulfillable` = **2434** in production, **2615** in dev-sandbox — **pre-existing**, not
96
+ created by this session.
97
+ - In dev-sandbox, ids **2480 / 2481 / 2482** belong to `vocabularies.id` / `vocabularies.uuid` /
98
+ `vocabularies.dtCreated`. In production the same ids are `item-fulfillments.returnAddressId`
99
+ (2480), `tracking-numbers.signatureType` (2481), `entitlements.serviceAddressId` (2482).
100
+ - Consequence: the shipped `Core/2026-07-10a - ItemFulfillmentReturnAddressIdRecordField.sql` and
101
+ `Core/2026-07-16a - TrackingNumberSignatureTypeRecordField.sql` **cannot be applied to dev-sandbox
102
+ as written** — they would fail on a duplicate primary key.
103
+
104
+ **2. The hazard this creates for the new rule (the important part).** Because the rule directs that
105
+ `recordFieldId` **should** be hardcoded in client-database migrations, a `Client/` file hardcoding
106
+ **2481** (production's `tracking-numbers.signatureType`) and run against **dev-sandbox** resolves to
107
+ **`vocabularies.uuid`** and would **silently grant permissions on the wrong field** — a silent
108
+ wrong-field write, strictly **worse** than a no-op.
109
+
110
+ **3. Question for jcardinal.** Should dev-sandbox `Core.RecordFields` be **reconciled to production**
111
+ so the stable-id premise holds in every environment — and if so, how, given those rows are already
112
+ referenced by client `AclFieldPermissions`? And **until** it is reconciled, what should a `Client/`
113
+ migration do: hardcode the id (risking the wrong field in non-prod) or resolve by the **stable
114
+ `uuid`**?
115
+
116
+ **4. Workaround of record (not a precedent).** When registering `tracking-numbers.signatureType` in
117
+ dev-sandbox during this session, `id` was **omitted** and `AUTO_INCREMENT` assigned **2764**. This is
118
+ **contrary to the 2026-07-31 rule** and was done as a **one-off manual environment repair** to
119
+ unblock testing. It is **not** a precedent for committed migrations — a committed `Core/` migration
120
+ must still carry a team-assigned `id` literal.
121
+
122
+ **5. Related observation, same escalation.** `Client/2026-07-22c -
123
+ TrackingNumberMeasureIdsFieldPermission.sql` and `Client/2026-07-16b -
124
+ TrackingNumberSignatureTypeFieldPermission.sql` resolve their `recordFieldId` via
125
+ `SET @fieldId = (SELECT … Core.RecordFields …)` before each INSERT block, with later blocks' `SET`s
126
+ positioned **after** earlier INSERTs. Two factual consequences observed: (a) reading
127
+ `Core.RecordFields` from a `Client/` file is itself a **Database-isolation violation** per
128
+ [architecture.md](../architecture.md); (b) when the file is run in chunks / per client selection, the
129
+ later block's variables are unset, `WHERE sibling.recordFieldId = NULL` matches nothing, and **0 rows
130
+ insert with no error** — this silently no-op'd the `measures.measureType` read grant **twice** (first
131
+ in production, then again in dev-sandbox), each time presenting as "empty unit dropdowns". The
132
+ architecture-consistent remedy is to **hardcode the `recordFieldId` literal**, which removes the
133
+ `Core` lookup entirely and with it the half-execution failure mode — but that is exactly the practice
134
+ blocked by the id-divergence hazard in item 2 above, which is why both go to jcardinal together.
135
+
136
+ ## Known remaining drift (dev-sandbox, as of 2026-08-03)
137
+
138
+ - `item-fulfillments.returnAddressId` — the **column exists** but there is **no Core RecordField**.
139
+ Only needed for the return-label flow.
140
+
141
+ ## Change history
142
+
143
+ - 2026-08-03 — TRUE-80494: created from repairing dev-sandbox so beta could exercise Fulfill & Ship —
144
+ completed `Client_Growrk` measure grants (2476/2477/2478, role 1), applied the missing
145
+ `ApiPayloadInterceptors` PRE/POST row for recordId 28, registered
146
+ `tracking-numbers.signatureType` (+ column, + mirrored `containsBattery` grants). Recorded the
147
+ diagnostic sequence (full-table interceptor compare; verify sibling grants exist before a clone, or
148
+ it inserts zero rows silently), the safe reference siblings, that **beta reads the dev-sandbox
149
+ cluster**, that **no carrier is testable outside production**, and that **beta shares production's
150
+ NetSuite account**. Also opened an **escalation to jcardinal** (evidence only — architecture.md
151
+ deliberately left unchanged) on observed `Core.RecordFields` id divergence between production and
152
+ dev-sandbox and the silent wrong-field hazard it creates for hardcoded `recordFieldId`s.
153
+ (mhammontree)
@@ -6,7 +6,7 @@ project: TOGa Supply
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-08-03
10
10
  owners: [mhammontree]
11
11
  files:
12
12
  - _underscore/Model/Client/Measure.php
@@ -184,14 +184,19 @@ bare string** — see the gotcha below before wiring a new select field.
184
184
  `renderEditShipmentFormInput` rendering it via `BaseDisabledInput` on an `isReadOnly` flag. **Not
185
185
  persisted to `IF.locationId`** for now — persisting would be a separate backend "set from SO"
186
186
  change plus a NetSuite-mapping check.
187
- - **Reference 1 / Reference 2 fields are placeholders (rendered, values NOT wired yet).**
188
- Definitions are settled: **Reference 1 = NetSuite Sales Order #, Reference 2 = NetSuite Purchase
189
- Order #** (Eric, authoritative — supersedes Skyler's earlier "Ref 1 = vendor account #, Ref 2 =
190
- company name"). Value sourcing/persistence/carrier-wiring is intentionally deferred; when wiring,
191
- verify: SO# = the document **ORDER #** (e.g. `281144`) vs the internal id (`7221433`, stored as
192
- `c_netsuiteInternalSalesOrderId`); the PO# lives in NetSuite and may not be in the 2.0 DB (the
193
- SO's `customerPurchaseOrder` field — already reused as the carrier `referenceString` — vs a
194
- separate linked NetSuite PO document).
187
+ - **Reference 1 / Reference 2 are WIRED (2026-07-31, TRUE-80494) — user-entered, NOT persisted.**
188
+ Definitions confirmed: **Reference 1 = NetSuite Sales Order #, Reference 2 = NetSuite Purchase
189
+ Order #** (the field-config placeholders were authoritative over the initial verbal description,
190
+ and testing confirmed it — `281144` = `SalesOrders.number` — entered into Reference 1). Per SME
191
+ (Skyler) the values are **user-entered, NOT prefilled from NetSuite, and NOT persisted**; their
192
+ **sole purpose is to reach the carrier and print on the label**. Implemented as **transient
193
+ query-string params** on the existing carrier RecordScript calls (`fulfillShipment` in
194
+ `UpdateShipmentApi.ts` → `upsShipmentApi`/`fedexShipmentApi`) — **no defaultValues, no save
195
+ payload, no DB columns, zero `dbchanges2` work**. Form enforces `characterLimit: 35` (carrier
196
+ limit). Carrier codes + the UPS "CustomerContext never prints" trap: see the
197
+ [carrier-shipping-labels doc](../../_underscore/features/carrier-shipping-labels.md). **⚠ Adding a
198
+ param to a RecordScript means the BACKEND MUST DEPLOY FIRST** — a frontend sending a param the
199
+ deployed PHP doesn't declare throws `Unknown named parameter` and breaks every call.
195
200
  - **Phone # is REQUIRED by design (verified, kept per Eric):** config `isRequired:true` +
196
201
  `showRequiredIndicator`, enforced by `validateFormOnSubmit`.
197
202
  - **Address block layout (Figma):** inline **left** labels (Addressee / Attention / Address 1 /
@@ -366,6 +371,42 @@ the call 403s and returns no label.
366
371
 
367
372
  ## Gotchas / known issues
368
373
 
374
+ - **⚠⚠ A config-driven validator MUST skip DISPLAY-ONLY fields — this caused a TOTAL production
375
+ submit outage (2026-07-29 → 2026-07-31, all clients).** `DUMMYUPDATESHIPMENTFIELDS.json` contains a
376
+ **display-only section header** (id 31, "Address") with **`valueKey: ""` and `isRequired: true`**.
377
+ react-hook-form's `getValues("")` **always returns `undefined`** (its internal `get` helper
378
+ short-circuits on a falsy path), so `validateFormOnSubmit` **always** set a validation error keyed
379
+ on **`""`**. This was harmless for months because the submit guard filtered blank keys
380
+ (`Object.keys(errors).filter(key => key && key.trim() !== "")`). The CodeRabbit **batch 2** change
381
+ replaced that guard with the **fresh boolean** returned by `validateFormOnSubmit`, which does **not**
382
+ filter blank keys → `hasErrors` always `true` → **silent early return → NO network request at all**.
383
+ It was invisible because the error is keyed on `""` and that field renders through `BaseInput`'s
384
+ `case "none"` branch, which has **no `errorMessage` slot**. Blast radius: **every client**, and all
385
+ three actions (Fulfill & Ship, Save, Save & Create New Shipment) share `onFormSubmit`.
386
+ **Two durable rules:**
387
+ 1. A config-driven validator must skip display-only fields —
388
+ `if (!field.valueKey || inputType === "none") continue;`
389
+ 2. **"No network request at all" is the diagnostic signature of client-side validation blocking**,
390
+ not a backend fault. Check the Network tab for the *absence* of a call before debugging the API.
391
+
392
+ **Decision (2026-07-31):** the fix shipped as a **minimal revert of the CALLER guard only**,
393
+ deliberately **keeping** `validateFormOnSubmit`/`checkDimensions` returning booleans so CodeRabbit
394
+ **batch 3**'s `clearErrors` fix survives. Rationale: smallest, safest diff to end a total production
395
+ outage. **Accepted trade-off:** this restores the first-submit-slips-through behaviour CodeRabbit
396
+ flagged, and CodeRabbit will likely re-flag it. The durable alternative — skip
397
+ `!field.valueKey || inputType === "none"` **inside** the validator, keeping both behaviours — is
398
+ recorded as future work (see the TRUE-80494 deployment doc).
399
+ - **⚠ `saveShipment` is NOT transactional, and the edit screen masks the fallout.** `saveShipment`
400
+ (`UpdateShipmentApi.ts`) performs **three sequential POSTs** — item-fulfillment → tracking-number →
401
+ bridge — with **no rollback**. When the tracking-number POST fails, the already-created
402
+ `ItemFulfillment` is **orphaned**; and because `prePost` defaults the stage to SHIPPED, that orphan
403
+ can appear in **Fulfilled Shipments as a real shipment with zero tracking numbers** (observed:
404
+ F100074, id 7180, dev-sandbox). The edit screen then hides it: `EditShipmentForm.tsx` (~line 287)
405
+ does `const tn = editShipment.itemFulfillmentTrackingNumbers?.[0]?.trackingNumber ?? {}`, turning
406
+ "no tracking number" into an **empty object** — so the page **loads looking fine** and only fails on
407
+ save with `PUT /tracking-numbers/undefined` → **EV-6 with `uuid: "undefined"`**. **Diagnostic rule: a
408
+ literal `"undefined"` in a request URL means a client-side value was never populated — look for a
409
+ masking `?? {}` fallback**, not a backend bug.
369
410
  - **⚠ The shared `GoogleMapsLink` mapped the RECIPIENT, not the address.** The shared
370
411
  `components/ui/GoogleMapsLink.tsx` built its query from `[addressee, line1, line2, city, zip]`,
371
412
  which had two bugs: (a) it included the **`addressee` (recipient name)**, so Google surfaced the
@@ -669,8 +710,14 @@ the call 403s and returns no label.
669
710
  50px); may explore stacking. Owners: David Mancol (design) + Alexandra Peterson (phase 2/3).
670
711
  **Partly landed 2026-07-14:** the flexible-gap part of the spec now ships as a CSS
671
712
  `clamp(2px,1vw,50px)` on the row gaps; column-count reduction and stacking are still open.
672
- - **Reference 1/2 value wiring** — fields render but values aren't sourced/persisted; see the form
673
- section (SO# vs internal id, PO# in NetSuite) before wiring.
713
+ - ~~Reference 1/2 value wiring~~ — **done 2026-07-31 (TRUE-80494)**: transient carrier params, not
714
+ persisted; see the form section + the carrier-shipping-labels doc.
715
+ - **Harden the validator instead of the caller** — replace the reverted caller-side blank-key guard
716
+ with a `!field.valueKey || inputType === "none"` skip **inside** `validateFormOnSubmit`, restoring
717
+ CodeRabbit's first-submit fix without re-breaking submit. See the outage gotcha.
718
+ - **Make `saveShipment` transactional (or clean up on failure)** — today a failed tracking-number POST
719
+ orphans an ItemFulfillment that shows as a fulfilled shipment; and drop the masking `?? {}` on the
720
+ edit rehydrate so a missing tracking number surfaces on load, not on save.
674
721
  - **Global-vs-per-SO Shipments list** — pending Eric's model decision; see the gotcha above.
675
722
  - **"Fulfill & Ship from an existing Item Fulfillment"** third entry mode — see Planned direction.
676
723
 
@@ -682,7 +729,12 @@ expected once QA/users start.
682
729
  The return-label feature (TRUE-79191) needs **four** things present in a given environment or it
683
730
  silently mis-behaves; treat these as the release checklist when promoting to a new env:
684
731
  1. **`ItemFulfillments.returnAddressId` column** (Client DB) — the persisted return address FK.
685
- 2. **RecordField 2434** (Core) — the record field backing `returnAddressId`.
732
+ 2. **The Core `RecordField` backing `returnAddressId`** — **id 2480 in production** (corrected
733
+ 2026-07-31; the migration previously said 2434, which in prod is `items.isFulfillable`). **Do not
734
+ verify a `RecordFields` id against the target environment before relying on it** — prod and
735
+ dev-sandbox have drifted (open escalation: see the
736
+ [non-prod metadata drift workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md));
737
+ in dev-sandbox this field is **not registered at all**.
686
738
  3. **`generateReturnLabel` RecordScript** (Client DB) — the scripted-API dispatch grant.
687
739
  4. **Bridge ACL logic groups for records 317–322** (Client DB) — or reads 403 EZ-1; see the
688
740
  tracking-number-bridges doc.
@@ -709,6 +761,21 @@ not the base `_Model_Client_ItemFulfillment`. Tested with GroWrk; UPS support wa
709
761
  for Compass and is not yet in prod.
710
762
 
711
763
  ## Change history
764
+ - 2026-08-03 — TRUE-80494 (hotfix + feature follow-up to TRUE-79191): **ended a TOTAL production
765
+ Fulfill & Ship submit outage** (all clients, since the 2026-07-29 deploy) — the display-only
766
+ "Address" section header (`valueKey: ""`, `isRequired: true`) always produced a validation error
767
+ keyed on `""` (RHF `getValues("")` is always `undefined`), which was previously filtered by the
768
+ caller's blank-key guard and became fatal once the caller branched on the validator's fresh boolean
769
+ → silent early return, **no network request at all**, invisible because `BaseInput`'s `case "none"`
770
+ has no error slot. Fixed by a **minimal revert of the caller guard only** (keeping the boolean
771
+ returns so CodeRabbit batch 3's `clearErrors` fix survives); validator-side skip recorded as future
772
+ work. **Wired Reference 1 / Reference 2** end-to-end as **transient, unpersisted** carrier params
773
+ (user-entered per SME Skyler; Ref1 = NS Sales Order, Ref2 = NS Purchase Order; `characterLimit: 35`;
774
+ zero dbchanges2 work) — **backend must deploy before frontend** because an undeclared RecordScript
775
+ param throws. Documented that **`saveShipment` is not transactional** (three sequential POSTs, no
776
+ rollback → orphaned ItemFulfillment that shows as *fulfilled* with no tracking, e.g. F100074/7180)
777
+ and that the edit rehydrate's `?? {}` masks it into a `PUT /tracking-numbers/undefined` → EV-6.
778
+ (mhammontree)
712
779
  - 2026-07-22 — TRUE-79191 (commit `e91f7029d`, full-stack): **IMPLEMENTED package Units of Measure** —
713
780
  the package weight + dimensions now have real Pound/Inch unit selectors that persist on the
714
781
  `TrackingNumber` and rehydrate on reopen (verified end-to-end). ZERO new tables: reused the client
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-09
10
- owners: ["ajean", "jcardinal"]
9
+ updated: 2026-08-03
10
+ owners: ["ajean", "jcardinal", "mhammontree"]
11
11
  files:
12
12
  - worker2/Worker/Team/Transcripts.php
13
13
  - worker2/Config/production.ini
@@ -140,6 +140,15 @@ process is identical for all.
140
140
  those files. Acceptable as a one-off — the ledger `s3Key` is updated to match — but if you
141
141
  ever need a minimal re-file, change the move test to compare only the `YYYY-MM-DD` folder
142
142
  segment and relocate while preserving the original filename.
143
+ - **There is NO transcript coverage before ~2026-06-12 — pre-June meetings are not in our store.**
144
+ `Team/Transcripts/Export` first ran successfully in production on **2026-06-12** (PR #78), and it
145
+ works off an **incremental watermark** with a bounded `lookbackDays`; nothing in its history shows
146
+ a multi-week backfill ever running. So searching `Team.TranscriptExports` /
147
+ `Team.TranscriptProcessing` / the `togaiq` KBs for a meeting older than ~2026-06-12 comes back
148
+ **empty even though the meeting WAS recorded**. Do not conclude "it wasn't recorded."
149
+ **Recovery path for an older meeting: Microsoft Graph** (which still retains it) or a manually
150
+ exported `.docx`/`.vtt` — not our own store. Hit in practice on 2026-08-03: a 2026-05-01 meeting
151
+ had to be supplied as a downloaded `.docx`.
143
152
  - **Cron rows cannot be `DELETE`d once they have fired.** `Core.WorkerJobs.cronJobId` has a FK
144
153
  (`WorkerJobs_CronJobs_FK`) to `CronJobs.id`, so a schedule change must `UPDATE` an existing
145
154
  row (and set `isActive = 0` on the ones being retired), not delete-and-reinsert. The historical
@@ -181,6 +190,12 @@ Policy gap; a `400` only on a UPN means the id was never resolved to a GUID. (A
181
190
 
182
191
  ## Change history
183
192
 
193
+ - 2026-08-03 — Documented the **pre-2026-06-12 coverage gap**: `Team/Transcripts/Export` first ran in prod on
194
+ 2026-06-12 (PR #78) and is watermark-incremental with a bounded `lookbackDays`, with no backfill
195
+ ever run — so any meeting older than ~2026-06-12 is absent from `Team.TranscriptExports` /
196
+ `Team.TranscriptProcessing` / `togaiq` despite having been recorded. Recovery is via Microsoft
197
+ Graph or a manual `.docx`/`.vtt` export. Found while recovering a 2026-05-01 meeting. No code
198
+ changed. (mhammontree)
184
199
  - 2026-07-09 — **`Export` re-architected to GRAPH-DIRECT** (merged into the ingestion pipeline).
185
200
  The `toga-private` staging bucket is dropped: `Export` is now a thin poller that records a
186
201
  discovery row and enqueues one `Process` job per transcript; `Process` downloads the VTT
@@ -20,8 +20,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
20
20
 
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
- - **api2** (API) — 20 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
- - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
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)
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)
@@ -5,13 +5,14 @@ project: API
5
5
  client: aig
6
6
  type: client-feature
7
7
  status: active
8
- updated: 2026-07-29
8
+ updated: 2026-08-03
9
9
  owners: ["mhammontree"]
10
10
  files:
11
11
  - _underscore/Model/Aig/Entitlement.php
12
12
  - dbchanges2/Client_Aig/2026-06-18a - TRUE-79534 AIG SaleItem codes.sql
13
13
  related:
14
14
  - clients/aig/features/contract-reconciliation.md
15
+ - 1.0/apps/library/features/toga2-api-client-and-bridge.md
15
16
  - 2.0/apps/api2/features/request-logging.md
16
17
  - 2.0/apps/api2/features/nested-relationship-writes.md
17
18
  - 2.0/apps/api2/architecture.md
@@ -121,6 +122,23 @@ Loading the catalog fixed intake, and because the feed re-sends outstanding cont
121
122
  **backlog of dropped contracts self-healed on the next scheduled run** — no manual back-fill was
122
123
  needed (see [contract reconciliation](contract-reconciliation.md)).
123
124
 
125
+ ## Business Unit (`UserDefined2`) — answered and DROPPED (nothing to build)
126
+
127
+ AIG (Alissa, 2026-05-04) confirmed the **Business Unit / Value** field needs **nothing returned to
128
+ AIG**: they already store it from the sale, and it is used only for **sales invoicing**, which is a
129
+ **different process from claims invoicing** (the one we participate in). We replied asking them to
130
+ **omit it from the API call entirely**. **No invoice-file template was ever needed.**
131
+
132
+ Verified there is no dead custom field to clean up: `c_businessUnit` exists **nowhere** in
133
+ `_underscore`, `api2`, or `dbchanges2` (the only `BusinessUnits` hits in the tree are unrelated, in
134
+ the `forecast` repo). Corroborated by AIG's field-mapping spreadsheet, where the `UserDefined2`
135
+ row's target column is **empty** while every other row is mapped.
136
+
137
+ **This — not the extra emails — was the item actually blocking Paulina from sending the mapping
138
+ document.** The two were conflated previously. See
139
+ [the library bridge doc](../../../1.0/apps/library/features/toga2-api-client-and-bridge.md) for the
140
+ resolved email-payload shape.
141
+
124
142
  ## Key files / entry points
125
143
 
126
144
  - **`_underscore/Model/Aig/Entitlement.php`** — `prePost(&$api, &$payload)` is the PRE-POST
@@ -223,6 +241,19 @@ this interceptor or use this dual-purpose Items pattern.
223
241
 
224
242
  ## Gotchas / known issues
225
243
 
244
+ - **`postPost`'s SINGLE-recipient registration email is correct by design — do not "fix" it.**
245
+ It sends the Staples Protection Plan registration email to
246
+ `addTo($payload->contact->primaryContactEmailAddress->emailAddress)` only. TRUE-79401's multi-email
247
+ work is for **verification/lookup only** (a support agent finding a caller by any of their
248
+ addresses); the team **explicitly rejected** CC/BCC-ing every address onto TogaDesk tickets, so
249
+ multi-recipient notification is out of scope. Settled in the 2026-05-01 meeting: "it's just for
250
+ visual, this is for verification, it's a string."
251
+ - **A wrong-shaped nested collection on intake returns HTTP 201 and silently drops the data.**
252
+ Both a flat/unknown key (a literal `UserDefined3`) and an array of bare strings
253
+ (`"contactEmailAddresses": ["a@b.com"]` instead of `[{"emailAddress": "a@b.com"}]`) are accepted
254
+ with **no error anywhere**. When verifying AIG's multi-email feed, assert **actual
255
+ `Client_Aig.ContactEmailAddresses` row counts** — never the HTTP status. See
256
+ [api2 nested-relationship writes](../../../2.0/apps/api2/features/nested-relationship-writes.md).
226
257
  - **A sale-item code missing from `Client_Aig.Items` currently produces an HTTP 500, not a clean
227
258
  "Missing AIG item ID."** In prod (TRUE-79534) a valid-but-unknown code made `$itemId` empty and
228
259
  the downstream fulfillment query threw MySQL ERROR 1064 — so the request 500'd and the contract
@@ -275,6 +306,15 @@ this interceptor or use this dual-purpose Items pattern.
275
306
 
276
307
  ## Change history
277
308
 
309
+ - 2026-08-03 — Review-only session (no code changed). **Business Unit (`UserDefined2`) is answered
310
+ and dropped**: AIG (2026-05-04) needs nothing returned — it is theirs, from the sale, for **sales**
311
+ invoicing (not claims invoicing); we asked them to omit it from the API call and **no invoice-file
312
+ template was needed**. Verified `c_businessUnit` exists nowhere in `_underscore`/`api2`/`dbchanges2`,
313
+ so there is no dead field to clean up; the mapping spreadsheet's `UserDefined2` target column is
314
+ empty. This — not the extra emails — was what blocked Paulina from sending the mapping document.
315
+ Also recorded that `postPost`'s **single-recipient** registration email is **correct by design**
316
+ (TRUE-79401 is verification/lookup only; multi-recipient CC/BCC was explicitly rejected), and the
317
+ 201-with-dropped-data trap when verifying the nested `contactEmailAddresses` feed. (mhammontree)
278
318
  - 2026-07-29 — TRUE-80179 / TRUE-79534 (**RESOLVED + correction**): the `STS_001` intake gap is
279
319
  **closed and it self-healed.** After the catalog load + collation fix shipped, AIG's next
280
320
  scheduled batch delivered all **156** outstanding `STS_001` contracts and every one succeeded
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.492",
3
+ "version": "1.0.494",
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",