toga-ai 1.0.1082 → 1.0.1083

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.
@@ -63,8 +63,9 @@
63
63
  | [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 |
64
64
  | [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 |
65
65
  | [Service request links (ServiceRequests_Tickets, ServiceRequests_ServiceRequests bridges)](features/service-request-links.md) | Many-to-many links from a service request to tickets and to other service requests; open before linking SRs, reading `_linkCount`, or touching the old `ServiceR |
66
+ | [2.0 Service Request status (`_status`) is derived; stages are manual-only; standard schedule / crew / location columns](features/service-request-status-and-stages.md) | How a 2.0 service request's `_status` is computed, what stages are for, the standard SR columns, and the set-stage write; open when an SR shows the wrong status |
66
67
  | [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 |
67
- | [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 |
68
+ | [SLA engine (_Component_Sla) — record-generic (tickets + service requests), two clocks, per-stage effects, client switch + operator recompute](features/sla-engine.md) | How 2.0 SLA due times, pauses and the per-client switch are computed and written for tickets AND service requests (`_Component_Sla`); open when an SLA countdown |
68
69
  | [_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 |
69
70
  | [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 |
70
71
  | [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,60 @@
1
+ ---
2
+ title: 2.0 Service Request status (`_status`) is derived; stages are manual-only; standard schedule / crew / location columns
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-10-19
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model/Client/ServiceRequest.php
13
+ - _underscore/Model/Client/ServiceRequestNote.php
14
+ - _underscore/Model/Client/User.php
15
+ - _underscore/Model/Client/Case.php
16
+ - _underscore/Model/Prudential/ServiceRequest.php
17
+ - _underscore/Model/Growrk/ServiceRequest.php
18
+ - _underscore/Model/Adyen/ServiceRequest.php
19
+ - _underscore/Model/Elite/ServiceRequest.php
20
+ - dbchanges2/Core/2026-10-19i - TRUE-82915 ServiceRequestInterceptors.sql
21
+ - dbchanges2/Client_Prudential/2026-10-19a - TRUE-82915 ServiceRequestStageStatusLinks.sql
22
+ - dbchanges2/Client_Growrk/2026-10-19a - TRUE-82915 ServiceRequestStageStatusLinks.sql
23
+ related:
24
+ - ./ticket-status-from-stage.md
25
+ - ./case-status-and-stage-gate.md
26
+ - ./sla-engine.md
27
+ - ./service-request-links.md
28
+ - ../../api2/features/api-payload-interceptors.md
29
+ ---
30
+
31
+ How a 2.0 service request's `_status` is computed, what stages are for, the standard SR columns, and the set-stage write; open when an SR shows the wrong status, you add a stage, or you add a base SR hook.
32
+
33
+ ## Summary
34
+
35
+ `_Model_Client_ServiceRequest::_status` is a `FIELD_SQL` calculated field (same idea as Tickets and Cases). Status comes from the SR's own fields; stages hold only manual states that cannot be derived. Full detail: toga25-desk `docs/decisions/0166-sr-status-derived-from-fields-stages-manual-only.md` and `0167-sr-site-schedule-crew-tags-reported-by.md`.
36
+
37
+ ## How it works
38
+
39
+ `_status` returns, in order:
40
+ 1. the linked status slug, when the stage has `serviceRequestStatusId`;
41
+ 2. `closed`, when the stage is unlinked and `isOpen = 0`;
42
+ 3. `in-progress`, when a Lead (`assignedToContactId`) or crew (`ServiceRequests_Users`) is set;
43
+ 4. `scheduled`, when `dtScheduledStart` is set;
44
+ 5. else `new`.
45
+
46
+ - **Stages = manual, non-derivable states only:** blockers (roadblocked / on-hold / awaiting-user / awaiting-parts — each needs a reason note in `ServiceRequestNotes`), pending-client-approval, resolved, closed. No stage is the normal flow.
47
+ - **Per-client stage → status links** go in `Client_<Name>/` files (e.g. `Client_Prudential` / `Client_Growrk` `2026-10-19a`), never a `Client/` fan-out.
48
+ - **Standard columns:** `assignedToContactId` (Lead), `reportedByContactId`, `locationId` (the site — NOT `deliverToAddressId`, which is the shipping address), `isRemote`, `dtScheduledStart` / `dtScheduledEnd` (NOT `dateDelivery`). `ServiceRequestTypes`: `isSiteVisit`, `iconName`, `colorHex`. Bridges `ServiceRequests_Users` (crew), `_Tags`, `_Files` (Core.Records 422-424, RecordFields 3106-3152).
49
+ - **Set stage** = table-based `PUT /v2/service-requests/{uuid}` with nested `serviceRequestNotes` (the reason note). A stage PUT without a uuid = 400 for signed-in users (CSO).
50
+ - **Crew "required"** is a UI rule only — integration-created SRs send no crew.
51
+ - **Base hooks** (`prePost` / `postPost` / `postPut` / `postPatch`) are registered for every client by `Core/2026-10-19i` (`clientId` / `apiId` NULL). They run the schedule check, the set-stage gate, and the [SLA engine](./sla-engine.md).
52
+
53
+ ## Gotchas
54
+
55
+ - **⚠ Never delete or rename existing stage rows.** Client integrations match stage NAMES: Prudential `postPut` ('Cancelled' / 'Update'), Growrk ('Completed'), worker 1.0 cron ('Exception' + stage id 2).
56
+ - **⚠ A client SR override must call `parent::` in every hook it overrides.** Before 2026-10-19 the Prudential / Growrk / Adyen / Elite `ServiceRequest` overrides did not, so every base SR hook was silently skipped for those clients. Adding a base hook? Check each `_Model_<Client>_ServiceRequest` calls `parent::` first (and that the parent declares the method — see [interceptors](../../api2/features/api-payload-interceptors.md#the-client-model-resolution-that-decides-which-class-must-define-the-method)).
57
+ - **⚠ A NULL stage is now the normal case — never INNER JOIN the SR stage.** `User::_openWorkCount` and `Case::_resolvedCount` used INNER JOIN on stage + `isOpen` and dropped stageless SRs. Rule: open = no stage OR stage `isOpen = 1`; open work counts the SR's own crew.
58
+
59
+ ## Change history
60
+ - 2026-10-19 — `_status` derived from fields; stages manual-only; schedule / crew / location / reported-by columns; client overrides call `parent::`; open counts include stageless SRs (TRUE-82915). (jcardinal)
@@ -1,65 +1,76 @@
1
1
  ---
2
- title: Ticket SLA engine (_Component_Sla) — two clocks, per-stage effects, client switch + operator recompute
2
+ title: SLA engine (_Component_Sla) — record-generic (tickets + service requests), two clocks, per-stage effects, client switch + operator recompute
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-16
9
+ updated: 2026-10-19
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Component/Sla/Sla.php
13
13
  - _underscore/Model/Client/Ticket.php
14
14
  - _underscore/Model/Client/TicketStage.php
15
15
  - _underscore/Model/Client/TicketNote.php
16
+ - _underscore/Model/Client/ServiceRequest.php
17
+ - _underscore/Model/Client/ServiceRequestNote.php
16
18
  - _underscore/Model/Core/Client.php
17
19
  - dbchanges2/Core/2026-10-16a - SlaReplyClockCore.sql
18
20
  - dbchanges2/Client/2026-10-16a - SlaReplyClockFanout.sql
19
21
  - dbchanges2/Client_True/2026-10-16a - SlaStageDataRepair.sql
20
- - dbchanges2/Client_Elite/2026-10-16a - SlaReplyClockOpenStage.sql
22
+ - dbchanges2/Core/2026-10-19k - TRUE-82915 ServiceRequestSlaCore.sql
23
+ - dbchanges2/Client/2026-10-19i - TRUE-82915 ServiceRequestSla.sql
21
24
  related:
22
25
  - ./ticket-status-from-stage.md
26
+ - ./service-request-status-and-stages.md
23
27
  - ../../api2/features/api-payload-interceptors.md
24
28
  - ../../api2/features/tickets-api.md
25
29
  - ../../dbchanges2/features/rerunnable-additive-inserts.md
26
30
  ---
27
31
 
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.
32
+ How 2.0 SLA due times, pauses and the per-client switch are computed and written for tickets AND service requests (`_Component_Sla`); open when an SLA countdown is wrong, a stage does not pause, you need to recompute a client, or you want SLA on another record type.
29
33
 
30
34
  ## Summary
31
35
 
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.
36
+ `_Component_Sla` (`_underscore/Component/Sla/Sla.php`) is the ONLY code that writes the stored SLA columns (`sla*` / `dtSla*` / `dtFirstResponse`). One engine, record-generic: every record-specific name comes from `_Component_Sla::descriptor($record)`. Static, shared by every 2.0 app, 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
37
 
34
38
  ## How it works
35
39
 
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.
40
+ - **Descriptor:** `descriptor(RECORD_TICKET | RECORD_SERVICE_REQUEST)` (`'ticket'` / `'serviceRequest'`) returns `record, label, table, typeColumn, stageColumn, contactColumn, targetsTable, stagesTable, hasReplyEffectColumn, notesTable, noteRecordColumn, noteStageColumn, noteAuthorUserColumn`. `allRecordTypes()` = what the client switch and recompute cover. **New record type = a new descriptor + the same columns — never a copy of the engine** (decided, CTO). Detail: toga25-desk `docs/decisions/0168-sla-engine-record-generic.md`.
41
+ - **Tickets (descriptor 1):** `TicketSlas`, `TicketStages.slaEffect` + `slaFirstResponseEffect`, `TicketNotes`, contact = `contactId`. Moving tickets onto the descriptor changed nothing (275-scenario old-vs-new harness and a live recompute replay: 0 diffs).
42
+ - **Service requests (descriptor 2):** same 9 stored SLA columns + 11 calc fields on `ServiceRequests`; targets `ServiceRequestSlas`; effect `ServiceRequestStages.slaEffect`. No reply-effect column (`hasReplyEffectColumn = false`) — the reply clock follows `slaEffect`. Contact = `requestedForContactId`. First response = first external note with a signed-in author (`authorUserId`) OR a contact other than the requester.
43
+ - **Targets:** `(typeColumn, urgencyId)` → minutes (MINUTES ×1, HOURS ×60, DAYS ×1440). Any other unit (e.g. `BUSINESS_DAYS`) = no target. No business-day math.
37
44
  - **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.
45
+ - **Resolution clock:** stage `slaEffect` (`RUN`/`PAUSE`/`STOP`, no stage = RUN). Columns `dtSlaPaused`, `slaPausedMinutes`. STOP time is charged like pause time; `_slaState` shows `STOPPED`.
46
+ - **Reply clock (tickets):** `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.
47
+ - **`resolvePauseState(params)`** is clock-agnostic, called once per clock. Do not fork the math.
48
+ - **Ticket first response** = `MIN(TicketNotes.dtStamp)` of external notes by a contact other than the ticket's own contact. Authorless notes never count.
49
+ - **Read side:** `_slaTime`, `_slaState`, `_slaPausedAt` (FIELD_SQL on the record model). `_slaPausedAt` returns the paused stamp of the clock being shown (reply while unanswered and a reply due exists, else resolution). UIs read it, not the raw columns.
43
50
  - **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.
51
+ - **Client switch:** `Core.Clients.isSlaEnabled` covers every record type. OFF = every SLA column NULL. Flipping it in `PUT /v2/clients/{uuid}` runs `applyClientSwitch` (backfill, skips STOP-stage records when turning on).
52
+ - **Operator recompute (no OFF/ON):** `PUT /v2/clients/{uuid}` body `{"recomputeSla": true}` — all record types. `_Model_Core_Client::prePut` strips the key (not a column), requires boolean `true`, parks it on `$api->passThrough`; `postPut` runs `recomputeClient` (STOP included). Allowed only for callers who can WRITE `Clients.isSlaEnabled`. A body 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).
53
+ - **One run per client:** the 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 "one at a time per client" operation.
54
+ - **Cross-client:** switch/recompute use `DB_TARGET_CLIENT` (`registerTargetClientDatabase`), never the caller's `DB_CLIENT`. Core and client data are never joined.
48
55
 
49
56
  ## Gotchas
50
57
 
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).
58
+ - **⚠ A stage that "never pauses" is usually data, not code.** Check the stage table's `slaEffect` (and `slaFirstResponseEffect` for tickets) in that environment first. QA alpha `Client_True` (2026-10-16): every ticket 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).
59
+ - **⚠ Writes that skip api2 skip the SLA.** worker 1.0 Prudential crons set `ServiceRequests.serviceRequestStageId = 2` by direct SQL — no pause is recorded until the next api2 write or a recompute. Accepted gap.
60
+ - **⚠ Full recompute resets accrued paused minutes on records no longer paused** (both engines, pre-existing). Know this before running `recomputeSla` on a client with long past pauses.
52
61
  - **⚠ 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.
62
+ - **No worker job for big clients** (decided). Recompute runs inside the request; a very large client can hit the 300 s request cap. Paged at 500 records.
63
+ - **`FIELD_LIST` does not validate ENUMs** — `TicketStage::save()` checks both effects explicitly. Keep that check (and add it on any new stage model) when adding a value.
64
+ - **Changing a stage's effect does not move existing records** until their next write or a recompute. Run `{"recomputeSla": true}` after a stage-effect data fix.
56
65
 
57
66
  ## Related
58
67
 
59
68
  - [Ticket status from stage](./ticket-status-from-stage.md)
69
+ - [Service request status and stages](./service-request-status-and-stages.md)
60
70
  - [API payload interceptors](../../api2/features/api-payload-interceptors.md)
61
71
  - [Tickets API](../../api2/features/tickets-api.md)
62
72
  - [Re-runnable additive migrations](../../dbchanges2/features/rerunnable-additive-inserts.md)
63
73
 
64
74
  ## Change history
75
+ - 2026-10-19 — Engine made record-generic via `descriptor()`; service requests are descriptor 2 (same columns, `ServiceRequestSlas`, reply clock follows `slaEffect`); tickets unchanged (0 diffs) (TRUE-82915). (jcardinal)
65
76
  - 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-18
9
+ updated: 2026-10-19
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -50,6 +50,7 @@ related:
50
50
  - ./language-translation-layer.md
51
51
  - ../../_underscore/features/persona-nav-visibility-gate.md
52
52
  - ../../../clients/compass-usa/features/approval-decision-flow.md
53
+ - ../../_underscore/features/service-request-status-and-stages.md
53
54
  ---
54
55
 
55
56
  How `_Model` prePost/postPost/prePut/postPut hooks are dispatched by the api2 /v2 engine only when a metadata row exists — the missing-row silent no-op, the wrong-row hard fatal, and the persistence/transaction traps; open before writing, activating, or debugging any interceptor.
@@ -343,7 +344,8 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
343
344
  - **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
344
345
  - **⚠ An own-row PRE guard that checks only `userId` is bypassable** — alias keys (`user`) map after PRE, and a parent-route POST overwrites the stamped FK. Allowlist keys + refuse parent routes. See [own-row guards](#own-row-guards-in-a-pre-interceptor).
345
346
  - **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before concluding a client has no interceptors.
346
- - **⚠ The same hook in BOTH `Core` (with `clientId`) and `Client_<X>` fires TWICE** — both tables are read, nothing dedupes. Adyen (2026-10-08): Core ids 15–17 duplicated client rows 1, 2, 5 → double worker jobs and double NetSuite queueing; fixed by deactivating the Core rows. Keep each `(record, phase, method)` in one table only. See [Adyen reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md#gotchas).
347
+ - **Duplicate rows are de-duplicated (since 2026-10-19, TRUE-82915).** `registerApiPayloadInterceptorLookupEntry` (`V2.php`) keeps ONE entry per `(record, PRE|POST, httpMethod)` across Core and client tables; the highest `minDepth` wins (NULL lowest). So a generic row (`clientId` / `apiId` NULL) plus a client's own row dispatches once. Before this, both fired (Adyen 2026-10-08: double worker jobs; Elite / Adyen SR `postPost` → double sales-order tasks). **Rollback order:** deactivate generic rows that overlap client rows BEFORE rolling api2 back, or the double dispatch returns. Pre-release check query: header of `dbchanges2/Core/2026-10-19i - TRUE-82915 ServiceRequestInterceptors.sql`. Still keep each key in one table.
348
+ - **⚠ A client override that skips `parent::` silently disables every base hook for that client.** Found 2026-10-19 on four `ServiceRequest` overrides — see [SR status and stages](../../_underscore/features/service-request-status-and-stages.md#gotchas). Adding a base hook = check every client subclass calls `parent::`.
347
349
  - **The method name is derived, so a typo'd enum silently misses.** A row with `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine finds no method and moves on.
348
350
  - **Registration is per-environment.** Like `RecordFields` / `RecordScripts` / ACL rows, this is metadata — "works in prod, not in beta/dev-sandbox" is the signature of drift, not of a bad deploy. See the [non-prod metadata drift repair workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
349
351
  - **⚠ An in-request read-after-write in an interceptor works on beta and fails 100% in production.** Reads default to the **reader** connection, which cannot see the request's own uncommitted row; dev-sandbox points writer and reader at the same endpoint, so it hides the bug. Pin with `setIsReadHostEnabled(false)` (restore in `finally`).
@@ -368,6 +370,7 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
368
370
  - [Email template sending](../../_underscore/features/email-template-sending.md)
369
371
  - [Elite SalesOrder → NetSuite push](../../../clients/elite/features/salesorder-netsuite-push.md)
370
372
  - [Compass sales-order line renumbering](../../../clients/compass-usa/features/sales-order-line-renumbering.md)
373
+ - [Service request status and stages](../../_underscore/features/service-request-status-and-stages.md)
371
374
 
372
375
  ## Change history
373
376
  - 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)
@@ -376,3 +379,4 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
376
379
  - 2026-10-16 — Command keys: strip in `prePut`, park on `passThrough`, act in `postPut`; empty body → `{}` (TRUE-82849). (jcardinal)
377
380
  - 2026-10-18 — Write intent that writes another record via internal request (caller ACL, rollback, `addNote` after the call) (TRUE-82876). (jcardinal)
378
381
  - 2026-10-17 — "Action table" pattern for audited actions; write intents before a stage gate; new interceptor rows ship in a file run after the code deploy (TRUE-82889). (jcardinal)
382
+ - 2026-10-19 — Lookup de-duplicates rows per record / PRE|POST / method (highest `minDepth` wins); client overrides must call `parent::` (TRUE-82915). (jcardinal)
@@ -49,7 +49,9 @@ controller.
49
49
  `fields`, `where`, `join` / `ojoin`, `sort`, `group`, `distinct`, `page`, `recordsPerPage`,
50
50
  `depth`, `calcDepth` (or one base64(JSON) `_` blob).
51
51
 
52
- - **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** Defaults:
52
+ - **Pagination is `page` + `recordsPerPage`. There is no `limit`/`offset`.** A `limit=5` on a
53
+ list GET is not ignored — it returns **404 `EV-14`** "Expecting one record ... multiple records
54
+ were found". Defaults:
53
55
  `recordsPerPage` **25**, `depth` **3**. Max `recordsPerPage` **10000** — exceeding it returns
54
56
  **`EV-13`** (HTTP 400).
55
57
  - **Sort:** `-field` = DESC, `+field` = ASC, bare `field` = ASC. All three are handled
@@ -656,6 +658,7 @@ If you need the behaviour, loop uuids client-side. Point 3 is the durable, non-o
656
658
  [V2 request logging](request-logging.md).
657
659
 
658
660
  ## Change history
661
+ - 2026-10-19 — `limit` on a list GET = 404 `EV-14`, use `recordsPerPage` (TRUE-82915). (jcardinal)
659
662
  - 2026-10-19 — Self-join `where` needs an `ojoin` alias (`Locations@p`); `_` field ACL bypass does not apply cross-client (TRUE-82911). (jcardinal)
660
663
  - 2026-10-18 — Grouped queries: stored root group key now selected + ACL-gated; known gaps (joined key, total count, `ORDER BY uuid` under ONLY_FULL_GROUP_BY) (TRUE-82876). (jcardinal)
661
664
  - 2026-10-17 — Child-path `where` must start at an inherent child; FK-first path → TableView join alias (TRUE-82872). (jcardinal)
@@ -667,7 +670,6 @@ If you need the behaviour, loop uuids client-side. Point 3 is the durable, non-o
667
670
  - 2026-09-18 — `in` instead of an OR-chain of `contains` shortens SQL only, not speed. (apeterson)
668
671
  - 2026-09-17 — Bulk DELETE by `where`: exists, unused, unsafe; zero matches looks like an ACL denial. (rgirish)
669
672
  - 2026-09-14 — `where` on a calculated field works with the bare name; `calcDepth` does not gate a named calc field; list GET cost model. (apeterson)
670
- - 2026-09-08 — `depth` read-back cost (~830s at `depth=5` on a write). (jcardinal)
671
673
  - 2026-09-04 — URL-encoded `where` → 500/`EO-1` (`Logs.Issue` 97). (rgirish)
672
674
  - 2026-09-01/02 — `_` field in `fields=` is never ACL-checked; inherent-child keys govern writes; bridge `InherentRecordChildren` rows. (apeterson, bala)
673
675
  - 2026-07-29 → 08-30 — Created; then: `fields=` does not narrow SELECT, `depth` races deletes, sort `+`/DISTINCT, uuid-READ ignores `where`, joins drive filter not shape, `calcDepth` default 1, FK omitted at max depth, PUT echo can omit fields. (tcox, bala, apeterson, jcardinal)
@@ -6,7 +6,7 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-17
9
+ updated: 2026-10-19
10
10
  owners: [bala, kyalamarthi, apeterson, ajean, jcardinal, rgirish]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
@@ -491,6 +491,7 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
491
491
  - **⚠ `NOT NULL DEFAULT <value>` does not save a `_Model` column** — the ORM writes an explicit NULL,
492
492
  which skips the DEFAULT and errors 1048. Ship a model default with the ALTER.
493
493
  - **⚠ `col = 0` never matches a NULL** — guard flag-enabling UPDATEs with `COALESCE(col, 0) = 0`, or the migration reports success and changes nothing.
494
+ - **⚠ A `WHERE col <> 'New text'` guard SKIPS a case-only change on a `_ci` column.** `'service request' <> 'Service Request'` is FALSE under `utf8mb4_general_ci`, so the copy UPDATE runs and changes nothing. Guard copy updates with `BINARY col <> 'New text'`. Bug `Core/2026-10-19j`; fixed in `Core/2026-10-19n`..`p` (TRUE-82915).
494
495
  - **⚠ An unguarded ACL grant migration duplicates silently — `AclRecordPermissions` has no unique key
495
496
  that stops it (found in prod 2026-09-15).** `_modules/netsuite/2026-09-11c - ItemClassRecordPermission.sql`
496
497
  is a plain `INSERT … VALUES` for `(recordId 105, roleId 3)` with **no `NOT EXISTS` guard**. Every
@@ -560,6 +561,7 @@ an INSERT that names only `uuid`/`tableViewId`/`recordFieldId`/`isVisible`/`inde
560
561
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
561
562
 
562
563
  ## Change history
564
+ - 2026-10-19 — `<>` copy-update guard skips a case-only change on a `_ci` column; use `BINARY` (TRUE-82915). (jcardinal)
563
565
  - 2026-10-17 — `TicketTypes.code` has no UNIQUE and may hold duplicates; guard seeds on `code` (TRUE-82889). (jcardinal)
564
566
  - 2026-10-16 — Backfill an api2-identifier column before the deploy (seed file), stragglers + ALTER after (TRUE-82841). (jcardinal)
565
567
  - 2026-10-16 — NOT NULL alternative for big tables: strict session + `ALGORITHM=INPLACE, LOCK=NONE`
@@ -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)_ — 110 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
24
+ - **_underscore** (_Underscore) _(framework core)_ — 111 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
25
25
  - **worker2** (Worker) — 91 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
26
26
  - **api2** (API) — 27 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1082",
3
+ "version": "1.0.1083",
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",