toga-ai 1.0.897 → 1.0.899

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,7 @@ project: Library
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-09-25
10
10
  owners: ["rgirish", "jcardinal"]
11
11
  files:
12
12
  - library/app/database.php
@@ -18,6 +18,7 @@ related:
18
18
  - ../../standards/backend-php.md
19
19
  - ../../apps/worker/features/netsuite-togasupply-per-client-sync.md
20
20
  - ../../../clients/compass-usa/workflows/odp-order-pipeline-to-netsuite.md
21
+ - ../../../../clients/compass-usa/features/oneuptime-netsuite-duplicate-sales-orders-monitor.md
21
22
  ---
22
23
 
23
24
  How a 1.0 mysqli connection dies during a long external call ("MySQL server has gone away"), why the built-in 2006 reconnect never fired, and the three helpers (`keepAlive`, `reconnect`, `queryForked`) that fix it; open before writing any cron that does a slow HTTP/SOAP call between two DB writes.
@@ -86,10 +87,10 @@ It returns **`false`** (rather than throwing) when the link has no config entry,
86
87
 
87
88
  When a cron creates a record in an external system and then stamps our DB, a dead connection at the stamp step produces a **silent duplicate**. `register_shutdown_function()` is the **only** code that still runs after `App_Error::handleException()` calls `exit`, so it is the only place an alert can fire.
88
89
 
89
- The working pattern (Compass ODP cron 5, `worker` commit `afce32ca`):
90
+ The alert pattern as shipped in Compass ODP cron 5 (`worker` commit `afce32ca`). Cron 5 has **no `keepAlive()` call** — it stamps via `App_Database::queryForked($sql, $clientToga2DatabaseLink)` (corrected 2026-09-25):
90
91
 
91
92
  1. **Arm** an `$unstampedNetsuiteOrder` array immediately *before* the external `add()`.
92
- 2. **Clear** it after a successful stamp, and also clear it when the remote system *rejects* the order (nothing was created, so nothing is orphaned).
93
+ 2. **Clear** it right after the stamp is handed to `queryForked()`, and also when the remote system *rejects* the order (nothing was created, so nothing is orphaned). **⚠ The forked write is not confirmed** (code comment says so): a failed fork leaves the order sent, unstamped, and the alarm already off → possible duplicate on the next run. Cron 5 relies on the [NetSuite duplicate SO monitor](../../../../clients/compass-usa/features/oneuptime-netsuite-duplicate-sales-orders-monitor.md) as backstop. If you need a confirmed stamp, clear only after a synchronous write succeeds.
93
94
  3. In the shutdown handler, if it is still armed, email devteam.
94
95
  4. **Reconnect `db_common` and `db_logs` first — NOT the client link.** `App_Email::send()` reads the suppression list on **db_common** (`App_Model_Common_EmailSuppressionList`, `App_Model_Common_EmailsQueued`) and writes its log row on **db_logs** (`App_Model_Logs_Email`). Reconnecting the client link buys nothing for the email.
95
96
  5. Wrap it in `catch (Throwable)` falling back to `error_log()`, which needs no database at all.
@@ -100,7 +101,7 @@ The working pattern (Compass ODP cron 5, `worker` commit `afce32ca`):
100
101
  - **⚠ A `catch` around a DB call proves nothing about whether an alert will fire.** Verify against the actual email/log table, not the code. Thirty-three failures produced zero emails.
101
102
  - **⚠ Kit / bundle orders are the ones that break.** Nothing about a part number causes this — the slow path does. On Compass ODP, `PC-KIT-OPT2` (10 expanded lines) accounted for 10 of 11 duplicated POs simply because kits are the orders whose `add()` runs past 180 s. Read a part-number correlation as "this is the slow path", not "this SKU is special".
102
103
  - **⚠ Reconnecting the wrong link is a silent no-op.** Emailing needs `db_common` + `db_logs`. A handler that only reconnects the client link will still die trying to send.
103
- - **⚠ Turning a latent version of this bug ON at volume is a separate risk.** The Compass race existed from 2026-09-15 but the cron was `"active": 0`, so it barely fired. Flipping it to `1` on 2026-09-21 produced 15 duplicate NetSuite SOs in two days. When re-enabling a long-dormant cron that writes to an external system, check its stamp path first.
104
+ - **⚠ Turning a latent version of this bug ON at volume is a separate risk.** The Compass race existed from 2026-09-15 but the cron was `"active": 0`, so it barely fired. Flipping it to `1` on 2026-09-21 produced most of the duplicates (22 POs affected Sep 15–22 in total). When re-enabling a long-dormant cron that writes to an external system, check its stamp path first.
104
105
  - **`keepAlive()` after the call must sit OUTSIDE the `try`/`catch`** and be guarded on the response being non-null, so a rejected call does not run it against state you have not checked.
105
106
 
106
107
  ## Decisions
@@ -110,4 +111,5 @@ The working pattern (Compass ODP cron 5, `worker` commit `afce32ca`):
110
111
  - `library/app/error.php:5-8` has `IMMEDIATELY_TERMINATE_INSTANCE_IF_ERROR_STRING_CONTAINS`, matched **on substring before severity**. A recoverable warning could terminate an EC2 instance.
111
112
 
112
113
  ## Change history
114
+ - 2026-09-25 — Corrected the cron 5 pattern: it stamps via `queryForked` with no `keepAlive`, and disarms the alarm before the forked write is confirmed; noted the monitor backstop. (rgirish)
113
115
  - 2026-09-22 — Doc created. Root-caused the "gone away" class: warning fires at `mysqli_select_db` before the 2006 check, `databaseClose()` never cleared `$connections`, `$link` captured above the retry loop. Added `App_Database::reconnect()` + `queryForked()` (`library` commit `60fdb888`) and documented the shutdown-handler alert pattern. Decided against raising `wait_timeout` from 180 s. (rgirish)
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-21
10
- owners: ["bala", "apeterson"]
9
+ updated: 2026-09-25
10
+ owners: ["bala", "apeterson", "rgirish"]
11
11
  files:
12
12
  - _underscore/Model/Core/Domain.php
13
13
  - api2/Component/Api/CrossClient/CrossClient.php
@@ -18,6 +18,7 @@ related:
18
18
  - ../../toga2-supply/workflows/client-host-scoping.md
19
19
  - ../../api2/features/encrypted-user-uuid-auth-handoff.md
20
20
  - ../../../../clients/compass-usa/features/approval-decision-flow.md
21
+ - ../../saml/features/gateway-environments-and-redirect-safety.md
21
22
  ---
22
23
 
23
24
  `Core.Domains` is the authoritative host↔client↔app↔env registry; a host is NOT derivable from env slug or api host — read the table. Open before building an app link.
@@ -137,6 +138,21 @@ app-specific lookup it will happily return a commerce domain for a supply link.*
137
138
  origin string (scheme included) against `Core.Domains` for that client + environment. Do not
138
139
  assume a sibling client's working row proves the host is registered for yours.
139
140
 
141
+ - **⚠ A row in this table is now an AUTHORIZATION, not just a lookup.** As of 2026-09-25 the SAML
142
+ gateway's `/acs` treats "a `Core.Domains` row exists, owned by this client + environment" as
143
+ permission to deliver the `?saml=` login handoff to that host — there is deliberately no separate
144
+ allowlist (an allowlist would have blocked 24 of the 48 live SSO domains). So **write access to
145
+ `Core.Domains` is control over where a credential payload lands.** `domain` is a plain
146
+ `FIELD_CHAR` with no validation and no `isActive` column, and it already holds `http://localhost`,
147
+ two ngrok URLs, and a malformed `'https://\040store.gamma.togacommerce.com'`. The gateway
148
+ structurally validates and **rebuilds** the URL via
149
+ `_Model_Core_ClientAuthentication::safeRedirectUrl()` before redirecting; see
150
+ [SAML gateway per-environment hosts & redirect safety](../../saml/features/gateway-environments-and-redirect-safety.md).
151
+ Related: in `Client_True` the role named "Base" maps to `coreRoleId` 3 (Super User), so ~2,826
152
+ users can `POST`/`PUT` `/domains`.
153
+ - **`/domains` responses are cached — `Core.Records` id 1 has `ttlCache = 15`.** A freshly inserted
154
+ row is not visible to a frontend immediately; an SSO lookup can keep returning the old answer.
155
+
140
156
  ## Related
141
157
 
142
158
  - [Environment configuration & provisioning](../../api2/workflows/environment-configuration-and-provisioning.md)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-09-25
10
10
  owners: ["mhammontree", "dfranks", "bala", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -21,6 +21,7 @@ related:
21
21
  - ./v2-deadlock-retry.md
22
22
  - ./environment-variable-drives-underscore-branch.md
23
23
  - ../../_underscore/features/error-reporting-issue-event.md
24
+ - ../../worker2/features/logs-retention-cleanup.md
24
25
  - ../../_underscore/features/order-for-delegation.md
25
26
  - ../../../../clients/aig/features/entitlement-intake.md
26
27
  - ../../../../clients/prudential/features/service-request-auth-rejection-alert.md
@@ -215,8 +216,10 @@ Both a **fallback recovery path** and a **scope diagnostic**. In the TRUE-80179
215
216
  - **Decode an `EO-1` via `error.id` = `<Issue.reference>-<Event.eventNumber>`** — search `Issue.reference` on the part BEFORE the dash; read `Issue.trace` (full), not the 255-char-truncated `errorMessage`.
216
217
  - **Query prod `Logs.Api` by narrowing on `dtStamp` (indexed) first**, collect ids, then fetch `mediumtext` payloads by id with `LEFT()`/`SUBSTRING()`. Never start from a `LIKE` over `requestPayload`/`responsePayload`.
217
218
  - **Never copy logged rows into committed artifacts** — they carry live Bearer tokens and PII.
219
+ - **`Logs_<Client>.Api` is pruned (once TRUE-81902 deploys)** — GET rows after 30 days, writes after 90. A missing older row is not proof the request never happened. See [Logs retention cleanup](../../worker2/features/logs-retention-cleanup.md).
218
220
 
219
221
  ## Change history
222
+ - 2026-09-25 — Linked the `Logs_<Client>.Api` retention job (GET 30d / writes 90d). (rgirish)
220
223
  - 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)
221
224
 
222
225
  - 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)
@@ -230,6 +233,7 @@ Both a **fallback recovery path** and a **scope diagnostic**. In the TRUE-80179
230
233
  - [V2 deadlock retry](./v2-deadlock-retry.md)
231
234
  - [ENVIRONMENT drives the `_underscore` branch](./environment-variable-drives-underscore-branch.md)
232
235
  - [Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md)
236
+ - [Logs retention cleanup](../../worker2/features/logs-retention-cleanup.md)
233
237
  - [AIG entitlement intake](../../../../clients/aig/features/entitlement-intake.md)
234
238
  - [Prudential service-request auth-rejection alert](../../../../clients/prudential/features/service-request-auth-rejection-alert.md)
235
239
  - [Compass MITS PO → SO item linking](../../../../clients/compass-usa/features/mits-po-to-so-item-linking.md)
@@ -3,7 +3,9 @@
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
5
  | [saml Architecture](architecture.md) | Gateway internals of the `saml` SAML 2.0 SP; open when working on `/acs`, `/meta`, cert/decryption, or deploy of the SSO gateway. |
6
+ | [/acs assertion trust gaps — what the gateway does NOT verify](features/acs-assertion-trust-gaps.md) | The exact list of SAML checks `/acs` skips today, with evidence, so nobody assumes the gateway proves who asserted an identity. |
6
7
  | [SAML Downstream Integration Contract](features/downstream-integration-contract.md) | Integration recipe for a downstream TOGa app to authenticate users via the SAML gateway (`saml.togahub.com`); open when wiring a new SSO-consuming app. |
8
+ | [SAML gateway per-environment hosts & redirect safety (samlHostUrl / safeRedirectUrl)](features/gateway-environments-and-redirect-safety.md) | Which SAML gateway host serves which environment, and how `/acs` decides where it is allowed to send the login handoff. |
7
9
  | [SAML Uptime Healthcheck Endpoint (/healthcheck)](features/healthcheck-endpoint.md) | Secret-gated `GET /healthcheck` synthetic-SSO probe on the saml gateway; open when working on SSO uptime monitoring or the probe's seeds/guardrails. |
8
10
  | [Rate SAML User Provisioning](features/rate-user-provisioning.md) | How a Rate SSO login finds or just-in-time provisions the user record chain in `Client_Rate`; open when touching Rate SSO or its user/customer/contact models. |
9
11
  | [Onboarding a New SSO Client with Just-In-Time User Provisioning](workflows/onboarding-a-new-sso-client.md) | Procedure to add a new client to the 2.0 saml gateway when its users don't pre-exist and must be created on first login (JIT provisioning); open when onboarding |
@@ -0,0 +1,80 @@
1
+ ---
2
+ title: /acs assertion trust gaps — what the gateway does NOT verify
3
+ framework: "2.0"
4
+ repo: saml
5
+ project: SAML SSO Gateway
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["rgirish"]
11
+ files:
12
+ - saml/Controller/Index.php
13
+ - _underscore/String.php
14
+ - _underscore/Model/True/ClientAuthentication.php
15
+ related:
16
+ - ../architecture.md
17
+ - ./gateway-environments-and-redirect-safety.md
18
+ - ./downstream-integration-contract.md
19
+ ---
20
+
21
+ The exact list of SAML checks `/acs` skips today, with evidence, so nobody assumes the gateway proves who asserted an identity. Open before trusting a gateway login, or when picking up the signature-verification work.
22
+
23
+ ## Summary
24
+
25
+ `/acs` accepts an assertion it has **not proven came from the expected IdP**, and its RelayState is
26
+ encrypted but **not authenticated**. Neither is fixed. Both need their own tickets. Everything
27
+ below was verified by reading `saml/Controller/Index.php` on 2026-09-25, not inferred.
28
+
29
+ ## How it works — what is actually checked
30
+
31
+ The live `/acs` path does exactly this: decrypt RelayState → check version + TTL → parse the
32
+ `SAMLResponse` → check `STATUS_SUCCESS` → decrypt the assertion → map identity. That is all.
33
+
34
+ ### Gap 1 — no IdP signature verification, and no SAML condition checks
35
+
36
+ `saml/Controller/Index.php` lines ~306-405 are **one comment block**, and the only
37
+ `XMLSecurityDSig::verify()` call in the file lives inside it. Grep confirms **zero** occurrences
38
+ anywhere outside that comment of:
39
+
40
+ - `Audience` — nothing checks the assertion was meant for us
41
+ - `Destination` — nothing checks it was meant for *this* gateway host
42
+ - `InResponseTo` — nothing binds the response to a request we sent
43
+ - `NotBefore` / `NotOnOrAfter` — the assertion's own validity window is ignored
44
+
45
+ Git history: the block arrived in commit `4bcee38` "Final changes for Compass SAML" and ends with
46
+ `echo` + `exit;` — it is debug code that was never switched on.
47
+
48
+ **Why this compounds:** `_Model_True_ClientAuthentication::getAuthenticatedSsoUser()` matches on
49
+ **email only**, with no issuer check. So any IdP the gateway already trusts (Compass, Prudential,
50
+ Office Depot) can assert a `@togatech.com` email and be handed the internal user.
51
+
52
+ The per-client IdP verification certs already exist in source as base64 blobs (Compass, Office
53
+ Depot, Prudential, RUMCSI, Agilant) — they are what a re-enabled verifier would load. Verification
54
+ is per-client; each IdP's signing cert must match its own client.
55
+
56
+ ### Gap 2 — RelayState is encrypted but not authenticated
57
+
58
+ `_String::encryptWithKey()` (`_underscore/String.php` ~line 277) is **AES-256-CBC with no MAC and
59
+ an attacker-supplied IV**. A security review demonstrated that flipping IV bits rewrites the
60
+ plaintext `time` field **without the key**. So the `abs()` TTL fix (see
61
+ [gateway environments & redirect safety](./gateway-environments-and-redirect-safety.md)) does not
62
+ stop replay on its own. There is also **no nonce and no single-use marking** — a RelayState can be
63
+ presented any number of times.
64
+
65
+ **Fix direction:** AES-256-GCM, or an HMAC verified with `hash_equals()` **before** decrypting,
66
+ plus a server-side single-use nonce. Changing `encryptWithKey()` affects every caller — it is also
67
+ the `?saml=` handoff and the api2 encrypted-user-uuid auth, so this is not a local change.
68
+
69
+ ## Gotchas
70
+
71
+ - **Do not describe the gateway as "authenticating" anyone to a client or an auditor.** Until Gap 1
72
+ is closed it authenticates the *originating TOGa request* (via RelayState), not the *asserting
73
+ IdP*.
74
+ - **Fixing the RelayState TTL is not replay protection.** Gap 2 makes the timestamp attacker-
75
+ controlled. Treat the TTL fix as hygiene, not as a control.
76
+ - **These are not "known and accepted" risks** — they are undone work. Do not close a ticket
77
+ citing this doc as sign-off.
78
+
79
+ ## Change history
80
+ - 2026-09-25 — Created; recorded that `/acs` verifies no IdP signature and no Audience/Destination/InResponseTo/NotBefore/NotOnOrAfter, and that RelayState uses unauthenticated AES-256-CBC with an attacker-supplied IV (rgirish)
@@ -0,0 +1,159 @@
1
+ ---
2
+ title: SAML gateway per-environment hosts & redirect safety (samlHostUrl / safeRedirectUrl)
3
+ framework: "2.0"
4
+ repo: saml
5
+ project: SAML SSO Gateway
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["rgirish"]
11
+ files:
12
+ - _underscore/Model/Core/ClientAuthentication.php
13
+ - saml/Controller/Index.php
14
+ - saml/.ebextensions/git.json
15
+ related:
16
+ - ../architecture.md
17
+ - ./downstream-integration-contract.md
18
+ - ./acs-assertion-trust-gaps.md
19
+ - ../workflows/rotating-the-saml-sp-certificate.md
20
+ - ../../_underscore/features/core-domains-app-host-registry.md
21
+ ---
22
+
23
+ Which SAML gateway host serves which environment, and how `/acs` decides where it is allowed to send the login handoff. Open when SSO works in production but not in beta/sandbox, or when touching any redirect in the gateway.
24
+
25
+ ## Summary
26
+
27
+ Two things used to be wrong here and both are fixed (2026-09-25, branch `TRUE-81993`):
28
+
29
+ 1. **One gateway per environment, not one gateway.** `saml.beta.togahub.com` was publishing
30
+ **production's** entityID and ACS URL, because the beta entry in the host map pointed at
31
+ `https://saml.togahub.com`. An IdP configured from beta's `/meta` would post to production.
32
+ 2. **`/acs` redirected to whatever string `Core.Domains.domain` held**, unvalidated. That is an
33
+ open redirect on a **credential delivery target** — the `?saml=` payload exchanges for real
34
+ tokens at `POST /auth/encrypted-user-uuid`.
35
+
36
+ Both now go through two helpers on `_Model_Core_ClientAuthentication`:
37
+ `samlHostUrl(string $environmentSlug): string` and `safeRedirectUrl(string $url): ?string`.
38
+
39
+ ## How it works
40
+
41
+ ### The gateway host map
42
+
43
+ `_underscore/Model/Core/ClientAuthentication.php` (~line 5):
44
+
45
+ ```php
46
+ const SAML_ASSERTION_CONSUMER_SERVICE_HOST_URL_BY_ENVIRONMENT = [
47
+ 'dev' => 'http://saml',
48
+ 'beta' => 'https://saml.beta.togahub.com',
49
+ 'sandbox-client' => 'https://saml.client.sandbox.togahub.com',
50
+ 'production' => 'https://saml.togahub.com'
51
+ ];
52
+ ```
53
+
54
+ This single map drives **both** the outbound AuthnRequest ACS URL **and** what `/meta` publishes
55
+ as entityID + ACS — so a wrong entry silently cross-wires two environments.
56
+
57
+ Read it via `samlHostUrl($environmentSlug)`, never by array index. It throws when the environment
58
+ has no gateway. Before this, the bare index meant **23 of the 27 `Core.Environments` rows**
59
+ produced a PHP warning plus a malformed URL instead of a clean error. The `saml` call site wraps
60
+ it in try/catch because `saml()` calls `set_exception_handler(null)` — an uncaught throw renders a
61
+ raw PHP exception page to the browser.
62
+
63
+ ### Deploy map — only THREE gateway hosts exist
64
+
65
+ 27 environments in `Core.Environments`, 5 config files in `saml/Config/`, **3 live hosts**, each in
66
+ a **different AWS account**:
67
+
68
+ | Host | EB environment | AWS account | Region | Branch (CodePipeline) |
69
+ |---|---|---|---|---|
70
+ | `saml.togahub.com` | `saml-production` | 654654170868 | us-east-1 | `_production` |
71
+ | `saml.beta.togahub.com` | `true-saml-beta` | 502614707982 | us-west-2 | `_beta` |
72
+ | `saml.client.sandbox.togahub.com` | `saml-sandbox-client` | 975050298201 | us-east-1 | `_sandbox-client` |
73
+
74
+ - A 4th pipeline, `SAML-Production` in account 502614707982, deploys to `true-saml-beta`'s sibling
75
+ `true-saml-production` — **that EB environment does not exist**. The pipeline is dead. It is also
76
+ the only pipeline that pulls `_underscore` through CodePipeline; the three live hosts get
77
+ `_underscore` at deploy time via `.ebextensions/git.json`.
78
+ - `true-saml-beta` was **9 commits behind** as of 2026-09-25 (last deploy 2026-09-04). Beta is not
79
+ kept current automatically — check before you debug a beta-only SSO problem.
80
+ - Beta account 502614707982 has **no `toga/ssl/*` secrets and no cross-account grant**, so beta
81
+ `/acs` will fail `Code = 5` the moment it is used. See the cert-rotation workflow's
82
+ cross-account section.
83
+
84
+ ### `safeRedirectUrl()` — structure validation, not an allowlist
85
+
86
+ ```php
87
+ public static function safeRedirectUrl(string $url): ?string
88
+ ```
89
+
90
+ Returns a **rebuilt** `scheme://host[:port][/path]`, or `null`. It rejects userinfo, query,
91
+ fragment, non-`http(s)` schemes, malformed hosts, and path traversal. It **preserves a plain path**
92
+ because `https://tools.togatech.com/sso` is the only `Core.Domains` row with a path — and it is an
93
+ SSO domain.
94
+
95
+ **Redirect to the return value, never to the argument.** Checking the host and then appending
96
+ `'?saml='` to the *stored* string lets a stored path/query steal the handoff. Verified: a stored
97
+ value of `https://app.togahub.com/redirect?next=https://evil.net&x=` passes a host check and
98
+ forwards the payload.
99
+
100
+ ### Domain ownership is the authorization
101
+
102
+ `singleSignOnServiceUrl()` takes `$domainUuid` straight from the query string and makes it the
103
+ RelayState redirect target. Nothing verified it belonged to the same client/environment. Three
104
+ guards now throw `_Exception_Validation` (HTTP 400 — **not** base `Exception`, which is a 500 plus
105
+ a developer alert):
106
+
107
+ 1. unknown domain,
108
+ 2. domain not owned by this `ClientAuthentication`'s client + environment,
109
+ 3. domain fails `safeRedirectUrl()`.
110
+
111
+ **There is deliberately no host allowlist — do not add one.** Two attempts were built and rejected:
112
+
113
+ - A hardcoded suffix allowlist would have **blocked 24 of the 48 live SSO domains**, including
114
+ Rate's production SSO (`hometechsupport.rate.com`) and every `agilantsolutions.com` internal tool.
115
+ - Moving the list to a `Core.Parameters` row (`SAML_REDIRECT_ALLOWED_HOSTS`) was rejected too — it
116
+ is the same list, one indirection away, and it duplicates authorization that `Core.Domains`
117
+ already holds.
118
+
119
+ **Final design:** `safeRedirectUrl()` validates URL *structure* only. The existence of a row in
120
+ `Core.Domains`, owned by the right client and environment, **is** the authorization. Net diff fell
121
+ 165 → 98 → 63 lines.
122
+
123
+ **Tradeoff accepted, and it is real:** write access to `Core.Domains` equals control of where a
124
+ login payload lands. `Core.Domains.domain` is a plain `FIELD_CHAR` with no validation and no
125
+ `isActive` column, and it already holds `http://localhost`, two ngrok URLs, and a malformed
126
+ `'https://\040store.gamma.togacommerce.com'`. See the Client_True "Base" role gotcha below.
127
+
128
+ ### RelayState TTL
129
+
130
+ The replay check was written backwards:
131
+
132
+ ```php
133
+ if ($relayState->time <= (time() + self::MAX_TIME_TO_LIVE)) // true for EVERY past timestamp
134
+ ```
135
+
136
+ A captured RelayState never expired. Now:
137
+
138
+ ```php
139
+ if (abs(time() - $relayState->time) <= self::MAX_TIME_TO_LIVE)
140
+ ```
141
+
142
+ **This alone does not stop replay** — RelayState is encrypted but not authenticated, so the `time`
143
+ field can be rewritten without the key. See [ACS assertion trust gaps](./acs-assertion-trust-gaps.md).
144
+
145
+ ## Gotchas
146
+
147
+ - **⚠ `Core.Domains` write access = redirect control, and in `Client_True` almost everyone has it.**
148
+ `Core.Records` id 1 exposes `Domains` as REST route `domains`, and `AclRecordPermissions` grants
149
+ Super User (`coreRoleId` 3) full CRUD. In `Client_True` the role **named** "Base" maps to
150
+ `coreRoleId` 3, so roughly **2,826 active users** can `POST`/`PUT` `/domains`. `Client_Compass`
151
+ maps its "Base" to `coreRoleId` 1 (Public) and keeps a separate SuperUser role with 115 people —
152
+ so this looks like a `Client_True` misconfiguration, not the intended model. Needs its own ticket.
153
+ - **A config file in `saml/Config/` does not mean a gateway exists.** 5 configs, 3 hosts.
154
+ - **Credentials are committed in this repo.** `saml/Config/production.ini` (DB password, AWS access
155
+ key, NetSuite creds) and `.ebextensions/git.json` (a GitHub PAT). Documented in the architecture
156
+ doc's gotchas; treat all of them as compromised until rotated.
157
+
158
+ ## Change history
159
+ - 2026-09-25 — Added `samlHostUrl()` (throws instead of a bare index that broke 23 of 27 environments), fixed beta's ACS host pointing at production, added `safeRedirectUrl()` closing the `/acs` open redirect, fixed the backwards RelayState TTL check, added `$domainUuid` ownership guards; recorded the deploy map and the decision to use `Core.Domains` ownership instead of an allowlist (rgirish)
@@ -6,8 +6,8 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-22
10
- owners: [tcox, apeterson]
9
+ updated: 2026-09-25
10
+ owners: [tcox, apeterson, rgirish]
11
11
  files:
12
12
  - toga2-commerce/src/lib/session.ts
13
13
  - toga2-commerce/src/hooks/useAuthenticationFlow.ts
@@ -24,6 +24,8 @@ related:
24
24
  - ../architecture.md
25
25
  - ../../toga25-supply/features/sso-redirect-and-session-gating.md
26
26
  - ../../../standards/frontend.md
27
+ - ../../../clients/compass-usa/features/saml-sso.md
28
+ - ../../saml/features/gateway-environments-and-redirect-safety.md
27
29
  ---
28
30
 
29
31
  How commerce decides whether a visitor is signed in, shows the SSO panel or the credential form, and keeps a token refresh bound to one identity. Open for "blank page at /", "SSO never fires", intermittent forced logout, or cross-user residue.
@@ -73,6 +75,7 @@ api2 **rotates** the refresh token. Concurrent 401s each spent a token the other
73
75
  - **⚠ Clearing a persisted key without resetting the store in memory does nothing** — the next `set()` writes the old user's state straight back.
74
76
  - **⚠ `zu-cart-store` is not a real key.** The cart persists under **`zu-cart`** (`src/stores/useCartStoreZu.ts`). The wrong name in the clear list left the previous user's cart alive through a 401 logout.
75
77
  - **⚠ Open — Compass Canada SSO is broken in production.** `https://compasscanada.togacommerce.com/home` shows the credential form though the client is SSO; the lookup returns no match. Candidates in order: no `Domains` row matching the exact `https://compasscanada.togacommerce.com` string; no `ClientAuthentications` row for that clientId+environmentId; `type` not the literal `SSO`; or the PUBLIC role lacking `AclFieldPermissions` to read `ClientAuthentications.type` (the call runs pre-login on the public token, so a null field fails silently). Diagnostic SQL handed over, **not yet run**. The production build is plain `vite build`, so `MODE` is `production` and the query asks for `Environments.slug` `PRODUCTION`.
78
+ - **⚠ `/domains` is cached — `Core.Records` id 1 carries `ttlCache = 15`.** After inserting or changing `Domains` / `ClientAuthentications` rows, the API can keep serving the old "no SSO" answer, so an immediate retest looks like the rows did not take. Suspected cause of Compass Canada SSO still showing `/login` on sandbox-client on 2026-09-25 after the rows were confirmed inserted.
76
79
  - **⚠ Open — toga-blox has the same public-token bug.** `toga-blox-npm/src/api/auth.ts` `fetchPublicToken` writes the anonymous token to `accessToken`; toga25-supply works around it in three places instead of fixing it. Needs its own ticket.
77
80
  - **⚠ Open — the SSO URL's host is not constrained** to the tenant's configured IdP domain (scheme only). No allowlist source exists today.
78
81
  - **⚠ Open — `AuthContext` still monkey-patches `window.history` `pushState`/`replaceState`.** Sentry patches history too, so the cleanup can clobber it; supply's poll-on-interval + focus is the replacement. Also `login()` still swallows errors after tokens are written.
@@ -83,5 +86,6 @@ api2 **rotates** the refresh token. Concurrent 401s each spent a token the other
83
86
  - **No per-session key prefix, and tokens stay in `localStorage`.** Renaming every zustand `persist` name silently orphans existing users' state on deploy and touches every call site; supply's cheaper answer is plain `localStorage.clear()` on logout since every key commerce writes is session state. httpOnly cookies would need backend work plus CSRF protection that does not exist — api2 is Bearer-only. Matches `2.0/standards/frontend.md` §12.
84
87
 
85
88
  ## Change history
89
+ - 2026-09-25 — Recorded that `GET /domains` is cached (`Core.Records` id 1, `ttlCache = 15`), the likely reason freshly inserted SSO rows still fall through to `/login` (rgirish)
86
90
  - 2026-09-22 — Public token moved out of `localStorage`; `hasSession()` + identity epoch + `AUTH_STORAGE_KEYS` added; SSO sign-in screen replaces the auto IdP jump; open-redirect and SSO-URL-scheme holes closed; single-flight refresh. TRUE-81991 (`e546db3d`). (apeterson)
87
91
  - 2026-09-21 — Blank page at `/` fixed: gate on stored `user.uuid`, `AuthRedirect` renders the spinner instead of `null`. Branch `TRUE-82172` (`1eebdd96`, `3dc53d9a`), **not merged or deployed**. (tcox)
@@ -30,6 +30,7 @@
30
30
  | [Error-Issue Auto-Resolution & Reopen (frequency-decay lifecycle)](features/error-issue-auto-resolution.md) | Issue-level OPEN→RESOLVED lifecycle on the escalation cron: auto-resolves an Issue after events decay to zero for a baseline-proportional quiet duration, auto-r |
31
31
  | [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding: ingest an item catalog from an S3 CSV and enrich each item from the Etilize datasheet, writing directly to `DB_CLIENT`; open |
32
32
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | Imports non-English item + feature text from Etilize into the client's `ItemTranslations` (and feature sidecar tables), writing directly to `DB_CLIENT`; open wh |
33
+ | [Logs retention cleanup worker (Infrastructure/Logs/Cleanup — Logs_<Tenant>.Api pruning)](features/logs-retention-cleanup.md) | Daily batched pruning of `Logs_<Tenant>.Api` (GET 30d, other methods 90d); open when changing log retention, adding a table to prune, or when an old Api log row |
33
34
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | DB-driven monitoring framework: one orchestrator applies anti-flap + notification rules to any registered "is X healthy?" child check; open when adding a monito |
34
35
  | [Bidirectional NetSuite ↔ Forecast ↔ ClickUp opportunity stage sync — BUILT (stage map, echo guard, claim split)](features/netsuite-clickup-opportunity-bidirectional-design.md) | Two-way NetSuite↔ClickUp opportunity STAGE sync (shared `_Component_Opportunity_StageMap`, actor echo guard, claim split, stale-claim takeover); open before tou |
35
36
  | [NetSuite Integrations Monitor (Monitor/Operations/NetsuiteIntegrations)](features/netsuite-integrations-monitor.md) | Cross-client health check that flags stuck NetSuite↔2.0 integrations; open when a NetSuite integration hangs or when building an all-client OneUptime sweep. |
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: [jcardinal, ajean]
9
+ updated: 2026-09-25
10
+ owners: [jcardinal, ajean, rgirish]
11
11
  files:
12
12
  - worker2/Controller/Index.php
13
13
  - worker2/Worker/
@@ -102,7 +102,9 @@ Split the action on `/`: last segment = method; everything before = class segmen
102
102
 
103
103
  ## Cleanup cron
104
104
 
105
- `_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days (≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
105
+ - `_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days (≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
106
+ - `Infrastructure/Email/Cleanup`: `Email` / `EmailAttachment` >90 days, 03:00 daily (prod CronJobs id 36), one unbatched multi-table DELETE.
107
+ - `Infrastructure/Logs/Cleanup`: batched pruning of every `Logs_<Tenant>.Api` (GET 30d, writes 90d), 04:00 CT. Audit-trail tables are not pruned. **Not deployed yet (TRUE-81902).** See [Logs retention cleanup](features/logs-retention-cleanup.md).
106
108
 
107
109
  ## Cron actions must return a result on BOTH paths
108
110
 
@@ -0,0 +1,84 @@
1
+ ---
2
+ title: Logs retention cleanup worker (Infrastructure/Logs/Cleanup — Logs_<Tenant>.Api pruning)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: draft
9
+ updated: 2026-09-25
10
+ owners: ["rgirish"]
11
+ files:
12
+ - worker2/Worker/Infrastructure/Logs/Cleanup.php
13
+ - dbchanges2/Core/2026-09-11a - CronJobs_LogsCleanup_insert.sql
14
+ related:
15
+ - ../architecture.md
16
+ - ./platform-cache-cleanup.md
17
+ - ../../api2/features/request-logging.md
18
+ - ../../_underscore/features/record-change-audit-log.md
19
+ ---
20
+
21
+ Daily batched pruning of `Logs_<Tenant>.Api` (GET 30d, other methods 90d); open when changing log retention, adding a table to prune, or when an old Api log row is missing.
22
+
23
+ ## Summary
24
+
25
+ `_Worker_Infrastructure_Logs_Cleanup::Run(int $maxAgeDays = 90, int $getMaxAgeDays = 30)`, action
26
+ `Infrastructure/Logs/Cleanup`. Deletes old rows from **every** `Logs_<Tenant>.Api`:
27
+
28
+ | Rows | Kept for |
29
+ |---|---|
30
+ | `method = GET` | 30 days (`getMaxAgeDays`) |
31
+ | POST / PUT / PATCH / DELETE | 90 days (`maxAgeDays`) |
32
+
33
+ **Status (2026-09-25): branch `TRUE-81902`, not merged or deployed.** The CronJobs row is not in prod
34
+ or dev-sandbox `Core.CronJobs` yet. Why: `Logs.Api` is ~96% of platform storage (1,644 GB of the
35
+ 1,705 GB logs cluster). GETs are read noise; writes are kept longer for troubleshooting.
36
+
37
+ **Not pruned (decided):**
38
+ - `Record` / `RecordField` / `CustomRecordField` (audit trail) — kept until an archive policy exists.
39
+ - `Email` / `EmailAttachment` — stay with the existing `Infrastructure/Email/Cleanup` job (prod
40
+ CronJobs id 36, 03:00 daily, 90 days, one unbatched multi-table DELETE). Not folded into this job
41
+ on purpose.
42
+
43
+ This replaces `LOG-RETENTION-90-DAY-PLAN.md`'s "90 days for every table, audit trail included".
44
+
45
+ ## How it works
46
+
47
+ 1. **Cron** (`dbchanges2/Core/2026-09-11a - CronJobs_LogsCleanup_insert.sql`): daily 04:00 CT,
48
+ `maxExecutionTime` 18000, parameters `{"maxAgeDays": 90, "getMaxAgeDays": 30}`, `isActive=1`,
49
+ guarded by `NOT EXISTS` on `action` so a re-run inserts nothing.
50
+ 2. **Cutoffs are computed once per run**, then applied to each `Logs_<Tenant>` DB. The database name
51
+ is checked with a regex before it goes into SQL.
52
+ 3. **Table-driven.** The `TABLES` const holds rows of
53
+ `[table, parentTable, parentKey, dateColumn, method, age-param-name]`. To prune another table,
54
+ add a row — no new code.
55
+ - **Children before parents** (FKs have no cascade). If a child hits the batch cap, its parent is
56
+ **deferred** to the next run.
57
+ - The **all-methods Api row runs before the GET row**, so the GET pass does not rescan rows the
58
+ 90-day pass would delete anyway.
59
+ 4. **Batched delete:** `DELETE ... LIMIT 5000`, **no `ORDER BY`**, up to `MAX_BATCHES_PER_TABLE`
60
+ (2000) batches per table, 100 ms sleep between batches.
61
+ 5. **Returns a JSON summary** to `WorkerJobs.output`: cutoffs, deleted rows per db/label,
62
+ `truncated`, `deferred` — per the
63
+ [return-on-both-paths rule](../architecture.md#cron-actions-must-return-a-result-on-both-paths).
64
+
65
+ ## Gotchas
66
+
67
+ - **No `ORDER BY id` on the batched DELETE** — it forces a filesort on a huge table.
68
+ - **Watch item:** the GET pass re-scans 30–90-day non-GET rows on every batch via `Api_dtStamp_IDX`.
69
+ If GET-pass time grows or `truncated` shows up in the output, add a composite `(method, dtStamp)`
70
+ index on `Api` via `dbchanges2/Logs_Client/`. Not needed now.
71
+ - **A missing Api log row older than 30 days (GET) / 90 days (write) is not proof the request never
72
+ happened** — once deployed, it was pruned. See [request logging](../../api2/features/request-logging.md).
73
+ - **Scheduling is a dbchanges2 change** — the job does not run in an environment until the CronJobs
74
+ SQL is applied there.
75
+
76
+ ## Change history
77
+ - 2026-09-25 — Created: Api-only pruning (GET 30d / others 90d), audit trail and Email left out; on branch TRUE-81902, not deployed (rgirish)
78
+
79
+ ## Related
80
+
81
+ - [Worker (worker2) Architecture](../architecture.md)
82
+ - [Platform Cache Cleanup](./platform-cache-cleanup.md)
83
+ - [V2 request logging](../../api2/features/request-logging.md)
84
+ - [Record change audit log](../../_underscore/features/record-change-audit-log.md)
@@ -6,8 +6,8 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["jcardinal", "mhammontree", "bala"]
9
+ updated: 2026-09-25
10
+ owners: ["jcardinal", "mhammontree", "bala", "rgirish"]
11
11
  files:
12
12
  - worker2/Worker/Monitor/Compass.php
13
13
  - worker2/Worker/Client/Compass.php
@@ -31,6 +31,7 @@ related:
31
31
  - ../../../1.0/apps/worker/features/oneuptime-worker-uptime-monitoring.md
32
32
  - ../../../clients/staples/features/oneuptime-sftp-folder-monitors.md
33
33
  - ../../../clients/compass-usa/features/oneuptime-ma-refresh-order-monitor.md
34
+ - ../../../clients/compass-usa/features/oneuptime-netsuite-duplicate-sales-orders-monitor.md
34
35
  ---
35
36
 
36
37
  OneUptime push-metric monitor pattern for worker2 (2.0): a self-contained cron action decides pass/fail and POSTs a string token to a OneUptime Incoming Request monitor; open when building/tuning a worker2 monitor, its thresholds, or the OneUptime import JSON.
@@ -41,8 +42,8 @@ A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, p
41
42
  `App_SystemMonitor_Compass` monitors. A worker2 cron action runs a self-contained check, decides
42
43
  pass/fail itself, and POSTs a JSON metric body to a OneUptime "Incoming Request" monitor — replacing
43
44
  the 1.0 tier's email alerts. The class is `abstract class _Worker_Monitor_Compass` and now carries a
44
- **suite of 11 monitors** covering the Compass USA / Office Depot integration (S3, DB, and mailbox
45
- checks). Designed to be **client-agnostic** — a reusable template for future monitors and clients.
45
+ **suite of 12 monitors** covering the Compass USA / Office Depot integration (S3, DB, mailbox, and
46
+ NetSuite SuiteQL checks). Designed to be **client-agnostic** — a reusable template for future monitors and clients.
46
47
 
47
48
  Distinct from the DB-driven email-orchestrator [Monitoring Framework](./monitoring-framework.md)
48
49
  (`_Worker_Monitor` orchestrator + `_Worker_Monitors_<Name>` children + `Core.Monitors` table, sends
@@ -69,7 +70,7 @@ disallows `const` at function scope), plus a `$push` closure using `_ApiRequest`
69
70
  method, so the class scales cleanly as monitors accumulate. The metric POST runs with **logging
70
71
  disabled** and **`throwExceptionsOnFailure` disabled**, so a failed ping never fails the worker job.
71
72
 
72
- ### The 11 monitors
73
+ ### The 12 monitors
73
74
 
74
75
  Each measures one Compass/Office Depot integration metric and pushes a decided alarm token.
75
76
 
@@ -126,6 +127,11 @@ Each measures one Compass/Office Depot integration metric and pushes a decided a
126
127
  'MA%'`), so this monitor exists to close that carve-out. Status-page name *"MA Employee Refresh Orders
127
128
  (Office Depot)"*. DEGRADED >10 waiting / >32h oldest, HIGH >20 / >72h. Full derivation in
128
129
  [Compass: MA refresh-order monitor](../../../clients/compass-usa/features/oneuptime-ma-refresh-order-monitor.md).
130
+ - **NetsuiteDuplicateSalesOrders** (NetSuite SuiteQL, added 2026-09-25) — Office Depot POs with more than
131
+ one **open** NetSuite SO in the last 7 days. `Core.CronJobs` id 80, `*/15`. **First monitor that queries
132
+ NetSuite instead of a Toga DB** (via `_Component_Api_Netsuite::send()`, `QUERY_SUITEQL`), because the
133
+ duplicates exist only in NetSuite. Details:
134
+ [Compass: NetSuite duplicate SO monitor](../../../clients/compass-usa/features/oneuptime-netsuite-duplicate-sales-orders-monitor.md).
129
135
 
130
136
  ### Payload contract & OneUptime criteria (critical — OneUptime cannot compare numbers)
131
137
 
@@ -551,3 +557,6 @@ production deploy.**
551
557
  - [Monitoring Framework](./monitoring-framework.md) — the parallel DB-driven, email-alert monitoring pattern
552
558
  - [Cloud S3 helpers](../../_underscore/features/cloud-s3-helpers.md) — `_Cloud::getS3Objects()` used to list the EDI bucket
553
559
  - [OneUptime 1.0 worker uptime monitoring](../../../1.0/apps/worker/features/oneuptime-worker-uptime-monitoring.md) — the 1.0 push-heartbeat predecessor
560
+
561
+ ## Change history
562
+ - 2026-09-25 — Added `NetsuiteDuplicateSalesOrders`, the first monitor that queries NetSuite (SuiteQL) instead of a Toga DB. (rgirish)
@@ -20,11 +20,11 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
20
20
  ## 2.0 framework
21
21
 
22
22
  - **_underscore** (_Underscore) _(framework core)_ — 90 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
23
- - **worker2** (Worker) — 78 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
+ - **worker2** (Worker) — 80 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
24
24
  - **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
25
25
  - **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
26
26
  - **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
27
- - **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
27
+ - **saml** (SAML SSO Gateway) — 9 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
28
28
  - **apiproxy** (API Proxy) — 1 doc(s) → [2.0/apps/apiproxy/INDEX.md](2.0/apps/apiproxy/INDEX.md)
29
29
  - **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
30
30
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
@@ -2,6 +2,7 @@
2
2
  title: Compass Canada
3
3
  framework: "2.0"
4
4
  apps:
5
+ - saml
5
6
  - _underscore
6
7
  - api2
7
8
  - toga2-supply
@@ -17,8 +18,8 @@ project: _Underscore
17
18
  client: compass-canada
18
19
  type: profile
19
20
  status: active
20
- updated: 2026-09-23
21
- owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree]
21
+ updated: 2026-09-25
22
+ owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree, rgirish]
22
23
  files: []
23
24
  related:
24
25
  - ../compass-usa/profile.md
@@ -37,6 +38,7 @@ related:
37
38
  - features/item-fulfillment-tableviews.md
38
39
  - ../../2.0/apps/toga2-commerce/features/expedited-shipping-gating.md
39
40
  - ../../2.0/apps/toga2-commerce/features/sso-redirect-and-session-gating.md
41
+ - ../compass-usa/features/saml-sso.md
40
42
  - ../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
41
43
  - ../../2.0/apps/_underscore/features/surface-resolver.md
42
44
  ---
@@ -20,11 +20,13 @@
20
20
  | [Compass ODP EDI File Retention & Import Retry (cron 3a)](features/odp-edi-file-retention-and-retry.md) | 1.0 | When cron 3a keeps vs deletes an ODP EDI file in S3, and why an api2 outage used to silently lose orders; open for lost ODP imports, retry behaviour, or the sti |
21
21
  | [Compass: ODP / Strategic Systems ASN CSV email import (and the scientific-notation tracking-number loss)](features/odp-strategic-systems-asn-csv-import.md) | 1.0 | The 1.0 worker cron that reads Office Depot / Strategic Systems ASN CSVs from email, why it routes by column count, and why a tracking number can arrive as `8.7 |
22
22
  | [Compass: MA refresh-order monitor (Monitor/Compass/MaRefreshOrderExceptions)](features/oneuptime-ma-refresh-order-monitor.md) | 2.0 | worker2 OneUptime push monitor counting Compass MA employee-refresh orders whose ODP SO has no NetSuite order yet; open when working on Compass MA monitoring or |
23
+ | [Compass: NetSuite duplicate sales-order monitor (Monitor/Compass/NetsuiteDuplicateSalesOrders)](features/oneuptime-netsuite-duplicate-sales-orders-monitor.md) | 2.0 | worker2 OneUptime push monitor that queries NetSuite (SuiteQL) for Office Depot POs with more than one open NetSuite sales order; open when working on Compass d |
23
24
  | [Compass USA — Fulfilled vs Partially Fulfilled (order total kept; per-line rule built then rejected)](features/order-fulfillment-status-per-line.md) | 2.0 | Why Compass USA shows Fulfilled on partly-shipped orders and why the per-line fix was built, measured, then reverted; open before touching Compass USA `_status` |
24
25
  | [People File Import admin page (tools /compass/people-file-import → worker2)](features/people-file-import-admin-page.md) | 1.0 | Browser page that lists the Compass PEOPLE files on SFTP and runs the importer on demand; open before changing the page or the importer's listing/guard code. |
25
26
  | [Compass PEOPLE-File User Lifecycle (duplicate accounts, reactivation grace window, raw-SQL deactivation)](features/people-file-user-lifecycle.md) | 2.0 | The nightly Compass PEOPLE-file cron's Users-row lifecycle (insert/reactivate/deactivate), why it creates duplicate accounts, and its raw-SQL/index traps; open |
26
27
  | [Persona Model & Levy-Sector Gating (worker2 PEOPLE cron)](features/persona-model-and-levy-gating.md) | 2.0 | Compass USA catalogue visibility is driven by **personas** in `Client_Compass`. |
27
28
  | [Compass Sales-Order Line-Number Renumbering (and why it hangs off the SO hooks)](features/sales-order-line-renumbering.md) | 2.0 | How Compass keeps SO lines numbered 1..N with no gaps, why the renumber rides the parent SO hooks, and the two-phase SQL that avoids a duplicate-key fatal; open |
29
+ | [Compass SAML SSO (Entra) — identity mapping, the @togatech.com bypass, and per-environment enablement](features/saml-sso.md) | 2.0 | How Compass users are matched from an Entra SAML assertion, the hardcoded `@togatech.com` test bypass that is live in production, and what it takes to turn SSO |
28
30
  | [Stranded Approval Reassignment (repointing approvals off dead duplicate Compass Users rows)](features/stranded-approval-reassignment.md) | 2.0 | The engine that repoints approvals stranded on dead duplicate Compass Users rows onto the live account; open when approvals are stuck on a re-hired employee's o |
29
31
  | [Compass: where a `TrackingNumbers` row comes from (invoice sync, NOT the ASN) and how it reaches a customer](features/tracking-number-origin-and-asn-link.md) | 1.0 | Why a Compass TrackingNumbers row is born from the invoice sync (not the ASN) and why `dtCreated` is the wrong clock for any shipping notification/SLA; open bef |
30
32
  | [Compass USA](profile.md) | 2.0 | Compass USA client profile: platforms, vendors/integrations, and the key features/traps across its 1.0+2.0 supply-chain commerce stack; open first when starting |
@@ -0,0 +1,66 @@
1
+ ---
2
+ title: "Compass: NetSuite duplicate sales-order monitor (Monitor/Compass/NetsuiteDuplicateSalesOrders)"
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["rgirish"]
11
+ files:
12
+ - worker2/Worker/Monitor/Compass.php
13
+ - worker/crons/toga2/compass/workflow/5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php
14
+ - dbchanges2/Core/2026-09-25b - Compass NetSuite Duplicate Sales Orders Monitor CronJob.sql
15
+ related:
16
+ - ../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md
17
+ - ../workflows/odp-order-pipeline-to-netsuite.md
18
+ - ../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md
19
+ - ./oneuptime-ma-refresh-order-monitor.md
20
+ - ../profile.md
21
+ ---
22
+ worker2 OneUptime push monitor that queries NetSuite (SuiteQL) for Office Depot POs with more than one open NetSuite sales order; open when working on Compass duplicate NetSuite SOs, cron 5 stamp failures, or any worker2 monitor that must query NetSuite.
23
+
24
+ ## Summary
25
+ `Monitor/Compass/NetsuiteDuplicateSalesOrders` (`_Worker_Monitor_Compass::NetsuiteDuplicateSalesOrders()`) alarms when any Office Depot PO has more than one **open** NetSuite SO. It follows the shared [OneUptime push-metric pattern](../../../2.0/apps/worker2/features/oneuptime-worker2-monitoring.md) — read that for payload contract and provisioning.
26
+
27
+ - **First worker2 OneUptime monitor that queries NetSuite, not a Toga DB.** Cron 5 duplicates exist ONLY in NetSuite (`Client_Compass` has one SO row), so a DB monitor cannot see them.
28
+ - It is the backstop for cron 5's unconfirmed forked stamp (see [pipeline gotcha](../workflows/odp-order-pipeline-to-netsuite.md#gotchas)).
29
+ - `Core.CronJobs` **id 80**, `*/15 * * * *`, `maxExecutionTime` 120, `parameters` NULL. Seed: `dbchanges2/Core/2026-09-25b - Compass NetSuite Duplicate Sales Orders Monitor CronJob.sql`. Run time ~6 s.
30
+ - Status page: Compass group, suggested name **"Office Depot Duplicate Orders in NetSuite"**.
31
+ - Push URL = in-method `$ONEUPTIME_URL` local (credential; never record the value).
32
+
33
+ ## How it works
34
+
35
+ ### Query (SuiteQL via `_Component_Api_Netsuite::send()`)
36
+ POST `QUERY_SUITEQL` with header `Prefer: transient`:
37
+ - `transaction.type = 'SalesOrd'`, `entity = 35581` ("ODP Veyer (B2B)" — cron 5 raises every SO on it).
38
+ - `otherrefnum LIKE '________-____%'` (ODP PO shape).
39
+ - `createddate >= SYSDATE - 7`.
40
+ - `GROUP BY otherrefnum HAVING COUNT(*) > 1`; per group count **open** = status not `'H'` (Closed) and not voided.
41
+
42
+ Coverage: cron 5 is the only automated NetSuite SO creator for Compass. worker2 `Netsuite/SalesOrder/CreateNetSuite` is not used for Compass; `worker/crons/toga2/compass/edi/2_create_so_netsuite.php` is unscheduled dead code. MA orders are hand-keyed on the same entity.
43
+
44
+ ### Payload + alarm
45
+ - `alarm:"HIGH"` when any PO has `openSalesOrderCount > 1` (even one).
46
+ - Groups whose extras are all Closed count as `cleanedDuplicateCount` (info only), so the alarm clears once ops closes the extra NetSuite SOs.
47
+ - Pushes `offendingPurchaseOrders` (comma list) + `duplicates[]` (max 20).
48
+ - SuiteQL failure → `status:"error"`, `error:"netsuite_query_failed"`; message capped at 500 chars because `send()` embeds the raw NetSuite body in its exception.
49
+
50
+ ### OneUptime criteria
51
+ | Criterion | Result |
52
+ |---|---|
53
+ | Body contains `"alarm":"HIGH"` | Same status as sibling Compass Sales Order Transmissions HIGH (orange/Degraded; status id copied from that monitor's export) + incident titled `{{requestBody.offendingPurchaseOrders}}` |
54
+ | Body contains `"status":"error"` | Offline + incident |
55
+ | Received in 16 min + `"alarm":"OK"` | Online |
56
+ | Not received in 35 min | Offline (imported `isEnabled:false`; enable after first push) |
57
+
58
+ No 20-min Degraded criterion — the source export had no Degraded status id. Import JSON was generated from a real export of the sibling monitor; not committed (import artifact).
59
+
60
+ ## Gotchas
61
+ - **⚠ The ODP-PO-shape filter is required.** Hand-keyed MA orders on entity 35581 carry junk `otherrefnum` values that repeat on purpose (`TBD` x7, a person's name x5 in Aug 2026). Without the filter they false-alarm.
62
+ - **Cron 5 joins several ODP POs into one `otherrefnum` with `" + "`** (e.g. `42019207-5125 + 42019108-5125`). Such a row still matches the shape filter and groups on the full joined string.
63
+ - **HIGH path not yet exercised end to end** (as of 2026-09-25). Only the OK path is proven in prod.
64
+
65
+ ## Change history
66
+ - 2026-09-25 — Created and deployed to prod; verified cronJobId 80 runs 11:45–12:45 all `isSuccess=1` ("0 open duplicate PO(s), 11 already cleaned in the last 7 days, alarm=OK; OneUptime push ok"). (rgirish)
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Compass SAML SSO (Entra) — identity mapping, the @togatech.com bypass, and per-environment enablement
3
+ framework: "2.0"
4
+ repo: saml
5
+ project: SAML SSO Gateway
6
+ client: compass-usa
7
+ type: client-feature
8
+ status: active
9
+ updated: 2026-09-25
10
+ owners: ["rgirish"]
11
+ files:
12
+ - _underscore/Model/Compass/ClientAuthentication.php
13
+ - saml/Controller/Index.php
14
+ related:
15
+ - ../../../2.0/apps/saml/features/downstream-integration-contract.md
16
+ - ../../../2.0/apps/saml/features/gateway-environments-and-redirect-safety.md
17
+ - ../../../2.0/apps/saml/features/acs-assertion-trust-gaps.md
18
+ - ../../compass-canada/profile.md
19
+ - ../../../2.0/apps/toga2-commerce/features/sso-redirect-and-session-gating.md
20
+ ---
21
+
22
+ How Compass users are matched from an Entra SAML assertion, the hardcoded `@togatech.com` test bypass that is live in production, and what it takes to turn SSO on for a Compass tenant in a non-production environment.
23
+
24
+ ## Summary
25
+
26
+ Compass overrides the base SSO mapper in `_Model_Compass_ClientAuthentication::getAuthenticatedSsoUser()`.
27
+ It matches users on **`c_hrEmpUsername`** from the SAML NameID — not on email, which is what the
28
+ base `_Model_True_ClientAuthentication` does.
29
+
30
+ Compass_Usa and Compass_Canada share the **same gateway and the same Entra app** per environment,
31
+ so enabling a second Compass tenant on an environment is a data change, not a code change.
32
+
33
+ ## How it works
34
+
35
+ ### Identity mapping
36
+
37
+ `_underscore/Model/Compass/ClientAuthentication.php` — the NameID is looked up against
38
+ `Users.c_hrEmpUsername`.
39
+
40
+ ### ⚠ The `@togatech.com` bypass (live in production)
41
+
42
+ ```php
43
+ if (stripos($nameId, '@togatech.com') !== false) {
44
+ $user = new _Model_Compass_User(4273);
45
+ ```
46
+
47
+ **Any NameID containing `@togatech.com` returns hardcoded user id 4273**, regardless of who logged
48
+ in. The comment in the code says "return gina's user if testing on devteam" — but there is no
49
+ environment guard, so it runs in **production** too.
50
+
51
+ Worse, the id is not stable across tenants, because 4273 is a per-client-database id:
52
+
53
+ | Tenant | User id 4273 is |
54
+ |---|---|
55
+ | `Client_Compass` (sandbox-client) | `GINA.PRINCE@COMPASS-USA.COM` |
56
+ | `Client_CompassCanada` | `mikhail.prime@icedistricthospitality.com` |
57
+
58
+ This is why a TOGa tester logging into Compass appears as someone else. It is also a production
59
+ backdoor: combined with the missing IdP signature check (see
60
+ [ACS assertion trust gaps](../../../2.0/apps/saml/features/acs-assertion-trust-gaps.md)), any
61
+ trusted IdP asserting a `@togatech.com` NameID lands on a real Compass user. Needs its own ticket;
62
+ **do not remove it casually** — confirm nobody's test flow depends on it first.
63
+
64
+ ### Enabling SSO for a Compass tenant on a non-production environment
65
+
66
+ The two rows that matter are `Core.Domains` and `Core.ClientAuthentications`. For Compass_Canada on
67
+ `sandbox-client` (2026-09-25), the `Domains` rows for
68
+ `compasscanada.client.sandbox.togacommerce.com` and `.togasupply.com` **already existed**; only the
69
+ two `ClientAuthentications` rows were missing.
70
+
71
+ - SQL: `dbchanges2/Core/2026-09-25a - CompassCanada SandboxClient SSO.sql`
72
+ - Resulting row: `ClientAuthentications` id 11, `clientId` 43, `environmentId` 24, `type` `SSO`
73
+
74
+ **Status: not yet working.** The rows are confirmed inserted in the client-sandbox `Core`, but the
75
+ frontend still redirects to `/login`. Leading suspect: `Core.Records` id 1 (`/domains`) has
76
+ `ttlCache = 15`, so the API may be serving a cached "no SSO" response. Unresolved — do not treat
77
+ Canada sandbox-client SSO as done.
78
+
79
+ ### How the frontend finds the SSO config
80
+
81
+ `toga2-commerce/src/hooks/useAuthenticationFlow.ts` (~lines 88-134) calls `GET /domains` filtered on
82
+ **both** its own origin **and** `Environments.slug` = its own Vite build `MODE`, joining
83
+ `ClientAuthentications` on `clientId` + `environmentId`. If `type == 'SSO'` it calls the scripted
84
+ API `/client-authentications/singleSignOnServiceUrl` and redirects.
85
+
86
+ **A build whose `MODE` does not match the environment slug falls through to `/login` with no
87
+ error.** That is the first thing to check on "SSO just shows the password form".
88
+
89
+ ## Gotchas
90
+
91
+ - **⚠ User id 4273 is not the same person in every Compass tenant.** Never copy a Compass user id
92
+ between `Client_Compass` and `Client_CompassCanada`.
93
+ - **The `@togatech.com` bypass has no environment guard** — it is live in production.
94
+ - **`/domains` is cached (`ttlCache = 15` on `Core.Records` id 1).** After inserting SSO rows, an
95
+ immediate frontend test can still see the old "no SSO" answer.
96
+ - **Both Compass tenants share one Entra app per environment** — so a gateway/metadata mistake on
97
+ one tenant breaks both.
98
+
99
+ ## Change history
100
+ - 2026-09-25 — Created; documented the `c_hrEmpUsername` mapper, the hardcoded `@togatech.com` → user 4273 bypass live in production, the Compass_Canada sandbox-client SSO enablement SQL (rows inserted, frontend still on `/login`, `ttlCache` suspected), and how the commerce frontend resolves an SSO config (rgirish)
@@ -2,6 +2,7 @@
2
2
  title: Compass USA
3
3
  framework: "2.0"
4
4
  apps:
5
+ - saml
5
6
  - _underscore
6
7
  - api2
7
8
  - toga2-supply
@@ -20,10 +21,11 @@ project: _Underscore
20
21
  client: compass-usa
21
22
  type: profile
22
23
  status: active
23
- updated: 2026-09-22
24
+ updated: 2026-09-25
24
25
  owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean, rgirish]
25
26
  files: []
26
27
  related:
28
+ - features/saml-sso.md
27
29
  - ../../2.0/apps/_underscore/features/sales-order-po-number-sourcing.md
28
30
  - features/persona-model-and-levy-gating.md
29
31
  - features/people-file-user-lifecycle.md
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-22
9
+ updated: 2026-09-25
10
10
  owners: ["rgirish", "bala", "dfranks", "jcardinal", "mhammontree"]
11
11
  files:
12
12
  - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
@@ -30,6 +30,7 @@ related:
30
30
  - clients/compass-usa/features/mits-po-transmission-to-vendors.md
31
31
  - clients/compass-usa/features/mits-po-to-so-item-linking.md
32
32
  - clients/compass-usa/profile.md
33
+ - clients/compass-usa/features/oneuptime-netsuite-duplicate-sales-orders-monitor.md
33
34
  ---
34
35
  The end-to-end Compass ODP order → NetSuite pipeline through the numbered 1.0 worker crons, plus the "this order never reached NetSuite" diagnostic method; open for any ODP-to-NetSuite tracing or cron-mechanics question.
35
36
 
@@ -139,13 +140,17 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
139
140
  - **⚠ Do NOT expect a 30-minute wait — branch (a) is normal.** If you assume half an hour is normal, a genuinely stalled transmission looks fine for 30 minutes. An order that has all its 850s back and is still untransmitted **two `*/10` runs later** is already abnormal — go look at the bridge rows (`PurchaseOrders_SalesOrders` / `SalesOrders_PurchaseOrders`) rather than waiting. The 30-minute timeout is the exception, reserved for orders where something never comes back.
140
141
  - **⚠ The 30-minute clock runs from the Office Depot PO's `dtCreated`** — the moment we imported the 850, not when the Compass order was raised. So re-importing an old EDI file **restarts the timer**, and a recovered order waits another 30 minutes.
141
142
  - **⚠ A late 850 produces a SECOND NetSuite sales order.** If a PO's 850 arrives *after* the Compass SO has already transmitted, the new ODP SO is picked up on its own and creates a **separate** NetSuite SO rather than joining the first.
142
- - **⚠ Duplicate NetSuite SOs with NO duplicate in Toga = the dead-connection stamp race (root-caused 2026-09-22, FIXED in `worker` `afce32ca`).** Symptom: Compass PO `42031181-1127` produced **three** NetSuite SOs (290875/290876/290877), each exactly **10 minutes** apart — 15 duplicate NetSuite SOs across 11 Compass POs on Sep 21-22. **`Client_Compass.PurchaseOrders` and `SalesOrders` each had exactly ONE row.** The duplication is only in NetSuite, so counting Toga rows will tell you nothing is wrong.
143
+ - **⚠ Duplicate NetSuite SOs with NO duplicate in Toga = the dead-connection stamp race (root-caused 2026-09-22, FIXED in `worker` `afce32ca`).** Symptom: Compass PO `42031181-1127` produced **three** NetSuite SOs (290875/290876/290877), each exactly **10 minutes** apart. **`Client_Compass.PurchaseOrders` and `SalesOrders` each had exactly ONE row.** The duplication is only in NetSuite, so counting Toga rows will tell you nothing is wrong — watch it with the [NetSuite duplicate SO monitor](../features/oneuptime-netsuite-duplicate-sales-orders-monitor.md).
144
+ - **True scope (SuiteQL on prod NetSuite, 2026-09-25):** **22** Office Depot POs had 2–5 NetSuite SOs each, **2026-09-15 → 2026-09-22** (not 15 dups / 11 POs / Sep 21-22 as first recorded). Last duplicate 2026-09-22 14:53 EST; fix `afce32ca` landed 16:05 EST; zero since. Baseline 2026-08-01 → 09-14: zero ODP-PO-shaped repeats. Sep 18–22 groups are cleaned (1 open copy, rest Closed).
145
+ - **⚠ OPEN — not investigated:** 9 POs from Sep 15–17 have 2–3 copies that are **all status "Billed"** — possible double invoicing to Office Depot, not confirmed: `42000479-5910` (3), `41974840-1214` (3), `41997932-1127`, `41979931-5910`, `41977620-6869`, `41974464-1214`, `41972814-1080`, `41995223-1135`, `41994912-5910`.
143
146
  - **Mechanism.** Cron 5 holds one MySQL connection across the slow NetSuite SOAP `add()`. `wait_timeout` on the prod client cluster is **180 seconds**. A kit order's `add()` runs past that, so the connection is dead when the stamp `UPDATE SalesOrders SET c_dtTransmittedToNetsuite ...` runs (`crons/.../5_create_netsuite_sales_orders_from_office_depot_purchase_orders.php:433`). The order exists in NetSuite but is **unstamped**, so the next `*/10` run re-sends it.
144
147
  - **Proof, not inference.** `Logs.Issue` **769** (reference `W0`), *"mysqli_select_db(): MySQL server has gone away"*, first seen 2026-09-15, 33 occurrences; trace `query.php:168 → database.php:431 → cron 5 :433`. **All 15** duplicate NetSuite SOs are followed by a gone-away event 8-10 s later, zero misses; the 10 SOs that stuck have no matching error. **Logs are CDT, NetSuite is EST — apply the 1-hour offset before comparing timestamps.**
145
148
  - **Why kits.** `PC-KIT-OPT2` (10 expanded lines, all $12.58) was 10 of the 11 duplicated POs. The part number does not cause this — kits are the slow path, so they are the orders whose connection times out.
146
- - **Fix (deployed in cron 5).** (a) `App_Database::keepAlive($clientToga2DatabaseLink)` before `$service->add()` and again after it returns (outside the try/catch, guarded on `!is_null($response)`) — the pattern sibling script `3b_import_strategic_systems_advance_shipping_notices.php` already had at `:616`/`:1399`/`:1411` and cron 5 never did. (b) A `register_shutdown_function()` that emails devteam when the process dies with an order created in NetSuite but unstamped in Toga. Full mechanics: [1.0 MySQL Connection Lifecycle](../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md).
149
+ - **Fix (deployed in cron 5, `afce32ca`).** (a) The stamp runs via `App_Database::queryForked($sql, $clientToga2DatabaseLink)` in a separate process — **there is NO `keepAlive()` call** (corrected 2026-09-25; the first write-up said there was). (b) A `register_shutdown_function()` that emails devteam if the process dies with an order created in NetSuite but unstamped. Full mechanics: [1.0 MySQL Connection Lifecycle](../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md).
150
+ - **⚠ Remaining gap: the forked stamp is NOT confirmed** (the code comment says so), and the shutdown alarm is disarmed right after the fork. A failed forked write leaves the order in NetSuite, unstamped, with no alert → possible duplicate on the next run. The [NetSuite duplicate SO monitor](../features/oneuptime-netsuite-duplicate-sales-orders-monitor.md) is the backstop.
151
+ - **Decision 2026-09-25 — monitor only, no more prevention in cron 5.** Untaken option: set a NetSuite `externalId` on the SO so NetSuite rejects a second create. Declined because the script already has fixes for this.
147
152
  - **⚠ The try/catch cron 5 added on Sep 21 to prevent exactly this is DEAD CODE.** Commit `fe4d110d` wrapped the stamp in a `try`/`catch` specifically to stop duplicates. It cannot fire: 1.0 turns the "gone away" **warning** into a fatal `exit` inside the error handler, so it never unwinds to a `catch`. Evidence: 33 `Logs.Issue` 769 occurrences, **zero** matching rows in `Logs_Compass.Email`. Only the shutdown handler runs after that `exit`.
148
- - **⚠ The Sep 21 push is what made a latent bug visible.** `fe4d110d` flipped this cron from `"active": 0` to `"active": 1` in `schedules/cron.worker.sync.json`, and `85eb8784` turned it into a 450-second bounded loop (`$startTime = time() + 450`) running every 10 minutes. The stamp race had existed since 2026-09-15; the job was simply off. Two days of running produced 15 duplicates. **Before re-enabling any long-dormant cron that writes to an external system, check its stamp path.**
153
+ - **⚠ The Sep 21 push is what made a latent bug visible.** `fe4d110d` flipped this cron from `"active": 0` to `"active": 1` in `schedules/cron.worker.sync.json`, and `85eb8784` turned it into a 450-second bounded loop (`$startTime = time() + 450`) running every 10 minutes. The stamp race had existed since 2026-09-15 (9 POs duplicated Sep 15–17 even while the schedule was off); two days of scheduled running then produced most of the rest (22 POs total, see above). **Before re-enabling any long-dormant cron that writes to an external system, check its stamp path.**
149
154
  - **⚠ The ODP PO number lands in `otherRefNum`, which NetSuite global search does NOT index.** Cron 5 sets `$salesOrder->otherRefNum = substr($rowSalesOrders['PurchaseOrderNumbers'], 0, 45)` (`:194`). Typing the Office Depot PO number into NetSuite's **global search box returns nothing** — the field is populated, it is simply not searchable. That silence reads exactly like "the order never reached NetSuite," and it is not. **Open the sales order by internal id instead:** take `c_netsuiteInternalSalesOrderId` from the **Office Depot** `SalesOrders` row (`customerId = 1`). Verified 2026-08-31 on ODP POs `41849836-1079` / `41849745-1127` / `41849655-5125` → SO internal ids `7437998` / `7437898` / `7438098`.
150
155
  - **⚠ Do not infer the schedule from transmission times.** Some transmissions cluster at **:00 and :30**, which reads like a 30-minute cron — it is not. The schedule is **`*/10`** (re-verified in `worker/schedules/cron.worker.sync.json` 2026-08-28); that clustering is the timeout branch of the release gate firing on the orders that can only release that way. Orders released by branch (a) do **not** cluster — they land on whichever `*/10` run follows their last 850 (e.g. `13:20:46`).
151
156
  - **⚠ Office Depot only sends an 850 back for what they buy FROM us (confirmed with ODP 2026-08-28).** A single Compass sales order can carry **two different kinds** of Compass PO, and **both** have `vendorId` = Office Depot:
@@ -158,9 +163,11 @@ The NetSuite writeback lands on the **downstream ODP SalesOrder (customerId=1)**
158
163
  - **Editing cron recipient lists — a grep hit is not a live hit.** Email recipients are hardcoded in the cron scripts, and **several dormant copies of the same script exist** under `crons/toga2/compass/edi/` and `crons/toga2/compass/DOA_edi_odp_flow/`. Before changing a recipient list, cross-check the file against `worker/schedules/*.json` for a referencing entry whose `active` is not `0`. Only the scheduled copies matter.
159
164
 
160
165
  ## Change history
161
- - 2026-09-22 — Root-caused the duplicate NetSuite SOs (15 across 11 POs, Sep 21-22) as the dead-MySQL-connection stamp race, not a Toga duplicate; fixed cron 5 with `keepAlive` around `add()` + a `register_shutdown_function` alert (`worker` `afce32ca`). Recorded that the Sep 21 try/catch is dead code and that `fe4d110d` re-enabling the cron is what surfaced the latent bug. (rgirish)
166
+ - 2026-09-25 — Corrected the fix description (forked stamp via `queryForked`, no `keepAlive`; stamp unconfirmed); widened duplicate scope to 22 POs Sep 15–22; flagged 9 all-Billed POs as open; recorded monitor-only decision and the `NetsuiteDuplicateSalesOrders` backstop. (rgirish)
167
+ - 2026-09-22 — Root-caused the duplicate NetSuite SOs (15 across 11 POs, Sep 21-22) as the dead-MySQL-connection stamp race, not a Toga duplicate; fixed cron 5 with a forked stamp + a `register_shutdown_function` alert (`worker` `afce32ca`). Recorded that the Sep 21 try/catch is dead code and that `fe4d110d` re-enabling the cron is what surfaced the latent bug. (rgirish)
162
168
 
163
169
  ## Related
170
+ - [Compass: NetSuite duplicate SO monitor](../features/oneuptime-netsuite-duplicate-sales-orders-monitor.md)
164
171
  - [1.0 MySQL Connection Lifecycle — keepAlive / reconnect](../../../1.0/apps/library/features/db-connection-lifecycle-and-reconnect.md)
165
172
  - [Compass Order Lifecycle & Data-Integrity Invariants](order-lifecycle-and-data-integrity.md)
166
173
  - [Compass MITS PO Transmission to Vendors](../features/mits-po-transmission-to-vendors.md)
@@ -2,6 +2,7 @@
2
2
  title: "TOGA Technology"
3
3
  framework: "2.0"
4
4
  apps:
5
+ - saml
5
6
  - _underscore
6
7
  - tools
7
8
  - worker2
@@ -14,11 +15,13 @@ project: _Underscore
14
15
  client: true
15
16
  type: profile
16
17
  status: active
17
- updated: 2026-09-16
18
- owners: [jcardinal, kyalamarthi, tcox]
18
+ updated: 2026-09-25
19
+ owners: [jcardinal, kyalamarthi, tcox, rgirish]
19
20
  files: []
20
21
  related:
21
22
  - clients/true/features/users-personas-data-model.md
23
+ - ../../2.0/apps/saml/features/gateway-environments-and-redirect-safety.md
24
+ - ../../2.0/apps/_underscore/features/core-domains-app-host-registry.md
22
25
  ---
23
26
 
24
27
  "True" — TOGa's internal/staff tenant (`Client_True`, identifier `True`, default email SSO mapper); open for its internal apps (Tools, sprint dashboard, togatech site) and its Talos-pricing internal-tenant role.
@@ -37,6 +40,26 @@ The public **TOGA Technology website** (repo `togatech`, standalone React SPA, a
37
40
 
38
41
  The **Tools** app (1.0; repo `tools`) authenticates True users via **SSO** through `saml.togahub.com` and reads the `Client_True` DB **read-only** to gate its internal tooling by staff persona (see `1.0/apps/tools/`). Its gateway return domain is registered in `Core.Domains` (`uuid 2927bc15-e347-4358-a430-fb28f9446d27`, `clientId 1`, `appId 30`, env 1, `https://tools.togatech.com/sso`).
39
42
 
43
+ ## ⚠ "Base" maps to Super User in Client_True
44
+
45
+ In `Client_True` the role **named `Base`** maps to **`coreRoleId` 3 = Super User**. Every other
46
+ tenant we checked does not do this: `Client_Compass` maps its `Base` to `coreRoleId` 1 (Public) and
47
+ keeps a separate SuperUser role with 115 people. This looks like a misconfiguration, not the
48
+ intended model.
49
+
50
+ The concrete consequence found 2026-09-25: `Core.Records` id 1 exposes `Domains` as the REST route
51
+ `domains`, and `AclRecordPermissions` grants Super User full create/read/update/delete. So roughly
52
+ **2,826 active `Client_True` users can `POST`/`PUT` `/domains`.**
53
+
54
+ That matters more than it used to, because `Core.Domains` ownership is now the authorization for
55
+ where the SAML gateway delivers a login handoff — see
56
+ [SAML gateway per-environment hosts & redirect safety](../../2.0/apps/saml/features/gateway-environments-and-redirect-safety.md).
57
+ Write access to `Core.Domains` is control over where a credential payload lands. Needs its own
58
+ ticket.
59
+
60
+ **Before reading any `Client_True` ACL grant, check what `coreRoleId` the role name actually maps
61
+ to — do not trust the name.**
62
+
40
63
  ## Talos Pricing — the internal tenant
41
64
 
42
65
  True is an **internal tenant** in the Talos Pricing platform (`TalosClients.isInternal = 1`, formal name **TOGA Technology**), not a paying client. It holds nearly all Talos usage and, via `defaultTenant`, all untagged AWS Bedrock spend, so it **must** exist as a contract row or there is no platform unit price at all. It is **costed and feeds the unit price** but is **excluded from fee recommendations** — modelling it as a zero-revenue contract made margin compute as 0%, parking it permanently in "Needs attention" and dragging blended portfolio margin negative. A contract row was created and signed 2026-08-03. Measured July 2026: $549.18 tagged of a $1,013.76 total. See [talos-pricing-automation](../../2.0/apps/worker2/features/talos-pricing-automation.md).
@@ -0,0 +1,83 @@
1
+ ---
2
+ type: session
3
+ slug: netsuite-clickup-opportunity-sync
4
+ title: NetSuite <-> ClickUp opportunity sync BUILT (stages, close/reopen, notes + files) — live verification pending
5
+ author: jcardinal
6
+ repos: [worker2, _underscore, dbchanges2, test]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-09-25
11
+ updated: 2026-09-25
12
+ ---
13
+
14
+ # Session: netsuite-clickup-opportunity-sync
15
+ **Date:** 2026-09-25
16
+ **Project/Repo:** worker2 + _underscore + dbchanges2 (2.0), test (NetSuite RESTlet)
17
+ **Task:** Implemented Aaron's NetSuite <-> ClickUp opportunity sync plan in worker2 (two-way stage sync with Likely to Close, close/reopen, NS notes -> CU comments, files both ways), deployed and turned on; first live run still to be verified. Continues Aaron's 2026-09-09 session (same slug).
18
+
19
+ > Full design + rules: KB `2.0/apps/worker2/features/netsuite-clickup-opportunity-bidirectional-design.md` and `netsuite-opportunity-documents-sync.md`. Read those first.
20
+
21
+ ---
22
+
23
+ ## What WORKED
24
+ - Business rules from Aaron + Eric (2026-09-24) implemented in ONE PHP map `worker2/Component/Opportunity/StageMap/StageMap.php` (`_Component_Opportunity_StageMap`). Each entry = target + "already matching" set. Offline test `Controller/OpportunityStageMapTest.php` run via CLI runner: all cases PASS, 1413 round trips, 0 would loop. Mutation check (broke BU Discovery entry) made the test fail as expected.
25
+ - Deployed to prod by jcardinal: SQL `Forecast/2026-09-24a`, `Forecast/2026-09-24b`, `Core/2026-09-24a`; `_underscore` Netsuite.php; worker2.
26
+ - NetSuite RESTlet deployed in PROD: script `customscript_rl_opportunity_documents` (internal 2831), deploy `customdeploy_rl_opportunity_documents` (deploy=1), Released, audience Administrator, param `custscript_opp_docs_upload_folder` = 2008026 (private folder "ClickUp Opportunity Uploads"). Integration "TOGa OAuth 2.0" (runs as Jeffrey Cardinal / Administrator) already had RESTlets scope. Host 1095849.restlets.api.netsuite.com matches what worker2 derives from production.ini.
27
+ - Live dry run proved end to end: `DRY RUN: NS->CU ...; CU->NS {"tasksChecked":2}` + 8 `would copy NS file ...` + `would copy NS note 48225 "Sync test" -> task 868k1ytc5`. So token, RESTlet, file search, note search, ClickUp team search all work.
28
+ - All flags then turned ON (jcardinal said "yes to both": copy all files incl. generated quote PDFs; stage sync in same deploy): `_Worker_Netsuite_OpportunityDocuments::SYNC_ENABLED = true`, `SYNC_START = '2026-09-24 00:00:00'`, `_Worker_Netsuite_Opportunity::SYNC_STAGE_TO_CLICKUP = true`, `_Worker_Clickup_Opportunity::SYNC_CLOSE_TO_NETSUITE = true`.
29
+ - Reviews (subagents): CTO (map as PHP not tables; no ApiRequest core edit), php-reviewer x2, sql-reviewer x2, cso x2 (final: SAFE TO SHIP), framework-pattern-checker (clean), JS reviewer. All findings fixed.
30
+
31
+ ## What did NOT work — DO NOT RETRY THESE
32
+ - **Testing worker2 via `?controller=OpportunityStageMapTest` URL** — blank page. worker2 routes EVERYTHING to `_Controller_Index::worker()`; no per-URL controllers. Prove pure logic with the CLI runner (stub `_Component`/`_Controller`, require file, call `run()`), or a worker action.
33
+ - **RESTlet search on `search.Type.OPPORTUNITY` with a `mainline` filter** — `SSS_INVALID_SRCH_FILTER: An nlobjSearchFilter contains invalid search criteria: mainline.` Use `search.Type.TRANSACTION` + `["type","anyof","Opprtnty"]` + mainline T.
34
+ - **`file.created` / `usernotes.notedate` filtered with `after` + a DATETIME string** — `UNEXPECTED_ERROR: An unexpected SuiteScript error has occurred`. Use `format.Type.DATE` with `onorafter` (day granularity; ledger drops repeats).
35
+ - **Creating User Notes via NS REST record API** — `Record type 'note' does not exist` (404). Must go through the RESTlet (`record.Type.NOTE`).
36
+ - **Reading note title/text/date via SuiteQL** — `NOT_EXPOSED - Not available for channel SEARCH`. SuiteQL `file` table IS exposed; notes are not.
37
+ - **Widening the shared NetSuite OAuth scope to add `restlets`** — not done on purpose: a token request for a scope the integration doesn't allow fails ALL NetSuite calls. sendRestlet() uses its own separate token.
38
+ - **First re-upload of the RESTlet file** didn't take — the error stack had identical line numbers to the old file. When a fix "doesn't work", compare stack line numbers before debugging.
39
+ - **Hand-escaped regex `[^\s@\\]` in a single-quoted PHP string** — PHP turned `\\` into `\`, escaping the `]` (preg compile error). Use `\x5C` for backslash.
40
+ - **`INSERT IGNORE` for the ledger reserve** — hides FK/other errors (silent skip). Replaced with plain INSERT + catch, treating only an existing row as "already done".
41
+
42
+ ## Not tried yet (candidates for next session)
43
+ - Verify the FIRST LIVE RUN (cron every 5 min): ClickUp task `868k1ytc5` has comment starting `NetSuite note from ...` with "Sync test"; task `868jh6x08` has attachment `TOGA_Technology.065763.v1.18.pdf`. Read `Core.WorkerJobs` (action `Netsuite/OpportunityDocuments/Sync`) and `Forecast.OpportunityDocuments` rows.
44
+ - Live stage test: change an opportunity's Sales Stage in NS -> task should move; output `stage set: X -> Y`. Close in ClickUp -> NS Likely to Close 124/14. Reopen -> NS 10% + ClickUp comment + NS User Note.
45
+ - Unverified live: NS workflows allowing entityStatus PATCH; RESTlet `addNote`/`addFile` (never run for real yet); ClickUp `date_updated` bumping when an attachment is added (if not, CU->NS files are never found); ClickUp attachment URLs being on `*.clickup-attachments.com`; TLS verify on EB hosts.
46
+ - Confirm BU Discovery interpretation with Aaron/Eric (see Decisions).
47
+ - Separate ticket: `_ApiRequest` core default does NOT verify TLS peers (ApiRequest.php:238) — affects every caller.
48
+ - Low: 3 NS files have no extension (e.g. `email_quote_excel_cpo_...`), so ClickUp may not preview them.
49
+
50
+ ## Current file state
51
+ All changes are on the developer's checked-out branches (worker2 `_production`; _underscore + dbchanges2 `toga25-desk`; test `master`). Deployed to prod by jcardinal. Check git for commit state — do not assume.
52
+
53
+ | File | Status | Notes |
54
+ |------|--------|-------|
55
+ | worker2/Component/Opportunity/StageMap/StageMap.php | new | Shared stage map (both directions), live option resolution |
56
+ | worker2/Controller/OpportunityStageMapTest.php | new | Pure test; run via CLI runner, not URL |
57
+ | worker2/Worker/Clickup.php | modified | CU->NS routed by Opportunities folder 90117060687, no early return |
58
+ | worker2/Worker/Clickup/Opportunity.php | modified | StageMap, close/reopen (SYNC_CLOSE_TO_NETSUITE=true), actor echo guard, Forecast link check, reopen comment + NS note, DB_LOGS commits, TLS |
59
+ | worker2/Worker/Netsuite/Opportunity.php | modified | Old map constants removed; claim split; syncStageToClickup (SYNC_STAGE_TO_CLICKUP=true); stale PENDING takeover + fenced endClaim; TLS |
60
+ | worker2/Worker/Netsuite/OpportunityDocuments.php | new | Notes/files sync cron action, ledger, SYNC_ENABLED=true, SYNC_START 2026-09-24 00:00:00 |
61
+ | _underscore/Component/Api/Netsuite/Netsuite.php | modified | Additive sendRestlet() with own restlets-scope token |
62
+ | dbchanges2/Forecast/2026-09-24a - Add dtClickupClaimed to Opportunities.sql | new, applied prod | Raw-SQL-only column, not on ORM models |
63
+ | dbchanges2/Forecast/2026-09-24b - Add OpportunityDocuments ledger.sql | new, applied prod | FK ON DELETE CASCADE (1.0 reconciler deletes Opportunities rows) |
64
+ | dbchanges2/Core/2026-09-24a - Insert Opportunity Documents Sync CronJob.sql | new, applied prod | */5 cron, uuid 961e7799-2b19-4b0c-add2-b551b6f82f6e |
65
+ | test/@jeff/netsuite/opportunity-documents/rl_opportunity_documents.js | new, deployed NS | RESTlet changes/getFile/addFile/addNote |
66
+
67
+ ## Decisions made
68
+ - **Stage map stays PHP, not Forecast tables** (CTO + jcardinal). Rows can't hold the "unless already X" rules; tables would add a cross-repo deploy for 8 rarely-changing options. Rejected: OpportunityStageMap/CloseStatusMap tables from Aaron's earlier design.
69
+ - **Likely to Close checked first, always wins** (Aaron/Eric). CU close writes Likely to Close only; Sales Stage never written for a close (Closed Won/Lost stages retired in NS).
70
+ - **Reopen in ClickUp** -> NS Likely to Close 10% + mapped stage, comment on BOTH ClickUp and NS User Note (Aaron/Eric: "A" + "C").
71
+ - **BU Discovery (my interpretation, CONFIRM):** matches NS Stage 2 and 3, so PQ -> BU -> Quote writes nothing; moving BACK into BU from a later stage writes Stage 3 (id 4) so NS doesn't drag the task forward again.
72
+ - **Notes/files: go-live forward only; ClickUp comments NOT sent to NS; copy all files including generated quote versions** (jcardinal "yes").
73
+ - **Security:** CU->NS only writes when Forecast.clickupTaskId == the task (Opportunity # is free text); RESTlet getFile only serves files attached to the given opportunity; attachment download only from https *.clickup-attachments.com; blocked script/web file types; RESTlet `changes` logged as counts only.
74
+ - **Claim:** guards creation only; stale PENDING (>30 min or NULL timestamp) taken over; final write fenced by our own claim timestamp so a slow job can't overwrite the new owner.
75
+
76
+ ## Blockers
77
+ none (pending live verification only). Still open from Aaron's 2026-09-09 session: 1.0 PRs library#880, worker#1718, tools#15 unmerged — nightly reconciler still runs without the delete guard.
78
+
79
+ ## Exact next step
80
+ > Check ClickUp task `868k1ytc5` for the "Sync test" comment and task `868jh6x08` for `TOGA_Technology.065763.v1.18.pdf`, then query prod `Forecast.OpportunityDocuments` (status per row) and `Core.WorkerJobs` for action `Netsuite/OpportunityDocuments/Sync` to confirm the first real runs succeeded. If anything is wrong, set that part's flag back to false and deploy.
81
+
82
+ ---
83
+ _Saved by /session-save on 2026-09-25_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.897",
3
+ "version": "1.0.899",
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",