toga-ai 1.0.560 → 1.0.562

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.
Files changed (23) hide show
  1. package/knowledge/1.0/apps/library/INDEX.md +2 -0
  2. package/knowledge/1.0/apps/library/features/app-class-placement-base-contracts.md +75 -0
  3. package/knowledge/1.0/apps/library/features/service-request-toga2-provisioning.md +135 -0
  4. package/knowledge/1.0/apps/library/features/toga2-api-client-and-bridge.md +29 -0
  5. package/knowledge/1.0/apps/test/features/static-no-db-regression-harness.md +18 -0
  6. package/knowledge/1.0/apps/togadesk/workflows/standalone-test-scripts.md +16 -0
  7. package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
  8. package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +71 -0
  9. package/knowledge/2.0/apps/api2/INDEX.md +1 -1
  10. package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +74 -2
  11. package/knowledge/2.0/apps/worker2/INDEX.md +1 -0
  12. package/knowledge/2.0/apps/worker2/features/creating-worker-actions.md +25 -0
  13. package/knowledge/2.0/apps/worker2/features/netsuite-salesorder-outbound-push.md +14 -0
  14. package/knowledge/2.0/apps/worker2/features/service-request-sales-order-generation.md +213 -0
  15. package/knowledge/INDEX.md +4 -4
  16. package/knowledge/clients/elite/INDEX.md +6 -2
  17. package/knowledge/clients/elite/features/desk-service-request-creation.md +136 -0
  18. package/knowledge/clients/elite/features/salesorder-netsuite-push.md +63 -9
  19. package/knowledge/clients/elite/features/salesorder-status-togadesk-reply.md +97 -0
  20. package/knowledge/clients/elite/features/service-request-to-salesorder-pipeline.md +109 -0
  21. package/knowledge/clients/elite/features/togadesk-service-request-intake.md +232 -0
  22. package/knowledge/clients/elite/profile.md +27 -5
  23. package/package.json +1 -1
@@ -3,6 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Library (1.0 Framework) Architecture](architecture.md) | `library` is the shared library repository for **all 1.0 (legacy) applications** — the `App_` framework. | library/_.php, library/app/, library/browser/ |
6
+ | [Where a new App_ class goes — the app/ folder IS a behavioral contract](features/app-class-placement-base-contracts.md) | In `library/app/`, choosing a folder is **not** a filing decision — the autoloader maps `App_<Folder>_<File>` to `app/<folder>/<file>.php`, and each folder's ba | library/app/model.php, library/app/client.php, library/app/api.php, library/app/api/servicerequest.php, library/app/api/volt.php, library/app/api/carrier/fedex.php |
6
7
  | [App_Sso — Reusable 1.0 SSO Initiation (SP-initiated SAML via saml.togahub.com)](features/app-sso-initiation.md) | `App_Sso` (`library/app/sso.php`) is the **1.0 port of the 2.0 SAML gateway's SP-initiated SSO initiation**, packaged as a reusable, framework-level capability | library/app/sso.php, library/sso/togahub_private_key.key |
7
8
  | [Cron Execution Monitoring (App_Framework check-in/out → CronJobExecutions)](features/cron-execution-monitoring.md) | `App_Framework::cronInitialization()` / `App_Framework::cronFinished()` (in `library/app/framework.php`) give every 1.0 (`App_`) cron job a check-in/check-out l | library/app/framework.php |
8
9
  | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view: 1. | library/app/model/toga/diagnostic.php, library/app/model/servicerequest.php |
@@ -15,5 +16,6 @@
15
16
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
16
17
  | [NetSuite SuiteQL/REST Shim — Field Semantics](features/netsuite-suiteql-rest-shim.md) | `App_Api_Netsuite_Rest` is the REST/SuiteQL replacement for the deprecated NetSuite SOAP toolkit. | library/app/api/netsuite/rest.php |
17
18
  | [NetSuite Sync Alert Monitor (App_SystemMonitor_NetSuiteIntegration)](features/netsuite-sync-alert-monitor.md) | `App_SystemMonitor_NetSuiteIntegration` (`library/app/systemmonitor/netsuiteintegration.php`, title **"NetSuite Sync Alert"**) is a 1.0 system monitor that watc | library/app/systemmonitor/netsuiteintegration.php, worker/crons/infrastructure/system_monitors.php |
19
+ | [App_Api_ServiceRequest — provisioning a TOGa 2.0 Service Request from 1.0](features/service-request-toga2-provisioning.md) | `App_Api_ServiceRequest` (`library/app/api/servicerequest.php`) is the shared 1.0-side class that turns a posted form into a **TOGa 2.0 Service Request** — it v | library/app/api/servicerequest.php, library/app/api/toga2.php, togadesk/desk/includes/classes/class.ticket.php |
18
20
  | [Startech PC Matic B2B Sync (library)](features/startech-pcmaticb2b-sync.md) | `library/app/api/toga2.php` handles bidirectional ticket sync for PC Matic B2B between TOGaDesk 1.0 and TOGA 2.0. | library/app/api/toga2.php, library/app/api/startechticket.php, worker/crons/toga2/startech/common_import_supporting_records.php |
19
21
  | [App_Api_Toga2 — TOGa2 API Client & 1.0↔2.0 Sync Bridge](features/toga2-api-client-and-bridge.md) | `App_Api_Toga2` (`library/app/api/toga2.php`, ~8400 lines) is the **1.0-side client for the TOGa 2 (`_underscore`/api2) public API** *and* the home of the cross | library/app/api/toga2.php, worker/crons/toga2/aig/sync_togasupply_aig.php, worker/crons/toga2/wje/sync_togasupply_wje.php, test/@Mark/AIG/test_multi_email.php |
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: Where a new App_ class goes — the app/ folder IS a behavioral contract
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-12
10
+ owners: [snaredla]
11
+ files:
12
+ - library/app/model.php
13
+ - library/app/client.php
14
+ - library/app/api.php
15
+ - library/app/api/servicerequest.php
16
+ - library/app/api/volt.php
17
+ - library/app/api/carrier/fedex.php
18
+ related:
19
+ - ../architecture.md
20
+ - ../../../clients/elite/features/togadesk-service-request-intake.md
21
+ ---
22
+
23
+ ## Summary
24
+
25
+ In `library/app/`, choosing a folder is **not** a filing decision — the autoloader maps
26
+ `App_<Folder>_<File>` to `app/<folder>/<file>.php`, and each folder's base class carries **real
27
+ inherited behavior**. Put a class in the wrong folder and it inherits a contract that does not
28
+ apply, silently. This cost three relocations of one class on 2026-08-12; the rule below is the
29
+ outcome.
30
+
31
+ **The rule:** a class that only makes HTTP calls to an external (or cross-framework) API belongs in
32
+ `app/api/` and should **extend nothing**.
33
+
34
+ ## What each base class actually obliges you to
35
+
36
+ | Folder | Base | What it really means |
37
+ |---|---|---|
38
+ | `app/model/` | `App_Model` | Requires `const TABLENAME` **and** `const DATABASE`, and constructs **by primary key**. Only for a class backed by a real 1.0 table. |
39
+ | `app/client/` | `App_Client` | Fiscal calendars and retail store mappings — `getName()`, `convertToFiscalPeriod()`, `getFiscalPeriods()`, `parseStoresRegionsMappingFile()`, `isEnabledForStore()`. It is **not** "the folder for client-specific code", and the folder is **FLAT** (no per-client subfolders). |
40
+ | `app/api/` | `App_Api` | The 1.0 **fulfilment-vendor** base: `submitFromPending($aryServiceRequestItemStatusIds)`, `getDatesForServiceRequest()`. It declares **no abstract members**, so extending it compiles cleanly and silently grants your class a vendor-submission API it cannot honour. |
41
+
42
+ The `App_Api` case is the trap: nothing forces you to implement anything, so the mistake never
43
+ surfaces as an error — it surfaces later as a caller invoking `submitFromPending()` on a class that
44
+ was never a fulfilment vendor.
45
+
46
+ ## Placement guidance
47
+
48
+ 1. **Backed by a 1.0 table?** → `app/model/…`, extend `App_Model`, declare `TABLENAME` + `DATABASE`.
49
+ 2. **Fiscal calendar / store-mapping behavior?** → `app/client/…` (flat), extend `App_Client`.
50
+ 3. **A vendor that fulfils service-request items?** → `app/api/…`, extend `App_Api`.
51
+ 4. **Anything else that just talks HTTP** (a REST client, a cross-framework provisioner) →
52
+ `app/api/…`, **extend nothing**. `App_Api_ServiceRequest` and `App_Api_Toga2` are both of this
53
+ kind.
54
+
55
+ **Nesting is fine and has precedent:** `app/api/volt.php` sits beside `app/api/volt/user.php`, and
56
+ `app/api/carrier/fedex.php` nests a carrier under a family folder. Grouping a family of API classes
57
+ in a subfolder does not require a shared base class.
58
+
59
+ ## Gotchas / known issues
60
+
61
+ - **"Extends cleanly" is not evidence of correct placement** in 1.0 — the bases have no abstract
62
+ members, so the compiler will never tell you.
63
+ - **`app/client/` is flat.** Do not create `app/client/<client>/` — that is not how the autoloader
64
+ or the existing tree is organised.
65
+ - All folder and file names under `app/` must be **lowercase** (autoloader lowercases before
66
+ lookup) — see [library architecture](../architecture.md).
67
+
68
+ ## Change history
69
+
70
+ - 2026-08-12 — Captured after `App_Api_ServiceRequest` was relocated three times (model → client →
71
+ api). Recorded what each base class actually carries — `App_Model` needs `TABLENAME`/`DATABASE`
72
+ and constructs by primary key; `App_Client` is fiscal-calendar/store-mapping behavior and its
73
+ folder is flat; `App_Api` is the fulfilment-vendor base whose lack of abstract members makes a
74
+ wrong `extends` silent — and the resulting rule: an HTTP-only class lives in `app/api/` and
75
+ extends nothing. (snaredla)
@@ -0,0 +1,135 @@
1
+ ---
2
+ title: App_Api_ServiceRequest — provisioning a TOGa 2.0 Service Request from 1.0
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-12
10
+ owners: [snaredla]
11
+ files:
12
+ - library/app/api/servicerequest.php
13
+ - library/app/api/toga2.php
14
+ - togadesk/desk/includes/classes/class.ticket.php
15
+ related:
16
+ - ./toga2-api-client-and-bridge.md
17
+ - ./app-class-placement-base-contracts.md
18
+ - ../../../clients/elite/features/desk-service-request-creation.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ `App_Api_ServiceRequest` (`library/app/api/servicerequest.php`) is the shared 1.0-side class that
24
+ turns a posted form into a **TOGa 2.0 Service Request** — it validates and normalises the posted
25
+ values, resolves the related 2.0 records it needs, refuses a duplicate, and POSTs the Service
26
+ Request with its nested units in a single request. All transport goes through
27
+ [`App_Api_Toga2::send()`](./toga2-api-client-and-bridge.md); this class holds no 1.0 table and does
28
+ no SQL.
29
+
30
+ First consumer is the Elite "New Service Request" button in TOGa Desk (see
31
+ [Elite desk Service Request creation](../../../clients/elite/features/desk-service-request-creation.md)),
32
+ but the class itself is client-agnostic — the caller passes the API endpoint in.
33
+
34
+ ## Key files / entry points
35
+
36
+ `abstract class App_Api_ServiceRequest` — public:
37
+
38
+ - `buildFromPostedValues(array $postedValues, string $ticketUuid, ?string $overrideWithApiEndpoint = null): array`
39
+ — validates + normalises the modal post into the 2.0 payload array (throws on a validation
40
+ failure; the caller converts that into the UI message).
41
+ - `searchTicketFromToga2(string $ticketUuid, ?string $overrideWithApiEndpoint = null)` — the
42
+ **duplicate guard**: does a Service Request already exist for this ticket?
43
+ - `createServiceRequestInToga2(array $serviceRequest, ?string $overrideWithApiEndpoint = null)` —
44
+ the POST (parent + nested units).
45
+ - `describeApiFailureMessage($response): string` — turns a 2.0 failure response into an
46
+ agent-readable message.
47
+
48
+ Private: `isStateInCountry()`, `findContactUuidByEmailAddress()`, `cleanPostedValue()`.
49
+
50
+ ## How it works
51
+
52
+ 1. **Build** — `buildFromPostedValues()` trims/length-caps every posted value
53
+ (`cleanPostedValue()`), resolves the requester contact by email, checks the selected state
54
+ really belongs to the selected country, and assembles the parent payload plus one nested
55
+ `serviceRequestUnits` entry per line.
56
+ 2. **Duplicate guard** — `searchTicketFromToga2()` looks the ticket uuid up in 2.0 before creating
57
+ anything.
58
+ 3. **Create** — `createServiceRequestInToga2()` POSTs **parent + nested `serviceRequestUnits` in
59
+ ONE request**. Related records inside that payload resolve by **natural key** — the request type
60
+ by *name*, the item by *partNumber* — while **customer, state and ticket are matched by uuid**.
61
+ 4. The caller (TOGa Desk) maps the outcome to its own status code and writes the ticket
62
+ history/reply.
63
+
64
+ ## The failure-check contract — `empty($response->isSuccess)` is always safe
65
+
66
+ This is the load-bearing fact behind three bugs fixed on 2026-08-12, and it is not obvious from the
67
+ call site.
68
+
69
+ `App_Api_Toga2::send()` (`library/app/api/toga2.php`, ~L432–450) **always returns a response object
70
+ that carries `isSuccess`**:
71
+
72
+ - it returns early **only** when `isSuccess` is true;
73
+ - when `isSuccess` is false and `$throwExceptionOnApiError = false`, it falls through the retry loop
74
+ and ends at `return $response` — **a failure arrives as a return value, not an exception**;
75
+ - if the response is not an object, or lacks the `isSuccess` property at all, it **throws**.
76
+
77
+ Therefore anything `send()` hands back can be checked with `empty($response->isSuccess)` — including
78
+ GETs. There is no "successful response without the property" case to defend against.
79
+
80
+ **The bug class this prevents:** every helper here calls `send()` with
81
+ `$throwExceptionOnApiError = false`, so a network blip, an auth failure or a 500 comes back as a
82
+ *response*. Code that reads straight past it to `$response->data->{resource}` sees "no records" and
83
+ concludes **"the thing does not exist"** — the most dangerous possible misreading for a lookup.
84
+
85
+ ## Fixed 2026-08-12 — three silent failures, one root cause
86
+
87
+ | Lookup | Old behavior on API failure | Consequence |
88
+ |---|---|---|
89
+ | duplicate Service Request | returned `null` | read as "no duplicate exists" → **a second Service Request on a ticket that already had one** |
90
+ | contact by email | returned `null` | read as "no contact exists" → **a duplicate contact created in 2.0** for someone 2.0 already held |
91
+ | state-belongs-to-country | returned `false` | a blip told the agent *"the selected state does not belong to the selected country"* — a **false accusation of bad input** |
92
+
93
+ `isStateInCountry()` now returns **`?bool` as a tri-state**: `true` confirmed, `false` a genuine
94
+ mismatch, **`null` unconfirmed** (exception or empty result). The submission is refused in both the
95
+ `false` and `null` cases — the difference is the **message**: only `false` blames the input, `null`
96
+ says the check could not be completed. Do not collapse this back into a `bool`.
97
+
98
+ ## 2.0 API rules this class had to learn the hard way
99
+
100
+ - **Naming `fields` on `GET /states` SUPPRESSES the nested country.** A plain request returns the
101
+ nested `country`; adding a `fields` list drops it. If a nested relation vanishes, suspect the
102
+ field list before suspecting the data.
103
+ - **Post a state by UUID, never by code.** `WA` and `NT` each belong to **two** countries, and the
104
+ API matches on code alone. `States.countryId` has `isIdentifier = 0`, so the nested country cannot
105
+ disambiguate it either — the uuid is the only unambiguous handle.
106
+ - **Natural-key resolution inside a nested write:** type by name, item by partNumber. Only
107
+ customer / state / ticket go by uuid.
108
+
109
+ ## Gotchas / known issues
110
+
111
+ - **A uuid is environment-scoped.** Any flow that reads uuids from one 2.0 environment and posts
112
+ them to another will fail or, worse, resolve to a different record. Read and write against the
113
+ **same** endpoint — see the hardcoded-endpoint gotcha in the
114
+ [Elite desk feature](../../../clients/elite/features/desk-service-request-creation.md).
115
+ - **This class extends nothing, deliberately.** Extending `App_Api` silently inherits the 1.0
116
+ fulfilment-vendor contract. See
117
+ [where a new App_ class goes](./app-class-placement-base-contracts.md).
118
+ - **Credentials.** Elite's `CLIENT_UUID_ELITE` / `API_UUID_ELITE` / `API_SECRET_ELITE` are class
119
+ constants on `App_Api_Toga2` (`library/app/api/toga2.php`). Read them from source; never
120
+ reproduce the values. An API secret living in a git-tracked constant is a standing concern — see
121
+ the credentials gotcha on [the transport doc](./toga2-api-client-and-bridge.md).
122
+
123
+ ## Change history
124
+
125
+ - 2026-08-12 — TRUE-80497: created `App_Api_ServiceRequest` as the shared 1.0→2.0 Service Request
126
+ provisioner. Recorded the `send()` failure contract (**`empty($response->isSuccess)` is always a
127
+ safe check, including on GETs**) and fixed **three** silent failures that all came from reading
128
+ past a failure response returned under `$throwExceptionOnApiError = false`: the duplicate-SR
129
+ lookup and the contact lookup both reported "does not exist" (allowing a second SR and a
130
+ duplicate contact), and the state/country check reported a genuine mismatch for an exception or
131
+ empty result. `isStateInCountry()` now returns `?bool` — `null` = unconfirmed, with its own
132
+ message; both `null` and `false` still refuse. Also recorded the 2.0 rules: `fields` on
133
+ `GET /states` suppresses the nested country; states must be posted by uuid (`WA`/`NT` are
134
+ ambiguous across countries and `States.countryId` has `isIdentifier = 0`); parent + nested
135
+ `serviceRequestUnits` post in one request with type/item resolved by natural key. (snaredla)
@@ -23,6 +23,7 @@ related:
23
23
  - ../../worker/features/netsuite-togasupply-per-client-sync.md
24
24
  - ../architecture.md
25
25
  - ./error-capture-1-0.md
26
+ - ../../../clients/elite/features/togadesk-service-request-intake.md
26
27
  ---
27
28
 
28
29
  ## Summary
@@ -78,6 +79,22 @@ belong to the NetSuite importer — documented in the per-client-sync doc, not h
78
79
  - Returns the decoded response object; callers read `->data->{resource}`, `->meta->nextPage`,
79
80
  `->isSuccess`, `->status`, `->messages[].code`.
80
81
 
82
+ > **`send()` ALWAYS returns something carrying `isSuccess` — so `empty($response->isSuccess)` is a
83
+ > safe failure check on anything it returns, GETs included** (verified 2026-08-12, ~L432–450). It
84
+ > returns early **only** when `isSuccess` is true; when `isSuccess` is false and
85
+ > `$throwExceptionOnApiError = false` it falls out of the retry loop to `return $response`; and if
86
+ > the response is not an object or lacks the property at all it **throws**. There is no
87
+ > "successful response without `isSuccess`" case to defend against.
88
+ >
89
+ > **The corollary is the dangerous part:** with `$throwOnError = false`, a network blip, an auth
90
+ > failure or a 500 arrives as a **response**, not an exception. Code that reads straight on to
91
+ > `->data->{resource}` sees an empty result and concludes **"the record does not exist"** — which,
92
+ > for a duplicate check or a contact lookup, is exactly the wrong conclusion (it creates a second
93
+ > record). Check `empty($response->isSuccess)` *first*, and keep "unknown" distinct from "absent".
94
+ > See [Elite Service Request intake](../../../clients/elite/features/togadesk-service-request-intake.md)
95
+ > for the bugs this
96
+ > caused and the `?bool` tri-state fix.
97
+
81
98
  > **`authenticate()` is also 1.0's ambient-client choke point for error reporting** (added
82
99
  > 2026-08-04). Every 1.0→2.0 cron passes through it holding its `UUID_CLIENT`, so it calls
83
100
  > `App_Error_Capture::setCurrentClientUuid()` — **store only, no lookup**: this is a happy-path
@@ -300,6 +317,10 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
300
317
  the `App_Model` layer; follow the surrounding escaping discipline when modifying.
301
318
  - **Per-record error isolation** in the TOGaDesk sync is via `try/catch` → `\Sentry\captureException`;
302
319
  a thrown exception elsewhere (transport, checkpoint read) still aborts the whole run.
320
+ - **Naming `fields` can SUPPRESS a nested relation that a plain request returns.** Observed
321
+ 2026-08-12 on `GET /states`: a bare request comes back with the nested `country`, but adding a
322
+ `fields` list drops it. This is the mirror image of the reverse-hasMany rule below — when a nested
323
+ object you expected is missing, suspect the field list before suspecting the data.
303
324
  - **A reverse hasMany collection is only returned if you add it to the fetch `fields` whitelist.**
304
325
  The `/contacts` fetch requests an **explicit** `fields` list from the 2.0 metadata API; a reverse
305
326
  hasMany collection (e.g. `contactEmailAddresses.emailAddress`) is **not** returned unless it is
@@ -365,6 +386,14 @@ enable flags** and an optional `$monitorTogadeskDepartmentIds[]`:
365
386
 
366
387
  ## Change history
367
388
 
389
+ - 2026-08-12 — Recorded the **`send()` return contract**: it always returns a response carrying
390
+ `isSuccess` (early-returns on true, falls through to `return $response` on false, throws when the
391
+ property is absent), so `empty($response->isSuccess)` is a safe failure check on anything it
392
+ returns — including GETs — and a `$throwOnError = false` failure must never be read as "the record
393
+ does not exist". Also recorded that naming `fields` on `GET /states` **suppresses** the nested
394
+ country a plain request returns. Surfaced building
395
+ [App_Api_ServiceRequest](../../../clients/elite/features/togadesk-service-request-intake.md)
396
+ (TRUE-80497). (snaredla)
368
397
  - 2026-08-10 — TRUE-79401 re-synced with `_production` (`c64164fe`, pushed); the sole conflict was a
369
398
  positional const-block collision, resolved by keeping both sides. Patch intact; deploy still
370
399
  unverified. `STARTECH_TOGADESK_CLIENTS` (credential registry, add a row per client onboard) is
@@ -87,9 +87,27 @@ already editing the method.
87
87
  write only in your own folder and ask before touching another developer's.
88
88
  - Lint 1.0 code with your **PHP 7.2** CLI (`php -l <file>`) — the prod worker/library target is
89
89
  7.2, so 7.4+/8.x syntax must not appear.
90
+ - **⚠ `php -l` with the default CLI PROVES NOTHING about 7.2.** On a normal dev box the `php` on
91
+ `PATH` is 8.x (8.1.10 on the machine where this was checked), and 8.x happily parses arrow
92
+ functions, typed properties, `??=` and `match` — the exact constructs that break the prod
93
+ worker. You must invoke a **real 7.2 binary** (e.g. the PHP 7.2.33 build bundled with Laragon;
94
+ per-machine paths are a developer-local detail and are deliberately not recorded here). Two
95
+ practical wrinkles when you do:
96
+ - if that build's own `php.ini` is broken (one was, a syntax error on line 1939), run it with
97
+ **`-n`** to skip the ini entirely;
98
+ - `-n` also drops **mbstring**, so anything using `mb_*` needs it re-added:
99
+ `-d extension_dir=<php>/ext -d extension=php_mbstring.dll`.
100
+
101
+ Targets to keep straight: **`library` (and its 1.0 consumers) is PHP 7.2+**, while
102
+ **`worker2` / `_underscore` enforce 8.1** — a helper shared between the two must satisfy 7.2.
90
103
 
91
104
  ## Change history
92
105
 
106
+ - 2026-08-12 — Sharpened the 7.2 lint gotcha into a real verification procedure: the default CLI is
107
+ PHP 8.x, so `php -l` there proves nothing; use an actual 7.2 binary, add `-n` if its `php.ini` is
108
+ broken, and re-add mbstring via `-d extension_dir=… -d extension=php_mbstring.dll` when `-n`
109
+ drops it. Recorded the split targets (library 7.2+, worker2/_underscore 8.1). (snaredla)
110
+
93
111
  - 2026-08-03 — Documented the static no-DB 1.0 harness technique, built for TRUE-79401
94
112
  (`test/@Mark/AIG/test_multi_email.php`, 12 cases, PHP 7.2.33). Key enabler recorded:
95
113
  `App_Database::sqlEscape()` is a pure `str_replace` (the `mysqli_real_escape_string` path is
@@ -44,6 +44,22 @@ web stack. Example harness: `C:\WWW\test\@Mark\TOGaDeskSupport\test_derive_custo
44
44
  output-buffer wrap (step 6).
45
45
  - Scripts should stay read-only against shared/mirrored data unless explicitly doing a data
46
46
  fix.
47
+ - **A desk method that calls `isOwner()` will REDIRECT AND EXIT under CLI unless you seed the
48
+ session globals.** `isOwner()` reads the `$isAdmin` / `$liu` globals and, when they are absent,
49
+ emits a `header('Location: …')` + `exit` — from a CLI harness that looks like the script simply
50
+ stopping with no output and no error. Seed both globals before calling in
51
+ (`$isAdmin`, `$liu` with at least `id`/`clientid`). **Be explicit about what that means:** the
52
+ harness then tests everything *after* the permission gate — it does **not** test the gate itself,
53
+ so a permission regression will pass. Hit while harnessing
54
+ `Ticket::createEliteServiceRequest()` (see the
55
+ [Elite desk Service Request feature](../../../clients/elite/features/togadesk-service-request-intake.md)).
56
+ - **Lint/run with the right PHP.** Desk + library code targets **7.2**, and the default CLI on a dev
57
+ box is 8.x — see the
58
+ [7.2 verification procedure](../../test/features/static-no-db-regression-harness.md).
47
59
 
48
60
  ## Change history
61
+ - 2026-08-12 — Added the `isOwner()` CLI trap: a desk method that calls it redirects + exits when
62
+ `$isAdmin`/`$liu` are unset, which reads as "the script did nothing"; seed the globals, and record
63
+ that doing so means the harness covers everything **after** the permission gate but not the gate.
64
+ Also cross-linked the PHP 7.2 verification procedure. (snaredla)
49
65
  - 2026-06-12 — captured from deriveCustomerId test harness work (mhammontree)
@@ -10,6 +10,7 @@
10
10
  | [_ApiRequest — JSON encode/decode & api-logging behavior](features/apirequest-json-content-type.md) | `_ApiRequest` is the 2.0 outbound HTTP client. | _underscore/ApiRequest.php |
11
11
  | [Assortment Name Translation (AssortmentTranslations sidecar)](features/assortment-name-translation.md) | Serves Assortment (product-grouping) **names** in multiple languages by adding a per-language **sidecar** table `AssortmentTranslations`, reusing the platform's | _underscore/Model/Client/AssortmentTranslation.php, dbchanges2/Client/2026-06-26a - AssortmentTranslations.sql, dbchanges2/Core/2026-06-26a - AssortmentTranslationsRecord.sql, dbchanges2/Client/2026-06-26b - AssortmentTranslationsAcl.sql |
12
12
  | [Asynchronous Query Execution (writes-only, via Worker)](features/async-query-execution.md) | `_Query` can run a **write** query asynchronously so a long/slow write does not hold a request-scoped DB connection open long enough to hit **"MySQL server has | _underscore/Query.php, worker2/Worker/Infrastructure/Database.php, worker2/Worker/Team/Transcripts.php |
13
+ | [FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract](features/calculated-sql-fields.md) | A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model` enforces by throwing at model-construction time**, not by convent | _underscore/Model.php |
13
14
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | test/@Mark/true-80824-fedex-inflate-test.php, dbchanges2/Client_Growrk/2026-08-10e - GrowrkUpsServiceMethodCodes.sql, _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/ShipmentRequest/ShipmentRequest.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
14
15
  | [Running 2.0 code from a bare CLI script (bootstrap + transactions)](features/cli-script-bootstrap.md) | A throwaway CLI script (a data check, a backfill dry-run, a render harness) that wants the real 2.0 framework — `_Model`, `_Query`, `_Database` — is **not** the | _underscore/Database.php, _underscore/Environment.php, api2/Initialize.php |
15
16
  | [_Cloud S3 helpers (copy / get / delete / list)](features/cloud-s3-helpers.md) | `_Cloud` centralizes AWS SDK S3 usage for the 2.0 stack so the `S3Client` never leaks into workers or app code. | _underscore/Cloud.php |
@@ -0,0 +1,71 @@
1
+ ---
2
+ title: FIELD_SQL calculated fields — the underscore-prefix + same-name-method contract
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-12
10
+ owners: [snaredla]
11
+ files:
12
+ - _underscore/Model.php
13
+ related:
14
+ - ../architecture.md
15
+ - ./model-magic-field-access.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ A `FIELD_SQL` (calculated) field on a `_Model` is bound by a **two-part contract that `_Model`
21
+ enforces by throwing at model-construction time**, not by convention:
22
+
23
+ 1. the field name **must** begin with `_`;
24
+ 2. a **`public static` method of the EXACT same name** must exist on the model.
25
+
26
+ Both are checked in `_underscore/Model.php` (~L130 and ~L132) while the field definitions are
27
+ parsed, so a violation is not a lint smell — the model is simply unusable.
28
+
29
+ ## How it works
30
+
31
+ ```php
32
+ public $_serialNumbers = [self::FIELD_SQL, self::FIELDOPT_SQL_TYPE => self::FIELD_CHAR];
33
+
34
+ public static function _serialNumbers(string $table = self::TABLE) {
35
+ return "(SELECT … FROM … WHERE …)";
36
+ }
37
+ ```
38
+
39
+ The name must match exactly because the expression is invoked **by variable method name**
40
+ (`_underscore/Model.php:641`):
41
+
42
+ ```php
43
+ $value = '(' . get_called_class()::$field($this::TABLE) . ')';
44
+ ```
45
+
46
+ `$field` is the property name, so there is no mapping layer, no camelCase conversion and no
47
+ fallback — `_serialNumbers` the property requires `_serialNumbers()` the method.
48
+
49
+ Options: `FIELDOPT_SQL_TYPE` (defaults to `FIELD_CHAR`) and `FIELDOPT_SQL_STORED` (default `false`,
50
+ recalculated per query; `true` persists to a real column on save).
51
+
52
+ ## Gotchas / known issues
53
+
54
+ - **The leading underscore is REQUIRED — it is not a camelCase-standard violation.** The TOGA
55
+ naming standard says database/class fields are camelCase, and a reviewer (human or agent) will
56
+ reflexively flag `_serialNumbers` and "fix" it to `serialNumbers`. Doing so **throws**:
57
+ *"All field which are of the type 'FIELD_SQL' must begin with an underscore"*. Calculated fields
58
+ are the documented exception; leave the underscore alone.
59
+ - **Renaming a calculated field is a two-line change.** Rename the property without renaming the
60
+ static method (or vice versa) and construction throws with *"A function returning a SQL
61
+ expression has not been defined…"*.
62
+ - **`FIELDOPT_SQL_STORED => true` needs a refresh after bulk migrations** — the stored value is
63
+ written on save, so rows changed by raw SQL keep a stale value.
64
+
65
+ ## Change history
66
+
67
+ - 2026-08-12 — Documented the enforced `FIELD_SQL` contract after `_serialNumbers` was queried as a
68
+ camelCase violation: `_Model` **throws** both when the field lacks the `_` prefix
69
+ (`_underscore/Model.php` ~L130) and when a static method of the exact same name is missing
70
+ (~L132), because the expression is invoked as `get_called_class()::$field($this::TABLE)` (L641).
71
+ The underscore prefix is therefore required, not stylistic. (snaredla)
@@ -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/Elite/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderItem.php, dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.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/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 |
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
  | [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 |
9
9
  | [ENVIRONMENT (not the EB environment name) decides the _underscore branch and Config file](features/environment-variable-drives-underscore-branch.md) | An api2 Elastic Beanstalk instance decides **which `_underscore` branch it clones** and **which `Config/<env>.ini` it loads** from the EB environment property * | api2/.ebextensions/git.php, api2/.ebextensions/php_include_underscore.config, api2/.ebextensions/git.sandbox-dev.json, api2/Config/beta.ini, api2/Config/sandbox-dev.ini, api2/Component/Api/V2/V2.php |
@@ -6,16 +6,18 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
10
- owners: ["mhammontree", "dfranks", "bala"]
9
+ updated: 2026-08-12
10
+ owners: ["mhammontree", "dfranks", "bala", "snaredla"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
14
14
  - _underscore/Model/Client/ItemFulfillment.php
15
15
  - _underscore/Model/Elite/SalesOrder.php
16
+ - _underscore/Model/Elite/ServiceRequest.php
16
17
  - _underscore/Model/Compass/SalesOrder.php
17
18
  - _underscore/Model/Compass/SalesOrderItem.php
18
19
  - dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql
20
+ - dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql
19
21
  related:
20
22
  - ./record-scripts.md
21
23
  - ../architecture.md
@@ -70,6 +72,13 @@ and prod — so a validation added to `_Model_Compass_PurchaseOrder::prePost` ca
70
72
  tenant's other APIs at all. See
71
73
  [Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
72
74
 
75
+ **One client / all APIs is the `Core` table with `clientId` set and `apiId` NULL** — it does not
76
+ have to live in `Client_<X>.ApiPayloadInterceptors`. Elite's service-request and sales-order rows
77
+ were registered that way in `Core` (`clientId 41`, `apiId NULL`, `minDepth 1`, records **35**
78
+ `service-requests` and **14** `sales-orders`) — see
79
+ [Elite SalesOrder → NetSuite push](../../../clients/elite/features/salesorder-netsuite-push.md).
80
+ **So "this client has no interceptors" is only true after you have checked BOTH tables.**
81
+
73
82
  ## The interceptor sees payload keys **verbatim** (no `c_` stripping, no rename)
74
83
 
75
84
  **Verified empirically** by a live HTTP probe against local dev
@@ -90,6 +99,25 @@ tenant's other APIs at all. See
90
99
  > result above is authoritative; the code path is misleading. Anyone tempted to reason it out
91
100
  > from V2.php will reach the wrong answer.
92
101
 
102
+ ### In a POST-processing interceptor, `$payload` is the RECORD — not a route-keyed envelope
103
+
104
+ A `postPost` / `postPut` hook receives **the record object itself, or an array of record objects**.
105
+ It is **not** wrapped under a route key, because api2 wraps the response into
106
+ `{ salesOrders: … }` **after** the post-processing interceptors have run. So the correct read is:
107
+
108
+ ```php
109
+ $record = (is_array($payload) ? ($payload[0] ?? null) : $payload);
110
+ $uuid = (string)($record->uuid ?? '');
111
+ ```
112
+
113
+ Two consequences worth internalising:
114
+
115
+ - **Never reassign `$payload` to the single record you pulled out.** `$payload` is by reference; a
116
+ multi-record response would lose everything after the first. Read into a local variable.
117
+ - Reaching for `$payload->salesOrders` (the HTTP-response shape) yields `null` and the hook
118
+ silently does nothing — the same class of failure as the `internalApiRequest` `data`-envelope
119
+ trap below.
120
+
93
121
  ## The dispatch is UNGUARDED — a row naming a method that does not exist hard-fatals the endpoint
94
122
 
95
123
  The derived name (step 2 above) is **pure convention with nothing validating it**, and there is
@@ -147,6 +175,16 @@ when `$outData` is non-null**, and a **successful DELETE always sets `$outData =
147
175
  `(recordId, POST, DELETE)` row is dead on arrival: the method is never called, and — exactly like
148
176
  the missing-row case — there is no error and no log line to tell you.
149
177
 
178
+ **The same `if ($outData)` guard (~L5932) is why an interceptor problem is scoped, not global.**
179
+ The whole post-processing block is skipped whenever there is no response data, and it only ever
180
+ resolves a class for the record the request actually touched. So a *missing* row, or a model with
181
+ no such method, cannot break unrelated traffic. This was checked under fire: during a "this will
182
+ break production" alarm, **254 live PUTs kept returning 200** while the suspect record had no
183
+ usable hook. The alarm was wrong. What *is* fatal is narrower and precise: a row that resolves to
184
+ a class which does not define the derived method — *"Call to undefined method"* — for **that**
185
+ record's writes only. Precedent for the cleanup:
186
+ `dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql`.
187
+
150
188
  **Therefore any logic that must react to a deletion runs in `preDelete`, and must exclude the row
151
189
  that is about to disappear**, because at `preDelete` time it is still present. The Compass line
152
190
  renumberer takes the id explicitly for this reason:
@@ -169,6 +207,17 @@ The chain is `_Model_Compass_Usa_X extends _Model_Compass_X extends _Model_Clien
169
207
  defined on the shared Compass base is inherited by both the Usa and Canada subclasses** — a
170
208
  per-region subclass does *not* need its own copy. Do not add duplicate hooks per region.
171
209
 
210
+ **⚠ …and by the same inheritance, a hook on the shared `_Model_Client_X` runs for EVERY tenant that
211
+ has a row for that record.** Client-specific behaviour must go on `_Model_<Client>_X`, never on
212
+ `_Model_Client_X`. Worked near-miss (Elite, 2026-08-12): putting the NetSuite push on
213
+ `_Model_Client_SalesOrder::postPost` would have pushed **Compass and Quad** sales orders to NetSuite
214
+ too — both subclasses call `parent::postPost()`, and Compass has an active `recordId 14` interceptor
215
+ row. The behaviour was placed on `_Model_Elite_SalesOrder` / `_Model_Elite_ServiceRequest` instead.
216
+
217
+ The corollary is that **reading the client subclass is not enough to know who a hook affects** —
218
+ you must also read the parent chain *and* the interceptor rows of every tenant that overrides the
219
+ same record.
220
+
172
221
  ### `Core.Records` facts for interceptor debugging
173
222
 
174
223
  - **`Core.Records.model` is UNIQUE** — exactly one record row per model class, so an interceptor's
@@ -286,6 +335,12 @@ and the failing environment**. It is a small table, and the drift is usually exa
286
335
  change that switches on a *code* path; if the PHP defining the method is not confirmed deployed to
287
336
  that environment, the row takes the endpoint down. Insert inactive, verify the deploy, then flip
288
337
  `isActive = 1` — and remember every environment activates independently.
338
+ - **⚠ A post interceptor's `$payload` is the record, not a route-keyed envelope** — and it is
339
+ by-reference, so pull the record into a local instead of reassigning `$payload`.
340
+ - **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record
341
+ inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
342
+ - **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before
343
+ concluding a client has no interceptors.
289
344
  - **The method name is derived, so a typo'd enum silently misses.** A row with
290
345
  `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine
291
346
  will simply find no method and move on.
@@ -310,6 +365,23 @@ and the failing environment**. It is a small table, and the drift is usually exa
310
365
 
311
366
  ## Change history
312
367
 
368
+ - 2026-08-12 — TRUE-80498/80501, from building the Elite service-request chain. (1) **A
369
+ post-processing `$payload` is the record itself (or an array of records), NOT wrapped in a route
370
+ key** — api2 applies the route envelope only *after* the post interceptors run, so
371
+ `$payload->salesOrders` is `null` and the hook silently no-ops; read into a local
372
+ (`is_array($payload) ? $payload[0] : $payload`) and never reassign the by-reference `$payload`,
373
+ which would drop every record after the first. (2) Recorded what the `if ($outData)` guard
374
+ (~L5932) actually buys: the post block is skipped when there is no response data and only
375
+ resolves the touched record's class, so a missing row or absent method **cannot** break unrelated
376
+ traffic — a "this will break prod" alarm was disproved by **254 live PUTs still returning 200**;
377
+ the fatal case remains narrow (a row resolving to a class without the derived method), with
378
+ `dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql` as the
379
+ cleanup precedent. (3) **A hook on the shared `_Model_Client_X` runs for every tenant with a row
380
+ for that record** — putting the Elite NetSuite push there would have pushed Compass and Quad
381
+ orders too (both call `parent::postPost()`, Compass has an active `recordId 14` row), so it went
382
+ on `_Model_Elite_*`. (4) **One-client/all-APIs registration lives in `Core` with `clientId` set
383
+ and `apiId NULL`** (Elite: `clientId 41`, records 35 + 14, `minDepth 1`) — "this client has no
384
+ interceptors" requires checking **both** tables. Line numbers are approximate. (snaredla)
313
385
  - 2026-08-11 — Three findings from the post-mortem of the `_Model_Compass_Usa_SalesOrderItem::postPost()`
314
386
  outage (investigation only, no code change). (1) **`postDelete` can never fire** — the
315
387
  post-processing block at `V2.php` ~L5692 runs only when `$outData` is non-null and a successful
@@ -35,6 +35,7 @@
35
35
  | [OneUptime Incident → ClickUp Task Sync (Monitor/Oneuptime/SyncIncidents)](features/oneuptime-incident-clickup-sync.md) | An internal/shared TOGA ops feature: worker2 polls the OneUptime API every 15 minutes for **currently-open** incidents and ensures a ClickUp task exists for eac | worker2/Component/Api/Oneuptime/Oneuptime.php, worker2/Worker/Monitor/Oneuptime.php, worker2/Config/production.ini, dbchanges2/Team/2026-08-10a - OneUptime Incident ClickUp Tasks.sql, dbchanges2/Core/2026-08-10b - OneUptime Incident Sync CronJob.sql, dbchanges2/Team/2026-08-12a - OneUptime Incident Tasks Closed Marker.sql |
36
36
  | [OneUptime push-metric monitors for 2.0 workers](features/oneuptime-worker2-monitoring.md) | A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, ported from the 1.0 `App_SystemMonitor_Compass` monitors. | worker2/Worker/Monitor/Compass.php, worker2/Worker/Client/Compass.php, worker2/composer.json, _underscore/Cloud.php, worker2/Worker/Monitor/Operations.php |
37
37
  | [Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-client-data- | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
38
+ | [Service Request → Sales Order → Purchase Order generation (Sync/ServiceRequest)](features/service-request-sales-order-generation.md) | `_Worker_Sync_ServiceRequest` turns a **Service Request into a Sales Order, and then into one Purchase Order per vendor**, for **any** tenant. | worker2/Worker/Sync/ServiceRequest.php, _underscore/Model/Elite/ServiceRequest.php, dbchanges2/Core/2026-08-06a - Service request and sales order payload interceptors.sql |
38
39
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
39
40
  | [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
40
41
  | [Talos Pricing Automation (worker2 Cron — Usage Import, AWS Actuals, Margins, Profiles)](features/talos-pricing-automation.md) | The worker2 half of the **Talos Pricing Platform** (see the talos `pricing-cogs-model` and tools `talos-pricing-ui` docs for the other halves). | worker2/Worker/Talos/Pricing.php, worker2/Database/TalosPricingCrons.sql, dbchanges2/Team/2026-08-03a - TalosPricingTokenCostModel.sql, dbchanges2/Team/2026-08-03b - TalosInternalTenant.sql, dbchanges2/Team/2026-08-03c - TalosIntegrationCostFactor.sql, dbchanges2/Core/2026-08-03a - Talos pricing cron jobs.sql |
@@ -223,6 +223,24 @@ Debugging consequence: **no row means either "the caller never queued it" or "th
223
223
  and never consumed."** Those have completely different fixes. Distinguish them with the **SQS queue
224
224
  metrics**, not the database.
225
225
 
226
+ ## ⚠ Renaming an action is a CROSS-REPO rename — deploy both sides together
227
+
228
+ The action name is a **string** held by the producers, not a symbol the compiler can check. Renaming
229
+ a method in `worker2` without renaming every `runTask(action: '…')` string — which usually lives in
230
+ **another repo** (`_underscore` models, api2 interceptor code, crons, `Core.CronJobs.action` rows) —
231
+ makes the dispatcher target a name that no longer exists.
232
+
233
+ **And the failure is silent.** Producers routinely wrap `runTask` in `try/catch` + `error_log` so a
234
+ queue failure cannot fail the originating write, so the user's record saves, nothing downstream
235
+ happens, and no error surfaces. Worked example: `GenerateSalesOrder` →
236
+ `GenerateSalesOrderFromServiceRequest` spans `worker2/Worker/Sync/ServiceRequest.php` and the
237
+ dispatch string in `_underscore/Model/Elite/ServiceRequest.php:44` — see the
238
+ [Service Request → Sales Order generation](./service-request-sales-order-generation.md).
239
+
240
+ Rules: rename **both sides in one PR and one deploy**; grep every repo for the old string (including
241
+ `Core.CronJobs.action` and migrations) before merging; and treat it exactly like the
242
+ `WorkerJobs.output` multi-producer column rename in [architecture.md](../architecture.md).
243
+
226
244
  ## Gotchas
227
245
 
228
246
  - Class must be **`abstract`** and methods **`public static`** or routing fails.
@@ -269,6 +287,13 @@ metrics**, not the database.
269
287
  commit-before-SQS transaction pattern that the worker relies on.
270
288
 
271
289
  ## Change history
290
+ - 2026-08-12 — Added **"renaming an action is a cross-repo rename"**: the action name is a string
291
+ held by the producer (usually an `_underscore` model, a cron row or a migration in another repo),
292
+ so renaming the worker method alone makes `runTask` dispatch to a name that no longer exists —
293
+ and because producers wrap `runTask` in try/catch + `error_log`, the whole downstream chain stops
294
+ **silently**. Rename both sides in one PR/deploy. Worked example: `GenerateSalesOrder` →
295
+ `GenerateSalesOrderFromServiceRequest` across worker2 and
296
+ `_underscore/Model/Elite/ServiceRequest.php:44`. (snaredla)
272
297
  - 2026-08-11 — Added the **nested-JSON-object → `stdClass` dispatch gotcha**: the dispatcher casts
273
298
  only the top level of `CronJobs.parameters` to an array before spreading as named args
274
299
  (`Controller/Index.php` ~L455/692), so a nested JSON object stays `stdClass` and a strict