toga-ai 1.0.625 → 1.0.626

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: Worker
6
6
  client: nycdoe
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-20
9
+ updated: 2026-08-20
10
10
  owners: [mhammontree, sking]
11
11
  files:
12
12
  - worker/crons/sync/nycdoe/send_ticket_updates.php
@@ -76,48 +76,87 @@ revert or never reach ServiceNow. It is the status-sync companion to the broader
76
76
  - `Hold` has **statusRank 6**, beating `Work in Progress` (rank 5) in the downgrade-guard
77
77
  added by TRUE-76812 — so a downstream sync cannot silently demote a held order.
78
78
 
79
- ### Status fields (ServiceNow side) — CORRECTED 2026-07-20
80
- > **The earlier "native `state` is authoritative; never use `u_status_task`" model was WRONG**
81
- > (see Change history 2026-07-20). Both fields exist and both matter, with distinct roles:
82
- - **`u_status_task` (custom) is the CLIENT-FACING, authoritative incident status** — the field
83
- DOE's own ServiceNow views/reporting read (values `Open`, `In Progress`, `On Hold`, …). **A hold
84
- MUST be written here or the client never sees it.** It is **writable from any active state**; a
85
- controlled test PATCH of `{u_status_task:'On Hold'}` returned success and PERSISTED for days
86
- (INC2192842 held 3+ days). **The TOGaDesk inbound sync keys off `u_status_task`**
87
- (`process_tickets.php:206` reads `$data->u_status_task`), and the RITM cron checks
88
- `u_status_task == 'On Hold'` (`send_request_item_updates.php:224`) — so `u_status_task` **is**
89
- the correct field for hold detection.
90
- - The native **`state`** field is ServiceNow's INTERNAL, SLA-bearing state machine. Setting
91
- `state=3` (On Hold) pauses the SNOW SLA clock but is **invisible to the DOE client**. ServiceNow
92
- only permits the native On Hold transition **FROM In Progress (`state=2`)** — pushing `state=3`
93
- from any other state (Assigned, or already Resolved/Closed/Canceled) is silently rejected (HTTP
94
- 200, empty body — see the empty-body gotcha). `hold_reason=10` is not a valid choice on this
95
- instance and is stored empty/dropped, but native On Hold still sticks without it.
79
+ ### Status fields (ServiceNow side) — CORRECTED 2026-08-20 (VENDOR-STATED, TRUE-81044)
80
+ > **This is the third answer to "which field is authoritative" and the first one that is not an
81
+ > inference.** TRUE-79922 concluded native `state` only; the 2026-07-20 pass reversed that to
82
+ > `u_status_task`; TRUE-81044 re-reverses it. Both earlier conclusions were **inferred from
83
+ > observed rejections**. This one is **stated directly by ServiceNow**, so prefer it.
84
+
85
+ **ServiceNow's stated rule:** to put a DOE Incident On Hold, the mandatory attribute set is
86
+ **`state` + `hold_reason` + `u_eta`, submitted TOGETHER in one payload.** A ServiceNow-side
87
+ **business rule derives the client-facing `u_status_task` FROM `state`** — so we must *not* write
88
+ `u_status_task` ourselves. Doing so was fighting their derivation rule.
89
+
90
+ - The native **`state`** field is what we drive. It is the SLA-bearing state machine *and*, via
91
+ their business rule, the source of the client-facing status. Push it with its two companions.
92
+ - ⚠ **SUPERSEDED:** the claim that ServiceNow "only permits the native On Hold transition FROM
93
+ In Progress (`state=2`)" was **inferred from empty-body 200 rejections and is now doubtful** —
94
+ an *incomplete mandatory attribute set* produces the identical silent rejection, and our
95
+ payload was in fact incomplete (see the empty-body gotcha). **The In-Progress gate has been
96
+ removed** (TRUE-81044). ServiceNow explicitly invited us to report back if holds still fail
97
+ from `Assigned`.
98
+ - **`hold_reason`** is mandatory. ⚠ **SUPERSEDED:** the claim that `hold_reason=10` "is not a valid
99
+ choice on this instance and is stored empty/dropped" was **WRONG** — code **10 ("Other") IS
100
+ valid**, but it additionally **requires the companion free-text field
101
+ `u_other_reason_on_hold`**. We were sending `10` with no companion — an incomplete mandatory set
102
+ — which is why it read as dropped.
103
+ - **`u_other_reason_on_hold`** (string) — required whenever `hold_reason=10`. Must never be empty.
104
+ - **`u_eta`** is mandatory on every hold push, **unconditionally** — not "only when it differs from
105
+ the remote value" (the old conditional silently dropped a mandatory attribute on any re-push with
106
+ an unchanged or null ETA).
107
+ - **`u_status_task` (custom) is the CLIENT-FACING status, but is now READ-ONLY to us** — DOE's own
108
+ ServiceNow views/reporting read it (`Open`, `In Progress`, `On Hold`, …) and their business rule
109
+ populates it from `state`. ⚠ **SUPERSEDED:** the 2026-07-20 instruction that "a hold MUST be
110
+ written here or the client never sees it" no longer holds — we removed both the hold and the
111
+ release writes. It remains the field **three code paths still READ** for hold detection (see the
112
+ gotcha on that outstanding dependency).
96
113
  - Numeric `state` codes seen on the wire: INC `On Hold`=**3**, `In Progress`=**2**;
97
- RITM `On Hold`=**8**, the scheduled-update state=**"-14"**.
114
+ RITM `On Hold`=**8**, the scheduled-update state=**"-14"**. `hold_reason` `Other`=**10**.
98
115
 
99
- ### Outbound (TOGaDesk → ServiceNow) — INC hold push (worker #1676 + #1677)
100
- When a DOE repair order is on hold, `send_ticket_updates.php` pushes:
101
- - **Always set `u_status_task='On Hold'`** — the client-facing field, writable from any active
102
- state; on release set it back to `'In Progress'`. This is what actually surfaces the hold to the
103
- client. Until the local status stops reverting (the library `qqStatus()` fix below) the outbound
104
- never sees `orderIsOnHold=true` and never pushes `u_status_task` at all — hence the deploy
105
- dependency.
106
- - **Set native `state=3` (On Hold) only when the incident is currently `In Progress`** — an SLA
107
- pause. Because ServiceNow rejects the transition from any other state (empty-body 200), the cron
108
- **defers without advancing `dtSynced`** when the incident is neither On Hold nor In Progress, and
109
- re-pushes only once it reaches In Progress.
110
- - **Closed-incident reconcile:** if the incident is already `Resolved`/`Closed`/`Canceled` on the
116
+ ### Outbound (TOGaDesk → ServiceNow) — INC hold push (TRUE-81044 current shape)
117
+ When a DOE repair order is on hold, `send_ticket_updates.php` pushes **the full mandatory set
118
+ together, unconditionally**:
119
+ - **`state=3` + `hold_reason=10` + `u_eta` + `u_other_reason_on_hold` in ONE payload, every run.**
120
+ No gate on the remote state, and no "only if the ETA changed" condition — either would drop a
121
+ mandatory attribute and earn a silent empty-body 200.
122
+ - **`u_other_reason_on_hold`** is populated from the analyst's hold note already being read from
123
+ `repair_order_notes` for `work_notes`, falling back to
124
+ `App_Model_TogaDesk_RepairOrder::statusDisplay($repairOrder->status)` so it is **never empty**.
125
+ - **`hold_reason` stays pinned to `SN_HOLD_REASON_DEFAULT` (10, "Other").** See the *decision* on
126
+ not mapping hold reasons, below.
127
+ - **No `u_status_task` write** on either the hold push or the hold release — ServiceNow's own
128
+ business rule derives it from `state`. The `SN_TASK_STATUS_ON_HOLD` / `SN_TASK_STATUS_IN_PROGRESS`
129
+ constants and the `$snTaskStatus` read were removed with it.
130
+ - **Idempotency + release both key off `state`, not `u_status_task`:** the "already held" guard is
131
+ `$snState != 'On Hold'` and the release trigger is `$snState == 'On Hold'`. **Rule: gate a push on
132
+ the field you actually write** — the old `$snTaskStatus` keys gated the push on a field we no
133
+ longer drive.
134
+ - **Closed-incident reconcile (unchanged, still correct):** if the incident is already
135
+ `Resolved`/`Closed`/`Canceled` on the
111
136
  SNOW side it can no longer be held — the cron **advances `dtSynced`** to accept the remote
112
137
  closure instead of retrying forever. (A canceled incident, INC2190846, had generated **2,392**
113
138
  failed empty-body PATCHes before this.)
114
139
  - The "Assigned → In Progress" auto-start block is still guarded so it never pushes `state:2` over
115
140
  a held order (TRUE-79922). Named constants: `SN_STATE_IN_PROGRESS` / `SN_STATE_ON_HOLD` /
116
- `SN_HOLD_REASON_DEFAULT` in `send_ticket_updates.php`, plus `SN_TASK_STATUS_ON_HOLD` /
117
- `SN_TASK_STATUS_IN_PROGRESS` for the `u_status_task` writes (#1677).
118
- - `send_request_item_updates.php` (RITM) pushes `state:"8"` (On Hold) and carries a defensive
119
- `HOLD_*` guard on the scheduled-update path so it can't push `state:"-14"` while held; its
120
- reverse-hold logic keys off `u_status_task == 'On Hold'`.
141
+ `SN_HOLD_REASON_DEFAULT` in `send_ticket_updates.php`.
142
+ - The existing **empty-body patch-response check** (~L493) still **throws**, so any remaining
143
+ rejection surfaces loudly instead of silently. Keep it.
144
+
145
+ > **DECISION (TRUE-81044) — do NOT map TogaDesk hold reasons onto ServiceNow hold-reason codes.**
146
+ > `hold_reason` stays pinned to `10` ("Other") and the human-readable reason is carried entirely in
147
+ > `u_other_reason_on_hold` / `work_notes`, sourced from TogaDesk. Rationale: ServiceNow's own states
148
+ > are not reliable, so mapping our six `HOLD_*` constants onto their code set buys nothing and adds
149
+ > a second source of truth. **Do not re-propose a reason-code mapping in a future session.**
150
+
151
+ ### RITM cron — deliberately unchanged by TRUE-81044
152
+ `send_request_item_updates.php` already pushes native `state:"8"` (On Hold) and **never wrote**
153
+ `u_status_task`, so TRUE-81044's ask was already satisfied there. It carries a defensive `HOLD_*`
154
+ guard on the scheduled-update path so it can't push `state:"-14"` while held; its reverse-hold logic
155
+ still **reads** `u_status_task == 'On Hold'`.
156
+ > **OPEN QUESTION:** ServiceNow's communication was scoped to **Incident** state. We did **not**
157
+ > extend `hold_reason` / `u_eta` / `u_other_reason_on_hold` to the RITM payload without
158
+ > confirmation. Whether the RITM On Hold transition carries the same mandatory-set requirement is
159
+ > unconfirmed — ask before changing the RITM payload.
121
160
 
122
161
  ### Inbound (ServiceNow → TOGaDesk)
123
162
  - `process_tickets.php` reads the client-facing **`u_status_task`** field
@@ -125,6 +164,8 @@ When a DOE repair order is on hold, `send_ticket_updates.php` pushes:
125
164
  bare **`STATUS_HOLD`** on the local repair order (this is exactly why the library `qqStatus()`
126
165
  fix below had to recognize bare `HOLD`). Both inbound "Assigned-force" `patchINC(state:2)` blocks
127
166
  are guarded against a local hold.
167
+ - **Since TRUE-81044 this read is a DEPENDENCY on ServiceNow's own business rule**, not on anything
168
+ we write — the outbound no longer sets `u_status_task`. See the outstanding-dependency gotcha.
128
169
 
129
170
  ### RITM "ETA-in-hold"
130
171
  - `send_request_item_updates.php` syncs `scheduled_date` (ETA) to ServiceNow **inside the
@@ -208,19 +249,32 @@ The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets
208
249
  auto-start fought the hold push — `state:3` then `state:2` pushed seconds apart, every run,
209
250
  so SNOW never stayed On Hold. Any future auto-start/auto-status block on these crons MUST
210
251
  short-circuit when the local RO is in a `HOLD_*` status.
211
- - **⚠ SUPERSEDED (see Change history 2026-07-20):** TRUE-79922 originally concluded that reading
212
- `u_status_task` (instead of native `state`) was a bug and that hold detection should be on native
213
- `state` only. That was **backwards.** `u_status_task` is the client-facing authoritative field
214
- and **is** the correct hold-detection field — the live inbound cron reads it at
215
- `process_tickets.php:206`. Native `state` is the internal SLA state, pushed only as a secondary
216
- pause when the incident is In Progress.
217
- - **The empty-body 200 (nycd3 `data/tasks` PATCH):** `App_Api_NYCDOEV2::patchINC`/`patchRITM`
218
- (both hit `/api/nycd3/data/tasks/{number}`) return **HTTP 200 with an EMPTY body (no `result`
219
- object)** when a requested native `state` transition is illegal — e.g. pushing `state=3` from a
220
- non-In-Progress state. This is silent: no exception, no error field. A canceled incident
221
- (INC2190846) generated **2,392** such failed PATCHes before the closed-incident reconcile was
222
- added. Gate native `state=3` on `snState == 'In Progress'`, and reconcile
223
- Resolved/Closed/Canceled by advancing `dtSynced`.
252
+ - **⚠ The "which field is authoritative" question has flipped TWICE. Read this before changing it a
253
+ fourth time.** TRUE-79922 said native `state` only (inferred). 2026-07-20 reversed it to
254
+ `u_status_task` (inferred from test PATCHes). **TRUE-81044 re-reversed it on ServiceNow's own
255
+ written instruction:** we write `state` + `hold_reason` + `u_eta` (+ `u_other_reason_on_hold`) and
256
+ **never write `u_status_task`**, because their business rule derives it from `state`. Only the
257
+ current answer is vendor-stated; do not re-litigate it from observed behavior alone.
258
+ - **⚠ An empty-body 200 does NOT tell you WHY (nycd3 `data/tasks` PATCH).**
259
+ `App_Api_NYCDOEV2::patchINC`/`patchRITM` (both hit `/api/nycd3/data/tasks/{number}`) return
260
+ **HTTP 200 with an EMPTY body (no `result` object)** on rejection — no exception, no error field.
261
+ **Critically, this response is IDENTICAL for an illegal state transition and for an INCOMPLETE
262
+ mandatory attribute set.** The previously documented rule "On Hold is only permitted FROM In
263
+ Progress" was *inferred* from this response and is now doubtful: our payload was genuinely
264
+ incomplete in two ways (`u_eta` sent only when it differed from the remote value, so any re-push
265
+ with an unchanged or null ETA dropped a mandatory attribute; and `u_other_reason_on_hold` never
266
+ sent at all alongside `hold_reason=10`). **Diagnostic rule for this integration: never infer a
267
+ state-machine rule from an empty-body 200 until you have verified payload completeness first.**
268
+ The In-Progress gate was removed on that basis. Separately, a canceled incident (INC2190846)
269
+ generated **2,392** such failed PATCHes before the closed-incident `dtSynced` reconcile was added.
270
+ - **⚠ OUTSTANDING DEPENDENCY (open, NOT closed by TRUE-81044) — we stopped WRITING `u_status_task`
271
+ but three paths still READ it.** Hold detection and RITM hold release now depend on ServiceNow's
272
+ business rule actually populating that field from `state`. If it doesn't, these break **silently**:
273
+ - `worker/crons/sync/nycdoe/process_tickets.php:206` — inbound hold detection
274
+ - `worker/crons/sync/nycdoe/send_request_item_updates.php:224` — RITM reverse-hold
275
+ - `worker/crons/sync/nycdoe/send_request_item_updates.php:139` — RITM closed-state check
276
+ **Verification owed on the FIRST hold that syncs after deploy:** read the incident back and confirm
277
+ `u_status_task` returns `On Hold`, via legacy `Logs.API` filtered `endpoint LIKE '%nycd3%'`.
224
278
  - Holds are **never auto-released** — if a SNOW user manually changes status, the outbound
225
279
  cron correctly re-asserts On Hold; that is intended, not a bug. Clear the hold in TOGaDesk.
226
280
  - **Diagnosing which writer dropped a hold:** `repair_order_history`
@@ -253,6 +307,26 @@ The SNOW round-trip note reconciliation (delete-and-reinsert in `process_tickets
253
307
  `repair_orders.status` directly cannot distinguish the six hold variants after a recompute.
254
308
 
255
309
  ## Change history
310
+ - 2026-08-20 — **TRUE-81044: re-corrected the field-authority model on ServiceNow's own written
311
+ instruction, and fixed the real root cause of the failing INC hold push.** ServiceNow stated the
312
+ mandatory attribute set for an Incident On Hold is **`state` + `hold_reason` + `u_eta` together**,
313
+ and that their business rule derives the client-facing `u_status_task` **from `state`** — so our
314
+ `u_status_task` writes were fighting it. `send_ticket_updates.php`: removed `u_status_task` from
315
+ both the hold push and the release (and the `SN_TASK_STATUS_*` constants / `$snTaskStatus` read);
316
+ now sends `state`+`hold_reason`+`u_eta` **unconditionally together** (the `In Progress` gate and
317
+ the "only if ETA differs" condition are **gone**); added `u_other_reason_on_hold`, required
318
+ alongside `hold_reason=10` ("Other" IS valid — the earlier "invalid/dropped" note was wrong) and
319
+ sourced from the analyst hold note with a `statusDisplay()` fallback so it is never empty;
320
+ re-keyed the idempotency guard and release trigger from `$snTaskStatus` to `$snState`. Root-cause
321
+ correction: the documented "On Hold only from In Progress" rule was **inferred** from empty-body
322
+ 200s, which do not distinguish an illegal transition from an **incomplete mandatory set** — ours
323
+ was incomplete. Decision: **no** mapping of the six TogaDesk `HOLD_*` constants onto ServiceNow
324
+ hold-reason codes; reason text comes from TogaDesk only. RITM cron intentionally untouched (already
325
+ compliant); open question whether RITM needs the same mandatory set. New open item: three paths
326
+ still **read** `u_status_task` — verify it comes back `On Hold` on the first post-deploy hold.
327
+ worker-only, single file (commit `ead92ad1e`, branch `TRUE-81044`, 34/-43); `php -l` clean on PHP
328
+ 7.2, so the `library`-before-`togadesk` deploy-ordering constraint does not apply here.
329
+ (mhammontree)
256
330
  - 2026-07-20 — **Corrected the authoritative-field model and shipped the INC hold push.**
257
331
  Controlled test PATCHes proved the earlier "native `state` is authoritative; never use
258
332
  `u_status_task`" conclusion (TRUE-79922) **backwards**: the custom **`u_status_task`** is the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.625",
3
+ "version": "1.0.626",
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",