toga-ai 1.0.428 → 1.0.430

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.
@@ -5,6 +5,7 @@
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
6
  | [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, api2/_.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql |
7
7
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
8
+ | [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
8
9
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
9
10
  | [Nested-relationship writes & child matching (link vs. create)](features/nested-relationship-writes.md) | When a 2.0 API write payload (`POST`/`PUT`) contains a **nested related object** (e.g. | api2/Component/Api/V2/V2.php |
10
11
  | [Record Scripts (computed/aggregate /v2 endpoints — the authoring contract)](features/record-scripts.md) | In api2 you almost never write a controller. | api2/Component/Api/V2/V2.php, _underscore/Model/Team/Sprint.php |
@@ -0,0 +1,64 @@
1
+ ---
2
+ title: "Health-check endpoint (/health liveness short-circuit)"
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-24
10
+ owners: [jcardinal]
11
+ files:
12
+ - api2/Controller/Index.php
13
+ related:
14
+ - ../architecture.md
15
+ - v2-api-error-codes.md
16
+ ---
17
+
18
+ ## Summary
19
+
20
+ `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200
21
+ **before** any routing, DB bootstrap, or V2 engine work runs. EB/LB (Elastic Beanstalk /
22
+ load balancer) probes depend on this always returning 200 cheaply — the architecture doc's
23
+ critical rule is literally "Don't break `/health`". This doc records the **matching contract**
24
+ so a teammate doesn't reintroduce a brittle exact-string check.
25
+
26
+ ## How it works
27
+
28
+ The short-circuit lives at the top of `Index.php::api()`, before host dispatch and before the
29
+ Core/Logs DB bootstrap. It matches against a class constant:
30
+
31
+ ```php
32
+ const HEALTH_CHECK_ROUTES = ['/health', '/v2/health'];
33
+ ```
34
+
35
+ The incoming path is **normalized** before comparison — lowercased, query string stripped,
36
+ trailing slash removed — then matched strictly:
37
+
38
+ ```php
39
+ $path = strtolower(rtrim(strtok($_SERVER['REQUEST_URI'] ?? '', '?'), '/'));
40
+ if (in_array($path, self::HEALTH_CHECK_ROUTES, true)) { /* return 200 */ }
41
+ ```
42
+
43
+ This means **all** of these probe variants return 200 and never touch routing or the DB:
44
+ `/health`, `/v2/health`, `/health/` (trailing slash), `/health?x=1` (query string), and any
45
+ case variant. On match it returns 200 with an empty object.
46
+
47
+ ## Why the normalization matters (the bug this fixed)
48
+
49
+ The original check was an exact match `$_SERVER['REQUEST_URI'] == '/health'`, so any variant
50
+ (`/v2/health`, trailing slash, query string) **fell through into the V2 routing engine**
51
+ instead of short-circuiting. There it hit auth/DB work and could surface as an **HTTP 500
52
+ (`EO-1`)** — a failing liveness probe. Health probes must degrade to a 200 short-circuit, not
53
+ enter routing. When adding a new probe path, add it to `HEALTH_CHECK_ROUTES` (already
54
+ normalized form: lowercase, no trailing slash, no query) rather than re-adding an exact-string
55
+ branch.
56
+
57
+ ## Change history
58
+ - 2026-07-24 — Replaced the brittle exact-string `REQUEST_URI == '/health'` check with a
59
+ `HEALTH_CHECK_ROUTES` constant + normalized (lowercase / strip query / strip trailing slash)
60
+ strict `in_array` match, so `/v2/health`, `/health/`, and `/health?x` all short-circuit to 200
61
+ before routing/DB work instead of falling through to the V2 engine and returning 500 (EO-1).
62
+ Verified via live curl on all variants. (jcardinal)
63
+ </content>
64
+ </invoke>
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-21
10
- owners: [mhammontree, tcox]
9
+ updated: 2026-07-24
10
+ owners: [mhammontree, tcox, jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - _underscore/Model/Client/TrackingNumber.php
@@ -35,6 +35,7 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
35
35
  | **EV-8** | Request field does not exist | A field sent in the payload isn't registered as a **`Core.RecordFields`** row for that record | Register the field in `Core.RecordFields` (see the "add a field to a V2 record" recipe in the ACL doc) |
36
36
  | **EV-9** | No permission to write field | The field exists but the caller's role has no **`AclFieldPermissions`** grant (`isWritable=1`) in the CLIENT DB | Add the `AclFieldPermissions` row for the role (clone a writable sibling field's grant) |
37
37
  | **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 |
38
+ | **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) |
38
39
  | **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 |
39
40
  | **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) | 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 |
40
41
  | **EV-5** | Duplicate `transactionId` | The globally-unique `transactionId` was reused | Send a fresh unique `transactionId` per request |
@@ -54,6 +55,15 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
54
55
  3. **EV-6 vs EZ-1** are the two halves of exposing a scripted API: EV-6 = no `Core.RecordScripts`
55
56
  route (the segment falls through to record lookup); EZ-1 = the route exists but there's no
56
57
  `AclRecordScripts` dispatch grant for the caller's role.
58
+ 5. **EO-1 can be a masked PHP warning, not an operation error.** Sentry's `ErrorHandler` in
59
+ api2 **escalates PHP warnings/notices into thrown exceptions**, so a latent "read property on
60
+ bool" or "undefined variable" surfaces to the client as an **HTTP 500 (EO-1)** rather than a
61
+ log line. Practical consequence: in `V2.php` you must guard against warnings as if they were
62
+ fatals — an unresolved route (or a parent FK-field path) leaves `$record = false`, and any
63
+ later `$record->id` deref becomes a 500. Return a clean envelope for unresolved records
64
+ **before** the interceptor/logging layer, and initialize loop-locals so no branch leaves a
65
+ variable undefined.
66
+
57
67
  4. **EO-1 ("no field called 'X' in the model") is NOT a DB problem.** EV-8 means the Core
58
68
  `RecordFields` registration is missing; EO-1 means the DB column and RecordFields are both
59
69
  present but the **generated PHP model class** lacks the field. Before touching migrations,
@@ -71,6 +81,12 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
71
81
 
72
82
  ## Change history
73
83
 
84
+ - 2026-07-24 — Added a second **`EO-1`** case: a latent PHP **warning** (unresolved route →
85
+ `$record->id` on `bool` in `V2.php::processRoutePairs()`, or an undefined loop variable)
86
+ escalates to an HTTP 500 because api2's Sentry `ErrorHandler` promotes warnings/notices into
87
+ exceptions. Fix = guard clause returning a clean envelope for unresolved records before the
88
+ post-processing interceptor layer + initialize `$record = false` at the loop top. Added
89
+ diagnosis note 5 (warnings surface as EO-1 500s). (jcardinal)
74
90
  - 2026-07-23 — TRUE-79533: added **`EZ-2`** (field-level READ authorization denied) — the read-side
75
91
  counterpart of `EV-9`; a registered field (no `EV-8`) with no `AclFieldPermissions` read grant.
76
92
  Clarified EV-8/EV-9/EZ-2 as three states of one field and the standard-vs-custom grant-table
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-09
9
+ updated: 2026-07-23
10
10
  owners: [mhammontree, sking]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/import_asn.php
@@ -248,6 +248,45 @@ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE ded
248
248
  164785 (internalId 6971859 → `38S0500` synced, 15 ROs) and PO 165751 (`50M7280` + `A3L980`
249
249
  NOT synced). PRs: library#831 (merge first) then worker#1670.
250
250
 
251
+ - **⚠ REGRESSION of the 2026-07-09 multi-PO fix — a cross-wired SO/PO stamp silently drops
252
+ install tickets (diagnosed 2026-07-23; deferred to a one-off backfill, cron NOT changed).**
253
+ The 2026-07-09 fix (library#831 + worker#1670) made Stage 5 resolve item receipts **only**
254
+ Sales-Order-wide via `App_NetSuite::listItemReceiptsCreatedFromSalesOrder($soId)` (which fans
255
+ across the POs from `listPurchaseOrdersCreatedFromSalesOrder($soId)`). It **replaced** —
256
+ rather than augmented — the old by-stamped-PO lookup (`listItemReceiptsCreateFromPurchaseOrder`).
257
+ So when an ASN item's stamped `netSuiteInternalPurchaseOrderId` is **not a child of** its
258
+ stamped `netSuiteInternalSalesOrderId`, the SO-wide fan-out never inspects the stamped PO,
259
+ finds zero receipts, and the cron **silently creates no repair order and raises no error** —
260
+ the same "serials never sync, no error" symptom as the bug it replaced, in a distinct case.
261
+ - **Fingerprint:** an item stamped an SO whose only child PO has no receipt, while the stamped
262
+ PO (often an internalId *lower* than the SO's, i.e. created earlier) carries the real receipt
263
+ with the matching serials.
264
+ - **Root cause of the cross-wired stamp (SME Skyler King confirmed) = a warehouse
265
+ manual-entry error, not automation.** Confirmed case ASN **26215** (customer PO
266
+ `S202641365`): original SO `6901611` processed only `DOE-30HSS1BB00` (qty 1), PO `6901612`,
267
+ item receipt `6910404` (ticketed fine, RO 811791). Two monitors `DOE-62C5GAR1US` (serial
268
+ `VKWD9092`) and `DOE-64B6MAR1UZ` (serial `V600F9M6`) were **manually** added to SO `6901611`
269
+ with a **manually** created PO `6910401`, received on item receipt `6910410`. Separately,
270
+ when the real ASN arrived, the automation created a **duplicate** SO `6996287` (auto-PO
271
+ `6996288`, never used to receive — the serials were already on `6910410`). Net: ASN items
272
+ 53905/53914 ended stamped SO=`6996287` (the duplicate) but PO=`6910401` (the manual PO under
273
+ the *original* SO), so SO-wide resolution finds nothing.
274
+ - **SME-confirmed correct end-state:** the units belong to the **original** SO (`6901611`);
275
+ the automation-created duplicate SO `6996287` / PO `6996288` is a NetSuite cleanup item
276
+ (cancel). The owed installs (one repair order per received unit) are **independent** of the
277
+ NetSuite duplicate and are created **TOGa Desk-side only**. Warehouse process has since been
278
+ corrected by training.
279
+ - **Recovery (chosen fix — one-off, not a cron change):** a run-once backfill reuses
280
+ `doeCreateInstallationRepairOrder` verbatim from Stage 5 (`3_create_installation_ticket.php`)
281
+ but scoped to a single ASN (id via CLI arg), resolving receipts from the **UNION** of the
282
+ SO's child POs *and* the item's stamped PO. It is idempotent (only units with
283
+ `togadeskRepairOrderId IS NULL`), guards double dispatch (skips a serial that already has a
284
+ `repair_order` in `db_togadesk`), never adds new `AdvanceShippingNoticeUnits`, is
285
+ serialized-only, and makes no NetSuite/ServiceNow writes. Confirmed on ASN 26215 (created the
286
+ two owed ROs). A **permanent union fix in the cron** (resolve receipts from SO child POs plus
287
+ the stamped PO) was considered but **deferred** — the team chose the one-off because the
288
+ warehouse process error is now prevented by training.
289
+
251
290
  ## Debugging & DB topology (DOE 1.0 sync)
252
291
 
253
292
  Reference for future DOE investigations — the 1.0 worker cannot run on a dev/Windows box;
@@ -265,6 +304,14 @@ use the toga DB MCP + `Logs.API` instead of running prod code locally.
265
304
  6971859 returned `totalRecords=1`, only `38S0500`.)
266
305
  - **`Bridge_NetSuite`** (legacy) has `SalesOrders` / `SalesOrderItems` / `SerialNumbers` but
267
306
  **no `PurchaseOrders` table** — POs are pulled live from the NetSuite API.
307
+ - **Stuck-vs-received check, per ASN item:** compare
308
+ `listItemReceiptsCreatedFromSalesOrder($stampedSO)` against
309
+ `listItemReceiptsCreateFromPurchaseOrder($stampedPO)`, dumping each receipt line's `itemName`
310
+ plus its `inventoryAssignment` serials. If the SO-wide call returns 0 but the stamped PO
311
+ returns the receipt with the matching serials, it is the cross-wired-stamp regression above.
312
+ - **`App_Model_Core_AdvanceShippingNotice` does NOT map `dtCreatedInstallationTicket`** — its
313
+ `__get` throws "No field or quick query defined for key …". Read that column via **raw SQL**,
314
+ not the model.
268
315
 
269
316
  ## Operating rules when changing this integration
270
317
 
@@ -278,6 +325,17 @@ use the toga DB MCP + `Logs.API` instead of running prod code locally.
278
325
  of the consumer query; `php -l` every touched file.
279
326
 
280
327
  ## Change history
328
+ - 2026-07-23 — Diagnosed a regression of the 2026-07-09 multi-PO fix: Stage 5's SO-wide receipt
329
+ resolution *replaced* the by-stamped-PO lookup, so a cross-wired stamp (an item's stamped PO
330
+ not a child of its stamped SO) silently drops install tickets with no error. Root cause of the
331
+ cross-wire = a warehouse manual-entry error (a duplicate SO was auto-created while the real
332
+ receipt sat under a manually-created PO on the original SO); SME Skyler King confirmed the
333
+ correct end-state (units on the original SO, duplicate SO cancelled, owed installs are TOGa
334
+ Desk-side only). Recovered stuck ASN 26215 with a one-off union-receipt backfill (union of SO
335
+ child POs + the stamped PO; idempotent; no NetSuite/ServiceNow writes) that created the two
336
+ owed ROs; a permanent union fix in the cron was considered but deferred (warehouse process now
337
+ corrected by training). Added a regression gotcha + two debugging notes. No production code
338
+ changed. (mhammontree; SME sking)
281
339
  - 2026-07-09 — Fixed silent install-ticket drop when a NetSuite SO is fulfilled across
282
340
  multiple POs: Stage 5 now resolves item receipts Sales-Order-wide via new helper
283
341
  `App_NetSuite::listItemReceiptsCreatedFromSalesOrder` (fans across all POs from the SO,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.428",
3
+ "version": "1.0.430",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",