toga-ai 1.0.623 → 1.0.625

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,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-20
10
10
  owners: ["dfranks", "jcardinal", "bala"]
11
11
  files:
12
12
  - _underscore/Component/Api/Netsuite/Netsuite.php
@@ -127,6 +127,23 @@ Updates do **not** need a new helper. A NetSuite record PATCH returns 204 with n
127
127
  `send()` already throws on non-2xx — so the update path is just `send('PATCH', RECORD.'/'.$id,
128
128
  $body)`. Only the create path (which must read the `Location` header) required the new primitive.
129
129
 
130
+ ### Idempotent creates — stamp an `externalId`, look it up with `eid:`
131
+
132
+ `createRecord()` has no idempotency of its own, so a caller that must not create twice should put an
133
+ **`externalId`** on the record it creates. Two things follow, and both are cheap:
134
+
135
+ - **NetSuite itself rejects a duplicate `externalId`** within the account, so it is a backstop even
136
+ when the caller's own pre-check misses.
137
+ - **A record can be read back by external id** with `GET /record/v1/<record>/eid:<externalId>` — no
138
+ SuiteQL involved. That matters because whether SuiteQL can **filter** on the native
139
+ `transaction.externalid` column in account 1095849 is **not yet verified** (see the custom-column
140
+ trap below for why a silent zero-row filter is a real risk here). `eid:` is the fallback.
141
+
142
+ Account 1095849 is **shared by every client**, so an external id must be globally unique and
143
+ prefixed — never a per-client record number. Worked example:
144
+ [Toga → NetSuite sales-order push](../../worker2/features/netsuite-salesorder-outbound-push.md)
145
+ (`toga-so-<salesOrder uuid>`).
146
+
130
147
  ## SuiteQL string-literal escaping — double the quote, never backslash
131
148
 
132
149
  SuiteQL is **ANSI SQL sent to NetSuite as the JSON `q` string** — it is **not** executed against
@@ -175,6 +192,13 @@ doc.)
175
192
 
176
193
  ## Change history
177
194
 
195
+ - 2026-08-20 — Added **idempotent creates via `externalId`**: `createRecord()` has no idempotency, so
196
+ stamp an `externalId` (NetSuite rejects a duplicate within the account) and read the record back
197
+ with `GET /record/v1/<record>/eid:<externalId>` — the non-SuiteQL fallback, which matters because
198
+ **filtering** on the native `transaction.externalid` column is still unverified in account 1095849.
199
+ Noted that the account is shared by every client, so external ids must be globally unique and
200
+ prefixed. (bala)
201
+
178
202
  - 2026-08-10 — **Fixed the `createRecord()` trailing-id regex** (`Netsuite.php` ~L198): re-delimited
179
203
  `'#/(\d+)(?:[?#]|$)#'` → `~`, so `preg_match` no longer returns `false` with
180
204
  *"Unknown modifier ']'"* and the method can actually return an internalId. Recorded the durable
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-13
9
+ updated: 2026-08-20
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Logs/Record.php
@@ -15,6 +15,9 @@ files:
15
15
  related:
16
16
  - ../../api2/features/request-logging.md
17
17
  - ../../../../clients/compass-usa/features/approval-decision-flow.md
18
+ - ../../../../clients/compass-usa/features/stranded-approval-reassignment.md
19
+ - ../../api2/features/api-payload-interceptors.md
20
+ - ../../worker2/features/creating-worker-actions.md
18
21
  ---
19
22
 
20
23
  ## Summary
@@ -90,6 +93,19 @@ single record: the milestone notes are the chapter headings, the field changes a
90
93
  automatically. Real example:
91
94
  [Compass PEOPLE-file user lifecycle](../../../../clients/compass-usa/features/people-file-user-lifecycle.md),
92
95
  whose deactivation batch is a raw `UPDATE Users SET isActive = 0 WHERE id IN (...)`.
96
+ - **⚠⚠ `_Model_Client_Logs_Record::addNote()` IS A SILENT NO-OP OUTSIDE api2.** `addNote()` only
97
+ **stages** rows into the class's static `$_notes` array. The **only** thing that ever flushes that
98
+ array is api2's `Component/Api/V2/V2.php`. Nothing in `_underscore` and nothing in `worker2`
99
+ flushes it. So **any worker or cron that calls `addNote()` loses the note entirely** — no error, no
100
+ warning, no row. This is the most dangerous failure mode on this page, because the calling code
101
+ reads as if it audited itself.
102
+
103
+ A worker that needs a note must either drain `_Model_Client_Logs_Record::$_notes` itself and INSERT
104
+ into `Logs_<Tenant>.Record`, or write the `Record` rows directly. Note that
105
+ **`Logs_<Tenant>.Record.clientId` is `NOT NULL`**, and a worker generally does not have an `$api`
106
+ to read the client id from — resolve it from `Core` (`Clients` JOIN `Databases` on `name`). Known
107
+ ids: `Client_Compass` = **2** (`Logs_Compass`), `Client_CompassCanada` = **43**
108
+ (`Logs_CompassCanada`). This applies to **every** client, not just Compass.
93
109
  - **⚠ `Logs_*` and `Core` are on different database clusters — you cannot join them in one query.**
94
110
  `Record.recordId` → `Core.Records` and `RecordField.recordFieldId` → `Core.RecordFields` are
95
111
  logical foreign keys across a cluster boundary. Resolve the `Core` ids in a **separate** query and
@@ -104,6 +120,14 @@ single record: the milestone notes are the chapter headings, the field changes a
104
120
 
105
121
  ## Change history
106
122
 
123
+ - 2026-08-20 — Added the **`addNote()` is a silent no-op outside api2** gotcha: `addNote()` only
124
+ stages into the static `$_notes` array and **only api2's `Component/Api/V2/V2.php` ever flushes
125
+ it**, so every `addNote()` call from `_underscore` or `worker2` loses its note with no error. A
126
+ worker must drain `$_notes` itself or write `Record` rows directly, and must resolve the
127
+ `NOT NULL` `Record.clientId` from `Core` (`Clients` JOIN `Databases` on `name`) because it has no
128
+ `$api` — `Client_Compass` = 2, `Client_CompassCanada` = 43. Found while building the Compass
129
+ [stranded approval reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
130
+ engine, but it is framework-wide and client-agnostic. (bala)
107
131
  - 2026-08-13 — Documented the `Logs_<Tenant>.Record` / `RecordField` field-change audit trail as its
108
132
  own subject: the two row shapes (field-change rows with `RecordField` children vs. milestone rows
109
133
  carrying only a `note`), scoping by `Record.recordId` = the **`Core.Records` model id**
@@ -119,3 +143,7 @@ single record: the milestone notes are the chapter headings, the field changes a
119
143
  request log, the other half of "what happened to this record".
120
144
  - [Compass Approval-Decision Flow](../../../../clients/compass-usa/features/approval-decision-flow.md)
121
145
  — the investigation this technique was generalized from.
146
+ - [Stranded Approval Reassignment](../../../../clients/compass-usa/features/stranded-approval-reassignment.md)
147
+ — the worker that hit the `addNote()` no-op and had to write its own `Record` rows.
148
+ - [API Payload Interceptors](../../api2/features/api-payload-interceptors.md) — why api2 is the only
149
+ tier that flushes staged notes.
@@ -3,7 +3,7 @@
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, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
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, api2/Controller/Index.php, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Rate/Entitlement.php, _underscore/Model/Compass/PurchaseOrder.php, _underscore/Model/Client/SalesOrder.php, _underscore/Model/Elite/SalesOrder.php, _underscore/Model/Elite/ServiceRequest.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, _underscore/Model/Compass/ApprovalDecision.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql, dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql, dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql, dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql |
7
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 |
8
8
  | [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. | api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php |
9
9
  | [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 |
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-20
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -14,10 +14,12 @@ files:
14
14
  - _underscore/Model/Client/ItemFulfillment.php
15
15
  - _underscore/Model/Rate/Entitlement.php
16
16
  - _underscore/Model/Compass/PurchaseOrder.php
17
+ - _underscore/Model/Client/SalesOrder.php
17
18
  - _underscore/Model/Elite/SalesOrder.php
18
19
  - _underscore/Model/Elite/ServiceRequest.php
19
20
  - _underscore/Model/Compass/SalesOrder.php
20
21
  - _underscore/Model/Compass/SalesOrderItem.php
22
+ - _underscore/Model/Compass/ApprovalDecision.php
21
23
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
22
24
  - dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
23
25
  - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
@@ -32,6 +34,9 @@ related:
32
34
  - ../../_underscore/features/recursive-item-fulfillments.md
33
35
  - ../../_underscore/features/config-group-access.md
34
36
  - ../../worker2/features/creating-worker-actions.md
37
+ - ../../../clients/compass-usa/features/stranded-approval-reassignment.md
38
+ - ../../_underscore/features/record-change-audit-log.md
39
+ - ../../_underscore/features/email-template-sending.md
35
40
  - ../../../clients/elite/features/salesorder-netsuite-push.md
36
41
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
37
42
  ---
@@ -394,7 +399,10 @@ request.** Never conclude "it works on beta" for an in-request read-after-write.
394
399
  - Pass **`false`** for `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`). Its throw is
395
400
  guarded by `!empty($this->response->messages)` on the **shared** response object, and
396
401
  `json_encode([])` is the **truthy** string `"[]"` — so it throws even with **zero real errors**
397
- once any message (even a warning) is present anywhere in the request.
402
+ once any message (even a warning) is present anywhere in the request. **This is not theoretical:**
403
+ it took Elite's production `POST /v2/sales-orders` down on 2026-08-20, from the *parent* model's
404
+ own `internalApiRequest` (see [the inherited-chain section](#-activating-a-clients-first-interceptor-row-also-switches-on-the-whole-inherited-chain)).
405
+ The throw site is `V2.php:2416`.
398
406
 
399
407
  ### Two more failure modes to expect on this path
400
408
 
@@ -408,6 +416,83 @@ request.** Never conclude "it works on beta" for an in-request read-after-write.
408
416
  ambiguous** ("never queued" vs "queued, never consumed"). Both are covered in
409
417
  [Creating Worker Actions](../../worker2/features/creating-worker-actions.md).
410
418
 
419
+ ## ⚠ Activating a client's FIRST interceptor row also switches on the whole INHERITED chain
420
+
421
+ A client whose `ApiPayloadInterceptors` set is **empty** has never run `postPost` at all — which
422
+ means it has also never run the **parent** `_Model_Client_<Record>::postPost()` that a client
423
+ override calls via `parent::`. Inserting the first row does not merely enable the code you wrote; it
424
+ enables **every inherited statement above it**, for the first time, in that environment, against that
425
+ client's data.
426
+
427
+ **Worked example — Elite, production, 2026-08-20** (Sentry `API-1Z8`, 12 events, escalating).
428
+ Activating two `sales-orders` rows made `POST /v2/sales-orders` return **`EO-1` 500 with the order
429
+ rolled back**, and the exception message was literally **`[]`**:
430
+
431
+ 1. Elite's `postPost` calls `parent::postPost()` first (correctly — approvals must still be created);
432
+ 2. the parent, `_underscore/Model/Client/SalesOrder.php:359`, reads `/approval-templates` via
433
+ `internalApiRequest` with **throwing left ON**;
434
+ 3. Elite has **zero `ApprovalTemplates`** rows, so that nested GET returns empty and puts a message
435
+ on the **shared** response object;
436
+ 4. `V2.php:2416` does `json_encode(array_filter($messages, ...ERROR...))` → the string `"[]"` →
437
+ **truthy** → throw, with no real error in it;
438
+ 5. the controller catches it, returns `EO-1`, and **rolls back** the write.
439
+
440
+ **Reading the signature:** an exception whose **message is `[]`** on a write route means *this* — a
441
+ message-array truthiness throw. Nothing is wrong with the payload, and the client's own interceptor
442
+ code may never have executed.
443
+
444
+ **Two fixes, both in shared code:**
445
+
446
+ - pass **`false`** as `internalApiRequest`'s 5th argument in the **parent** (narrow, per-caller); or
447
+ - fix **`V2.php:2416`** to test the **filtered array** rather than `json_encode()` of it — which
448
+ fixes **every** caller on this path. Senior-owned: raise it rather than patching one caller.
449
+
450
+ **Before activating a row for a client that has none:** walk the parent chain for the derived method
451
+ and confirm the data each inherited step depends on actually exists for that tenant (here: at least
452
+ one `ApprovalTemplates` row). This is the same reason activation must be staged `isActive = 0` first
453
+ — see the gotchas.
454
+
455
+ ## Calling a real interceptor FROM a worker — the minimal `$api` shim
456
+
457
+ The reverse direction of the section above: not "queue a worker from an interceptor" but **"run the
458
+ production interceptor from a worker"**, so a backfill or repair job reuses the business rules
459
+ instead of reimplementing them. This is the right instinct — a duplicated auto-approve threshold or
460
+ email-routing rule will drift from the real one.
461
+
462
+ **The technique: measure the actual `$api` surface, then fake exactly that.** `$api` looks
463
+ un-fakeable because it is huge, but any single interceptor chain touches very little of it. Trace the
464
+ whole chain reachable from the entry point and you usually find a handful of properties.
465
+
466
+ Worked measurement (2026-08-20, `_Model_Compass_ApprovalDecision::postPut()`): the **entire** `$api`
467
+ surface reachable from that method is **three properties** — `$api->client`, `$api->httpPayload`,
468
+ `$api->recordUuid`. So a worker can build a 3-property shim and call the genuine `prePut`/`postPut`.
469
+ The Compass repair engine does exactly this; see
470
+ [Stranded Approval Reassignment](../../../clients/compass-usa/features/stranded-approval-reassignment.md).
471
+
472
+ Rules for doing it safely:
473
+
474
+ - **Trace the chain, per class — do not generalize the surface from a sibling.**
475
+ `$api->internalApiRequest` and `$api->route` *do* appear in
476
+ `_underscore/Model/Compass/SalesOrder.php`, but only inside
477
+ `_Model_Compass_SalesOrder::postPut()` — a **different class**, not in the `ApprovalDecision`
478
+ chain. Assuming "the Compass models need `internalApiRequest`" would have produced a shim that was
479
+ both wrong and larger than needed. Re-measure for every entry point.
480
+ - **A shim is a hard-fail contract.** If the interceptor later starts reading a fourth property, the
481
+ worker gets a fatal on a `stdClass` with no such property. That is the correct outcome (loud, not
482
+ silent), but it means the shim must be built where a reviewer will see it, not buried.
483
+ - **Emails still go out.** The interceptors send real mail through the real pipeline, so a worker
484
+ that runs them is a sending path — cap it, add a `dryRun`, and commit per unit of work, because a
485
+ sent email cannot be rolled back.
486
+ - **Do not use the interceptor path just to send an email.** For a plain send there is a documented
487
+ non-API entry point: `_Model_Client_EmailTemplate::send(string $clientIdentifier, ...)`, versus the
488
+ `$api`-based `sendEmail()`. See
489
+ [Email Template Sending](../../_underscore/features/email-template-sending.md). Faking `$api` is
490
+ for reusing *business logic*, not for reaching the mailer.
491
+ - **Notes will not be logged.** `_Model_Client_Logs_Record::addNote()` is flushed only by
492
+ `Component/Api/V2/V2.php`, so notes staged by the interceptor you just ran are **dropped** in a
493
+ worker. See
494
+ [Record Change Audit Log](../../_underscore/features/record-change-audit-log.md).
495
+
411
496
  ## Worked example — the EV-10 that was not a code bug
412
497
 
413
498
  `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
@@ -442,6 +527,11 @@ and the failing environment**. It is a small table, and the drift is usually exa
442
527
  `Core.Records` row, so every change is direct SQL and nothing records who made it. Snapshot the
443
528
  client's row set into the ticket before and after any change — that dump is your only rollback
444
529
  and your only history.
530
+ - **⚠ The FIRST row for a client activates INHERITED code, not just yours.** An empty
531
+ `ApiPayloadInterceptors` set means the shared `_Model_Client_<Record>::postPost()` has never run
532
+ for that tenant either. Elite's first two prod rows took `POST /v2/sales-orders` down through the
533
+ **parent's** `/approval-templates` read — exception message literally `[]`. Check the parent chain
534
+ and its data prerequisites before flipping a client's first row on.
445
535
  - **⚠ A migration that enables an interceptor must insert `isActive = 0`.** Activation is a *data*
446
536
  change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
447
537
  that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
@@ -505,6 +595,26 @@ and the failing environment**. It is a small table, and the drift is usually exa
505
595
 
506
596
  ## Change history
507
597
 
598
+ - 2026-08-20 (later pass) — Recorded that **activating a client's FIRST interceptor row switches on
599
+ the entire INHERITED chain**, with the Elite production outage as the worked example: two new
600
+ `sales-orders` rows made every `POST /v2/sales-orders` return `EO-1` 500 with the order rolled
601
+ back, because the *parent* `_Model_Client_SalesOrder::postPost()` — running for the first time for
602
+ that tenant — reads `/approval-templates` with `internalApiRequest` throwing **on**, Elite has zero
603
+ `ApprovalTemplates`, and `V2.php:2416`'s `json_encode(array_filter(...))` yields the **truthy**
604
+ string `"[]"`. Documented the diagnostic signature (an exception whose message is literally `[]`)
605
+ and the two shared-code fixes (parent passes `false`, or `V2.php:2416` tests the filtered
606
+ **array** — senior-owned, not changed). (bala)
607
+ - 2026-08-20 — Added **"Calling a real interceptor FROM a worker — the minimal `$api` shim"**: the
608
+ reverse of the queue-from-interceptor path. A worker can reuse production interceptor logic by
609
+ measuring the `$api` surface an interceptor chain actually touches and faking only that — verified
610
+ that the **entire** surface reachable from `_Model_Compass_ApprovalDecision::postPut()` is three
611
+ properties (`client`, `httpPayload`, `recordUuid`). Recorded that the surface must be re-measured
612
+ **per class** (`$api->internalApiRequest` / `$api->route` appear in
613
+ `_Model_Compass_SalesOrder::postPut()`, a different class not in that chain), that the shim is a
614
+ deliberately loud hard-fail contract, that running interceptors really does send email, that
615
+ `_Model_Client_EmailTemplate::send($clientIdentifier, …)` is the right tool when you only want a
616
+ mail, and that staged `addNote()` notes are silently dropped outside api2. (bala)
617
+
508
618
  - 2026-08-18 — TRUE-81049: documented that a **POST-processing interceptor cannot persist by
509
619
  mutating `$payload`** — `V2.php:5934` passes `$outData`, the outbound response, which the engine
510
620
  returns and never saves, so the caller sees the value while the column stays NULL. Added the
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-18
9
+ updated: 2026-08-20
10
10
  owners: [mhammontree, tcox, jcardinal, ajean, bala]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -48,6 +48,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
48
48
  | **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 |
49
49
  | **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) |
50
50
  | **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) |
51
+ | **EO-1** | Operation failed — the exception message is literally **`[]`** (an empty JSON array) | Not an operation error at all, but a **message-array truthiness throw**. `internalApiRequest`'s throw is guarded on `!empty($this->response->messages)`, and `V2.php:2416` builds that as `json_encode(array_filter($messages, ...ERROR...))` — with zero real errors it is the **string `"[]"`, which PHP treats as truthy**. So any message (even a warning) anywhere in the request throws — including a nested GET that legitimately returns nothing. Live case (2026-08-20): Elite's `postPost` → `parent::postPost()` → `/approval-templates` with **no `ApprovalTemplates` rows** → every `POST /v2/sales-orders` 500s and the order **rolls back** | Short term, pass `false` as `internalApiRequest`'s 5th argument (`$throwExceptionsOnError`) in the calling model. Real fix: `V2.php:2416` tests the filtered **array** instead of its JSON string — that fixes every caller. Background: [API payload interceptors](api-payload-interceptors.md) |
51
52
  | **EO-2** | *"invalid configuration / A required database could not be reached while initializing the request"* — **almost always a lie** | The `catch (Throwable $dbBootstrapError)` in `api2/Controller/Index.php` (~L364) wraps the **entire request dispatch**, not just the DB bootstrap, so **any** Throwable that escapes query processing is relabelled EO-2. Real causes seen: MySQL 1054 on a missing column, a promoted PHP warning, a model fatal | **Ignore the message text.** Get the real exception from Sentry (`captureException`) or, on newer builds, from `identifiers.message` in the envelope; then resolve `error.id` per diagnosis note 9 |
52
53
  | **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 |
53
54
  | **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 |
@@ -272,6 +273,12 @@ Full mechanics:
272
273
  - The response-envelope shape and code families: see [api2 architecture](../architecture.md).
273
274
 
274
275
  ## Change history
276
+ - 2026-08-20 — Added a **fourth `EO-1` case: an exception message that is literally `[]`.** It is a
277
+ message-array truthiness throw, not an operation error — `internalApiRequest`'s throw is guarded on
278
+ the **shared** `response->messages`, and `V2.php:2416`'s `json_encode(array_filter(...))` is the
279
+ truthy string `"[]"` when there are **zero** real errors, so a nested GET that legitimately returns
280
+ nothing 500s the whole write and rolls it back (live case: Elite `/approval-templates` with no
281
+ `ApprovalTemplates` rows). (bala)
275
282
  - 2026-08-18 - **Corrected the EV-8 remedy.** "Register the field in `Core.RecordFields`" is not the
276
283
  only route: a **calculated (`_`-prefixed) field** declared on a shared `_Model_Client_*` model is
277
284
  fully exposed by a **client-scoped `CustomRecordFields` row + `AclCustomFieldPermissions` grant,
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-10
9
+ updated: 2026-08-20
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - worker2/Worker/Netsuite/SalesOrder.php
@@ -54,8 +54,10 @@ spreads `parameters` as named arguments):
54
54
 
55
55
  Supporting privates: `getClientContext`, `fetchSalesOrder`, `buildNetSuiteOrder`,
56
56
  `resolveNetSuiteCustomerId`, `resolveNetSuiteItemId`, `runSuiteQl`, `escapeSuiteQl`,
57
- `assertOrderMatchesUuid`, `doCreate`, `doUpdate`. Constants: `SUITEQL_ITEM_TYPE__GROUP = 'Group'`,
58
- `TOGA_FETCH_DEPTH = 4`.
57
+ `assertOrderMatchesUuid`, `doCreate`, `doUpdate`, `buildExternalId`,
58
+ `findNetSuiteOrderByExternalId`, `writeBackNetSuiteId`. Constants:
59
+ `SUITEQL_ITEM_TYPE__GROUP = 'Group'`, `TOGA_FETCH_DEPTH = 4`,
60
+ `NETSUITE_EXTERNAL_ID_PREFIX = 'toga-so-'`.
59
61
 
60
62
  ## How it works
61
63
 
@@ -73,9 +75,12 @@ Supporting privates: `getClientContext`, `fetchSalesOrder`, `buildNetSuiteOrder`
73
75
  quote, `''`, it never backslashes it).
74
76
  5. **A missing customer / item / location mapping aborts the whole order** rather than sending a
75
77
  partial one. A missing **shipping method** is simply omitted so NetSuite applies its default.
76
- 6. Create → `_Component_Api_Netsuite::createRecord()` (id from the 204/201 `Location` header);
77
- update → `send('PATCH', …)`. The new internalId is written back to
78
- `SalesOrders.c_netsuiteInternalSalesOrderId`.
78
+ 6. Create → `_Component_Api_Netsuite::createRecord()` (id from the 204/201 `Location` header),
79
+ with an **`externalId` stamped on the record** so the create is idempotent — `doCreate` now has
80
+ four outcomes (`created` / `adopted` / `recovered` / `dryRun`), see
81
+ [Idempotency](#idempotency--the-netsuite-externalid) below; update → `send('PATCH', …)`.
82
+ The internalId is written back to `SalesOrders.c_netsuiteInternalSalesOrderId` by the shared
83
+ `writeBackNetSuiteId()` (three paths now save it).
79
84
  7. `dryRun = true` builds and returns the payload without calling NetSuite.
80
85
 
81
86
  ### The job carries the order snapshot — the worker does not re-read it
@@ -97,6 +102,87 @@ process (on a **reader** connection) cannot see the new row at all. See
97
102
  can never be atomic with the MySQL commit anyway, and holding a transaction open across a 12s
98
103
  external call risks row locks and dropped connections. The push stays **async**.
99
104
 
105
+ ## Idempotency — the NetSuite `externalId`
106
+
107
+ Every order this worker creates is stamped with a NetSuite **`externalId`**, so *"have I already
108
+ sent this order?"* is answerable **from NetSuite** rather than from Toga's own write-back. That
109
+ turns the orphan failure below from a manual-reconciliation job into a self-healing one.
110
+
111
+ **Format: `toga-so-<salesOrder uuid>`** — `NETSUITE_EXTERNAL_ID_PREFIX` + the order uuid, built by
112
+ `buildExternalId()`; 44 chars, well inside NetSuite's 255 limit.
113
+
114
+ - **The uuid, NOT `SalesOrders.number`.** NetSuite account **1095849 is shared by every client**,
115
+ while `number` is only unique within one client's database — Elite's `SA100005` and Compass's
116
+ `SA100005` can both exist. Keying on `number` would eventually make NetSuite refuse a legitimate
117
+ order because a **different client's** order already claimed that external id.
118
+ - **The `toga-so-` prefix** because `externalId` is shared with other integrations in that same
119
+ account; a bare uuid says nothing about where the record came from.
120
+ - **⚠ The format must not change once orders carry it.** Orders already in NetSuite would not be
121
+ found under a new format, so the adopt/recover paths below would silently miss them and create
122
+ duplicates. Treat `buildExternalId()` as a frozen contract.
123
+
124
+ ### `doCreate` has four outcomes
125
+
126
+ | Result | When | What it does |
127
+ |---|---|---|
128
+ | `created` | nothing in NetSuite carries this external id | creates the record with `externalId` stamped on it |
129
+ | `adopted` | `findNetSuiteOrderByExternalId()` found one | **no second create** — writes that id back |
130
+ | `recovered` | `createRecord()` threw but the record exists anyway | claims the id, keeps the original error in a `createError` field |
131
+ | `dryRun` | `$dryRun = true` | reports `externalId` and `alreadyInNetSuite`, creates nothing |
132
+
133
+ If the create genuinely failed **and** nothing exists in NetSuite, the original exception is
134
+ **rethrown untouched** — a real failure must never be swallowed by the recovery path.
135
+
136
+ **Two independent layers, deliberately:**
137
+
138
+ 1. the SuiteQL pre-check — `SELECT id FROM transaction WHERE externalid = '<escaped>'`; **more than
139
+ one row throws** rather than guessing which record is the order; and
140
+ 2. `externalId` on the record itself, so **NetSuite** refuses the duplicate even if the lookup misses.
141
+
142
+ ### Write-back failure no longer asks for manual reconciliation
143
+
144
+ The old error on a failed write-back said *"manual reconciliation required"*. It now names **both**
145
+ the internal id and the external id and says a later run will find the record — because with the
146
+ external id that is now true. The regression test asserting the old wording was updated
147
+ **deliberately**: the contract improved, so the assertion moved with it (not a string-match fix).
148
+
149
+ ### Verifying the lookup — you have to fake the orphan first
150
+
151
+ `CreateNetSuite` refuses at the top when `c_netsuiteInternalSalesOrderId` is already set, and that
152
+ guard runs **before the dry-run branch**, so the external-id lookup is unreachable on an
153
+ already-synced order. To exercise it (or the orphan-recovery path) against real data:
154
+
155
+ 1. `UPDATE SalesOrders SET c_netsuiteInternalSalesOrderId = NULL` for an order whose NetSuite record
156
+ already carries the external id;
157
+ 2. **dry-run** it — `alreadyInNetSuite` returning the id proves the SuiteQL filter works, `null`
158
+ means it does not;
159
+ 3. a real (non-dry) run then returns `adopted` and restores the id, so the test cleans up after
160
+ itself.
161
+
162
+ ### ⚠ Still unverified: can SuiteQL FILTER on `transaction.externalid` in this account?
163
+
164
+ A `created` result **cannot distinguish** *"the lookup ran and found nothing"* from *"the lookup is
165
+ broken and always finds nothing."* This project has already hit a NetSuite column that could be
166
+ `SELECT`ed but silently returned zero rows when filtered on (see the
167
+ [REST client doc](../../_underscore/features/netsuite-rest-client.md)), so the live check above is
168
+ still owed. **Fallback if the filter does not work:** `GET /record/v1/salesOrder/eid:<externalId>`,
169
+ which does not depend on SuiteQL at all.
170
+
171
+ ### Proven against live NetSuite (2026-08-20)
172
+
173
+ Four real orders were created and written back by **direct worker invocation** (not the
174
+ interceptor): NetSuite **7358493, 7359455, 7359555, 7366038**, each taking **~11–13s** — which is
175
+ why this push is asynchronous. The duplicate guard was proven by a real second attempt on an
176
+ already-synced order being refused. All four ran with `hasSnapshot = 0` (direct calls), so they do
177
+ **not** exercise the interceptor-queued path — see the
178
+ [Elite client-feature](../../../clients/elite/features/salesorder-netsuite-push.md).
179
+
180
+ **⚠ These four orders reached PRODUCTION client data.** There is no NetSuite sandbox: every
181
+ environment points at live account 1095849, so the inbound NetSuite → Toga sync imported these
182
+ dev-sandbox test orders into **production** `Client_Elite` as real sales orders. The row-level
183
+ cleanup caveat is in the Elite doc; the general rule is in
184
+ [running worker2 locally](../workflows/running-worker2-locally.md).
185
+
100
186
  ## Earlier history worth keeping (2026-06-24, moved from the inbound doc)
101
187
 
102
188
  - **REST-only since 2026-06-24** — the SOAP `NetSuiteService` was fully removed from this file. Item
@@ -115,14 +201,26 @@ process (on a **reader** connection) cannot see the new row at all. See
115
201
  `CreateNetSuite`/`UpdateNetSuite` as thin wrappers — which also closed a TOCTOU, since the decision
116
202
  and the write now use the same fetched record.
117
203
 
118
- ## Test harness (76 tests, all passing)
204
+ ## Test harness (85 tests, all passing)
119
205
 
120
206
  `test/@Bala/tests/netsuite_salesorder_payload_tests.php` loads the **real** worker file with
121
207
  stubbed dependencies and drives the private methods by **reflection**, so it tests shipped code
122
208
  rather than a copy (same technique as
123
209
  [DB-free interceptor unit testing](../../_underscore/features/model-interceptor-unit-testing.md)).
124
210
  Coverage: header fields, mapping resolution, SuiteQL quote-doubling, missing-mapping aborts, dry
125
- run, create, update, the job-carried-snapshot path, and the uuid-match guard.
211
+ run, create, update, and the job-carried-snapshot path, plus:
212
+
213
+ - **the uuid-match guard** — mismatch refused, refused on a dry run too, refused when the carried
214
+ order has no uuid, checked on update as well as create, reported **before** the already-synced
215
+ check, and a matching order still accepted;
216
+ - **the external id** — built and stamped on the create payload, adoption skips the create,
217
+ adoption still writes back, recovery after a failed create, a genuine failure still surfaces the
218
+ **real** error and writes nothing, two matching records refuses to guess, the query really does
219
+ filter on `externalid`, dry run reports `externalId` + `alreadyInNetSuite`, and updates never
220
+ touch external ids.
221
+
222
+ Stub additions this needed: `$throwOnCreate` and `$createPayloadsSeen` on the NetSuite stub, and a
223
+ `$nextRows` queue on the `_Query` stub so `getClientContext()` resolves.
126
224
 
127
225
  ## Gotchas / known issues
128
226
 
@@ -131,7 +229,11 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
131
229
  `#`-delimiter regex bug did exactly this — see the
132
230
  [REST client doc](../../_underscore/features/netsuite-rest-client.md)) leaves a live NetSuite
133
231
  order with **nothing in Toga pointing at it**. Two Elite orders were orphaned in NetSuite account
134
- 1095849 this way. **Never blind-retry a failed create** — check NetSuite for the record first.
232
+ 1095849 this way. **As of 2026-08-20 this is recoverable:** a re-run finds the record by
233
+ `externalId` and returns `adopted`/`recovered` instead of creating a second one (see
234
+ [Idempotency](#idempotency--the-netsuite-externalid)). That only holds for orders created **with**
235
+ an external id — the two pre-existing orphans carry none, so they still need manual linking, and
236
+ a blind retry on any order created before this change can still duplicate.
135
237
  - **This action does not retry itself.** Worker actions have no DLQ and no auto-retry: a throw
136
238
  records `isSuccess = 0` and stops. Unlike the 1.0 cron it replaces (which re-selected unsent
137
239
  orders every tick and was therefore self-healing), an event-triggered push fires **once**. Pair
@@ -142,6 +244,13 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
142
244
  *"Unknown named parameter"* from the dispatcher — which on a stale instance looks like a code bug
143
245
  rather than a deploy problem (see the
144
246
  [deploy workflow](../../api2/workflows/codepipeline-codeconnections-deploy.md)).
247
+ - **⚠ Commit before any file-wide rewrite — a clean `git status` is evidence about HEAD, not
248
+ evidence your work exists.** The entire `externalId` implementation in this file was **lost once**
249
+ and had to be re-applied: it had only ever existed in the working tree, and a file-wide comment
250
+ rewrite overwrote it. `git status` reported the file **clean** while the code was missing, and
251
+ `grep` found the new constant in **no** branch (`TRUE-80497`, `_beta`, `_sandbox-dev`). What
252
+ surfaced it was the **test suite** — 8 failures accurately describing what the code no longer did.
253
+ Commit before a file-wide edit or a branch change, and never read "clean" as "saved".
145
254
  - **The inbound importer in this file is untouched and unrelated.** Do not "tidy" shared-looking
146
255
  helpers across the banner; there are none.
147
256
  - **⚠ `getClientContext()` is copy-pasted across three workers and has already DRIFTED.** The same
@@ -156,6 +265,23 @@ run, create, update, the job-carried-snapshot path, and the uuid-match guard.
156
265
 
157
266
  ## Change history
158
267
 
268
+ - 2026-08-20 — **Made the create idempotent with a NetSuite `externalId`**
269
+ (`NETSUITE_EXTERNAL_ID_PREFIX = 'toga-so-'`, `buildExternalId()`,
270
+ `findNetSuiteOrderByExternalId()`, and `writeBackNetSuiteId()` extracted because three paths now
271
+ save the id): `doCreate` returns `created` / `adopted` / `recovered` / `dryRun`, a genuine failure
272
+ still rethrows untouched, and the guard is two-layer (SuiteQL pre-check **and** `externalId` on the
273
+ record so NetSuite itself refuses a duplicate). **Decided** the format is
274
+ `toga-so-<salesOrder uuid>` and is frozen — the uuid, not `SalesOrders.number`, because NetSuite
275
+ account 1095849 is shared by every client and `number` is only unique per client DB. Reworded the
276
+ write-back failure away from "manual reconciliation required" (it is now self-healing). Recorded
277
+ that the already-synced guard runs **before** the dry-run branch, so verifying the lookup needs
278
+ `c_netsuiteInternalSalesOrderId` NULLed first; that **whether SuiteQL can filter on
279
+ `transaction.externalid` is still unverified** (fallback `GET /record/v1/salesOrder/eid:<id>`); the
280
+ first live end-to-end proof (7358493 / 7359455 / 7359555 / 7366038, ~11–13s each, direct
281
+ invocation — and they round-tripped into **production** Elite data); the harness at **85** tests;
282
+ and the lesson that a clean `git status` does not mean uncommitted work survived a file-wide
283
+ rewrite. (bala)
284
+
159
285
  - 2026-08-12 — Recorded that `getClientContext()` is duplicated across three workers
160
286
  (`Netsuite/SalesOrder`, `Sync/ServiceRequest`, `Sync/SalesOrderStatus`) and has **drifted**: only
161
287
  the Sync/ServiceRequest copy orders the `Apis` credential lookup (`ORDER BY id ASC`), so the other
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-12
10
- owners: ["snaredla"]
9
+ updated: 2026-08-20
10
+ owners: ["snaredla", "bala"]
11
11
  files:
12
12
  - worker2/Worker/Sync/ServiceRequest.php
13
13
  - _underscore/Model/Elite/ServiceRequest.php
@@ -156,6 +156,15 @@ have it. See the `c_`-column convention in `2.0/standards/framework-rules.md`.
156
156
  - **The NetSuite actions in this chain are `Netsuite/SalesOrder/CreateNetSuite` and
157
157
  `Netsuite/SalesOrder/UpdateNetSuite`.** There is **no** `Netsuite/SalesOrder/Push` — do not write
158
158
  that string into a migration, a test or a doc.
159
+ - **⚠ It does not set `locationId` — which blocks the NetSuite push downstream (open, 2026-08-20).**
160
+ Orders this worker creates land with `locationId` **NULL** (prod Elite `SalesOrders` **279** /
161
+ `SA100000`, from `serviceRequestId 3`), and `Netsuite/SalesOrder/CreateNetSuite` then fails loudly:
162
+ *"no NetSuite location mapped on line 1 or on the order — map
163
+ `Locations.c_netsuiteInternalLocationId`"*. **The mapping data is complete** (prod Elite
164
+ `Locations` 1 and 2 → 196, 3 → 5), so this is a missing **field**, not a missing mapping. The fix
165
+ belongs **here, not in the push**: an order should record where it ships from regardless of
166
+ NetSuite, and `buildNetSuiteOrder()` deliberately refuses to guess a warehouse when the data does
167
+ not say. See [the NetSuite push](./netsuite-salesorder-outbound-push.md).
159
168
 
160
169
  ## Verifying the whole chain — `test_elite_chain_toga2.php`
161
170
 
@@ -188,6 +197,11 @@ Recorded so nobody re-debugs them as bugs. All four were confirmed against Elite
188
197
 
189
198
  ## Change history
190
199
 
200
+ - 2026-08-20 — Recorded an **open defect: the worker leaves `locationId` NULL**, so the downstream
201
+ `Netsuite/SalesOrder/CreateNetSuite` push fails loudly on every order it generates (prod Elite
202
+ `SalesOrders` 279 / `SA100000`). The prod Elite `Locations` → NetSuite mappings are complete, so
203
+ this is a missing field rather than missing config, and the push will **not** default it — guessing
204
+ a warehouse is what its fail-loud check exists to prevent. (bala)
191
205
  - 2026-08-12 (later pass) — Recorded three deployment/verification facts: the action rename
192
206
  `GenerateSalesOrder` → `GenerateSalesOrderFromServiceRequest` is a **two-repo, single-deploy**
193
207
  change (the `runTask` string lives in `_underscore/Model/Elite/ServiceRequest.php:44`, and a
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-08-19
10
- owners: ["kyalamarthi"]
9
+ updated: 2026-08-20
10
+ owners: ["kyalamarthi", "bala"]
11
11
  files:
12
12
  - worker2/index.php
13
13
  - worker2/composer.json
@@ -85,6 +85,15 @@ through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_u
85
85
  appears not to have been applied to dev-sandbox since roughly February 2026. A dry run there dies
86
86
  on the missing column, which reads like a code bug. Use a local fixture instead, and see
87
87
  [repairing non-prod drift](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
88
+ - **⚠ There is NO NetSuite sandbox — every environment writes to the LIVE account (1095849).**
89
+ A "beta" or dev-sandbox test that creates a NetSuite record creates it in **production NetSuite**,
90
+ and the inbound NetSuite → Toga sync then imports it straight back into **production Toga** as real
91
+ client data. Four dev-sandbox Elite test orders (NetSuite **7358493 / 7359455 / 7359555 /
92
+ 7366038**) arrived in prod `Client_Elite.SalesOrders` **261–266** exactly this way. Before running
93
+ any NetSuite **write** test, decide up front how you will identify and clean up the records in
94
+ **both** systems — a stable `externalId` makes them findable, see the
95
+ [sales-order push](../features/netsuite-salesorder-outbound-push.md) — and never bulk-delete a row
96
+ range afterwards: unrelated records land in the same range (7369129 and 7374431 did).
88
97
  - **⚠ SECURITY — live credentials are committed in this repo; they are locations to fix, not
89
98
  settings to copy.** Do not paste any of these values anywhere, including a knowledge doc:
90
99
  - `worker2/.ebextensions/git.json` carries a **live GitHub personal access token**, used so
@@ -101,6 +110,11 @@ through `_Worker::runTask` locally: the dev config's `[cloud] aws_worker_queue_u
101
110
 
102
111
  ## Change history
103
112
 
113
+ - 2026-08-20 — Recorded that **there is no NetSuite sandbox**: every environment points at live
114
+ account 1095849, so a dev-sandbox/beta write test creates **production** NetSuite records **and**
115
+ the inbound sync round-trips them into **production Toga** client data (four Elite test orders
116
+ landed in prod `Client_Elite.SalesOrders` 261–266). Plan the two-system cleanup before testing a
117
+ NetSuite write, and never bulk-delete the resulting row range. (bala)
104
118
  - 2026-08-19 — Created while testing the customer account-type sync (TRUE-80206) with no usable
105
119
  shared environment: recorded the five-step local boot (composer install with the `ext-zip` opt-out,
106
120
  `set_include_path()` for `_underscore` vs. EB's `php_include_underscore.config`, `ENVIRONMENT` →