toga-ai 1.0.1045 → 1.0.1046
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.
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/features/sla-engine.md +65 -0
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +4 -1
- package/knowledge/2.0/apps/dbchanges2/features/rerunnable-additive-inserts.md +12 -1
- package/knowledge/INDEX.md +1 -1
- package/package.json +1 -1
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
| [SO↔PO and TO↔PO bridge tables are TWO tables in OPPOSITE directions (upstream vs downstream)](features/sales-order-purchase-order-bridge-direction.md) | The two SO↔PO (and two TO↔PO) bridge tables mean opposite things (upstream = customer PO, downstream = vendor PO); open when an SO/TO↔PO query returns an empty |
|
|
56
56
|
| [Per-client sales-order status filter (Surface FILTER_SET → table meta `filterOptions`)](features/sales-order-status-filter-surface.md) | Per-client status-filter dropdown on the sales-orders table, driven by a Surface `FILTER_SET`; open when the offered statuses are wrong for a tenant or when onb |
|
|
57
57
|
| [Shipping Carriers & Carrier Account Numbers (records, matching, duplication, deletion)](features/shipping-carriers-and-accounts.md) | Per-client `ShippingCarriers` / `ShippingCarrierAccountNumbers` model, how the syncs match/create carrier rows, and the duplication + blank-carrier + pagination |
|
|
58
|
+
| [Ticket SLA engine (_Component_Sla) — two clocks, per-stage effects, client switch + operator recompute](features/sla-engine.md) | How 2.0 ticket SLA due times, pauses and the per-client switch are computed and written (`_Component_Sla`); open when an SLA countdown is wrong, a stage does no |
|
|
58
59
|
| [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` static string utilities: use `encodeToAsciiHtmlEntities` for any dynamic text going into a 2.0 email; open when escaping email text or parsing substri |
|
|
59
60
|
| [Surface FIELD ↔ RecordField binding (maxLength / validationType / isWritable fold-in)](features/surface-field-recordfield-binding.md) | Resolver capability that folds `Core.RecordFields` input-contract metadata (maxLength/validationType/isWritable) onto a surface FIELD element; open when a 2.5 s |
|
|
60
61
|
| [DECLINED 2026-10-08 — re-key Client.SurfaceOverrides from numeric Core ids to uuids](features/surface-overrides-uuid-rekey.md) | **Decision 2026-10-08 (jcardinal): keep numeric ids; no uuid re-key.** The plan below is not scheduled; see the Layer boundary in [surface-resolver](surface-res |
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Ticket SLA engine (_Component_Sla) — two clocks, per-stage effects, client switch + operator recompute
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: _underscore
|
|
5
|
+
project: _Underscore
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-10-16
|
|
10
|
+
owners: [jcardinal]
|
|
11
|
+
files:
|
|
12
|
+
- _underscore/Component/Sla/Sla.php
|
|
13
|
+
- _underscore/Model/Client/Ticket.php
|
|
14
|
+
- _underscore/Model/Client/TicketStage.php
|
|
15
|
+
- _underscore/Model/Client/TicketNote.php
|
|
16
|
+
- _underscore/Model/Core/Client.php
|
|
17
|
+
- dbchanges2/Core/2026-10-16a - SlaReplyClockCore.sql
|
|
18
|
+
- dbchanges2/Client/2026-10-16a - SlaReplyClockFanout.sql
|
|
19
|
+
- dbchanges2/Client_True/2026-10-16a - SlaStageDataRepair.sql
|
|
20
|
+
- dbchanges2/Client_Elite/2026-10-16a - SlaReplyClockOpenStage.sql
|
|
21
|
+
related:
|
|
22
|
+
- ./ticket-status-from-stage.md
|
|
23
|
+
- ../../api2/features/api-payload-interceptors.md
|
|
24
|
+
- ../../api2/features/tickets-api.md
|
|
25
|
+
- ../../dbchanges2/features/rerunnable-additive-inserts.md
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
How 2.0 ticket SLA due times, pauses and the per-client switch are computed and written (`_Component_Sla`); open when an SLA countdown is wrong, a stage does not pause, or you need to recompute a client.
|
|
29
|
+
|
|
30
|
+
## Summary
|
|
31
|
+
|
|
32
|
+
`_Component_Sla` (`_underscore/Component/Sla/Sla.php`) is the ONLY code that writes `Tickets.sla*` / `dtSla*` / `dtFirstResponse`. It is static, shared by every 2.0 app, and called from api2 payload interceptors (not model save hooks). Two clocks — resolution and reply (first response) — each with its own pause bookkeeping, driven by per-stage effects stored as client data.
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
- **Targets:** `TicketSlas (ticketTypeId, urgencyId)` → minutes (MINUTES ×1, HOURS ×60, DAYS ×1440). Any other unit (e.g. `BUSINESS_DAYS`) = no target. No business-day math.
|
|
37
|
+
- **Due:** `dtCreated + targetMinutes + pausedMinutes`, per clock.
|
|
38
|
+
- **Resolution clock:** effect = `TicketStages.slaEffect` (`RUN`/`PAUSE`/`STOP`, no stage = RUN). Columns `dtSlaPaused`, `slaPausedMinutes`. STOP time is charged like pause time; `_slaState` shows `STOPPED`.
|
|
39
|
+
- **Reply clock:** effect = `TicketStages.slaFirstResponseEffect` (`RUN`/`PAUSE`, column default `PAUSE`, no stage = RUN). Columns `dtSlaFirstResponsePaused`, `slaFirstResponsePausedMinutes`. Seeded RUN only for new / open / reopened stages. Keeps tracking after the first response so stamps stay right.
|
|
40
|
+
- **`resolvePauseState(params)`** is clock-agnostic and called once per clock. Reuse it for the Service Request SLA clocks — do not fork the math.
|
|
41
|
+
- **First response** = `MIN(TicketNotes.dtStamp)` of external notes by a contact other than the ticket's own contact. Authorless notes never count.
|
|
42
|
+
- **Read side:** `_slaTime`, `_slaState`, `_slaPausedAt` (FIELD_SQL on `_Model_Client_Ticket`). `_slaPausedAt` returns the paused stamp of the clock being shown (reply while unanswered and a reply due exists, else resolution). The Desk countdown reads it, not the raw columns.
|
|
43
|
+
- **Time = DB clock** (`SELECT NOW()` on the client writer), read once, then advanced by PHP elapsed time.
|
|
44
|
+
- **Client switch:** `Core.Clients.isSlaEnabled`. OFF = every SLA column NULL. Flipping it in `PUT /v2/clients/{uuid}` runs `applyClientSwitch` (backfill, skips STOP-stage tickets when turning on).
|
|
45
|
+
- **Operator recompute (no OFF/ON):** `PUT /v2/clients/{uuid}` body `{"recomputeSla": true}`. `_Model_Core_Client::prePut` strips the key (not a column), requires boolean `true`, parks it on `$api->passThrough`; `postPut` runs `recomputeClient` (all tickets, STOP included). Allowed only for callers who can WRITE `Clients.isSlaEnabled` (same field ACL as the V2 write gate). A body that is empty after the strip becomes `{}` in V2.php and `postPut` still runs. Pattern: [command keys in interceptors](../../api2/features/api-payload-interceptors.md#passing-state-from-a-pre-hook-to-a-post-hook).
|
|
46
|
+
- **One run per client:** the shared backfill takes `GET_LOCK('sla-recompute-<clientId>', 0)` on the SAME connection as its writes, `RELEASE_LOCK` in `finally`. Lock busy → `_Exception_Validation` 400. Any failure throws, so the API write rolls back. Reuse this shape for any "only one at a time per client" operation.
|
|
47
|
+
- **Cross-client:** the switch/recompute path uses `DB_TARGET_CLIENT` (`registerTargetClientDatabase`), never the caller's `DB_CLIENT`. Core and client data are never joined.
|
|
48
|
+
|
|
49
|
+
## Gotchas
|
|
50
|
+
|
|
51
|
+
- **⚠ A stage that "never pauses" is usually data, not code.** Check `TicketStages.slaEffect` / `slaFirstResponseEffect` per stage in that environment first. QA alpha `Client_True` (2026-10-16): every stage stayed RUN because the `Client/2026-09-29d` seed matched by slug before the slugs existed; fixed by `Client_True/2026-10-16a - SlaStageDataRepair.sql`. See [fan-out seed keyed on a slug](../../dbchanges2/features/rerunnable-additive-inserts.md#gotchas).
|
|
52
|
+
- **⚠ The lock is released before api2 commits.** A second recompute started right after can overlap the first's uncommitted writes. Accepted (operator-only, rare) — do not copy this gap into a high-traffic operation; hold the lock past commit there.
|
|
53
|
+
- **No worker job for big clients** (decided). The recompute runs inside the request, so a very large client can hit the 300 s request cap. Paged at 500 tickets.
|
|
54
|
+
- **`FIELD_LIST` does not validate ENUMs** — `TicketStage::save()` checks both effects explicitly. Keep that check when adding a value.
|
|
55
|
+
- **Changing a stage's effect does not move existing tickets** until their next write or a recompute. Run `{"recomputeSla": true}` after a stage-effect data fix.
|
|
56
|
+
|
|
57
|
+
## Related
|
|
58
|
+
|
|
59
|
+
- [Ticket status from stage](./ticket-status-from-stage.md)
|
|
60
|
+
- [API payload interceptors](../../api2/features/api-payload-interceptors.md)
|
|
61
|
+
- [Tickets API](../../api2/features/tickets-api.md)
|
|
62
|
+
- [Re-runnable additive migrations](../../dbchanges2/features/rerunnable-additive-inserts.md)
|
|
63
|
+
|
|
64
|
+
## Change history
|
|
65
|
+
- 2026-10-16 — Reply clock gets its own per-stage effect (`slaFirstResponseEffect`, pauses outside new/open/reopened); operator recompute via `{"recomputeSla": true}`; per-client named lock (TRUE-82849). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-16
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -254,6 +254,8 @@ A `postPut`/`postPost` runs after the save, so it cannot compare old vs new valu
|
|
|
254
254
|
|
|
255
255
|
Main use: **fire a POST side effect only on a REAL field change.** Edit forms resend unchanged fields (TOGa Commerce resends `contact` on every order save), so "key present in `$api->httpPayload`" is not "value changed". In `prePut`, read the current DB value, compare to the payload, store a bool; `postPut` acts only when it is true. Both hooks need their own interceptor row — if the PRE row is missing, the key is absent, so fall back to the old check rather than silently skipping. Reference: `_Model_Compass_SalesOrder::rememberWhetherOrderForPersonIsChanging()` (TRUE-82727, see [Compass approval-decision flow](../../../clients/compass-usa/features/approval-decision-flow.md)). Also used to carry a non-column field past PRE: Rate's `PASSTHROUGH_KEY_FULFILLMENT_PREFERENCE` ([AIG contract creation](../../../clients/rate/features/aig-contract-creation.md)).
|
|
256
256
|
|
|
257
|
+
**Command keys** (a non-column payload key that asks for an action): `prePut` strips the key (api2 would reject an unknown column), validates the exact value, parks it on `$api->passThrough`; `postPut` runs the action. Gate it on write access to a real field (field ACL). A body that is empty after the strip becomes `{}` in V2.php and `postPut` still runs. Reference: `{"recomputeSla": true}` on `PUT /v2/clients/{uuid}` ([SLA engine](../../_underscore/features/sla-engine.md)).
|
|
258
|
+
|
|
257
259
|
### Own-row guards in a PRE interceptor
|
|
258
260
|
|
|
259
261
|
A PRE hook that limits a user to their own rows must close four bypasses — each found by review on the `user-global-settings` guard (TRUE-82613):
|
|
@@ -322,3 +324,4 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
|
|
|
322
324
|
- 2026-10-08 — `internalApiRequest` "[]" throw also fires on the OUTER request's warnings (e.g. WO-1); throw site now ~L2436; Core + client duplicate rows fire twice. (rgirish)
|
|
323
325
|
- 2026-10-09 — Own-row guards in PRE hooks (alias keys + parent-route bypasses, PRE before field ACL); user-global-settings reference; known open holes. (jcardinal)
|
|
324
326
|
- 2026-10-15 — PRE → POST state hand-off via `$api->passThrough`; detect a real field change, not a resent one (TRUE-82727). (bala)
|
|
327
|
+
- 2026-10-16 — Command keys: strip in `prePut`, park on `passThrough`, act in `postPut`; empty body → `{}` (TRUE-82849). (jcardinal)
|
|
@@ -6,7 +6,7 @@ project: Database Changes
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-10-
|
|
9
|
+
updated: 2026-10-16
|
|
10
10
|
owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
|
|
11
11
|
files:
|
|
12
12
|
- dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
|
|
@@ -18,7 +18,9 @@ files:
|
|
|
18
18
|
- dbchanges2/Client_Compass/2026-09-21a - BackfillUpstreamItemFulfillmentUnits.sql
|
|
19
19
|
- dbchanges2/Client_Compass/2026-09-23a - BundlesTableViewFieldFlags.sql
|
|
20
20
|
- dbchanges2/Client_Compass/2026-09-24a - DeduplicateBundleItemPrices.sql
|
|
21
|
+
- dbchanges2/Client_True/2026-10-16a - SlaStageDataRepair.sql
|
|
21
22
|
related:
|
|
23
|
+
- ../../_underscore/features/sla-engine.md
|
|
22
24
|
- ../architecture.md
|
|
23
25
|
- ../../worker2/features/netsuite-salesorder-open-orders-sync.md
|
|
24
26
|
- ../../_underscore/features/acl-permission-chain.md
|
|
@@ -534,12 +536,21 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
|
|
|
534
536
|
and a nullable FK column takes it, leaving a structurally broken row that looks inserted. Prefer a
|
|
535
537
|
`JOIN` (so a missing lookup inserts **no row** at all) or add the lookup to the guard's
|
|
536
538
|
`WHERE EXISTS`.
|
|
539
|
+
- **⚠ A `Client/` fan-out seed keyed on a slug that ANOTHER folder sets can silently no-op.** Files in
|
|
540
|
+
`Client/` and `Client_<Name>/` run in no fixed order across folders, so a fan-out `UPDATE … WHERE slug
|
|
541
|
+
= '…'` that runs before the client file back-fills the slugs matches zero rows and reports success.
|
|
542
|
+
Found 2026-10-16: `Client/2026-09-29d` left every QA alpha `Client_True` stage `slaEffect = RUN`
|
|
543
|
+
(Resolved/Closed slugs NULL); fixed forward by `Client_True/2026-10-16a - SlaStageDataRepair.sql`.
|
|
544
|
+
Rule: in the same file, first back-fill the slug from the stable code (guarded), then seed by slug;
|
|
545
|
+
end with a verify SELECT and check it per environment. See [SLA engine](../../_underscore/features/sla-engine.md).
|
|
537
546
|
- **`Core.RecordFields` has NO `sql` column.** A calculated field's SQL is **not** stored in
|
|
538
547
|
metadata — it lives in the `_underscore` model as a `FIELD_SQL` static method. Do not go looking
|
|
539
548
|
for a migration to change a calculated field's expression; there is none. See
|
|
540
549
|
[FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
|
|
541
550
|
|
|
542
551
|
## Change history
|
|
552
|
+
- 2026-10-16 — Fan-out seed keyed on a slug another folder sets can silently no-op; back-fill slugs
|
|
553
|
+
first, verify per environment (TRUE-82849). (jcardinal)
|
|
543
554
|
- 2026-10-01 — Added the `_Model` + column-DEFAULT trap: `_Model` writes every declared field, so an
|
|
544
555
|
explicit NULL skips a `NOT NULL DEFAULT` clause and the insert dies 1048; a unique key over a
|
|
545
556
|
nullable column enforces nothing, so the model default must ship with the ALTER
|
package/knowledge/INDEX.md
CHANGED
|
@@ -21,7 +21,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
21
21
|
|
|
22
22
|
## 2.0 framework
|
|
23
23
|
|
|
24
|
-
- **_underscore** (_Underscore) _(framework core)_ —
|
|
24
|
+
- **_underscore** (_Underscore) _(framework core)_ — 100 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
25
25
|
- **worker2** (Worker) — 87 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
26
26
|
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
27
27
|
- **dbchanges2** (Database Changes) _(framework core)_ — 22 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
package/package.json
CHANGED