toga-ai 1.0.839 → 1.0.841

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: Database Changes
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [sking]
11
11
  files:
12
12
  - dbchanges/index.php
@@ -108,6 +108,7 @@ The repo ships `config.alpha/beta/demo/hotfix/prod/stage/test/worker.ini`, there
108
108
  - **A comment-only chunk stops the whole queue** (error 1065 + `exit()` on the prod path). Your malformed file blocks unrelated teammates' migrations — a team-wide failure, not a local one.
109
109
  - **No `DELIMITER`, no stored routines, no explicit `COMMIT`.** See the guard-pattern section.
110
110
  - **Error 1093** is why the preflight subquery must be wrapped in a derived table.
111
+ - **⚠ The repo does NOT describe the whole live schema — never conclude "that column/index does not exist" from `dbchanges` alone.** `Core.AdvanceShippingNoticeQueue.dedupeKey` and `.alertSentAt` exist in production (`dedupeKey` confirmed UNIQUE) but **no migration for them exists anywhere in this repo** — they were created from a `library` commit (TRUE-77676). Found 2026-09-16 because a reviewer read the repo and reached the wrong conclusion. Verify columns and indexes against the **database**.
111
112
  - **A merged PR is not an applied change.** Confirmed live 2026-08-13 (PR dbchanges#265): after the merge into `_production`, both target tables were still 100% unchanged. Never tell a customer or a ticket "applied" off a merge — always re-read the data.
112
113
  - **A cross-database change half-applies without warning.** Same incident: the `TOGaDeskSupport` file landed and the `Core` file did not, leaving `assets.tag` 87/87 correct while `Core.AdvanceShippingNoticeUnits.assetTag` was 87/87 still empty — a state that *looks* fixed in the UI. Cause is the one-credential-set / folder-is-a-database model above. Verify every database in the change, per-pair.
113
114
  - **Retry semantics are a feature**: because the data change and the `_dbchanges` row share a transaction, a failing file re-runs next cycle. Do not "help" it with a manual COMMIT.
@@ -6,7 +6,7 @@ project: TOGa Desk
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: ["jcardinal", "mhammontree"]
11
11
  files:
12
12
  - desk/includes/controllers/actions/central/
@@ -62,6 +62,10 @@ The "PREPARE SHIPMENT" button opens modal `desk/template/modals/central/addTrack
62
62
  Dispatch flows branch on hardcoded client ids (e.g. Walmart / Walmart Plus, WJE, Office Depot / Compass, AIG, RUMCSI) for department mapping, scheduling rules, and notification copy — always confirm the client arm before changing shared central logic.
63
63
 
64
64
  ## Gotchas
65
+ - **⚠ Two undocumented, UNFIXED date skews on `repair_orders` (`TOGaDeskSupport`) — do not build queries on these columns.** Found 2026-09-16 (TRUE-82027); the writing code was not traced, blast radius untraced (other crons, reports, the desk UI all read `dtUpdated`). Needs its own ticket to fix at source in togadesk.
66
+ - **`repair_orders.dtUpdated` runs exactly 240 minutes (4 h) behind** the clock used by `repair_order_notes.notesdate` and `Common.NYCDOETickets.dtSynced`. Verified across 61 orders, exactly −240 every time — likely a timezone bug in the togadesk web app.
67
+ - **`repair_orders.lastupdateddate` is 720 minutes (12 h) off on SOME rows** and correct on others — an AM/PM format bug (`h` vs `H`). Example: INC2087375 `dtUpdated` 2026-09-15 18:01:34 vs `lastupdateddate` 2026-09-15 06:01:34.
68
+ - **Do NOT "fix" a consumer with a `+240 MINUTE` offset.** 240 is almost certainly UTC-vs-Eastern and becomes 300 at the DST change on 2026-11-01, so a hardcoded offset silently breaks twice a year. The DOE outbound cron sidestepped it by removing `dtUpdated` from its selection query entirely.
65
69
  - Actions are split across two locations: the inline `actions.php` switch **and** `controllers/actions/central/*.php` (the default fallback). Check both when tracing an action.
66
70
  - Status recomputation is centralized in `updateStatus()` — write paths that bypass it can leave an order in an inconsistent state.
67
71
  - **`updateStatus()` / `qqStatus()` silently overwrite status via a raw `UPDATE`** (no `repair_order_history` / `repair_order_notes` row). `qqStatus()` (`library/app/model/togadesk/repairorder.php` ~L356) is a computed SQL `CASE` that recomputes `repair_orders.status` from technician / dispatch / ETA / parts data on every `updateStatus()` call (fired by `scheduleDispatch`, `technicianOnsite`, `technicianEnRoute`, `awaitingDispatch`, `reworkAwaitingScheduling`, and part flows). If a status is not explicitly gated inside `qqStatus()`, it will be recomputed away with no audit trail — this is **shared, client-agnostic** logic (no tenant conditional), so a change hits every repair-order client.
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-17
9
+ updated: 2026-09-18
10
10
  owners: [tcox, bala, apeterson, jcardinal, rgirish]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -364,6 +364,23 @@ successfully.
364
364
  > `group=_status` is separately known to fail. And `where` still never prunes a nested child array
365
365
  > (see below).
366
366
 
367
+ #### ⚠ `in` instead of an OR-chain of `contains` shortens the SQL - it does NOT make the filter fast
368
+
369
+ A calculated field's expression is inlined per row, so **no operator makes it indexable**. Swapping
370
+ an OR chain of `contains` for one `in` clause only shrinks the query text.
371
+
372
+ Measured on sandbox-client 2026-09-18 (TRUE-81379, Compass approvals list): after the blox `in`
373
+ serializer shipped, the request sends the single clause
374
+ `where=(_status:in:pendingApproval:pendingInitialApproval)` - and the endpoint still takes
375
+ **14.66 s** (`GET /v2/sales-orders?recordsPerPage=15&page=1&sort=-number&...`, plus joins on
376
+ Addresses and ojoins on Locations/Contacts/Users). `_status` on SalesOrders is calculated, so every
377
+ sales order (~11,000 in `pendingApproval` alone) is evaluated before paging - strongly supported,
378
+ but not yet proven against the generated SQL.
379
+
380
+ **Do not read `in` in the network tab as "the slow filter is fixed."** Making it fast means taking
381
+ the calculated field out of the filter path (a stored/indexed status column, or a pre-filtered set),
382
+ not tuning the serializer.
383
+
367
384
  ## Encoding rules (the query string is never urldecoded at parse time)
368
385
 
369
386
  - Encode a literal `%` in a LIKE pattern as **`%25`**.
@@ -540,6 +557,9 @@ If you need the behaviour, loop uuids client-side. Point 3 is the durable, non-o
540
557
  [V2 request logging](request-logging.md).
541
558
 
542
559
  ## Change history
560
+ - 2026-09-18 — Recorded that swapping an OR-chain of `contains` for a single `in` on a
561
+ calculated field only shortens the SQL text and does not speed it up: the Compass approvals list
562
+ (`_status:in:...`) still takes 14.66 s with the `in` clause live (TRUE-81379). (apeterson)
543
563
  - 2026-09-17 — Documented that **bulk `DELETE` by `where` is implemented but unused and unsafe to
544
564
  adopt**: the branch is real (`V2.php:5554` / `:5697` / `:5740`, returns `meta.deletedRecordCount`)
545
565
  but has zero callers, is non-atomic with no rollback, and — the decisive part — **zero matching
@@ -6,7 +6,7 @@ project: TOGa Blox
6
6
  client: shared
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [jcardinal, apeterson, tcox, rgirish]
11
11
  files:
12
12
  - toga-blox/.github/workflows/publish.yml
@@ -32,6 +32,10 @@ Core model:
32
32
  - **Version derivation (CI owns the suffix; dev owns only the base).** CI reads `version` from `package.json`, **strips any `-<suffix>`**, does **`patch + 1`**, then appends `-<mode>.<GITHUB_RUN_NUMBER>`. Base `1.0.317` on `_sandbox-client` run 104 publishes `1.0.318-sandbox-client.104`. The developer controls only the BASE version committed in `package.json` on the feature branch. **Never hand-write the `-<mode>.N` suffix** — CI generates it. There is no manual `npm publish`; CI is the only publisher.
33
33
  - **A GitHub branch and an npm channel are unrelated.** Creating a `_<mode>` branch in a *consuming app* repo (e.g. `toga2-commerce`) only tells Amplify what to build — it does **not** create a blox channel. A channel exists **only** when this repo (`toga-blox-npm`) publishes with `npm publish --tag <mode>`, which `publish.yml` now does automatically for any `_<mode>` branch.
34
34
 
35
+ - **The trailing `.N` is the shared `GITHUB_RUN_NUMBER`**, not a per-package counter. It counts runs across **all** `_*` branches, so gaps in a channel's sequence (e.g. `.164`–`.167` missing) are runs that published other branches — never a failed or lost publish.
36
+ - **CI never commits the version back.** Step 9 runs `npm version --no-git-tag-version`, so `package.json` in git stays at the base (e.g. `1.1.9`) forever and never matches what npm shows. That mismatch is by design.
37
+ - **⚠ Never hand-bump `package.json` to a published prerelease value.** Committing `1.1.10-sandbox-client.169` makes CI strip the suffix, read the base as `1.1.10`, and publish `1.1.11-...` next run — silently moving the base a patch. Edit `package.json` only to deliberately move the base (e.g. `1.1.9` → `1.2.0`).
38
+
35
39
  **What was removed (and why it's safe):** the old workflow hardcoded a branch list (`_production/_gamma/_beta/_qc-security`) with a production special case: `_production` published dist-tag `latest` with a plain (non-prerelease) version, committed the bump back, and git-tagged. **All gone.** Consequence: the workflow no longer maintains the `@latest` npm tag. Fine because consumers never install bare `npm install @agilant/toga-blox`; they install by explicit `@<mode>` channel (commerce's `amplify.yml`) or by a pinned/ranged version in `package.json`.
36
40
 
37
41
  ## Steps
@@ -74,6 +78,21 @@ Practical consequence: a consumer's `.npmrc` needs the FontAwesome registry line
74
78
 
75
79
  **⚠ Never commit a token VALUE in `.npmrc`.** Live FontAwesome tokens have been found **committed** in `toga25-supply/.npmrc` and `info/.npmrc`, and one was pushed in `bdr` commit `88809c3`. Treat every token found this way as compromised and rotate it. The correct shape is an env-var placeholder resolved at build time — exactly what blox's own `publish.yml` does (see the "Secret hygiene done right" gotcha below). This is the same violation recorded in [`../../../standards/frontend.md`](../../../standards/frontend.md); what is **new here is that only the FontAwesome token was ever needed** — the npmjs token in those files bought nothing.
76
80
 
81
+ ### You cannot publish blox from a laptop — and `npm login` makes it worse
82
+
83
+ Publishing **is** the push to a `_*` branch; there is no supported local path.
84
+
85
+ - The publish credential is the GitHub Actions secret **`NPM_TOKEN`** (granular, bypass-2FA), consumed as `NODE_AUTH_TOKEN`. It lives only in GitHub secrets — never on a laptop.
86
+ - npm requires **either** an interactive 2FA code **or** a granular bypass-2FA token to publish. The `agilant-blox` npm account has **no 2FA**, so the bypass token is the only route.
87
+
88
+ | Symptom | What it means |
89
+ |---|---|
90
+ | `403 Forbidden - PUT .../@agilant%2ftoga-blox … Two-factor authentication or granular access token with bypass 2fa enabled is required` | You are on a web-session token — which is what `npm login` writes over the `//registry.npmjs.org/:_authToken=` line in `~/.npmrc`. Not publishable. |
91
+ | `404 Not Found - PUT` | No token at all; npm returns 404, not 401, for an unauthenticated publish. Easy to misread as "bad version". |
92
+ | `npm whoami` prints `agilant-blox` | Proves only that the token can **read**. Says nothing about publish rights or the bypass flag — never treat it as proof publishing will work. |
93
+
94
+ Dates: the **August 2026** npm change removed 2FA-bypass for *account and package management* only (token creation, maintainer/org changes) — publishing was unaffected. Bypass-2FA tokens lose **direct publish around January 2027**; move `publish.yml` to trusted publishing (OIDC) or staged publishing before then. That is a CI change, since CI is already the only publisher.
95
+
77
96
  ## Gotchas
78
97
 
79
98
  - **⚠ There is a STALE UNDERSCORED dist-tag on the registry — `_sandbox-client` (with the underscore) is NOT the channel.** Re-verified 2026-09-02, the registry still carries **both** `_sandbox-client` = `1.0.316-sandbox-client.1` (vestigial, predates the current pipeline; it has **not** moved in over a month) **and** `sandbox-client` = `1.1.2-sandbox-client.142` (the live channel head). Branch `_<mode>` maps to channel `<mode>` with the underscore **stripped** (same convention as `amplify.yml`), so the **branch** `_sandbox-client` tracks the **tag** `sandbox-client`. Asking npm for "the latest `_sandbox-client` tag" literally installs something **141 publishes behind**. Always resolve the head with `npm view @agilant/toga-blox dist-tags` and install the underscore-less name.
@@ -88,7 +107,7 @@ Practical consequence: a consumer's `.npmrc` needs the FontAwesome registry line
88
107
  3. No feature branch pending merge **touches the `version` line** — normally true, because feature branches never edit it.
89
108
  If any one fails, bump on the feature branch instead. Note the bump commit lands *only* on `_sandbox-client` and is never merged back, which is precisely what generates the "N behind" release plumbing.
90
109
  - **`gh` is not installed on every dev machine.** The skill's `gh run watch` monitoring step simply does not work there. Fall back to polling the registry: `npm view @agilant/toga-blox@sandbox-client version` (or `npm dist-tag ls @agilant/toga-blox`) until the new version appears — the registry is the authoritative confirmation anyway.
91
- - **Consuming apps pin exactly, so a publish is only half the deploy.** After `1.0.334-sandbox-client.136` shipped, `toga25-supply` on `_sandbox-client` was still pinned to `1.0.333-sandbox-client.134` and needed a pin bump + reinstall. Likewise the feature branch stays at its old base version after the bump lands on `_sandbox-client` expect the two to be out of step and don't read it as a problem.
110
+ - **A publish is only half the deploy and the consumer must depend on the LITERAL dist-tag.** In `toga25-supply/package.json` the dependency is the bare tag string (`"@agilant/toga-blox": "sandbox-client"`), **not** a version range. `npm install @agilant/toga-blox@sandbox-client` rewrites it to a `^` range, which then stops following base bumps — re-set the literal tag, re-run `npm install`, commit `package-lock.json`. Verify what is installed with `node -p "require('./node_modules/@agilant/toga-blox/package.json').version"`. Exact pins drift: branch `TRUE-81379` was pinned to `1.1.3-production.166` while `node_modules` held `1.1.10-sandbox-client.163` check this first when a fix "works locally but not after deploy". Likewise the feature branch keeps its old base version after a bump lands on `_sandbox-client`; that is expected.
92
111
  - **⚠ Credentials have been found in the local git remote URL.** Verified 2026-08-26: the `origin` remote for the `toga-blox-npm` checkout had a **GitHub PAT embedded in plaintext in the URL** (visible to anything that runs `git remote get-url origin`, and to any tool that reads `.git/config`). Check for this and re-point the remote at SSH or a credential helper; treat any token found this way as compromised and rotate it. This is the local-config sibling of the committed-`.npmrc` violation already recorded in `../../../standards/frontend.md`.
93
112
  - **FontAwesome v7 `style`-prop TS2322** (fixed 2026-07-21 in `getFontAwesomeIcon.tsx`, and a reusable pattern for any consumer). FA v7 types the `FontAwesomeIcon` `style` prop as `CSSProperties & CSSVariables`, where `CSSVariables` requires a `--fa-*` custom-property index signature that plain `React.CSSProperties` lacks. Cast the passed style to `React.CSSProperties & Record<\`--${string}\`, string>` — the value type must be `string` (**not** `string | number`; `CSSVariables` requires `string`). Runtime and function signature are unchanged, so it is safe for the ~40 consumers.
94
113
  - **The `_production`/`_beta` build copies only `.scss` — a plain `.css` component stylesheet is silently dropped from the tarball.** On `_production` and `_beta` the `build` script is `tsc && copyfiles -u 1 "src/**/*.scss" dist`: `tsc` compiles a component's `import "./Foo.css"` into the emitted JS, but `copyfiles` never ships the `.css`, so the published package contains JS importing a file that isn't in the tarball. Consumers then fail their Vite/Rollup build with `Could not resolve "./Foo.css"` (seen with `EnvironmentBadge` in `@agilant/toga-blox@beta` 1.0.316-beta.108, breaking `toga2-commerce`). **Convention on the prod/beta build: author component stylesheets as `.scss`, never `.css`** (a flat `.css` file is valid SCSS as-is, so renaming is the zero-risk fix — no build-script change). Note the build description in `architecture.md` (copies `*.scss/*.css/*.module.css`) reflects the in-progress `feature-new-table` branch, **not** the current `_production`/`_beta` build; that feature branch is not to be merged to prod/beta just to pick up the wider copy glob.
@@ -3,6 +3,6 @@
3
3
  | Doc | Framework | Summary |
4
4
  |-----|-----------|---------|
5
5
  | [NYCDOE EDI 850 Receive & 997 Acknowledgement](features/edi-850-receive-and-997-ack.md) | 1.0 | DOE's EDI 850-in → NetSuite SO + inline 997-out ack; open for the 997 build, its "always accept" stub, or a DOE ack complaint. |
6
- | [NYCDOE Ticket Hold-Status Sync (ServiceNow ⇄ TOGaDesk)](features/hold-status-sync.md) | 1.0 | Bidirectional DOE ticket hold-status sync (ServiceNow ⇄ TOGaDesk) and the three defects that let holds silently revert; open before changing DOE hold logic or " |
6
+ | [NYCDOE Ticket Hold-Status Sync (ServiceNow ⇄ TOGaDesk)](features/hold-status-sync.md) | 1.0 | Bidirectional DOE ticket hold-status sync (ServiceNow ⇄ TOGaDesk), the four defects that let holds silently revert or never re-send, and the outbound cron's sel |
7
7
  | [NYCDOE ServiceNow / ASN Integration](features/servicenow-integration.md) | 1.0 | Full DOE ServiceNow/ASN integration map — ticket mirror, ASN → NetSuite SO/TOGa Desk pipeline, outbound status/POD, EDI; open for any DOE cron/pipeline work or |
8
8
  | [New York City Department of Education](profile.md) | 1.0 | NYC DOE client profile (1.0 worker tier, ServiceNow + EDI); open for DOE's platform scope, endpoints, API clients, and where its ticket/ASN/EDI logic lives. |
@@ -6,13 +6,15 @@ project: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [mhammontree, sking]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/send_ticket_updates.php
13
13
  - worker/crons/sync/nycdoe/process_tickets.php
14
14
  - worker/crons/sync/nycdoe/send_request_item_updates.php
15
15
  - library/app/model/togadesk/repairorder.php
16
+ - library/app/model/common/nycdoeticket.php
17
+ - dbchanges/Common/MH/2026-09-16.sql
16
18
  - library/app/api/nycdoev2.php
17
19
  - togadesk/desk/includes/classes/class.repair.php
18
20
  related:
@@ -21,20 +23,21 @@ related:
21
23
  - ../../../1.0/apps/worker/architecture.md
22
24
  ---
23
25
 
24
- Bidirectional DOE ticket hold-status sync (ServiceNow ⇄ TOGaDesk) and the three defects that let holds silently revert; open before changing DOE hold logic or "which SNOW field is authoritative."
26
+ Bidirectional DOE ticket hold-status sync (ServiceNow ⇄ TOGaDesk), the four defects that let holds silently revert or never re-send, and the outbound cron's selection/throttle model; open before changing DOE hold logic, the selection query, or "which SNOW field is authoritative."
25
27
 
26
28
  ## Summary
27
29
 
28
- DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and **stay held** — holds are SLA-bearing in both systems. Covers the bidirectional hold-status sync for DOE Incidents (INC) and Request Items (RITM), the authoritative business rule, and the three independent defects that let holds silently revert or never reach ServiceNow. Status-sync companion to the broader [ServiceNow / ASN integration](servicenow-integration.md); the underlying crons and `App_Api_NYCDOEV2` plumbing are documented there.
30
+ DOE ticket **hold** status must round-trip between ServiceNow (SNOW) and TOGaDesk and **stay held** — holds are SLA-bearing in both systems. Covers the bidirectional hold-status sync for DOE Incidents (INC) and Request Items (RITM), the authoritative business rule, and the four independent defects that let holds silently revert or never reach ServiceNow. Status-sync companion to the broader [ServiceNow / ASN integration](servicenow-integration.md); the underlying crons and `App_Api_NYCDOEV2` plumbing are documented there.
29
31
 
30
32
  > **DOE "tickets" are `repair_orders` rows** (`App_Model_TogaDesk_RepairOrder`, `db_togadesk` / legacy `TOGaDeskSupport`, **clientid = 16**) — NOT the generic `tickets` table / `class.ticket.php`. All four DOE crons filter to client 16 / `NYCDOETickets`, so hold behavior here is **NYCDOE-only**; no other client is affected.
31
33
 
32
34
  ## How it works
33
35
 
34
- ### Three distinct hold-failure paths — different repos, mechanisms, symptoms
35
- A held DOE order could fail to persist/propagate via *any* of three unrelated paths; all three had to be closed (see *Coordinated three-repo fix & deploy ordering*):
36
+ ### Four distinct hold-failure paths — different repos, mechanisms, symptoms
37
+ A held DOE order could fail to persist/propagate via *any* of four unrelated paths. Paths 1–3 are about the hold being **LOST**; path 4 is about a correctly-held order never being **RE-SENT** (see *Coordinated three-repo fix & deploy ordering*):
36
38
  - **TRUE-79922 (worker crons):** the SNOW-side `state` 3⇄2 oscillation — an outbound self-clobber pushing `state:2` over a held `state:3`, plus an inbound wrong-field read — reverted the order to **"In Progress"**. Fixed (still pending deploy at time of writing).
37
39
  - **TRUE-80060 (`library` shared model):** the local `qqStatus()` recompute silently demoting the order to a scheduling status (**"Orders Assigned / Awaiting Scheduling"**, `ORDER_ASSIGNED_AWAITING_SCHEDULING`) whenever `updateStatus()` ran. Fixed. In a **shared, client-agnostic** model method — surfaced by DOE but affecting every TogaDesk repair-order client; durable mechanism in [Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md).
40
+ - **TRUE-82027 (worker cron, the outbound push never re-ran):** the whole outbound push body sat inside `while ($rowNote = ...)` over new `repair_order_notes`, so **nothing was PATCHed unless a NEW note existed**. A held order gets no new notes — it is already held — so its hold+ETA were pushed once when the hold note was fresh and never again, and an **ETA change alone NEVER reached ServiceNow on any DOE incident**. This directly contradicted rule 2 below ("re-asserts On Hold every run"): it did not. Evidence: **77 of 78** held DOE orders stuck, zero hold pushes in 3 days; INC2196570's last hold push was 2026-08-25, found 2026-09-16; INC2087375's ETA changed 2026-09-15 18:01, the cron selected it at 18:21, made one GET and PATCHed nothing (local `dtEta` 2026-09-17 08:00 vs SNOW `u_eta` 2026-09-07 08:00 — ten days stale). **Fix:** the push body now runs **once per repair order per pass**, driven by the order's **current** `$repairOrder->status`, not by `$rowNote['newstatus']`. *(failure path #4)*
38
41
  - **TRUE-80060 (`togadesk` desk app):** a hold set **via a note/comment** (`Repair::addNotes`) never reached ServiceNow. `addNotes` recorded the new status only on `repair_order_notes.newstatus` — never writing the `repair_orders.status` cache nor calling `updateStatus()` — and the TRUE-75199 real-time-sync optimization suppressed `repair_orders.dtUpdated`. The worker reads the **cached** `repair_orders.status` and selects orders by `dtUpdated >= dtSynced`, so an on-hold-with-comment order left a stale non-hold cache **and** an un-bumped `dtUpdated` — the worker never selected it and never pushed `state:3`. Fixed.
39
42
 
40
43
  ### The authoritative business rule (SME-confirmed — contact-center)
@@ -66,12 +69,40 @@ When a DOE repair order is on hold, `send_ticket_updates.php` pushes **the full
66
69
  - **`hold_reason` stays pinned to `SN_HOLD_REASON_DEFAULT` (10, "Other").** See the *decision* on not mapping hold reasons.
67
70
  - **No `u_status_task` write** on either the hold push or the hold release — ServiceNow's business rule derives it from `state`. The `SN_TASK_STATUS_ON_HOLD` / `SN_TASK_STATUS_IN_PROGRESS` constants and the `$snTaskStatus` read were removed with it.
68
71
  - **Idempotency + release both key off `state`, not `u_status_task`:** the "already held" guard is `$snState != 'On Hold'` and the release trigger is `$snState == 'On Hold'`. **Rule: gate a push on the field you actually write** — the old `$snTaskStatus` keys gated the push on a field we no longer drive.
69
- - **Closed-incident reconcile (unchanged, still correct):** if the incident is already `Resolved`/`Closed`/`Canceled` on the SNOW side it can no longer be held — the cron **advances `dtSynced`** to accept the remote closure instead of retrying forever. (A canceled incident, INC2190846, had generated **2,392** failed empty-body PATCHes before this.)
72
+ - **⚠ Closed-incident reconcile — the NO-PATCH is DELIBERATE, do not "fix" it.** if the incident is already `Resolved`/`Closed`/`Canceled` on the SNOW side it can no longer be held — the cron **advances `dtSynced`** to accept the remote closure instead of retrying forever. (A canceled incident, INC2190846, had generated **2,392** failed empty-body PATCHes before this.) **Two reviewers have now independently filed this branch as a missing-PATCH bug — a `php-reviewer` agent and CodeRabbit on GitHub. Both were rejected.** PATCHing a closed incident returns HTTP 200 with an empty body forever; adding a PATCH here recreates the retry storm.
70
73
  - The "Assigned → In Progress" auto-start block is still guarded so it never pushes `state:2` over a held order (TRUE-79922). Named constants: `SN_STATE_IN_PROGRESS` / `SN_STATE_ON_HOLD` / `SN_HOLD_REASON_DEFAULT` in `send_ticket_updates.php`.
71
74
  - The existing **empty-body patch-response check** (~L493) still **throws**, so any remaining rejection surfaces loudly. Keep it.
72
75
 
73
76
  > **DECISION (TRUE-81044) — do NOT map TogaDesk hold reasons onto ServiceNow hold-reason codes.** `hold_reason` stays pinned to `10` ("Other") and the human-readable reason is carried entirely in `u_other_reason_on_hold` / `work_notes`, sourced from TogaDesk. Rationale: ServiceNow's own states are not reliable, so mapping our six `HOLD_*` constants onto their code set buys nothing and adds a second source of truth. **Do not re-propose a reason-code mapping.**
74
77
 
78
+ ### Outbound selection, watermark & throttle (TRUE-82027 current shape)
79
+ `send_ticket_updates.php` picks its work from `Common.NYCDOETickets`. Two columns, two distinct meanings — **do not merge them again**:
80
+ - **`dtSynced` = notes high-water mark ONLY** — "which `repair_order_notes` have I forwarded" (`notesdate > dtSynced`).
81
+ - **`dtLastChecked` (new: nullable datetime + index, `dbchanges/Common/MH/2026-09-16.sql`) = "when did I last look at this ticket" ONLY** — drives the throttle and the queue order.
82
+
83
+ Before TRUE-82027 `dtSynced` carried **both** meanings: a standalone assignment-group block (added by TRUE-78295, 2026-04-14) stamped `dtSynced = App_Model::DATETIME_NOW`. Writing meaning 2 into a field read as meaning 1 poisoned both — it pushed `dtSynced` past `repair_orders.dtUpdated`, so the old `dtUpdated >= dtSynced` selection gate could never match again and the ticket locked itself out permanently.
84
+
85
+ Current selection query:
86
+ - **`repair_orders.dtUpdated` is no longer referenced at all** — removed, not compensated (see the TOGaDeskSupport date-skew gotcha; compensating with a fixed offset is a trap).
87
+ - **State-based eligibility:** an **OPEN** order is always eligible; a **COMPLETE** order is eligible only while it has unforwarded notes, then drops out for good.
88
+ - **Throttle:** `dtLastChecked <= NOW() - INTERVAL 6 MINUTE`.
89
+ - **`ORDER BY dtLastChecked ASC`** replaced `ORDER BY RAND()`, which gave no coverage guarantee and could starve an order forever.
90
+ - The standalone assignment-group block was **deleted** — the main payload already diffs `assignment_group`, so it was a duplicate GET, a second code path, and the source of the lockout stamp.
91
+ - Debug pin already in the file: the commented `#AND NYCDOETickets.ticketNumber = '...'` line scopes a pass to one ticket.
92
+
93
+ ### Failure alerting instead of a throw (TRUE-82027)
94
+ **DECISION (Mark, 2026-09-17): no circuit breaker** — 1.0 is transitioning to 2.0 under a separate task, so the cheap path was chosen.
95
+ - A failed ServiceNow PATCH **no longer throws**; it records the incident and the run continues, then all failures are emailed **once per run** to sking@togatech.com / mhammontree@togatech.com.
96
+ - **Throttled to one email per 60 minutes** via a marker row in `Core.AdvanceShippingNoticeQueue` keyed `dedupeKey = 'system|ticket_sync_patch_failure_alert'` — the same mechanism as `system|po_reconciliation_alert` in `2_send_serials_to_netsuite.php`. Worst case drops from ~240 emails/day to 24.
97
+ - **⚠ Open the throttle window ONLY AFTER a successful `send()`.** `App_Email::send()` throws; stamping `alertSentAt` *before* sending would let a failed send silently suppress the alert for a full hour — and since the PATCH no longer throws, that alert is now the **only** signal. Hence two functions: `doeShouldSendThrottledAlert()` reads the window, `doeMarkAlertSent()` opens it.
98
+ - **The invalid-assignment-group email had never sent, ever** — the block built the email and never called `$email->send()` (every sibling DOE cron does, e.g. `2_send_serials_to_netsuite.php:346`). Added, behind the same throttle on its own key `system|ticket_sync_invalid_group_alert`.
99
+
100
+ ### Verifying a TRUE-82027-class change (no sandbox — verified in production)
101
+ There is **no sandbox for the SNOW integration**; verification happens in prod. Deploy order is **dbchanges → library → worker**, and dbchanges `_production` does **not** auto-apply — the migration lands on the next worker EB deploy. After deploy, check legacy `Logs.API` filtered `endpoint LIKE '%nycd3%'` for:
102
+ 1. A held order PATCHing `state:3` with **no new note**.
103
+ 2. INC2087375's `u_eta` moving from 2026-09-07 to 2026-09-17.
104
+ 3. `SELECT COUNT(*) FROM Common.NYCDOETickets WHERE dtLastChecked IS NOT NULL` climbing.
105
+
75
106
  ### RITM cron — deliberately unchanged by TRUE-81044
76
107
  `send_request_item_updates.php` already pushes native `state:"8"` (On Hold) and **never wrote** `u_status_task`, so TRUE-81044's ask was already satisfied there. It carries a defensive `HOLD_*` guard on the scheduled-update path so it can't push `state:"-14"` while held; its reverse-hold logic still **reads** `u_status_task == 'On Hold'`.
77
108
  > **OPEN QUESTION:** ServiceNow's communication was scoped to **Incident** state. We did **not** extend `hold_reason` / `u_eta` / `u_other_reason_on_hold` to the RITM payload without confirmation. Whether the RITM On Hold transition carries the same mandatory-set requirement is unconfirmed — ask before changing the RITM payload.
@@ -107,6 +138,8 @@ The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets
107
138
  ### Reference facts (DOE sync debugging)
108
139
  - **SNOW request/response logs:** logged to the legacy `Logs` schema, **`API` table** (`App_Model_Logs_API`, `db_logs`, `_databaseNameOverride 'Logs'`). `requestPayload` / `responsePayload` are queryable CHAR columns — the primary debugging avenue. Filter `endpoint LIKE '%nycd3%'`; the custom DOE endpoint base is **`/api/nycd3/`**. The state:3↔state:2 oscillation in TRUE-79922 was proven from this table (INC2074899, INC2185183 showed `state:3` then `state:2` pushed seconds apart in one run).
109
140
  - **`NYCDOETickets` (`App_Model_Common_NYCDOETicket`, `db_common` / legacy `Common`)** stores the inbound SNOW record in `rawData`, a **`FIELDTYPE_BLOBSTORAGE`** field (S3 bucket `asifiles`, key `prod/Common/NYCDOETickets/rawData/{id}`) — **NOT a queryable DB column.**
141
+ - **⚠ `repair_orders.dtUpdated` is skewed — never build a selection query on it.** It is written exactly **240 minutes behind** the clock used by `repair_order_notes.notesdate` and `NYCDOETickets.dtSynced`. **Do NOT compensate with `+240 MINUTE`** — 240 is almost certainly UTC-vs-Eastern and becomes 300 at the DST change (2026-11-01), so a hardcoded offset breaks twice a year. TRUE-82027 sidestepped it by removing `dtUpdated` from the query. Mechanism + the second (720-minute) skew: [Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md).
142
+ - **⚠ `Logs.API` history is capped at 30 days from 2026-09-17** (Jeff, sprint review) and existing logs are being purged. This investigation depended on history far older than that (9,914 PATCHes back to 2026-08-20; a hold last pushed 22 days earlier; the 2,392-call storm). **Slow-burn bugs like this can no longer be traced backwards — they must be caught by alerting.** NYCDOE stays 1.0 so its logs stay in legacy `Logs.API`; Compass/Elite/WJE move their API logging to 2.0. Agreed: hold the purge until TRUE-82027 is verified in production.
110
143
  - **Worker prod code cannot run on a Windows dev box.** The DB/S3 data layer fetches EC2 instance-metadata (IMDSv2) credentials and fails off-EC2. Investigate with the toga DB MCP and the `Logs.API` table instead of running the crons locally.
111
144
 
112
145
  ## Gotchas
@@ -118,9 +151,11 @@ The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets
118
151
  - `worker/crons/sync/nycdoe/process_tickets.php:206` — inbound hold detection
119
152
  - `worker/crons/sync/nycdoe/send_request_item_updates.php:224` — RITM reverse-hold
120
153
  - `worker/crons/sync/nycdoe/send_request_item_updates.php:139` — RITM closed-state check
121
- **Verification owed on the FIRST hold that syncs after deploy:** read the incident back and confirm `u_status_task` returns `On Hold`, via legacy `Logs.API` filtered `endpoint LIKE '%nycd3%'`.
154
+ **CONFIRMED BROKEN in production (TRUE-82027, 2026-09-15).** The verification is done: ServiceNow's business rule is **NOT** deriving `u_status_task` from `state`. Live GET on INC2087375 (legacy `Logs.API` id 170862727, 2026-09-15 18:21:34) returned `"state":"On Hold"` with `"u_status_task":"Open"`. All three reads above are therefore reading the wrong value today. Not a "verify this" item any more — it needs a fix (drive those reads off `state`) or a vendor fix to the business rule.
155
+ - **A merged PR is not a deployed change — TRUE-81044 is merged to `_production` but NOT deployed** (merge commit worker `2f4dcfe9e`). Production payloads on 2026-09-15 still write `u_status_task` and still never send `u_other_reason_on_hold` (**0 occurrences across 9,914 PATCHes since 2026-08-20**). Read the live payload in `Logs.API` before concluding a fix is live.
122
156
  - Holds are **never auto-released** — if a SNOW user manually changes status, the outbound cron correctly re-asserts On Hold; that is intended, not a bug. Clear the hold in TOGaDesk.
123
157
  - **Diagnosing which writer dropped a hold:** `repair_order_history` (`repairid, userid, dtStamp, note`) records *user-driven* status changes. A hold reverting with **no** history row between the hold and the revert = a raw-`UPDATE` writer (`updateStatus`/`qqStatus`), **not** the worker inbound cron (`process_tickets.php` contains no reference to `ORDER_ASSIGNED_AWAITING_SCHEDULING`). Note that **since TRUE-80060** a *status-changing* `class.repair.php::addNotes` now **calls** `updateStatus()` (so it too can move the cache via that raw `UPDATE`); a **pure comment** still writes nothing to `repair_orders.status`. This history-gap technique is how TRUE-80060 was pinned to `qqStatus()` rather than the SNOW round-trip.
158
+ - **KNOWN REMAINING GAP (needs a follow-up ticket) — the invalid-assignment-group email reports only the LAST ticket of a run.** `$invalidAssignmentGroups` is declared **inside** the outer `do/while`, so it resets every ticket; the email body now says so explicitly. Agreed fix: declare it before the loop (as `$failedPatches` already is) **and** group output by assignment group with a capped ticket list — one unmapped group can cover 60+ tickets. Rare in practice; the default group is TOGA's own, historically named "ASI Systems".
124
159
  - **KNOWN REMAINING GAP (needs a follow-up ticket):** both `qqStatus()` hold gates live inside the `type IN (ONSITE_REPAIR, ONSITE_SERVICE)` branch. The `DEPOT_REPAIR` branch (`repairorder.php` from ~L799) has **no hold gate at all**, so a `DEPOT_REPAIR` order set to any hold status is still silently recomputed out of hold by `updateStatus()` — same class of bug, untouched by TRUE-80060.
125
160
  - **KNOWN REMAINING GAP (needs a follow-up ticket) — inbound note reconciliation is paged too small.** `process_tickets.php`'s inbound comment reconciliation fetches only `page_size=50, page_number=1` of the SNOW journal, then **deletes any local note not in that set** (the remainder-delete ~L1326–1330). An order with **>50** comments/work_notes can have local notes silently deleted. It is also inconsistent with `addNotes`, which fetches `page_size=100`.
126
161
  - **KNOWN REMAINING GAP — `buildDoubleFieldArray` collapses unsynced notes.** In `process_tickets`, `buildDoubleFieldArray` keys on `IFNULL(referenceId,0)`, so **all** NULL-`referenceId` notes collapse to key `'0'`, making the remainder-delete handle multiple unsynced notes inconsistently.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-09-18
10
10
  owners: [mhammontree, sking]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/import_asn.php
@@ -129,6 +129,9 @@ Vendor SFTP ───(legacy_import_asn.php, ser+non-ser)─┘ [UNIQUE ded
129
129
 
130
130
  ### Debugging & DB topology (DOE 1.0 sync)
131
131
  The 1.0 worker cannot run on a dev/Windows box; use the toga DB MCP + `Logs.API` instead of running prod code locally.
132
+ - **⚠ `Logs.API` retention is 30 days from 2026-09-17** (Jeff, sprint review) and existing logs are being purged — so it is no longer a *historical* debugging avenue, only a recent-window one. Bugs that burn slowly (a hold not re-pushed for 22 days; 9,914 PATCHes traced back to 2026-08-20) can no longer be reconstructed after the fact and must be caught by **alerting** instead. NYCDOE stays 1.0, so its logs remain in legacy `Logs.API`; Compass/Elite/WJE move their API logging to 2.0.
133
+ - **`repair_orders.dtUpdated` and `.lastupdateddate` are both time-skewed in `TOGaDeskSupport`** — never select or diff on them, and never compensate with a fixed offset. Detail: [Field-Service Dispatch](../../../1.0/apps/togadesk/features/field-service-dispatch.md).
134
+ - **`dbchanges` does not describe the real 1.0 schema** — some live columns were created outside the repo. Verify indexes/columns against the database, not the repo: [Authoring & Shipping a 1.0 dbchanges SQL File](../../../1.0/apps/dbchanges/workflows/authoring-and-shipping-sql-files.md).
132
135
  - **`db_core` alias → legacy (V1) environment, schema `Core`** — holds the FLAT tables `AdvanceShippingNotices` / `AdvanceShippingNoticeItems` / `AdvanceShippingNoticeUnits` (columns incl. `togadeskRepairOrderId`, `serialNumber`, `netSuiteInternalSalesOrderId`, `netSuiteInternalPurchaseOrderId`, `customerPurchaseOrder`). The 1.0 cron reads **legacy/Core**.
133
136
  - **Do NOT confuse with prod (V2) `Client_Nycdoe`** — a DIFFERENT, normalized schema (`AdvanceShippingNoticeItemUnits` with a `unitId` FK). The 1.0 cron does not read V2.
134
137
  - **NetSuite SOAP request/response payloads** are logged in legacy `Logs.API` — filter `sourceJob LIKE '%3_create_installation_ticket%'`; `requestPayload` contains the PO internalId. (The multi-PO case was verified this way: the receipt search for PO 6971859 returned `totalRecords=1`, only `38S0500`.)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.839",
3
+ "version": "1.0.841",
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",