toga-ai 1.0.500 → 1.0.501
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.
|
@@ -7,7 +7,7 @@ client: shared
|
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
9
|
updated: 2026-08-03
|
|
10
|
-
owners: ["mhammontree"]
|
|
10
|
+
owners: ["mhammontree", "dfranks"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
13
13
|
- _underscore/Model/Client/ItemFulfillment.php
|
|
@@ -46,6 +46,42 @@ interceptor mechanism: the symptom looks exactly like a code bug.
|
|
|
46
46
|
Because the registration is a **data** row, it is per-environment: the code ships with a deploy, the
|
|
47
47
|
hook only becomes live when the migration lands.
|
|
48
48
|
|
|
49
|
+
## Registration is also **per-API** — the scoping is DB config, not code
|
|
50
|
+
|
|
51
|
+
The interceptor row carries an **`apiId`** (plus `isActive`) alongside `recordId`,
|
|
52
|
+
`prePostProcessing` and `httpMethod`, and both a **`Core.ApiPayloadInterceptors`** and a
|
|
53
|
+
**`Client_<X>.ApiPayloadInterceptors`** table are consulted. So a hook runs only for the APIs that
|
|
54
|
+
have a row.
|
|
55
|
+
|
|
56
|
+
**Consequence worth internalising: a guard you place in an interceptor is implicitly scoped to
|
|
57
|
+
whichever APIs have interceptor rows, and that scoping lives in data, not in the PHP.** Reading
|
|
58
|
+
the model tells you nothing about who it applies to, and **adding a row for another API silently
|
|
59
|
+
widens the blast radius** of every check in that hook. Worked example: Compass `recordId 17`
|
|
60
|
+
(`purchase-orders`) has exactly two rows, both `apiId = 2` (MITS Service Hub), identical in local
|
|
61
|
+
and prod — so a validation added to `_Model_Compass_PurchaseOrder::prePost` cannot fire for the
|
|
62
|
+
tenant's other APIs at all. See
|
|
63
|
+
[Compass MITS PO → SO Item Linking](../../../clients/compass-usa/features/mits-po-to-so-item-linking.md).
|
|
64
|
+
|
|
65
|
+
## The interceptor sees payload keys **verbatim** (no `c_` stripping, no rename)
|
|
66
|
+
|
|
67
|
+
**Verified empirically** by a live HTTP probe against local dev
|
|
68
|
+
(`test/@dave/Junk Drawer/probe_c_prefix_payload_mapping.php`):
|
|
69
|
+
|
|
70
|
+
- `$payload` carries the keys **exactly as the client sent them**. Sending `mitsSalesOrder` yields
|
|
71
|
+
`$payload->mitsSalesOrder`; sending `c_mitsSalesOrder` yields `$payload->c_mitsSalesOrder`.
|
|
72
|
+
**Column mapping happens AFTER the interceptor runs.**
|
|
73
|
+
- The rename in **`Client_<X>.Apis_CustomRecordFields.overrideFieldName`** governs only which
|
|
74
|
+
field names the API **accepts on input**. Without a rename row the renamed name is rejected with
|
|
75
|
+
*"a field specified in your request does not exist"*; the **raw model field name is always
|
|
76
|
+
accepted**. Rename rows are themselves `apiId`-scoped, so two APIs writing the same record can
|
|
77
|
+
legitimately use different names for one field.
|
|
78
|
+
|
|
79
|
+
> **⚠ Do not re-derive this from the source.** Static reading of `api2/Component/Api/V2/V2.php`
|
|
80
|
+
> around **L7384** — where `$modelVarsToSet` is cast to an object after a `$flippedRenamedFields`
|
|
81
|
+
> flip — *looks* like the interceptor receives MODEL-level field names. It does not. The probe
|
|
82
|
+
> result above is authoritative; the code path is misleading. Anyone tempted to reason it out
|
|
83
|
+
> from V2.php will reach the wrong answer.
|
|
84
|
+
|
|
49
85
|
## Worked example — the EV-10 that was not a code bug
|
|
50
86
|
|
|
51
87
|
`_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on
|
|
@@ -78,6 +114,13 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
78
114
|
|
|
79
115
|
## Change history
|
|
80
116
|
|
|
117
|
+
- 2026-08-03 — Added two verified behaviours from a live dev probe: (1) interceptor registration
|
|
118
|
+
is **per-API** (`apiId` on the row, in both the Core and `Client_<X>` tables), so a guard in a
|
|
119
|
+
hook is scoped by **DB config rather than code** and a new row widens its blast radius; (2) the
|
|
120
|
+
interceptor receives payload keys **verbatim** — no `c_` stripping, no rename — with
|
|
121
|
+
`Apis_CustomRecordFields.overrideFieldName` governing only which names the API *accepts* on
|
|
122
|
+
input, and column mapping happening after the hook. Flagged the misleading `V2.php` ~L7384
|
|
123
|
+
`$flippedRenamedFields` path that suggests the opposite. (dfranks)
|
|
81
124
|
- 2026-08-03 — TRUE-80494: documented the mechanism after an `EV-10`
|
|
82
125
|
(`itemFulfillmentStageId cannot be null`) in dev-sandbox turned out to be a **missing
|
|
83
126
|
`ApiPayloadInterceptors` `(28, PRE, POST)` row**, not a code fault — `prePost` is dispatched only
|
|
@@ -31,35 +31,53 @@ production data-integrity bug.
|
|
|
31
31
|
|
|
32
32
|
## Who actually calls `POST /v2/purchase-orders` (MITS is NOT the only caller)
|
|
33
33
|
|
|
34
|
-
`/v2/purchase-orders` for Compass is a **shared endpoint with two
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
`/v2/purchase-orders` for Compass is a **shared endpoint with two inbound callers**. Identify the
|
|
35
|
+
caller from `Logs_<Client>.Api.apiId`, which is a FK to **`_Model_Client_Api` →
|
|
36
|
+
`Client_<X>.Apis`** — *not* to `Core.ClientApiIdentities` (`_underscore/Model/Client/Logs/Api.php:17`
|
|
37
|
+
declares `FIELDOPT_FOREIGNKEY_MODEL => '\_Model_Client_Api'`, and `_Model_Client_Api::TABLE =
|
|
38
|
+
'Apis'`). `Client_Compass.Apis` is: **1 = Agilant** (internal), **2 = MITS Service Hub**,
|
|
39
|
+
3 = Compass Group, 4 = Office Depot (Cxml).
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
**The two callers are told apart by the FIELD NAME, not by whether a MITS SO is present at all:**
|
|
42
|
+
|
|
43
|
+
| `apiId` | Caller | SO field sent | Values | `purchaseOrderItems` |
|
|
39
44
|
|---|---|---|---|---|
|
|
40
|
-
|
|
|
41
|
-
|
|
|
42
|
-
|
|
|
45
|
+
| 2 | **MITS Service Hub** | **`mitsSalesOrder`** (unprefixed) | only `SA…` or `MR…` | always present |
|
|
46
|
+
| 1 | **Agilant** (internal) | **`c_mitsSalesOrder`** (`c_`-prefixed) | numeric, e.g. `472981379001` | present |
|
|
47
|
+
| 1 | **Agilant** (internal) | **neither field** | — | **absent entirely** |
|
|
43
48
|
|
|
44
49
|
Measured over 60 days of inbound `POST /v2/purchase-orders` (`Logs_Compass.Api`):
|
|
45
|
-
- **MITS — 4,547 posts, 100% carry the unprefixed `mitsSalesOrder`**; `SA` 2,928,
|
|
46
|
-
Never `MA`, never any other prefix. **No `SA` value has ever been sent by
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
50
|
+
- **MITS (apiId 2) — 4,547 posts, 100% carry the unprefixed `mitsSalesOrder`**; `SA` 2,928,
|
|
51
|
+
`MR` 1,619. Never `MA`, never any other prefix. **No `SA` value has ever been sent by any other
|
|
52
|
+
api.**
|
|
53
|
+
- **Agilant (apiId 1) — 2,697 posts**: 2,455 carry `c_mitsSalesOrder`, and **242 carry neither
|
|
54
|
+
field**. In a 30-day slice: 1,508 `c_mitsSalesOrder` posts all had items; **136 posts had
|
|
55
|
+
neither field and no `purchaseOrderItems` key**. What upstream process creates those item-less
|
|
56
|
+
header-first POs is **not established** — we know only which api posts them.
|
|
57
|
+
- Compass **Canada** is a separate tenant with its own `Client_CompassCanada.Apis` ids — resolve
|
|
58
|
+
the caller per tenant; never hardcode one.
|
|
59
|
+
|
|
60
|
+
**Why the two field names exist:** `Client_Compass.Apis_CustomRecordFields` holds exactly **one**
|
|
61
|
+
row for `c_mitsSalesOrder` — recordId 17 → `overrideFieldName` `mitsSalesOrder`, scoped to
|
|
62
|
+
**apiId 2 only**. So MITS is *permitted* to send the unprefixed name and every other api must use
|
|
63
|
+
the raw model field name. `Client_CompassCanada` is configured identically. See
|
|
64
|
+
[API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md) for how
|
|
65
|
+
per-api renames and interceptor scoping interact.
|
|
52
66
|
|
|
53
67
|
**Consequences for any validation added to this endpoint:**
|
|
54
|
-
- **The unprefixed `mitsSalesOrder` is the MITS marker** — not
|
|
68
|
+
- **The unprefixed `mitsSalesOrder` is the MITS marker** — not an identity string, and not the
|
|
55
69
|
`SA`/`MR` prefix. Supporting evidence: `prePost` reads `$payload->mitsSalesOrder`; the only
|
|
56
70
|
`c_mitsSalesOrder` occurrence in the codebase is the model field declaration at
|
|
57
|
-
`_underscore/Model/Compass/PurchaseOrder.php:18`; and
|
|
58
|
-
|
|
59
|
-
|
|
71
|
+
`_underscore/Model/Compass/PurchaseOrder.php:18`; and an interceptor receives payload keys
|
|
72
|
+
**verbatim** — there is no `c_` stripping and no rename applied to `$payload` (verified by live
|
|
73
|
+
probe; see the interceptor doc).
|
|
74
|
+
- **The interceptor itself is api-scoped by DB config.** `Client_Compass.ApiPayloadInterceptors`
|
|
75
|
+
has exactly two rows for recordId 17 (`purchase-orders`), **both `apiId = 2`**, identical in
|
|
76
|
+
local and prod. So `prePost`/`postPost` — and therefore any guard placed in them — **never
|
|
77
|
+
execute for apiId 1 at all**. Adding a row for another api silently widens the blast radius.
|
|
60
78
|
- A "reject purchase orders with zero line items" rule keyed on the **unprefixed** field cannot
|
|
61
|
-
touch the 136 item-less
|
|
62
|
-
would reject all of them.
|
|
79
|
+
touch the 136 item-less posts: they carry neither field *and* their api has no interceptor row.
|
|
80
|
+
Gating on **item count alone** at a shared layer would reject all of them.
|
|
63
81
|
- **`MA` orders never arrive from MITS.** Compass creates them manually; they go to ODP, reach us
|
|
64
82
|
over EDI, and land on an exception report for manual NetSuite entry. (An earlier belief that MA
|
|
65
83
|
is a third MITS prefix to filter on is wrong.)
|
|
@@ -67,17 +85,16 @@ Measured over 60 days of inbound `POST /v2/purchase-orders` (`Logs_Compass.Api`)
|
|
|
67
85
|
> **⚠ Querying trap — a substring match on `mitsSalesOrder` conflates the two callers**, because
|
|
68
86
|
> `c_mitsSalesOrder` *contains* it. Anyone characterising this traffic from `Logs_Compass.Api`
|
|
69
87
|
> must match the **exact key**; a pattern like `"mitsSalesOrder":"` also silently misses the
|
|
70
|
-
> prefixed form, which is how this session first reached the wrong conclusion that
|
|
71
|
-
> sends an SO reference. Second trap: payloads appear **both minified**
|
|
88
|
+
> prefixed form, which is how this session first reached the wrong conclusion that the second
|
|
89
|
+
> caller never sends an SO reference. Second trap: payloads appear **both minified**
|
|
72
90
|
> (`"mitsSalesOrder":"MR…"`) **and pretty-printed** (`"mitsSalesOrder": "MR…"`, space after the
|
|
73
|
-
> colon), so a naive pattern under-counts a second way.
|
|
91
|
+
> colon), so a naive pattern under-counts a second way. Third trap: `Logs_<Client>.Api.apiId`
|
|
92
|
+
> resolves against **`Client_<X>.Apis`**, not `Core.ClientApiIdentities` — reading it in the wrong
|
|
93
|
+
> id space mislabels every caller.
|
|
74
94
|
|
|
75
|
-
> **
|
|
76
|
-
>
|
|
77
|
-
>
|
|
78
|
-
> those posts all carry items, but a future item-less cXML post carrying `c_mitsSalesOrder` would
|
|
79
|
-
> then be wrongly rejected. This **cannot be settled from logs** — it needs a dev-environment
|
|
80
|
-
> check.
|
|
95
|
+
> **RESOLVED (was open):** the framework does **not** strip the `c_` prefix when populating
|
|
96
|
+
> `$payload` for an interceptor — keys arrive verbatim. A guard keyed on `$payload->mitsSalesOrder`
|
|
97
|
+
> therefore cannot be entered by a request that sent `c_mitsSalesOrder`.
|
|
81
98
|
|
|
82
99
|
## How it works
|
|
83
100
|
- **SA orders (normal):** each inbound PO item carries `createdFromSalesOrderItem.lineNumber`
|
|
@@ -207,6 +224,15 @@ USA and Canada share the parent handler unchanged.
|
|
|
207
224
|
need item backfill from the sibling ODP SO first).
|
|
208
225
|
|
|
209
226
|
## Change history
|
|
227
|
+
- 2026-08-03 — **Corrected the caller identification** recorded earlier the same day:
|
|
228
|
+
`Logs_<Client>.Api.apiId` is a FK to `Client_<X>.Apis`, **not** `Core.ClientApiIdentities`, so
|
|
229
|
+
the second caller is **Agilant (apiId 1, internal)** — not a cXML/EDI channel. Traffic counts
|
|
230
|
+
are unchanged; what creates the item-less header-first POs is **not** established. Added the
|
|
231
|
+
`Apis_CustomRecordFields` per-api rename (recordId 17 `c_mitsSalesOrder` → `mitsSalesOrder`,
|
|
232
|
+
apiId 2 only; Canada identical) as the reason two field names exist, and the fact that the
|
|
233
|
+
recordId-17 interceptor rows are **both apiId 2**, so a guard in `prePost` never runs for
|
|
234
|
+
apiId 1. **Resolved** the open `c_`-prefix question: payload keys reach an interceptor verbatim.
|
|
235
|
+
(dfranks)
|
|
210
236
|
- 2026-08-03 — Documented that `/v2/purchase-orders` has **two** Compass inbound channels (MITS
|
|
211
237
|
`153531108` vs `COMPASS-CXML`; Canada is a third identity `409531`), told apart by **field
|
|
212
238
|
name**: unprefixed `mitsSalesOrder` = MITS, `c_mitsSalesOrder` = cXML, neither = the item-less
|
package/package.json
CHANGED