toga-ai 1.0.497 → 1.0.498

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: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-01
9
+ updated: 2026-08-03
10
10
  owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -87,6 +87,28 @@ produced a different hash on every request — dedup never actually worked.
87
87
  cannot orphan a curated Issue: an admin repoints the new fingerprint at the existing Issue
88
88
  from the Tools console to merge them.
89
89
 
90
+ ### Where each exception class routes (the class you throw IS the routing decision)
91
+
92
+ | Thrown class | HTTP | Sentry | `Logs.Issue` | Fingerprint / routing |
93
+ |---|---|---|---|---|
94
+ | `_Exception_Validation` | **400** | no | **no** | n/a — bad client input. Still leaves a full `Logs.Api` row (see [api2 request logging](../../api2/features/request-logging.md)) |
95
+ | `_Exception_Business` | **500** | yes | **yes** | stable `issueKey` (refactor-immune) → business recipients configured in the Tools console |
96
+ | plain global `Exception` | **500** | yes | **yes** | **trace**-based fingerprint (moves when code moves) → developer ClickUp task |
97
+ | `_Exception` | — | — | — | **never throw it deliberately** (below) |
98
+
99
+ Verified against `api2/Controller/Index.php` (≈L205-245, L369-377) and `_Error.php`
100
+ (`captureException` ≈L222-265, `buildFingerprint` ≈L455-490) on **2026-08-03**, while TRUE-78188
101
+ was still in flight — **re-verify against `Controller/Index.php`** rather than trusting the table
102
+ blindly.
103
+
104
+ **`_Exception` must never appear at a deliberate throw site.** Its constructor takes **five**
105
+ required args (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because it is an
106
+ *error-handler shape* populated by `_Error` from `set_error_handler` context — not a general
107
+ exception. `throw new _Exception('msg')` raises an `ArgumentCountError` **before** the intended
108
+ exception is ever constructed, so the real signal is destroyed. Jeff states this rationale in the
109
+ `Exception/Business.php` docblock. Census in `_underscore`: 173 plain `throw new Exception` vs 14
110
+ `_Exception`.
111
+
90
112
  ### `_Exception_Business` — identity immune to refactoring
91
113
 
92
114
  `_underscore/Exception/Business.php` carries a stable `issueKey` plus a `minimumUrgency` floor.
@@ -267,6 +289,12 @@ clientUserId). **Neither was built.** As built instead:
267
289
 
268
290
  ## Change history
269
291
 
292
+ - 2026-08-03 — Added the **exception-class routing table** (`_Exception_Validation` → 400/no
293
+ Issue; `_Exception_Business` → 500 + `issueKey`-fingerprinted Issue to business recipients;
294
+ plain `Exception` → 500 + trace-fingerprinted Issue to a developer task) and recorded that
295
+ **`_Exception` must never be thrown deliberately** — its 5-arg error-handler constructor raises
296
+ `ArgumentCountError` first. Verified against `Controller/Index.php` / `_Error.php` while
297
+ TRUE-78188 was still in flight. (dfranks)
270
298
  - 2026-08-01 — **Decided: `Logs` cluster tables use SINGULAR names.** Renamed all six
271
299
  post-deployment (`Issues`→`Issue`, `Events`→`Event`, `IssueFingerprints`→`IssueFingerprint`,
272
300
  `IssueClickupTasks`→`IssueClickupTask`, `IssueEmailAddresses`→`IssueEmailAddress`,
@@ -6,8 +6,8 @@ project: API
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-28
10
- owners: [jcardinal, bala, mhammontree]
9
+ updated: 2026-08-03
10
+ owners: [jcardinal, bala, mhammontree, dfranks]
11
11
  files:
12
12
  - api2/Controller/Index.php
13
13
  - api2/Component/Api/V2/V2.php
@@ -102,6 +102,12 @@ One ~2,000-line `execute()` then `processRoutePairs()`:
102
102
  `public`, `delegator`/`encrypted`. JWTs HS256, `sub` access/refresh (access 3600s,
103
103
  refresh 30d); signing secret **rotated** with current+previous accepted across the
104
104
  boundary. The JWT `id` claim carries the full identity used for ACL.
105
+ **`/v2/auth/oauth` credential transport:** OAuth 2.0 `client_credentials`. `client_id` +
106
+ `client_secret` may arrive as **HTTP Basic** *or* as POST body fields — **Basic takes
107
+ priority** when both are present. `scope` is **required**; `grant_type` is **optional** and
108
+ defaults to `client_credentials` (accepted: `client_credentials`, `password`,
109
+ `refresh_token`). The call mints a JWT; subsequent requests carry `Authorization: Bearer`.
110
+ (`api2/Component/Api/V2/V2.php` L414-460.)
105
111
  4. **Response envelope** — `{transactionId, timestamp, authority, audience, isSuccess,
106
112
  status, error, messages[], meta{}, data{}}`; `isSuccess` = 2xx status.
107
113
  5. **Transaction logging** — every request logged (to client/core Logs DB, or as a JSONL
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
9
+ updated: 2026-08-03
10
10
  owners: ["mhammontree", "dfranks"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -46,6 +46,21 @@ In `api2/Component/Api/V2/V2.php` (~L2168) every request is logged and routed:
46
46
  - There is also a **config-gated branch** (~L2166): when `_Config::api('log_filepath')` is set,
47
47
  the log entry is written to a **file** on the host instead of the DB.
48
48
 
49
+ ### A rejected request (HTTP 400) is NOT unlogged
50
+
51
+ `api2/Controller/Index.php` (L369-377) handles a non-success response by **committing** the
52
+ `DB_LOGS` and `DB_CLIENT_LOGS` transactions and rolling back **only** the business-data
53
+ transaction. A rejection therefore still writes a **full `Client_Logs.Api` / `Core_Logs.Api`
54
+ row** — request payload, response payload, `apiId`, `sourceIp`, `transactionId` — while the
55
+ business row is discarded.
56
+
57
+ So a 400 **is** SQL-queryable and attributable to the calling credential. What a 400 lacks is a
58
+ `Logs.Issue` row: **no alerting, no aggregation, no owner** (see
59
+ [Error Reporting — Issue/Event Capture](../../_underscore/features/error-reporting-issue-event.md)).
60
+ That is the deciding factor when choosing between `_Exception_Validation` (400, logged as a
61
+ request, no Issue) and `_Exception_Business` (500, Sentry + a routed Issue). Do **not** reach for
62
+ `_Exception_Business` merely to "make sure the rejection is recorded" — it already is.
63
+
49
64
  ## Blind spots when auditing a client's API traffic
50
65
 
51
66
  Looking only at `Logs_<Client>.Api` misses four sources:
@@ -95,6 +110,11 @@ re-send.
95
110
 
96
111
  ## Change history
97
112
 
113
+ - 2026-08-03 — Corrected a common wrong assumption: an **HTTP 400 leaves a full log row**.
114
+ `Controller/Index.php` L369-377 commits `DB_LOGS`/`DB_CLIENT_LOGS` and rolls back only the
115
+ business transaction, so rejections are queryable and attributable to the credential; what they
116
+ lack is a `Logs.Issue` (no alerting/owner) — the deciding factor between `_Exception_Validation`
117
+ and `_Exception_Business`. (dfranks)
98
118
  - 2026-07-29 — Documented (TRUE-80179) that the core/writer `Logs.Api` retains the **full original
99
119
  `requestPayload`** on failed writes, making it a **replayable backlog source** and a scope
100
120
  diagnostic: dedupe to one-per-contract with a window function on
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-08-01
9
- owners: [jcardinal, mhammontree]
8
+ updated: 2026-08-03
9
+ owners: [jcardinal, mhammontree, dfranks]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
@@ -573,15 +573,42 @@ $c = 'Hello ' . $world;
573
573
 
574
574
  * Always use exceptions to handle unexpected conditions.
575
575
 
576
- ### Interceptor / model client-error rejections → throw `_Exception_Validation`, not `_Exception`
577
-
578
- A hard-block raised inside a pre/post interceptor (or model op) to reject a **bad client request**
579
- must throw **`_Exception_Validation`** (`_underscore/Exception/Validation.php`), which
580
- `api2/Controller/Index.php` (≈lines 204–222) maps to `DEFINED_MESSAGE_ERROR_BAD_REQUEST` →
581
- **HTTP 400** with the message carried in the response envelope (so the front end can drive a
582
- toaster). Throwing a plain `_Exception` yields an **HTTP 500 + stack trace** — wrong for a
583
- client-input rejection and an information leak. First applied: the three Rate WH hard-blocks
584
- (missing / invalid / duplicate service address), TRUE-79533.
576
+ ### Choosing an exception class is a ROUTING decision, not style
577
+
578
+ At a deliberate throw site the class you pick determines the HTTP status, whether Sentry sees
579
+ it, whether a `Logs.Issue` is created, how that Issue is fingerprinted, and **who gets
580
+ notified**. Pick deliberately:
581
+
582
+ | Throw this | When | HTTP | Sentry | `Logs.Issue` | Goes to |
583
+ |---|---|---|---|---|---|
584
+ | `_Exception_Validation` | bad **client input** — a hard-block in a pre/post interceptor or model op | **400** | no | no | the caller, via the response envelope (front end can toast it) |
585
+ | `_Exception_Business` | a **business condition a business user can act on and a developer cannot fix** | 500 | yes | yes, keyed by a stable `issueKey` | business recipients configured in the Tools `/errors` console |
586
+ | plain global `Exception` | a genuine **code defect / unexpected state** | 500 | yes | yes, fingerprinted by **trace** | a developer ClickUp task |
587
+
588
+ `_Exception_Business`'s constructor is `(issueKey, message, minimumUrgency)`. The `issueKey` is
589
+ the fingerprint, so the Issue's identity survives refactoring; a plain `Exception`'s
590
+ trace-based fingerprint changes when the code moves.
591
+
592
+ **A 400 is not "unlogged."** `api2/Controller/Index.php` commits the log transactions and rolls
593
+ back only the business transaction, so a rejection still writes a full `Logs.Api` row (request
594
+ + response payload, `apiId`, `sourceIp`, `transactionId`). What a 400 lacks is a `Logs.Issue` —
595
+ no alerting, no aggregation, no owner. Do not upgrade a client-input rejection to
596
+ `_Exception_Business` just to "make sure it's recorded."
597
+
598
+ ### NEVER `throw new _Exception(...)`
599
+
600
+ `_Exception` is an **error-handler shape**, not a general exception: its constructor requires
601
+ five arguments (`errorNumber`, `errorString`, `errorFile`, `errorLine`, `trace`) because
602
+ `_Error` populates it from `set_error_handler` context. `throw new _Exception('msg')` raises an
603
+ `ArgumentCountError` **before** your exception is ever constructed, destroying the real signal.
604
+ Throwing a plain `_Exception` also yields an HTTP 500 + stack trace — wrong for a client-input
605
+ rejection and an information leak. Census in `_underscore`: 173 plain `throw new Exception` vs
606
+ 14 `_Exception`.
607
+
608
+ Sources: `_underscore/Exception.php`, `Exception/Validation.php`, `Exception/Business.php`,
609
+ `_underscore/Error.php`, `api2/Controller/Index.php`. First applied: the three Rate WH
610
+ hard-blocks (TRUE-79533). Taxonomy verified 2026-08-03 **while TRUE-78188 was still in flight** —
611
+ re-verify against `Controller/Index.php` before relying on it.
585
612
 
586
613
  ### Comprehensive Error Handling
587
614
 
@@ -6,8 +6,8 @@ project: _Underscore
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-07-13
10
- owners: ["jcardinal", "rgirish"]
9
+ updated: 2026-08-03
10
+ owners: ["jcardinal", "rgirish", "dfranks"]
11
11
  files:
12
12
  - _underscore/Model/Compass/PurchaseOrder.php
13
13
  - worker/crons/toga2/compass/workflow/3a_import_office_depot_purchase_orders.php
@@ -29,6 +29,56 @@ production data-integrity bug.
29
29
  - USA/Canada subclasses (`Model/Compass/Usa|Canada/PurchaseOrder.php`) are empty and inherit
30
30
  this behavior — edit the parent.
31
31
 
32
+ ## Who actually calls `POST /v2/purchase-orders` (MITS is NOT the only caller)
33
+
34
+ `/v2/purchase-orders` for Compass is a **shared endpoint with two unrelated inbound channels**,
35
+ distinguished in `Core.ClientApiIdentities` (clientId 2). **The channels are told apart by the
36
+ FIELD NAME, not by whether a MITS SO is present at all:**
37
+
38
+ | Identity | Channel | SO field sent | Values | `purchaseOrderItems` |
39
+ |---|---|---|---|---|
40
+ | `153531108` | MITS | **`mitsSalesOrder`** (unprefixed) | only `SA…` or `MR…` | always present |
41
+ | `COMPASS-CXML` | cXML / EDI | **`c_mitsSalesOrder`** (`c_`-prefixed) | numeric, e.g. `472981379001` | present |
42
+ | `COMPASS-CXML` | cXML / EDI | **neither field** | — | often **absent entirely** |
43
+
44
+ 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, `MR` 1,619.
46
+ Never `MA`, never any other prefix. **No `SA` value has ever been sent by a non-MITS caller.**
47
+ - **COMPASS-CXML — 2,697 posts**: 2,455 carry `c_mitsSalesOrder`, and **242 carry neither field**.
48
+ In a 30-day slice: 1,508 `c_mitsSalesOrder` posts all had items; **136 posts had neither field
49
+ and no `purchaseOrderItems` key** — the legitimate header-first EDI POs.
50
+ - Compass **Canada** posts under a **separate identity (`409531`)**, so never hardcode an
51
+ identity id.
52
+
53
+ **Consequences for any validation added to this endpoint:**
54
+ - **The unprefixed `mitsSalesOrder` is the MITS marker** — not the identity id, and not the
55
+ `SA`/`MR` prefix. Supporting evidence: `prePost` reads `$payload->mitsSalesOrder`; the only
56
+ `c_mitsSalesOrder` occurrence in the codebase is the model field declaration at
57
+ `_underscore/Model/Compass/PurchaseOrder.php:18`; and `Core.ApiPayloadInterceptors` has **no
58
+ rows for `recordId = 17`** (the `purchase-orders` record), so nothing rewrites field names in
59
+ flight.
60
+ - A "reject purchase orders with zero line items" rule keyed on the **unprefixed** field cannot
61
+ touch the 136 item-less EDI posts — they carry neither field. Gating on **item count alone**
62
+ would reject all of them.
63
+ - **`MA` orders never arrive from MITS.** Compass creates them manually; they go to ODP, reach us
64
+ over EDI, and land on an exception report for manual NetSuite entry. (An earlier belief that MA
65
+ is a third MITS prefix to filter on is wrong.)
66
+
67
+ > **⚠ Querying trap — a substring match on `mitsSalesOrder` conflates the two callers**, because
68
+ > `c_mitsSalesOrder` *contains* it. Anyone characterising this traffic from `Logs_Compass.Api`
69
+ > 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 cXML never
71
+ > sends an SO reference. Second trap: payloads appear **both minified**
72
+ > (`"mitsSalesOrder":"MR…"`) **and pretty-printed** (`"mitsSalesOrder": "MR…"`, space after the
73
+ > colon), so a naive pattern under-counts a second way.
74
+
75
+ > **OPEN / UNVERIFIED:** it is **not** confirmed whether the 2.0 framework strips the `c_` prefix
76
+ > when populating `$payload` for an interceptor. If it does, cXML requests carrying
77
+ > `c_mitsSalesOrder` would also enter a guard keyed on `$payload->mitsSalesOrder`. Harmless while
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.
81
+
32
82
  ## How it works
33
83
  - **SA orders (normal):** each inbound PO item carries `createdFromSalesOrderItem.lineNumber`
34
84
  (the MITS line, which includes configuration-line offsets). `prePost` remaps it to the real
@@ -50,6 +100,17 @@ production data-integrity bug.
50
100
  because SA/MR originate in Compass (their Compass SO+items pre-exist) whereas MA is
51
101
  ODP-originated, so nothing else would populate it.
52
102
 
103
+ ### MR and SA consume PO line items differently (asymmetry inside one method)
104
+ - **MR** — `handleMrOrder` (≈L54-57) **silently skips** any item whose
105
+ `vendorItem->item->partNumber` is empty while building `salesOrderItems`. So an MR PO whose
106
+ lines *all* lack a `partNumber` passes a naive `count($payload->purchaseOrderItems) > 0` check
107
+ and still creates an **empty Sales Order**. A "usable item" filter for MR must therefore test
108
+ `partNumber`, not array length.
109
+ - **SA** — items are linked via `createdFromSalesOrderItem` →
110
+ `salesOrderItemPurchaseOrderItems`, and the quantity cross-check (≈L318-324) explicitly
111
+ `continue`s past `partNumber`-less lines rather than discarding them. Applying the MR
112
+ `partNumber` filter to SA would **reject valid orders**.
113
+
53
114
  ## Data model
54
115
  - `SalesOrderItems_PurchaseOrderItems` (`Client_Compass`): `uuid`, `salesOrderItemId`,
55
116
  `purchaseOrderItemId`. One row per (SO item, PO item) the PO fulfills.
@@ -59,6 +120,15 @@ production data-integrity bug.
59
120
  USA and Canada share the parent handler unchanged.
60
121
 
61
122
  ## Gotchas / known issues
123
+ - **SQL injection in `prePost` (OPEN as of 2026-08-03).** `_underscore/Model/Compass/PurchaseOrder.php`
124
+ **L232** interpolates the raw client payload straight into SQL:
125
+ `WHERE SalesOrders.number LIKE '{$payload->mitsSalesOrder}'` — no escape, no cast. The **same
126
+ value is escaped at L301**, so the file is internally inconsistent and the correct pattern is
127
+ already present. Fix with `_Database::escape()` per the 2.0 back-end standard. Reachable only by
128
+ a caller holding valid Compass API credentials (OAuth2 `client_credentials`), so it is **not**
129
+ anonymous or internet-facing — normal priority, not an emergency. Separately, five sites in this
130
+ file (L148, L301, L461, L462, L471) use **`addslashes()`** where the standard requires
131
+ `_Database::escape()`.
62
132
  - **Spurious cross-part links from the `postPost` lineNumber join (fixed 2026-06-11).** The
63
133
  `postPost` link pass matched `SalesOrderItems.lineNumber = PurchaseOrderItems.lineNumber`
64
134
  for *all* orders. For SA orders that is wrong: PO line numbers are PO-local, so whenever an
@@ -137,6 +207,16 @@ USA and Canada share the parent handler unchanged.
137
207
  need item backfill from the sibling ODP SO first).
138
208
 
139
209
  ## Change history
210
+ - 2026-08-03 — Documented that `/v2/purchase-orders` has **two** Compass inbound channels (MITS
211
+ `153531108` vs `COMPASS-CXML`; Canada is a third identity `409531`), told apart by **field
212
+ name**: unprefixed `mitsSalesOrder` = MITS, `c_mitsSalesOrder` = cXML, neither = the item-less
213
+ header-first EDI POs. So a zero-item rejection keyed on the unprefixed field is safe, while
214
+ gating on item count alone is not. Recorded the substring/pretty-print querying traps that
215
+ conflate the two callers, and left open whether the framework strips the `c_` prefix for
216
+ interceptor payloads. Also recorded the MR-vs-SA `partNumber` item-consumption
217
+ asymmetry (MR silently skips `partNumber`-less lines → empty SO despite `count() > 0`) and an
218
+ open SQL-injection at L232 plus five `addslashes()` sites. Corrects the earlier belief that
219
+ `MA` arrives from MITS. (dfranks)
140
220
  - 2026-07-13 — Fixed empty MA orders: the 3a ODP-PO-import cron's new-SO branch now builds
141
221
  `salesOrderItems` from the 850 PO items (the MA back-linking block excludes MA by design).
142
222
  Forward-only; 192 historical empties still need item backfill. (rgirish)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.497",
3
+ "version": "1.0.498",
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",