toga-ai 1.0.866 → 1.0.868

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,14 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-09-22
10
+ owners: ["bala", "rgirish"]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Model/Client/SalesOrder.php
14
14
  - _underscore/Model/Client/TransferOrder.php
15
15
  related:
16
16
  - ../architecture.md
17
+ - ../../api2/features/v2-deadlock-retry.md
17
18
  - ../../worker2/features/netsuite-transferorder-outbound-push.md
18
19
  - ../../worker2/features/netsuite-salesorder-outbound-push.md
19
20
  ---
@@ -42,6 +43,10 @@ Verified in `_underscore/Model.php` ~L546:
42
43
  2. **The next value is `MAX(numeric part) + 1` scoped by `WHERE number LIKE '<prefix>%'`.** So pre-existing numbers in another format are ignored: NYCHH's 1,930 legacy transfer-order numbers (e.g. `286906`) do not participate, and the sequence starts clean at `TA100000`.
43
44
  3. **There is NO zero padding.** The output is prefix + integer, so `TA00000` is not achievable without changing `Model.php`.
44
45
 
46
+ **⚠ The generator is NOT concurrency-safe — read and insert can split.** `SELECT MAX(...)+1` and the `INSERT` are two separate statements with **no lock between them**. Two concurrent POSTs read the same MAX, pick the same number, and the loser gets **MySQL 1062** — a 400 to the caller, not a retry. Seen live on Prudential `/v2/service-requests` (`Duplicate entry 'SR131023' for key 'ServiceRequests.number'`).
47
+
48
+ **Today's mitigation is a retry, not a fix.** api2 replays the whole request for two routes only (`/v2/advance-shipping-notices`, `/v2/service-requests`) — see [V2 request retry](../../api2/features/v2-deadlock-retry.md). **Every other `FIELD_AUTOINCREMENT` model has the same exposure and no mitigation**: SalesOrder, PurchaseOrder, Ticket, ItemReceipt, InventoryAdjustment, TransferOrder. A duplicate-number clash on those routes still returns 400. The durable answer is making the read and insert one operation in `Model.php`; that is a bigger, separate change and is **not done**.
49
+
45
50
  ## Gotchas
46
51
 
47
52
  - **⚠ A prefix on a SHARED model is not unique across clients.** Because the option lives on `_Model_Client_TransferOrder`, every tenant starts at `TA100000` — the scan is per client database. Any cross-client identifier must therefore NOT be keyed on `number`: NetSuite account **1095849 is shared by every client** and enforces `externalId` uniqueness account-wide, which is why both pushes key their external id on the record **uuid** (`toga-so-<uuid>` / `toga-to-<uuid>`) — see [the transfer-order push](../../worker2/features/netsuite-transferorder-outbound-push.md).
@@ -49,6 +54,7 @@ Verified in `_underscore/Model.php` ~L546:
49
54
  - **Keeping a Toga-side number permanently takes TWO changes, not one** (written 2026-09-02, then reverted at the developer's request — recorded so nobody rediscovers it): (1) a guard in `writeBackNetSuiteId()` so the push does not overwrite `number`, and (2) dropping `number` from the PUT in both `library/app/api/toga2.php` (~L4295) and `_underscore/Trait/Netsuite/TransferOrder.php`. Miss (2) and the 1.0 importer compares `number` and PUTs NetSuite's over the top on its next run, so the guard alone achieves nothing. Both files are back to untouched (verified with an empty `git diff`).
50
55
  - **`FIELD_AUTOINCREMENT` is one of the field types special-cased by `buildSqlFieldValue()`**, so a null value on it does NOT collapse a `load()` WHERE clause the way a null `FIELD_CHAR` does — see the collapsed-WHERE OOM note in [_underscore architecture](../architecture.md).
51
56
  - **Adding the option to a model does not renumber anything** and does not touch existing rows; it only affects inserts that arrive with `number` NULL.
57
+ - **Concurrent inserts can pick the same number** (unlocked `MAX+1` then `INSERT`) → MySQL 1062 → 400. Only two api2 routes retry it; every other model is exposed. See [V2 request retry](../../api2/features/v2-deadlock-retry.md).
52
58
  - **A front-end that sends `number` silently disables the feature.** There is no error — the posted value is simply accepted. Check the actual POST body before concluding the generator is broken.
53
59
 
54
60
  ## Related
@@ -56,4 +62,7 @@ Verified in `_underscore/Model.php` ~L546:
56
62
  - [_underscore architecture](../architecture.md)
57
63
  - [NetSuite TransferOrder outbound push](../../worker2/features/netsuite-transferorder-outbound-push.md)
58
64
  - [NetSuite SalesOrder outbound push](../../worker2/features/netsuite-salesorder-outbound-push.md)
59
- </content>
65
+
66
+ ## Change history
67
+ - 2026-09-22 — Recorded that the `MAX+1` generator is not concurrency-safe (unlocked read-then-insert → 1062), which routes retry it today, and that every other FIELD_AUTOINCREMENT model is still exposed. (rgirish)
68
+ - 2026-09-02 — `TA` / 100000 added to the shared `_Model_Client_TransferOrder`. (bala)
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
10
- owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
9
+ updated: 2026-09-22
10
+ owners: ["mhammontree", "dfranks", "bala", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
13
  - api2/Controller/Index.php
@@ -98,6 +98,10 @@ $log->save();
98
98
  - The prefix is truncated to `TRANSACTION_ID_MAX_LENGTH` (255) minus the suffix, because a silent MySQL column-width chop would re-collide.
99
99
  - The loop is **deliberately unbounded and cannot spin**: every UNIQUE column changes per attempt (`_Model` mints a fresh `uuid` inside each `save()`, `id` is auto-increment, the resend number only climbs), so each try eliminates one taken value and never re-asks for the same one.
100
100
  - **It also removes one `SELECT` per API request** against an ~11.5M-row table.
101
+ - **⚠ The loop's own 1062s must NOT leak into the caller's error state (fixed 2026-09-22).** `saveApiLogUnderItsOwnTransactionId()` runs **between** a failed business INSERT and `Controller/Index.php`'s retry check, so its deliberate collisions were **overwriting `_Database::$lastErrorNumber`** and silently disarming the whole unique-race retry — `/v2/service-requests` returned 21 × `400 EV-10` duplicate-number errors that should have self-healed. The fix saves and restores both `_Database::$lastErrorNumber` and `$retriableUniqueRace` in a `try/finally` **inside this method** (not at the call site), so only this loop's own errors are undone. **Rule: logging is bookkeeping and must never change the request's retry decision.** Full chain in [V2 request retry](./v2-deadlock-retry.md).
102
+ - **A null `transactionId` does NOT break the first save** — checked and rejected as a finding, recorded so it is not re-raised. `Logs.Api.transactionId` is UNIQUE with `Null: YES`, and **MySQL allows unlimited NULLs in a UNIQUE index**; 4 such rows already exist in `dev-sandbox` `Logs_Prudential.Api`. A null never raises 1062 and never enters the resend loop.
103
+ - **The loop is unbounded** (`for ($resendNumber = 1; ; ...)`) — reviewed 2026-09-22 and deliberately left as-is, because every pass changes a UNIQUE column so it cannot re-ask for a taken value. Do not add a bound without re-checking that reasoning.
104
+ - **⚠ This method only exists on `_sandbox-dev`.** Commit `db94a26` never landed on `_production`. A branch cut from `_production` can therefore carry the **call site** without the **method**, and every DB-logged request fatals with *call to undefined method*. This bit branch `TRUE-82095`. **When you branch off `_production`, verify the callee exists — not just the caller.**
101
105
 
102
106
  **The duplicate comes from BELOW the application — you cannot stop it, only tolerate it.** Do not chase this as a double-click. The frontend axios interceptor (`toga2-commerce/src/api/axiosInstance.ts`) mints a **fresh uuid per request**, so a genuine double-click produces **two different ids**. Two requests sharing one id means the **identical bytes were replayed by something under the app**:
103
107
  - **2026-08-18 (production)** — the two copies arrived through **two different Zscaler proxy nodes** (`136.226.49.71` and `136.226.49.80`) with **different ALB trace ids**.
@@ -202,6 +206,9 @@ Both a **fallback recovery path** and a **scope diagnostic**. In the TRUE-80179
202
206
  - **A throw from a PRE interceptor leaves NO Api-log row** — 400 with null `transactionId`/`authority`/`audience`, real message in `messages[0].identifiers.message`. The only durable record is a `Logs.Issue` from `_Exception_Business`; a plain `_Exception_Validation` leaves no trace anywhere.
203
207
  - **An uncaught PHP fatal can leave NO log row anywhere** (incl. GETs, which return an empty 500 body). Empty 500 body + missing Api row = a fatal thrown before response assembly. Diagnose from the data (orphan sweeps), not the log.
204
208
  - **apiproxy retries a 5xx across regions** — one action = up to six near-identical 500 rows sharing one `transactionId`; a succeeding retry logs a clean 200 to the client log. Collapse on `transactionId`; join the two logs by it.
209
+ - **The log-save must save/restore `_Database::$lastErrorNumber` + `$retriableUniqueRace`** — its own deliberate 1062s silently disarmed the unique-race retry until 2026-09-22. Logging must never change a request's retry decision.
210
+ - **`saveApiLogUnderItsOwnTransactionId()` (`db94a26`) is on `_sandbox-dev` only** — a branch off `_production` may have the call site but not the method, and fatals with *call to undefined method*.
211
+ - **A null `transactionId` is fine** — UNIQUE allows unlimited NULLs in MySQL; it never raises 1062.
205
212
  - **A duplicate `transactionId` must not fail the request** — save first, rename on 1062 (`-1`, `-2`, from the original id), swallow non-1062. A check-then-insert cannot work (uncommitted rows are invisible). The duplicate originates below the app (proxies/LBs resending a GET's bytes); the server tolerates, the client cannot prevent. Accepted exposure: two concurrent duplicate POSTs would both commit.
206
213
  - **`Logs.Api.transactionId` is UNIQUE on prod/`sandbox-client`/`dev-sandbox` but only a NON-unique index locally** — a local concurrency test proves nothing; confirm the constraint in the target env.
207
214
  - **`Logs.Api.transactionId` stores a timestamp/random uuid, not the envelope's `transactionId`** — you cannot look a request up by its envelope `transactionId`; use `dtStamp` window + `route`. The inner sub-request row's `transactionId` is random by design.
@@ -210,6 +217,7 @@ Both a **fallback recovery path** and a **scope diagnostic**. In the TRUE-80179
210
217
  - **Never copy logged rows into committed artifacts** — they carry live Bearer tokens and PII.
211
218
 
212
219
  ## Change history
220
+ - 2026-09-22 — Log-save now saves/restores `_Database` error state in `try/finally`; its own 1062s were disarming the unique-race retry on `/v2/service-requests`. Also recorded the `_production`-branch missing-method hazard and the rejected null-`transactionId` finding. (rgirish)
213
221
 
214
222
  - 2026-09-21 — Added `Logs_<Client>.Api.originalUserId` (delegation audit: NULL = none, never equal to `userId`) and the rule that outbound `direction = OUT` rows stamp neither user column. (bala)
215
223
 
@@ -5,15 +5,16 @@ repo: api2
5
5
  project: API
6
6
  client: shared
7
7
  type: feature
8
- status: draft
9
- updated: 2026-09-16
10
- owners: [jcardinal, bala, ajean]
8
+ status: active
9
+ updated: 2026-09-22
10
+ owners: [jcardinal, bala, ajean, rgirish]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
14
14
  - _underscore/Database.php
15
15
  - _underscore/Query.php
16
16
  - _underscore/Model/Compass/AdvanceShippingNotice.php
17
+ - _underscore/Model.php
17
18
  related:
18
19
  - ../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
19
20
  - ../../../../clients/compass-usa/features/asn-to-item-fulfillment.md
@@ -31,9 +32,11 @@ api2's front controller ("**Prong A**") can **detect a retriable concurrency fai
31
32
  1. **MySQL deadlock (1213) / lock-wait timeout (1205)** — the original case (a same-PO ASN write deadlock; see the [save-cascade doc](../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md)).
32
33
  2. **Retriable unique-key get-or-create race (1062)** — a concurrent same-key INSERT lost the race; a whole-request replay on a fresh snapshot lets the retry's GET reuse the winner's committed row (added 2026-08-28 for the Compass ASN data-loss race).
33
34
 
34
- The replay is **route-scoped by an allowlist** (`DEADLOCK_RETRIABLE_ROUTES`) — currently only `/v2/advance-shipping-notices` — because whole-request in-process replay is **only safe for routes with no non-rollbackable external side effect**. The retry gate is now **`wasDeadlock() || wasRetriableUniqueRace()`**.
35
+ The replay is **route-scoped by an allowlist** (`DEADLOCK_RETRIABLE_ROUTES` — currently `['/v2/advance-shipping-notices', '/v2/service-requests']`) — because whole-request in-process replay is **only safe for routes with no non-rollbackable external side effect**. The retry gate is now **`wasDeadlock() || wasRetriableUniqueRace()`**.
35
36
 
36
- > Status (2026-08-28): **in the working tree only** — not committed or deployed. Pending sandbox validation + regression tests.
37
+ > **Status (2026-09-22).** Working on `_sandbox-dev` and **proven under real concurrency** (10/10 concurrent SR POSTs, zero duplicates). **Production still has NO race handling at all** for this — prod needs this fix plus the original `TRUE-82095` work. Prod evidence: 12 duplicate-number failures since 2026-08-18, latest `RITM6811124` / `REQ4510546`, which only succeeded on a manual resubmit as `SR131026`.
38
+
39
+ **Constants (verified 2026-09-22):** `MAX_DEADLOCK_ATTEMPTS = 5` (not 3), backoff 20ms-120ms widening per attempt, `DEADLOCK_RETRIABLE_ROUTES = ['/v2/advance-shipping-notices', '/v2/service-requests']`.
37
40
 
38
41
  ## How it works
39
42
 
@@ -42,7 +45,7 @@ The replay is **route-scoped by an allowlist** (`DEADLOCK_RETRIABLE_ROUTES`) —
42
45
  - **The ASN model** calls the IF-create `internalApiRequest` with **`throwExceptionsOnError:false`**; on an empty response with `lastErrorNumber === 1062` it sets `$retriableUniqueRace = true` and returns null. `postPost` then **aborts immediately** (issues no further query).
43
46
  - **The controller's Prong A gate** becomes `wasDeadlock() || wasRetriableUniqueRace()`. Replaying the whole request on a fresh REPEATABLE-READ snapshot lets the retry's GET see the winner's committed IF and reuse it.
44
47
 
45
- Bounded by `MAX_DEADLOCK_ATTEMPTS = 3`, gated to `DEADLOCK_RETRIABLE_ROUTES = ['/v2/advance-shipping-notices']`.
48
+ Bounded by `MAX_DEADLOCK_ATTEMPTS = 5`, gated to `DEADLOCK_RETRIABLE_ROUTES = ['/v2/advance-shipping-notices', '/v2/service-requests']`.
46
49
 
47
50
  **V2 concurrency mechanics (durable framework knowledge)** — the V2-engine facts that decide **how any interceptor-level write race must be handled**, and why the fix is a whole-request replay and not an in-place recovery:
48
51
  - **Response poisoning (load-bearing).** A failed nested save via `internalApiRequest` calls `addDefinedMessage(EV-10)` on the **shared** `$api->response`, setting a non-2xx status **before** control returns. So even if an interceptor catches the throw and "recovers," `execute()` recomputes `isSuccess = false` (`V2.php` ~2154) and **self-rolls-back DB_CORE + DB_CLIENT** (~2157-2159). **An interceptor therefore CANNOT recover a failed internal save in place** — the only correct recovery is a whole-request replay. This same poisoning drives the Prong A retry gate and, on exhaustion, produces the coherent EV-10 response.
@@ -50,7 +53,7 @@ Bounded by `MAX_DEADLOCK_ATTEMPTS = 3`, gated to `DEADLOCK_RETRIABLE_ROUTES = ['
50
53
  - **`_Database::$lastErrorNumber` is sticky.** Set only on query failure, never cleared by a later success; reset per attempt via `clearLastError()`. A recorded **deadlock (1213/1205) is protected from overwrite** by a later error; a **1062 is NOT** protected — so read/act on a 1062 promptly.
51
54
  - **The prod client cluster runs REPEATABLE-READ.** A same-transaction re-SELECT after a losing concurrent INSERT is **blind to the winner's commit** — only a locking read or a **fresh-snapshot whole-request replay** sees it. This is why in-place retry inside the transaction cannot work and the replay must start a new transaction.
52
55
 
53
- **How it works — deadlock path.** Detection primitive (`_underscore`, reusable): `_Database` gains `public static ?int $lastErrorNumber`, the consts `ERRNO_DEADLOCK` (1213) and `ERRNO_LOCK_WAIT_TIMEOUT` (1205), plus `clearLastError()` and `wasDeadlock()`. `_Query` records the driver errno **at its throw site**, and the recording is **sticky**: a later unrelated error does **not** overwrite an already-recorded deadlock. That stickiness lets a deadlock caught inside a model-save `try/catch` still be visible to the outer controller and trigger a retry. Retry loop (`api2/Controller/Index.php`): the v2 dispatch is wrapped in a bounded loop (`MAX_DEADLOCK_ATTEMPTS = 3`). On an unsuccessful response where `_Database::wasDeadlock() || _Database::wasRetriableUniqueRace()` is true and the route is in `DEADLOCK_RETRIABLE_ROUTES`, the controller:
56
+ **How it works — deadlock path.** Detection primitive (`_underscore`, reusable): `_Database` gains `public static ?int $lastErrorNumber`, the consts `ERRNO_DEADLOCK` (1213) and `ERRNO_LOCK_WAIT_TIMEOUT` (1205), plus `clearLastError()` and `wasDeadlock()`. `_Query` records the driver errno **at its throw site**, and the recording is **sticky**: a later unrelated error does **not** overwrite an already-recorded deadlock. That stickiness lets a deadlock caught inside a model-save `try/catch` still be visible to the outer controller and trigger a retry. Retry loop (`api2/Controller/Index.php`): the v2 dispatch is wrapped in a bounded loop (`MAX_DEADLOCK_ATTEMPTS = 5`). On an unsuccessful response where `_Database::wasDeadlock() || _Database::wasRetriableUniqueRace()` is true and the route is in `DEADLOCK_RETRIABLE_ROUTES`, the controller:
54
57
  1. rolls back all open transactions;
55
58
  2. wipes `_Database::$_transactionStarts` so every connection re-begins via the umbrella lazy-transaction path on replay;
56
59
  3. resets the query + model caches;
@@ -74,12 +77,53 @@ Two consequences of the typed exception worth knowing:
74
77
  - **`_Exception_Database` extends `Exception` deliberately — not `RuntimeException`.** A design choice, not an oversight: callers that catch `RuntimeException` to mean "bad data, skip this row" must **not** accidentally swallow a DB failure. It also means any *new* exception type added near this code must extend `Exception` directly, or existing catch blocks change meaning — a live case in [Forecast.Sales import](../../_underscore/features/forecast-sale-import.md).
75
78
  - **Every SQL failure in worker2 is now `_Exception_Database`.** So any code that compares `get_class($e)` or matches on DB error **text** changes behaviour under this protocol. Audit for those before assuming the change is transparent.
76
79
 
80
+ ### ⚠ The API logger used to SILENTLY DISARM this retry — `$lastErrorNumber` must be saved/restored (2026-09-22)
81
+
82
+ **Symptom:** `/v2/service-requests` returned `400 EV-10` *"Duplicate entry 'SR131023' for key 'ServiceRequests.number'"* — 21 consecutive failures, all ~1.8s with no backoff visible. ServiceNow was **not** sending duplicates. The retry existed and was correctly wired; it just never fired.
83
+
84
+ **The root-cause chain, in order:**
85
+ 1. **`ServiceRequests.number` is not a MySQL auto-increment.** `_Model` `FIELD_AUTOINCREMENT` does a `SELECT MAX(number)+1` and then an `INSERT`, **with no lock between them** (see [FIELD_AUTOINCREMENT record numbering](../../_underscore/features/autoincrement-record-numbering.md)). Two concurrent POSTs read the same MAX, pick the same number, and the loser gets MySQL 1062.
86
+ 2. **The retry was already built for exactly this.** `_underscore/Model.php` sets `_Database::$retriableUniqueRace` on that 1062, and `api2/Controller/Index.php` replays the whole request when `_Database::wasRetriableUniqueRace()` is true. That helper requires **both** the flag **and** `$lastErrorNumber === 1062`.
87
+ 3. **The logger destroyed the second half of that condition.** `_Component_Api_V2::saveApiLogUnderItsOwnTransactionId()` runs **between the failed INSERT and the retry check**, inside `execute()`. Its resend loop **deliberately causes its own 1062s** — it renames `transactionId` on collision and retries (see [V2 request logging](./request-logging.md)). Those bookkeeping 1062s **overwrote the caller's `$lastErrorNumber`**, so `wasRetriableUniqueRace()` returned false and the replay never ran.
88
+
89
+ **Fix:** save and restore **both** `_Database::$lastErrorNumber` and `_Database::$retriableUniqueRace` in a `try/finally` **inside** `saveApiLogUnderItsOwnTransactionId()`. Scoped inside that method — *not* at the call site — so it only undoes that loop's own errors; deadlocks, the ASN route, and every other route behave exactly as before.
90
+
91
+ **The durable rule: logging is bookkeeping and must never change the request's retry decision.** Any code that runs between a failure and the code that inspects it must leave `_Database`'s error state exactly as it found it. Recall that `$lastErrorNumber` is **sticky but a 1062 is NOT protected from overwrite** (unlike a recorded deadlock) — so a 1062 is the error class most easily lost this way.
92
+
93
+ **Side benefit:** the same fix repairs **ASN** (`_Model_Compass_AdvanceShippingNotice`), which sets the same flag and had the identical exposure.
94
+
95
+ ### Verifying a concurrency fix — the gate-file pattern
96
+
97
+ A plain background loop spreads requests out over time and can **miss the collision window entirely**, so it produces a false pass. What works: N `curl` workers each **block on a shared gate file**, then a single `touch` releases all of them at the same instant.
98
+
99
+ Two details that cost time:
100
+ - The v2 auth token is at **`.data.tokens.access`** in the auth response — **not** `.data.token`.
101
+ - Confirm the UNIQUE constraint exists in the environment you are testing (see [V2 request logging](./request-logging.md)) — a local pass proves nothing.
102
+
103
+ **Result (2026-09-22, `sandbox-dev`):** 10 concurrent SR POSTs → **10/10 HTTP 201**, `SR130589..SR130598`, zero duplicates. Response times stair-stepped 1.32s → 4.78s in ~0.4s steps — that shape **is** the retry backoff actually running, and is the signal that the retry engaged. **Control:** the same route the previous day, before the fix, returned **21 × 400**.
104
+
105
+ ### Deferred hardening (reviewed 2026-09-22, deliberately NOT changed)
106
+
107
+ Both were considered and left alone on purpose. Do not "fix" either without re-checking the reasoning.
108
+
109
+ - **The `V2.php` resend loop is unbounded** (`for ($resendNumber = 1; ; ...)`). Safe **today only because every pass changes a UNIQUE column**, so it cannot re-ask for a taken value.
110
+ - **`_underscore/Model.php` marks ANY 1062 as a retriable race** without checking *which* unique key failed. `ServiceRequests` has three unique keys (`PRIMARY`, `uuid`, `number`) and only `number` is the benign race. Harmless today: `uuid` is a random uuid so a collision is effectively impossible, and `PRIMARY` cannot duplicate on INSERT.
111
+
112
+ ### Rejected finding — a null `transactionId` does NOT break the first log save
113
+
114
+ Checked and **wrong**; recorded so it is not raised again. `Logs.Api.transactionId` is a UNIQUE index with `Null: YES`. **MySQL permits unlimited NULLs in a UNIQUE index**, and 4 such rows already exist in `dev-sandbox` `Logs_Prudential.Api`. A null therefore never raises 1062 and never enters the retry loop.
115
+
116
+
77
117
  ## Gotchas
78
118
 
79
119
  - Detection is built on the **MySQL errno** (1213/1205), not on matching the response text — deliberately, because the raw error text is not a reliable signal (and, separately, api2's validation error body still leaks the failing SQL + DB name; see [V2 API error codes](./v2-api-error-codes.md)).
80
- - Retry is bounded (`MAX_DEADLOCK_ATTEMPTS = 3`) with jittered backoff — a collision damper, not a substitute for removing the deadlock at its source.
120
+ - Retry is bounded (**`MAX_DEADLOCK_ATTEMPTS = 5`**, not 3) with a 20ms-120ms widening backoff — a collision damper, not a substitute for removing the collision at its source.
121
+ - **Anything running between the failing query and the retry check must save/restore `_Database::$lastErrorNumber` + `$retriableUniqueRace`.** The API logger's own 1062s disarmed this retry completely for weeks; a 1062 is not protected from overwrite the way a deadlock is.
122
+ - **Test concurrency with a gate file**, not a background loop — a loop spreads the requests out and can miss the race. Auth token is at `.data.tokens.access`.
123
+ - **A branch cut from `_production` may have the CALL SITE but not the METHOD.** `saveApiLogUnderItsOwnTransactionId()` (commit `db94a26`) exists only on `_sandbox-dev`; branch `TRUE-82095` came off `_production` and would have fataled with *call to undefined method* on every DB-logged request. When branching off `_production`, verify the **callee** exists, not just the caller.
81
124
  - `ERRNO_DUPLICATE_ENTRY` is absent from `_beta`/`_sandbox-dev` — naming it directly fatals there; define a local `1062` constant and read `$lastErrorNumber` via `property_exists()`.
82
- - Only `/v2/advance-shipping-notices` is on `DEADLOCK_RETRIABLE_ROUTES`; do not add a route without proving it is replay-safe (no irreversible side effect, no mutated superglobal).
125
+ - `DEADLOCK_RETRIABLE_ROUTES` is now `/v2/advance-shipping-notices` **and** `/v2/service-requests`; do not add a route without proving it is replay-safe (no irreversible side effect, no mutated superglobal).
126
+ - **Every other `FIELD_AUTOINCREMENT` model has the same unlocked generator** — SalesOrder, PurchaseOrder, Ticket, ItemReceipt, InventoryAdjustment, TransferOrder — and **none is on `DEADLOCK_RETRIABLE_ROUTES`**, so a duplicate-number clash on those routes still returns 400. The durable answer is fixing the number generator so the read and the insert cannot split; that is a bigger, separate change.
83
127
  - Do not reach for this protocol for an HTTP 429 — use `_ApiRequest::setAutoRetry()`.
84
128
 
85
129
  ## Related
@@ -90,3 +134,9 @@ Two consequences of the typed exception worth knowing:
90
134
  - [V2 API error codes](./v2-api-error-codes.md)
91
135
  - [V2 request logging](./request-logging.md)
92
136
  - [ENVIRONMENT decides the `_underscore` branch](./environment-variable-drives-underscore-branch.md)
137
+
138
+ ## Change history
139
+ - 2026-09-22 — Found and fixed the reason this retry never fired on `/v2/service-requests`: the API logger's own 1062s overwrote `_Database::$lastErrorNumber`; now saved/restored in `try/finally` inside `saveApiLogUnderItsOwnTransactionId()`. Proven 10/10 under real concurrency on sandbox-dev. Also fixes ASN. Prod still unpatched. (rgirish)
140
+ - 2026-09-22 — Corrected constants: `MAX_DEADLOCK_ATTEMPTS = 5` (was documented as 3); `DEADLOCK_RETRIABLE_ROUTES` now includes `/v2/service-requests`. (rgirish)
141
+ - 2026-09-16 — Scoped the protocol against `_ApiRequest::setAutoRetry()` (MySQL vs HTTP retry). (bala)
142
+ - 2026-08-28 — Added the retriable unique-key race (1062) path for the Compass ASN data-loss race. (jcardinal)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
9
+ updated: 2026-09-22
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/Worker/Monitor/Clickup.php
@@ -25,14 +25,19 @@ worker2 cron that checks the team's ClickUp webhook subscriptions and pages via
25
25
 
26
26
  Catches the **silent webhook outage** EB health checks cannot see: if ClickUp suspends or fails our inbound webhook (`https://webhook.togahub.com/clickup`), inbound tasks stop arriving and nothing else notices.
27
27
 
28
- Distinct from and **stricter than** the sibling email watchdog [`_Worker_Clickup_Health`](./clickup-connectivity-watchdog.md): this alarms on any `togahub.com` webhook not `health.status = "active"`; the watchdog only emails on `status = "failing"` in business hours.
28
+ Distinct from the sibling email watchdog [`_Worker_Clickup_Health`](./clickup-connectivity-watchdog.md): the watchdog emails on `status = "failing"` in business hours; this monitor reports to OneUptime around the clock and only escalates on `"suspended"`, a missing subscription, or a blind read (see the alarm rule below). A plain `"failing"` status is **not** alarmed here — that softer signal stays with the email watchdog.
29
29
 
30
30
  ## How it works
31
31
 
32
32
  - Reads subscriptions: `_Component_Api_Clickup::send('GET','/team')` → for each team `_Component_Api_Clickup::send('GET','/team/{id}/webhook')`.
33
- - POSTs a metric body via `_ApiRequest` to a OneUptime incoming-request heartbeat URL: `alarm` (`OK`/`DEGRADED`), `probe` (`OK`/`DEGRADED`), plus `webhookCount`, `activeCount`, `failures`.
34
- - **Alarm rule:** `DEGRADED` when any webhook whose endpoint contains `togahub.com` is not `health.status = "active"`.
35
- - **Push URL is an in-file `$ONEUPTIME_URL` local, not `[oneuptime]` config** — the established team convention for every `Worker/Monitor/*` file (Operations, Staples, Compass, Aig, Prudential, Nycdoe, Fleet). A cso review suggested reading it from config; the developer rejected that on purpose — the heartbeat URL is treated as low-sensitivity and stays inline, matching the siblings. See the credential note in [OneUptime push-metric monitors](./oneuptime-worker2-monitoring.md). Ships blank with a soft-fail guard until the real URL is pasted in.
33
+ - POSTs a metric body via `_ApiRequest` to a OneUptime incoming-request heartbeat URL: `alarm` (`OK`/`DEGRADED`/`HIGH`), `probe` (`OK`/`DEGRADED`), plus `webhookCount`, `okCount`, `suspendedCount`, `blindCount`, `failureCount`, `failures`, `checkedAtUtc`.
34
+ - **Alarm rule (three levels, revised 2026-09-22 — quieter than the original "not active = bad"):**
35
+ - `HIGH` (full failure / Offline) — **blind read**: the ClickUp call threw, OR a monitored `togahub.com` webhook has no readable `health.status`. "We don't know the status."
36
+ - `DEGRADED` — a monitored `togahub.com` webhook has `health.status = "suspended"`, OR no `togahub.com` subscription exists at all. A known outage we can see.
37
+ - `OK` — every monitored webhook is readable and not suspended (so `active`, `failing`, any other readable status → OK).
38
+ - Precedence: `HIGH` > `DEGRADED` > `OK`. `probe` is `OK` on any successful read (even when `alarm=HIGH` from a blind webhook) and `DEGRADED` only when the ClickUp read itself failed.
39
+ - **OneUptime criteria must match the tokens** (config lives outside the repo): Request Body Contains `"alarm":"HIGH"` → Offline + incident; Contains `"alarm":"DEGRADED"` → Degraded; received in 1 min AND Contains `"alarm":"OK"` → Online (OK-gated recovery).
40
+ - **Push URL is an in-file `$ONEUPTIME_URL` local, not `[oneuptime]` config** — the established team convention for every `Worker/Monitor/*` file (Operations, Staples, Compass, Aig, Prudential, Nycdoe, Fleet). A cso review suggested reading it from config; the developer rejected that on purpose — the heartbeat URL is treated as low-sensitivity and stays inline, matching the siblings. See the credential note in [OneUptime push-metric monitors](./oneuptime-worker2-monitoring.md). The `$push` guard still soft-fails on a blank URL so the job can deploy inert.
36
41
  - Cron row: `dbchanges2/Core/2026-09-21c - Insert ClickUp Webhook Health Monitor CronJob.sql`. OneUptime import artifact `clickup-webhook-health.json` lives in a local staging dir, not the repo (import artifacts are not version-controlled — see the pattern doc's provisioning runbook).
37
42
 
38
43
  ## Gotchas
@@ -42,6 +47,7 @@ Distinct from and **stricter than** the sibling email watchdog [`_Worker_Clickup
42
47
  - Keep the metric POST non-fatal — a monitoring outage must never break the worker job.
43
48
 
44
49
  ## Change history
50
+ - 2026-09-22 — Reworked the alarm rule from "any status ≠ active → DEGRADED" to three levels: `HIGH` on a blind read (ClickUp call failed, or a webhook has no readable `health.status`), `DEGRADED` on `"suspended"` or no `togahub.com` subscription, `OK` for everything else (`active`/`failing`/other). Original was too sensitive. Also hardened: blind-path OneUptime push wrapped in try/catch (non-fatal), `health` guarded against a non-object, stale "Left BLANK" URL comment removed (URL is live). Payload fields changed: `activeCount` → `okCount`, added `suspendedCount`/`blindCount`. OneUptime monitor criteria must be set to match `"alarm":"HIGH"` and `"alarm":"DEGRADED"`. (jcardinal)
45
51
  - 2026-09-21 — Created `_Worker_Monitor_Clickup::CheckWebhook` (OneUptime, `*/10`, stricter than the email watchdog). Recreated the inbound webhook under the service account (`pk_87469444`, new id `bd6168ab-…`, deleting the old OAuth-owned one) so both this monitor and `_Worker_Clickup_Health` can see it — the two were previously blind to it (token-ownership gotcha above). (jcardinal)
46
52
 
47
53
  ## Related
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.866",
3
+ "version": "1.0.868",
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",