toga-ai 1.0.806 → 1.0.808
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/features/acl-permission-chain.md +51 -1
- package/knowledge/2.0/apps/_underscore/features/model-interceptor-unit-testing.md +44 -1
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/api-key-authentication-and-scoping.md +113 -0
- package/knowledge/2.0/apps/api2/features/api-payload-interceptors.md +8 -1
- package/knowledge/2.0/apps/api2/features/request-logging.md +17 -1
- package/knowledge/2.0/apps/toga25-supply/features/action-button-rule-engine.md +35 -2
- package/knowledge/2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md +32 -2
- package/knowledge/2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md +151 -9
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-usa/INDEX.md +1 -0
- package/knowledge/clients/compass-usa/features/approval-decision-flow.md +83 -1
- package/knowledge/clients/compass-usa/profile.md +14 -1
- package/knowledge/clients/compass-usa/workflows/granting-api-access.md +142 -0
- package/knowledge/clients/nychh/features/transfer-order-netsuite-push.md +64 -2
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -46,6 +46,14 @@ AclRecordPermissions → AclLogicGroups → AclLogicGroupExpressions →
|
|
|
46
46
|
`sqlExpression` (TEXT). Unique on `(recordId, slug)`. The conventional "always allow" row is
|
|
47
47
|
`slug = 'all'`, `sqlExpression = '1'`.
|
|
48
48
|
|
|
49
|
+
> **A half-built chain fails with a NAMED message — search for it before guessing.** `V2.php`
|
|
50
|
+
> tracks two flags as it walks a permission, `$aclLogicGroupsDefined` and
|
|
51
|
+
> `$aclLogicGroupExpressionsDefined` (set ~L3930 and ~L5614). When the first is false the request is
|
|
52
|
+
> rejected with the exact message **`No ACL Logic Groups defined for ACL Record Permissions`**
|
|
53
|
+
> (~L4437 / ~L5845); a missing expression binding fails on the second flag the same way. So an
|
|
54
|
+
> `AclRecordPermissions` row with no `AclLogicGroups` row returns UNAUTHORIZED carrying that string
|
|
55
|
+
> — which means the **chain**, not the grant, is the problem.
|
|
56
|
+
|
|
49
57
|
**Field visibility — `AclFieldPermissions`.** Record-level access alone does **not** expose
|
|
50
58
|
fields. Each field needs a row: `recordFieldId`, `roleId`, `isWritable` (0 = read-only, 1 =
|
|
51
59
|
writable). Without it the field is rejected on write **and** is unreadable:
|
|
@@ -314,6 +322,13 @@ Measured 2026-09-08: production `Core.Records` holds **328 rows, max id 358**; d
|
|
|
314
322
|
> divergence recorded as an open question in the
|
|
315
323
|
> [non-prod drift doc](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md), and it is why
|
|
316
324
|
> the hardcoded-literal rule above is scoped to **established** rows only.)
|
|
325
|
+
>
|
|
326
|
+
> **One client/pair can match completely, and that still is not portability.** Checked 2026-09-10
|
|
327
|
+
> for Compass: `Core.Records` ids, every `Core.RecordFields` id for the nine records in an ACL grant
|
|
328
|
+
> script, plus `Client_Compass.AclRecordExpressions` and `CustomRecordFields` ids, were **identical
|
|
329
|
+
> between production and client-sandbox**, so the prod-written script ran unchanged on
|
|
330
|
+
> client-sandbox. Treat that as a **per-client, per-environment-pair** measurement you re-take each
|
|
331
|
+
> time — never as licence to skip resolving ids to names.
|
|
317
332
|
|
|
318
333
|
**3. In PRODUCTION you cannot join `Core` and `Client_<Tenant>` in one statement — so the diff query
|
|
319
334
|
that works in sandbox breaks in prod.** Prod splits them across `prod-core` and `prod-client`; both
|
|
@@ -686,6 +701,31 @@ consequence, and the reason the "mirror a sibling field" practice works: **grant
|
|
|
686
701
|
`Base` also grants it to the worker.** Conversely, when auditing what an end customer can reach,
|
|
687
702
|
remember the reverse — a grant added "for the worker" on `Base` reaches customers too.
|
|
688
703
|
|
|
704
|
+
### ⚠ That union is `Apis_Roles` and NOTHING else — and `appId` does not scope an API key
|
|
705
|
+
|
|
706
|
+
Two facts verified in `api2/Component/Api/V2/V2.php` on 2026-09-10, both of which decide how you
|
|
707
|
+
scope a machine consumer:
|
|
708
|
+
|
|
709
|
+
1. **Roles are ASSIGNED, not merged (~L1639).** On API-key auth, `_Model_Client_Apis_Role` is
|
|
710
|
+
searched for the `apiId` and the result is assigned straight to `$id->client->roles` — the
|
|
711
|
+
`array_merge($id->client->roles, $clientRoles)` line immediately above it is **commented out**.
|
|
712
|
+
Core roles are then derived from those client roles' non-NULL `coreRoleId` values, so a new role
|
|
713
|
+
with `coreRoleId` NULL is perfectly fine for records whose `Core.Records.aclDatabase` is
|
|
714
|
+
`CLIENT`. A key's power is exactly its `Apis_Roles` rows.
|
|
715
|
+
2. **`appId` scoping is void for an API key (~L1518 / L1961 / L3201).** `$this->app` is only set
|
|
716
|
+
when the JWT carries an app uuid, i.e. the domain/user login flow. API-key auth never sets it,
|
|
717
|
+
and every permission filter starts `(!isset($this->app) || is_null($perm->appId) || …)` — so
|
|
718
|
+
**every `appId`-scoped `AclRecordPermissions` row applies to an API key**. A grant deliberately
|
|
719
|
+
narrowed to one app (e.g. `appId = 5`, TOGa Commerce) is fully reachable by any key holding that
|
|
720
|
+
role.
|
|
721
|
+
|
|
722
|
+
> **Consequence: the only way to limit a machine consumer is to give it its OWN role.** Wiring
|
|
723
|
+
> several `Apis` rows to the same shared roles means the key identifies the caller but constrains
|
|
724
|
+
> nothing. Worked example (first per-key role, Compass 2026-09-10):
|
|
725
|
+
> [Giving a Compass API consumer its own scoped key](../../../../clients/compass-usa/workflows/granting-api-access.md),
|
|
726
|
+
> and the auth/token mechanics in
|
|
727
|
+
> [API-Key Authentication](../../api2/features/api-key-authentication-and-scoping.md).
|
|
728
|
+
|
|
689
729
|
## Client-DB grant migrations are re-runnable NO-OPs, and that is the design
|
|
690
730
|
|
|
691
731
|
A `NOT EXISTS`-guarded `AclFieldPermissions` insert that finds its grants already present inserts
|
|
@@ -715,6 +755,16 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
|
|
|
715
755
|
and every repo is on the **same branch** so the generated model matches the DB.
|
|
716
756
|
|
|
717
757
|
## Change history
|
|
758
|
+
- 2026-09-14 — Added the **API-key scoping** rules, from building the first per-key Compass API role.
|
|
759
|
+
Three durable points, all verified in `V2.php`: a half-built chain fails with the exact message
|
|
760
|
+
**`No ACL Logic Groups defined for ACL Record Permissions`** (flags `$aclLogicGroupsDefined` /
|
|
761
|
+
`$aclLogicGroupExpressionsDefined`, ~L3930/L4437/L5614/L5845); an API key's roles are **assigned**
|
|
762
|
+
from `Apis_Roles` and merge with nothing (~L1639, the `array_merge` is commented out), with core
|
|
763
|
+
roles derived from `coreRoleId`; and **`appId` does not scope an API key** because `$this->app` is
|
|
764
|
+
only set on the domain/user login flow (~L1518/L1961), so every `appId`-scoped grant applies — the
|
|
765
|
+
only real lever is a dedicated role per key. Also recorded that Compass's `Core.Records` /
|
|
766
|
+
`RecordFields` / `AclRecordExpressions` / `CustomRecordFields` ids matched prod and client-sandbox
|
|
767
|
+
exactly, as a per-client measurement and not portability. No code changed. (bala)
|
|
718
768
|
- 2026-09-08 — Added the **cross-environment ACL comparison** rules, from a read-only Adyen
|
|
719
769
|
"works in sandbox, not in prod" investigation. Three durable points: **pin the environment from
|
|
720
770
|
the JWT first** (a shared API domain resolves its tenant from the token, so `api.beta.togahub.com`
|
|
@@ -6,12 +6,13 @@ project: _Underscore
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-11
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Test/bootstrap.php
|
|
13
13
|
- _underscore/Test/Prudential/ServiceRequestTest.php
|
|
14
14
|
- test/@Bala/tests/netsuite_salesorder_payload_tests.php
|
|
15
|
+
- _underscore/Test/Compass/
|
|
15
16
|
related:
|
|
16
17
|
- ../../../../clients/prudential/features/service-request-address-validation.md
|
|
17
18
|
- ../../worker2/features/netsuite-salesorder-outbound-push.md
|
|
@@ -44,6 +45,7 @@ Prudential `validateCustomer()` guard (see the service-request address-validatio
|
|
|
44
45
|
4. Run it with:
|
|
45
46
|
|
|
46
47
|
```
|
|
48
|
+
# there is no phpunit binary in the repo - see the section below
|
|
47
49
|
phpunit --bootstrap _underscore/Test/bootstrap.php _underscore/Test
|
|
48
50
|
```
|
|
49
51
|
|
|
@@ -51,6 +53,29 @@ The initial suite covers the `validateCustomer()` guard: absent `customer`, null
|
|
|
51
53
|
empty-string `uuid`, whitespace-only `uuid`, error-message content, and a valid `uuid` passing.
|
|
52
54
|
Verified 6/6 passing against the real code.
|
|
53
55
|
|
|
56
|
+
### There is no PHPUnit - newer suites are standalone runnable scripts
|
|
57
|
+
|
|
58
|
+
`_underscore` still has **no `composer.json` and no `phpunit` binary**, so the `phpunit --bootstrap`
|
|
59
|
+
line above only works if a developer has PHPUnit installed globally. The pattern that actually
|
|
60
|
+
travels is a **plain runnable script**: `php Test/Compass/<file>.php`, **exit 0 on pass**. The
|
|
61
|
+
Compass step-1 approval guard (`_underscore/Test/Compass/`, branch `TRUE-81900`) added three shapes
|
|
62
|
+
worth reusing:
|
|
63
|
+
|
|
64
|
+
1. **Stubbed** - fakes `_Query` and drives the real guard down every branch. No database.
|
|
65
|
+
2. **Real-database** - points `_Query` at a **local MySQL copy** so the generated SQL actually
|
|
66
|
+
executes against the real schema. It finds its own fixtures **by query** and reports **SKIP**
|
|
67
|
+
(not FAIL) when a particular copy lacks them, so it never fails for the wrong reason.
|
|
68
|
+
3. **Replay sweep** - replays real historic approvals and compares **every** verdict against plain
|
|
69
|
+
SQL (6,000 approvals for that guard).
|
|
70
|
+
|
|
71
|
+
The stubbed suite proves the logic, the real-database suite proves the **SQL** is valid against the
|
|
72
|
+
live schema, and the sweep proves the verdict matches production reality. They are complementary -
|
|
73
|
+
picking only one leaves a whole class of bug uncovered.
|
|
74
|
+
|
|
75
|
+
**Prove the suite catches regressions with mutation testing.** There is no coverage tool here, so
|
|
76
|
+
three deliberate breaks were introduced and each had to be caught before the suite was trusted. Do
|
|
77
|
+
this for any hand-rolled suite.
|
|
78
|
+
|
|
54
79
|
## Gotchas / known issues
|
|
55
80
|
|
|
56
81
|
- **Reflection on private methods is the pattern here** — the interceptor validators are private,
|
|
@@ -69,7 +94,25 @@ Verified 6/6 passing against the real code.
|
|
|
69
94
|
`--bootstrap` file to wire up requires. Adding more model tests means extending
|
|
70
95
|
`Test/bootstrap.php` with the stubs that model needs.
|
|
71
96
|
|
|
97
|
+
- **⚠ On a PUT, read request-only intent from `$api->httpPayload`, never from the merged
|
|
98
|
+
record.** The merged record always carries `isApproved` from the **existing DB row**, so a guard
|
|
99
|
+
that read it there treated a plain manager reassignment as a **false approval**. The stubbed suite
|
|
100
|
+
caught this during development - it is the concrete payoff of the stub-the-DB pattern.
|
|
101
|
+
- **A real-database suite must SKIP on a missing fixture, not FAIL.** Local prod copies differ
|
|
102
|
+
between developers; a suite that hard-fails when a specific order is absent gets ignored, and an
|
|
103
|
+
ignored suite protects nothing.
|
|
104
|
+
|
|
72
105
|
## Change history
|
|
106
|
+
- 2026-09-11 - **Corrected the run story and added the three-suite pattern.** `_underscore` has no
|
|
107
|
+
PHPUnit binary, so suites are **standalone scripts** run as `php Test/<path>.php` (exit 0 = pass).
|
|
108
|
+
The Compass step-1 approval guard shipped three complementary suites in `_underscore/Test/Compass/`
|
|
109
|
+
(branch `TRUE-81900`): a **stubbed** one that fakes `_Query` and covers every branch, a
|
|
110
|
+
**real-database** one that runs the generated SQL against a local prod copy and reports **SKIP**
|
|
111
|
+
when fixtures are missing, and a **replay sweep** that re-decides 6,000 real approvals and compares
|
|
112
|
+
each verdict against plain SQL. **Mutation testing** (three deliberate breaks, all caught) was used
|
|
113
|
+
to prove the suite detects regressions. Recorded the real bug the stubbed suite found: on a PUT the
|
|
114
|
+
merged record always carries `isApproved` from the DB row, so reading it there instead of
|
|
115
|
+
`$api->httpPayload` turns a manager reassignment into a false approval. (bala)
|
|
73
116
|
- 2026-08-10 — Noted that the stub + reflect-into-privates pattern also carries to **worker2 actions**
|
|
74
117
|
driven from the `test` repo (`test/@Bala/tests/netsuite_salesorder_payload_tests.php`, 76 tests,
|
|
75
118
|
plain PHP, loading the real `Worker/Netsuite/SalesOrder.php`) — a second worked example of testing
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
| Doc | Summary |
|
|
4
4
|
|-----|---------|
|
|
5
5
|
| [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. |
|
|
6
|
+
| [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>.Apis` row and gets a bearer token. |
|
|
6
7
|
| [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | A `_Model`'s `prePost()` / `postPost()` / `prePut()` / `postPut()` hooks are **not** called by the model. |
|
|
7
8
|
| [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli |
|
|
8
9
|
| [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | `_Component_Api_Cxml` accepts a supplier `ShipNoticeRequest` and translates it into a `POST /v2/advance-shipping-notices` on the V2 JSON engine. |
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: API-Key Authentication (/v2/auth/api) and what a key can actually reach
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: api2
|
|
5
|
+
project: API
|
|
6
|
+
client: shared
|
|
7
|
+
type: feature
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- api2/Component/Api/V2/V2.php
|
|
13
|
+
related:
|
|
14
|
+
- ../architecture.md
|
|
15
|
+
- ./request-logging.md
|
|
16
|
+
- ../../_underscore/features/acl-permission-chain.md
|
|
17
|
+
- ../../../../clients/compass-usa/workflows/granting-api-access.md
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Summary
|
|
21
|
+
|
|
22
|
+
A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>.Apis` row and gets
|
|
23
|
+
a bearer token. **The key's power is exactly the union of its `Apis_Roles` rows and nothing else.**
|
|
24
|
+
Two consequences that surprise people: roles are **assigned, not merged**, so an API key never picks
|
|
25
|
+
up any other role; and `appId` scoping on an ACL grant **does not constrain an API key at all**,
|
|
26
|
+
because API-key auth never sets `$this->app`. If you want one consumer limited to a few reads, the
|
|
27
|
+
only working lever is giving that key **its own role** (see
|
|
28
|
+
[granting Compass API access](../../../../clients/compass-usa/workflows/granting-api-access.md) for
|
|
29
|
+
a worked example).
|
|
30
|
+
|
|
31
|
+
## The authentication call
|
|
32
|
+
|
|
33
|
+
- **Route:** `POST {base}/auth/api`. The public caller base is `https://api.togahub.com/v2`.
|
|
34
|
+
- **`transactionId` goes in the QUERY STRING**, not the body, and must be globally unique
|
|
35
|
+
(reuse → `EV-5`).
|
|
36
|
+
- **Body is exactly three fields:**
|
|
37
|
+
- `client` — the `Core.Clients` uuid
|
|
38
|
+
- `api` — the `Client_<Client>.Apis` row uuid
|
|
39
|
+
- `secret` — that row's secret
|
|
40
|
+
- **Response:** `data.tokens.access` and `data.tokens.refresh`, with
|
|
41
|
+
`expiresIn.access = 3600` (1 hour) and `expiresIn.refresh = 2592000` (30 days). Subsequent calls
|
|
42
|
+
carry `Authorization: Bearer <access>`.
|
|
43
|
+
|
|
44
|
+
> **⚠ Do not copy the `authority` field into caller documentation.** The response envelope's
|
|
45
|
+
> `authority` reports the **internal** server that handled the request (observed: `api1.togahub.com`,
|
|
46
|
+
> `api-writer.togahub.com`). Callers must always use `https://api.togahub.com/v2`.
|
|
47
|
+
|
|
48
|
+
### `maxAuthsPerHour` throttles authentication, not traffic
|
|
49
|
+
|
|
50
|
+
`Apis.maxAuthsPerHour` (client keys are typically `255`) is enforced at ~L1131 of `V2.php` by
|
|
51
|
+
counting `Logs_<Client>.Api` rows with `isAuthRequest = 1` in the last hour. Over the limit returns
|
|
52
|
+
a message telling the caller to re-use tokens. Tell integrators to authenticate once, cache the
|
|
53
|
+
access token for the hour, and use the refresh token — not to call `/auth/api` per request.
|
|
54
|
+
|
|
55
|
+
## How an API key's authorization is decided
|
|
56
|
+
|
|
57
|
+
At `V2.php` ~L1639, once the `Apis` row authenticates:
|
|
58
|
+
|
|
59
|
+
1. `_Model_Client_Apis_Role` is searched for that `apiId`.
|
|
60
|
+
2. Those `roleId`s are **assigned** to `$id->client->roles` — the `array_merge(...)` line directly
|
|
61
|
+
above it is **commented out**. So the key holds those roles and only those roles.
|
|
62
|
+
3. Core roles are then derived by selecting `Client_<Client>.Roles.coreRoleId` for those client
|
|
63
|
+
roles, skipping NULLs.
|
|
64
|
+
|
|
65
|
+
**A new client role with `coreRoleId` NULL is fine** for any record whose `Core.Records.aclDatabase`
|
|
66
|
+
is `CLIENT`. Core roles only matter for records flagged `CORE` (the permission scan at ~L3159+).
|
|
67
|
+
|
|
68
|
+
### ⚠ `appId` cannot scope an API key — every appId-scoped grant applies
|
|
69
|
+
|
|
70
|
+
`$this->app` is only set when the JWT carries an app uuid (`V2.php` L1518 / L1961), which is the
|
|
71
|
+
**domain/user login** flow. On API-key auth it is never set, and every permission filter reads:
|
|
72
|
+
|
|
73
|
+
```php
|
|
74
|
+
(!isset($this->app) || is_null($perm->appId) || $perm->appId == $this->app->id)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
With `$this->app` unset the first clause short-circuits **true**, so **all** `appId`-scoped
|
|
78
|
+
`AclRecordPermissions` rows for the key's roles apply. A grant that was deliberately narrowed to one
|
|
79
|
+
app (e.g. `appId = 5`, TOGa Commerce) is fully available to any API key holding that role. Scope a
|
|
80
|
+
machine consumer with a **dedicated role**, never with `appId`.
|
|
81
|
+
|
|
82
|
+
### The grant chain still applies
|
|
83
|
+
|
|
84
|
+
An `AclRecordPermissions` row on its own grants nothing — the request fails with
|
|
85
|
+
`No ACL Logic Groups defined for ACL Record Permissions`. Full rules, tables and checklist:
|
|
86
|
+
[ACL Permission Chain](../../_underscore/features/acl-permission-chain.md).
|
|
87
|
+
|
|
88
|
+
## Auditing what a key actually did
|
|
89
|
+
|
|
90
|
+
`Logs_<Client>.Api` carries `apiId`, `dtStamp`, `method`, `route`, `responseCode`, `queryString`,
|
|
91
|
+
`requestPayload` and `responsePayload`. **Filter on `apiId`** to get one consumer's exact calls and
|
|
92
|
+
responses — this is how you answer "what did this integrator really use". Note the client-vs-core
|
|
93
|
+
log split (failed writes and pre-scope auth failures land in core `Logs`):
|
|
94
|
+
[V2 Request Logging](./request-logging.md).
|
|
95
|
+
|
|
96
|
+
## Gotchas
|
|
97
|
+
|
|
98
|
+
- **Roles are replaced, not merged.** Auditing a key means reading `Apis_Roles` and nothing else.
|
|
99
|
+
- **`appId` is not a security boundary for machine callers.** See above.
|
|
100
|
+
- **A shared role across keys means the key identifies the caller but does not limit it.** If
|
|
101
|
+
several `Apis` rows point at the same roles, every one of them can do everything those roles
|
|
102
|
+
allow, regardless of what the integration's published documentation says.
|
|
103
|
+
- **Never record a secret value in a doc or a committed SQL file.** Document only where it lives
|
|
104
|
+
(the `Client_<Client>.Apis` row) and hand it over out of band.
|
|
105
|
+
|
|
106
|
+
## Change history
|
|
107
|
+
- 2026-09-14 — Created from the Compass Refresh-team API build. Recorded the `/auth/api` call shape
|
|
108
|
+
(`transactionId` in the query string; body `{client, api, secret}`), the 1 hour / 30 day token
|
|
109
|
+
lifetimes, the `maxAuthsPerHour` auth-only throttle, and two authorization facts verified in
|
|
110
|
+
`V2.php`: API-key roles are **assigned** from `Apis_Roles` (the `array_merge` is commented out,
|
|
111
|
+
~L1639), and `$this->app` is never set for key auth so **every `appId`-scoped ACL row applies**.
|
|
112
|
+
Also noted the `authority` response field exposes the internal host and must not be published to
|
|
113
|
+
callers. (bala)
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-11
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -623,6 +623,13 @@ and the failing environment**. It is a small table, and the drift is usually exa
|
|
|
623
623
|
specifics belong in `_Model_<Slug>_X` overrides + interceptor hooks, **not** in `V2.php`.
|
|
624
624
|
|
|
625
625
|
## Change history
|
|
626
|
+
- 2026-09-11 - Live instance of the "not registered = dead code" rule: `recordId 179`
|
|
627
|
+
(approval-decisions) carried only PRE/PUT, POST/PUT and POST/POST in **both** Compass tenants, so a
|
|
628
|
+
newly added `_Model_Compass_ApprovalDecision::prePost()` would never have run and would have raised
|
|
629
|
+
**no error**. `dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql`
|
|
630
|
+
and its `Client_CompassCanada` twin add the PRE/POST row, `NOT EXISTS`-guarded so they are safe to
|
|
631
|
+
re-run. Checking the registration table **before** writing a new hook is the cheap step that avoids
|
|
632
|
+
this. (bala)
|
|
626
633
|
|
|
627
634
|
- 2026-09-02 — Recorded the **deliberate shared-model exception**: a hook may live on
|
|
628
635
|
`_Model_Client_X` when the behaviour is client-agnostic and the interceptor row is the gate (no row,
|
|
@@ -6,7 +6,7 @@ project: API
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["mhammontree", "dfranks", "bala", "jcardinal"]
|
|
11
11
|
files:
|
|
12
12
|
- api2/Component/Api/V2/V2.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
- _underscore/Model/Core/Logs/Api.php
|
|
16
16
|
related:
|
|
17
17
|
- ../architecture.md
|
|
18
|
+
- ./api-key-authentication-and-scoping.md
|
|
18
19
|
- ./api-payload-interceptors.md
|
|
19
20
|
- ./v2-api-error-codes.md
|
|
20
21
|
- ./v2-deadlock-retry.md
|
|
@@ -362,6 +363,16 @@ fact AIG **does** post live entitlements — the writes were **failing (HTTP 500
|
|
|
362
363
|
core `Logs`**, not `Logs_Aig`. See
|
|
363
364
|
[AIG entitlement intake → Intake is a live API feed](../../../../clients/aig/features/entitlement-intake.md).
|
|
364
365
|
|
|
366
|
+
### Auditing ONE API consumer — filter `Logs_<Client>.Api` on `apiId`
|
|
367
|
+
|
|
368
|
+
`Logs_<Client>.Api` carries `apiId` alongside `dtStamp`, `method`, `route`, `responseCode`,
|
|
369
|
+
`queryString`, `requestPayload` and `responsePayload`. Filtering on a single `apiId` is how you
|
|
370
|
+
answer "what did this integrator actually call, and what did it get back" — it was used to verify
|
|
371
|
+
the new Compass Refresh key's routes and to lift real sample responses for its reference document.
|
|
372
|
+
`isAuthRequest = 1` rows are that key's `/auth/api` calls, and are what `Apis.maxAuthsPerHour`
|
|
373
|
+
counts. The blind spots above still apply — the key's **failed writes land in core `Logs`**. See
|
|
374
|
+
[API-Key Authentication](./api-key-authentication-and-scoping.md).
|
|
375
|
+
|
|
365
376
|
## Querying prod `Logs.Api` without timing out (narrow first, fetch payloads last)
|
|
366
377
|
|
|
367
378
|
Prod `Logs.Api` is large enough that **any `LIKE` over `requestPayload` / `responsePayload`
|
|
@@ -426,6 +437,11 @@ re-send.
|
|
|
426
437
|
> replay operationally, not by pasting payloads into the KB.
|
|
427
438
|
|
|
428
439
|
## Change history
|
|
440
|
+
- 2026-09-14 — Added the per-consumer audit note: filter `Logs_<Client>.Api` on **`apiId`** to get
|
|
441
|
+
one API key's exact routes, payloads and response codes (`isAuthRequest = 1` rows are its
|
|
442
|
+
`/auth/api` calls, the ones `Apis.maxAuthsPerHour` counts). Used to verify the new Compass Refresh
|
|
443
|
+
key. Cross-linked the new
|
|
444
|
+
[API-Key Authentication](./api-key-authentication-and-scoping.md) doc. (bala)
|
|
429
445
|
- 2026-09-10 — Sharpened the client-vs-core routing on the auth codes: a **401 always lands in the
|
|
430
446
|
base `Logs`** (decided in the auth chain ~L2088 before routing, client unresolved) while a **403
|
|
431
447
|
lands in `Logs_<Client>`** (client already known) — verified on Prudential 31 Aug–1 Sep, where
|
|
@@ -6,13 +6,15 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
10
|
-
owners: [apeterson]
|
|
9
|
+
updated: 2026-09-11
|
|
10
|
+
owners: [apeterson, bala]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.ts
|
|
13
13
|
- toga25-supply/src/pages/SalesOrders/helpers/evaluateEnableRule.test.ts
|
|
14
14
|
- toga25-supply/src/pages/SalesOrders/helpers/buildPatchedTenantFields.ts
|
|
15
15
|
- toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowDetailInputs.tsx
|
|
16
|
+
- toga25-supply/src/layout/RecordApprovalModal/view/ApprovalFlowForm.tsx
|
|
17
|
+
- toga25-supply/src/surface/evaluateSurfaceRule.ts
|
|
16
18
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASS/approvalActionFields.json
|
|
17
19
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderApprovalModalsLayout/viewModel/FIELDS/COMPASSCANADA/approvalActionFields.json
|
|
18
20
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
|
|
@@ -151,7 +153,38 @@ Retire the shim once all configs express flags as `Rule`.
|
|
|
151
153
|
comparison must stay config-only; adding to `NAMED_RULES` is the rare exception, not the
|
|
152
154
|
default.
|
|
153
155
|
|
|
156
|
+
- **⚠ A named rule must use a TRUTHY check, never `!== null`.** `stepTwoAssigned` tested
|
|
157
|
+
`stepTwo.AssignedTo?.uuid !== null`, which is **true** when the API omits `AssignedTo` altogether
|
|
158
|
+
(`undefined !== null`) - so a missing step-2 manager *enabled* the Approve button and let an admin
|
|
159
|
+
strand the order. Now a truthy check on the uuid. The `poNumberEntered` rule directly below it
|
|
160
|
+
already carried a comment warning about this exact trap. The matching server-side guard is in
|
|
161
|
+
[Compass Approval-Decision Flow](../../../clients/compass-usa/features/approval-decision-flow.md).
|
|
162
|
+
- **⚠ The evaluator is DUPLICATED in the surface layer.** `src/surface/evaluateSurfaceRule.ts`
|
|
163
|
+
holds its own copy of the named rules and had the identical `!== null` bug. A change to a named
|
|
164
|
+
rule must be made in **both** files - fixing one is not a fix.
|
|
165
|
+
- **Resolving a flag is not the same as gating the submit.** `isApproveButtonEnabled` disabled the
|
|
166
|
+
approve controls but **not Save Changes**, and "Approve All Stages" writes the decision straight
|
|
167
|
+
into form state, so a blocked approval still posted. `ApprovalFlowForm.tsx` now gates the Save
|
|
168
|
+
button **and** `handleValidSubmit` on an `isApprovalBlocked` flag, which requires **both** that
|
|
169
|
+
approving is disabled **and** that the approve is *newly* selected - compared against
|
|
170
|
+
`defaultValues`, because a stage approved earlier already defaults to `"1"`. The disabled Save
|
|
171
|
+
reuses the existing tooltip.
|
|
172
|
+
- **`stepTwoAssigned` is configured only for COMPASS and COMPASSCANADA** (`approvalActionFields.json`).
|
|
173
|
+
DEFAULT and QUAD gate Approve on `order.purchaseOrderDetails.uuid` instead, so a change to this
|
|
174
|
+
rule cannot reach them.
|
|
175
|
+
|
|
154
176
|
## Change history
|
|
177
|
+
- 2026-09-11 - **Fixed `stepTwoAssigned` reading a missing assignee as assigned.** The rule tested
|
|
178
|
+
`stepTwo.AssignedTo?.uuid !== null`, which is true when the API omits `AssignedTo` entirely, so the
|
|
179
|
+
Approve button was enabled on orders with no step-2 manager and admins stranded four prod orders.
|
|
180
|
+
Changed to a truthy uuid check, **in both** `helpers/evaluateEnableRule.ts` and the duplicate copy
|
|
181
|
+
in `src/surface/evaluateSurfaceRule.ts`. Also gated **Save Changes** (and `handleValidSubmit`) in
|
|
182
|
+
`ApprovalFlowForm.tsx` on a new `isApprovalBlocked` flag - disabling only the approve controls was
|
|
183
|
+
not enough, because "Approve All Stages" writes the decision into form state and the save still
|
|
184
|
+
posted; the flag compares against `defaultValues` so a previously approved stage is not treated as
|
|
185
|
+
a new approval. Affects COMPASS and COMPASSCANADA only; DEFAULT/QUAD gate on
|
|
186
|
+
`purchaseOrderDetails.uuid`. Ticket TRUE-81900. **Uncommitted on `_production` at capture time.**
|
|
187
|
+
(bala)
|
|
155
188
|
- 2026-08-31 — **Added named rule `stepOneUndecided` and extended `buildPatchedTenantFields` to
|
|
156
189
|
resolve flags on `approvalWorkflow.stages[].inputs[]`**, so a VIP-pre-approved step-2 manager can
|
|
157
190
|
be reassigned (Compass USA + Compass Canada). `ApprovalFlowDetailInputs` now treats a resolved
|
|
@@ -6,8 +6,8 @@ project: TOGa 2.5 Supply
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
10
|
-
owners: [apeterson, tcox, jcardinal]
|
|
9
|
+
updated: 2026-09-11
|
|
10
|
+
owners: [apeterson, tcox, jcardinal, bala]
|
|
11
11
|
files:
|
|
12
12
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/hooks/usePurchaseOrderDetails.ts
|
|
13
13
|
- toga25-supply/src/pages/SalesOrders/view/SalesOrderRecordModalLayout/viewModel/useSalesOrderRecordModalLayoutModel.tsx
|
|
@@ -358,7 +358,37 @@ Scaffold per the `ItemRecordModalLayout` pattern (`src/layout/ItemRecordModalLay
|
|
|
358
358
|
[v2-rest-query-contract](../../api2/features/v2-rest-query-contract.md) and
|
|
359
359
|
[Transfer Orders page](transfer-orders-page.md).
|
|
360
360
|
|
|
361
|
+
- **⚠ The approval modal has TWO approve paths, and the payload shape tells them apart.**
|
|
362
|
+
Both are dispatched from `RecordApprovalModal/hooks/useApprovalDecisionsMutation.ts`:
|
|
363
|
+
- `type: "single"` (the green per-stage tick) goes through `makeApprovalDecision` /
|
|
364
|
+
`updateApprovalDecision` in `api/approvalDecisionsApi.ts` and sends `isApproved` as a
|
|
365
|
+
**boolean** plus a `note` field.
|
|
366
|
+
- `type: "workflow"` ("Approve All Stages" / Approval Workflow) goes through
|
|
367
|
+
`helpers/handleFormatApprovalWorkflowPayload.ts` and sends `isApproved` as the **string `"1"`**
|
|
368
|
+
with **no** note.
|
|
369
|
+
|
|
370
|
+
That difference is how you tell from `Logs_<Tenant>.Api` which button a user actually pressed -
|
|
371
|
+
the first step in any approval investigation, and the reason a bug can live in one path only.
|
|
372
|
+
- **⚠ The single-stage approve must NOT send `assignedToUserId`.** `makeApprovalDecision()`
|
|
373
|
+
and `updateApprovalDecision()` hardcoded `assignedToUserId: { uuid: userUuid }`, so every
|
|
374
|
+
green-tick approve re-sent the **logged-in user** as the stage assignee whether or not anyone
|
|
375
|
+
touched the field. An admin approving the manager stage therefore **overwrote the real manager
|
|
376
|
+
with themselves**, and the order timeline then read "Manager Approved" - it looked like the
|
|
377
|
+
manager had approved. Removed from both functions; `decidedByUserId` already records who approved.
|
|
378
|
+
The workflow path was always correct (it sends the assignee only when it actually changed), which
|
|
379
|
+
is exactly why the bug never reproduced through "Approve All Stages". Real orders: SA136901,
|
|
380
|
+
SA136807, SA137080. Client context:
|
|
381
|
+
[Compass Approval-Decision Flow](../../../clients/compass-usa/features/approval-decision-flow.md).
|
|
382
|
+
|
|
361
383
|
## Change history
|
|
384
|
+
- 2026-09-11 - ⚠ **Removed the hardcoded `assignedToUserId` from the single-stage approve
|
|
385
|
+
payload** (`approvalDecisionsApi.ts`). Every green-tick approve was re-sending the logged-in user
|
|
386
|
+
as the stage assignee, so an admin approving the manager stage silently replaced the real manager
|
|
387
|
+
and the timeline then read "Manager Approved" (prod: SA136901, SA136807, SA137080). Also recorded
|
|
388
|
+
the **two approve paths and their differing payload shapes** (`single` = boolean `isApproved` +
|
|
389
|
+
`note`; `workflow` = string `"1"`, no note), which is how the two are told apart in
|
|
390
|
+
`Logs_<Tenant>.Api` and why the workflow path never showed the bug. Ticket TRUE-81900.
|
|
391
|
+
**Uncommitted on `_production` at capture time.** (bala)
|
|
362
392
|
- 2026-09-04 — Added the Pattern 6 gotcha that the **item modal's VIEW is Surface-driven while its
|
|
363
393
|
EDIT is still JSON**: a field visible only in edit mode means a missing client `SurfaceOverride`,
|
|
364
394
|
not a data bug (found on Compass's "Restrict to Persona" field). Detail in
|
|
@@ -6,7 +6,7 @@ project: Worker
|
|
|
6
6
|
client: shared
|
|
7
7
|
type: feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- worker2/Worker/Netsuite/SalesOrder.php
|
|
@@ -47,9 +47,16 @@ re-read this doc if you last saw it on 2026-09-02: **the NetSuite customer is th
|
|
|
47
47
|
destination location**, and `description` is now sent as the NetSuite **`memo`**. Committed as
|
|
48
48
|
worker2 **`cbafd25`** on `_production` and deployed.
|
|
49
49
|
|
|
50
|
-
**
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
**Deployed 2026-09-14 and confirmed working end to end.** Two more things landed since 2026-09-08
|
|
51
|
+
and are the reason to re-read this doc:
|
|
52
|
+
|
|
53
|
+
1. **The push was sending no ship-to address at all**, so NetSuite used the customer's default site
|
|
54
|
+
on every order. Fixed by reading the destination Location's address, which needs a depth-6 read.
|
|
55
|
+
2. **Sending that address then broke every create with an HTTP 400 tax error** from 2026-09-09 to
|
|
56
|
+
2026-09-14. The cause was **NetSuite customer data, not code** — the customer still carried the
|
|
57
|
+
retired tax group **-8**. Read
|
|
58
|
+
[the tax-item section](#-the-customers-netsuite-tax-item-must-be-avatax-or-every-create-400s)
|
|
59
|
+
before you touch anything tax related here, and before onboarding a new client to this push.
|
|
53
60
|
|
|
54
61
|
## Key files / entry points
|
|
55
62
|
|
|
@@ -104,11 +111,11 @@ status columns, so none are sent.
|
|
|
104
111
|
| `tranDate` | `dateOrder`, date only; **omitted** when null |
|
|
105
112
|
| `otherRefNum` | the PO number (the developer's explicit requirement) |
|
|
106
113
|
| `memo` | `TransferOrders.description` — the NYCHH custom form renders it as **"ORDER DESCRIPTION"** |
|
|
107
|
-
| `shippingAddress` | the **destination** Location's `primaryLocationAddress.address` (added 2026-09-09,
|
|
114
|
+
| `shippingAddress` | the **destination** Location's `primaryLocationAddress.address` (added 2026-09-09, live 2026-09-14). The key is **omitted** when the destination has no address — and **omitting it is a bug, not a safe default**: NetSuite then ships to the customer's default site. See below. |
|
|
108
115
|
| `externalId` | `toga-to-<transfer order uuid>` |
|
|
109
116
|
| line `item` / `quantity` | each transfer-order item |
|
|
110
117
|
| line `rate` | **0** — this is the zero-value part |
|
|
111
|
-
| line `location` | the **origin** warehouse |
|
|
118
|
+
| line `location` | the **origin** warehouse. **`location` is NOT a ship-to** — NetSuite never uses a line's location for delivery. |
|
|
112
119
|
|
|
113
120
|
### ⚠ Destination = CUSTOMER, origin = WAREHOUSE (this was built backwards first)
|
|
114
121
|
|
|
@@ -146,6 +153,12 @@ no NetSuite customer for.
|
|
|
146
153
|
> multi-site tenant those are different NetSuite records, and the difference is invisible until
|
|
147
154
|
> someone picks the non-default site.
|
|
148
155
|
|
|
156
|
+
**A consequence worth knowing before you go hunting in supply:** `resolveTransferOrderCustomer()`
|
|
157
|
+
always lands on the **client's** customer, so **changing the destination location cannot change the
|
|
158
|
+
NetSuite customer**. When a problem is customer-level (a tax item, a hold, a wrong subsidiary),
|
|
159
|
+
picking a different destination site is not a workaround — the destination only supplies the
|
|
160
|
+
ship-to address.
|
|
161
|
+
|
|
149
162
|
**Verified live 2026-09-08 on SO 289187 (id 7463537):** destination Woodhull (customer **31925**),
|
|
150
163
|
customer on the NetSuite order came out **4490 / NYC Health + Hospitals**, and ORDER DESCRIPTION
|
|
151
164
|
showed the transfer order's `description`.
|
|
@@ -155,7 +168,7 @@ destination whose customer id already equals the client's own (**30205** and **3
|
|
|
155
168
|
and new rules produce the same id for them.
|
|
156
169
|
|
|
157
170
|
|
|
158
|
-
### ⚠ The ship-to address was NEVER sent — and it needs a depth-6 read (fixed 2026-09-09,
|
|
171
|
+
### ⚠ The ship-to address was NEVER sent — and it needs a depth-6 read (fixed 2026-09-09, live 2026-09-14)
|
|
159
172
|
|
|
160
173
|
`buildSalesOrderShapeFromTransferOrder()` never set a `shipToAddress` key, and the `shippingAddress`
|
|
161
174
|
block in `buildNetSuiteOrder()` (~L922) only runs when that key exists. So **every transfer order
|
|
@@ -210,6 +223,35 @@ null, and the code fix would have been a **silent no-op**. Fixed by
|
|
|
210
223
|
> row drops the branch silently instead of erroring — see
|
|
211
224
|
> [nested FK ACL embedding](../../api2/features/nested-fk-acl-embedding.md).
|
|
212
225
|
|
|
226
|
+
#### ⚠ Dropping the address is NOT a workaround — NetSuite ships to the customer default, silently
|
|
227
|
+
|
|
228
|
+
When the payload omits the ship-to address the create **succeeds**, so it looks like a fix. It is
|
|
229
|
+
not. NetSuite falls back to whichever entry in the **customer's** address book is flagged
|
|
230
|
+
`defaultshipping = T`, with no warning anywhere in the response. Because the customer is the client's
|
|
231
|
+
whole account (previous section), that is one fixed site for **every** destination — NYCHH customer
|
|
232
|
+
28908 has **124** saved addresses and the default is *NYCHHC Jacobi Dental* (address id 6568575,
|
|
233
|
+
1400 Pelham Parkway South, Bronx NY 10461). **5 of the 6** transfer orders that reached NetSuite
|
|
234
|
+
before the fix shipped there regardless of their real destination (orders 7453238, 7462009, 7463537,
|
|
235
|
+
7467799, 7482373); only 7462031 was right, and only by accident — it predates the switch to the
|
|
236
|
+
parent customer and was built on destination sub-customer 33674, whose own default happens to be the
|
|
237
|
+
destination.
|
|
238
|
+
|
|
239
|
+
> **Durable rule: "drop the address to get past the 400" is a wrong-address bug, not a fix.** A
|
|
240
|
+
> successful create proves nothing about where the goods go. And the line-level `location` does not
|
|
241
|
+
> save you — that is the **source** warehouse (NetSuite internal id 117 for NYCHH) and NetSuite never
|
|
242
|
+
> uses it for delivery.
|
|
243
|
+
|
|
244
|
+
#### Depth 6 is verified, and there is a decoy branch next to it
|
|
245
|
+
|
|
246
|
+
`_Model_Client_TransferOrder::NETSUITE_READ_DEPTH = 6` was checked against the raw
|
|
247
|
+
`Core.WorkerJobs.parameters` blob of a real job, not just reasoned about: at 6 the full
|
|
248
|
+
`destinationLocation.primaryLocationAddress.address.state.country` branch comes through.
|
|
249
|
+
|
|
250
|
+
**The decoy:** the same payload also carries `contact.contactLocations[].location...address`, and
|
|
251
|
+
*that* branch runs out of depth and has **no `state`**. If you are debugging a missing state or
|
|
252
|
+
country, check which branch you are reading before you raise the depth again — the destination
|
|
253
|
+
branch is complete.
|
|
254
|
+
|
|
213
255
|
### `description` → `memo` cannot leak into a plain sales order
|
|
214
256
|
|
|
215
257
|
`memo` is sent only from the transfer-order shape. There is **no `memo` RecordField and no `memo`
|
|
@@ -229,6 +271,76 @@ bridge first** (that is what the Create Transfer Order modal writes), and falls
|
|
|
229
271
|
[SO↔PO bridge direction](../../_underscore/features/sales-order-purchase-order-bridge-direction.md)
|
|
230
272
|
for why the bridge, not the column, is the real source.
|
|
231
273
|
|
|
274
|
+
## ⚠ The customer's NetSuite tax item must be AVATAX, or every create 400s
|
|
275
|
+
|
|
276
|
+
**Symptom.** Every transfer order create fails with NetSuite HTTP **400**:
|
|
277
|
+
|
|
278
|
+
```
|
|
279
|
+
Error while accessing a resource. Invalid shippingtaxcode reference key -8 for subsidiary 1.
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
`Core.WorkerJobs` ids **1534082, 1534805, 1535235, 1536824, 1536920** (all 2026-09-14). It started on
|
|
283
|
+
**2026-09-09**, the day the push began sending a ship-to address, and it blocked **every** transfer.
|
|
284
|
+
|
|
285
|
+
**Cause — customer data, not code.** NetSuite customer **28908** (NYC Health + Hospitals, the
|
|
286
|
+
client-level customer from `Core.Clients.netsuiteCustomerInternalId`) still carried the **retired tax
|
|
287
|
+
group -8** as its `taxitem`. When an order carries an **inline ship-to address**, NetSuite rebuilds
|
|
288
|
+
the address on the order and re-checks the shipping tax code against **subsidiary 1**; `-8` fails that
|
|
289
|
+
check and the whole create is rejected. No address, no re-check — which is exactly why this only
|
|
290
|
+
appeared once the address fix went in.
|
|
291
|
+
|
|
292
|
+
**Fix.** NetSuite ops changed `taxitem` on customer **28908** from `-8` to **5050 (AVATAX)**. No code
|
|
293
|
+
change. Verified working the same day. This is the normal value here: **7151** of subsidiary 1's
|
|
294
|
+
customers are already on 5050, and only **61** are still on -8.
|
|
295
|
+
|
|
296
|
+
> **Durable rule: before a new client's first transfer order, check that its NetSuite customer's
|
|
297
|
+
> `taxitem` is 5050 AVATAX.** This is an onboarding step, not a bug to debug later — the first order
|
|
298
|
+
> for any customer still on -8 will fail with the message above.
|
|
299
|
+
|
|
300
|
+
### ⚠ Pinning `taxItem` / `shippingTaxCode` in the payload does NOTHING
|
|
301
|
+
|
|
302
|
+
This cost several failed attempts, so it is written down in the code as well (comment in
|
|
303
|
+
`buildNetSuiteOrder()` just below the `shippingAddress` block):
|
|
304
|
+
|
|
305
|
+
- **NetSuite derives both fields from the customer AFTER applying the address and discards whatever
|
|
306
|
+
we send.** Proven twice: jobs **1534805** and **1536824** both sent `-7` and both still failed
|
|
307
|
+
reporting **-8**.
|
|
308
|
+
- **Stripping `state` and `country` from the shipping address does not help either** (job
|
|
309
|
+
**1535235**). *Any* inline address at all triggers the re-check.
|
|
310
|
+
|
|
311
|
+
A `NETSUITE_TAX_CODE__NOT_TAXABLE = '-7'` constant and its pin block were added and then **removed
|
|
312
|
+
again**. A comment now sits in their place warning not to re-add them. **Do not re-add them.**
|
|
313
|
+
|
|
314
|
+
### ⚠ -8 is NOT a dead id — it lives in `taxgroup`, not `salestaxitem`
|
|
315
|
+
|
|
316
|
+
An earlier code comment claimed -8 "points at nothing". **That was wrong**, and it sent the debug off
|
|
317
|
+
in the wrong direction:
|
|
318
|
+
|
|
319
|
+
| Id | Name | Table |
|
|
320
|
+
|---|---|---|
|
|
321
|
+
| **-8** | `-Not Taxable-` | **`taxgroup`** — exists and is **active** |
|
|
322
|
+
| **-7** | `-Not Taxable-` | **`salestaxitem`** |
|
|
323
|
+
|
|
324
|
+
Two different tables, the same display name. **Checking only `salestaxitem` for a tax code id will
|
|
325
|
+
tell you it does not exist when it does.** Query both before concluding an id is dead — SuiteQL
|
|
326
|
+
details in [NetSuite SuiteQL API reference](../../../../1.0/apps/library/features/netsuite-suiteql-api-reference.md).
|
|
327
|
+
|
|
328
|
+
### Who else is still on -8 (scanned 2026-09-14, re-check before onboarding)
|
|
329
|
+
|
|
330
|
+
**61** NetSuite customers on subsidiary 1 remain on tax group -8. The ones that touch Toga:
|
|
331
|
+
|
|
332
|
+
| NetSuite customer | Who | Risk today |
|
|
333
|
+
|---|---|---|
|
|
334
|
+
| **34283** Yale New Haven Health | a **client-level** customer in `Core.Clients` | will fail its **first** transfer order; client DB has **0** transfer orders today |
|
|
335
|
+
| **2661** Endeavor Health | a **client-level** customer in `Core.Clients` | same — **0** transfer orders today |
|
|
336
|
+
| **31584** Coney Island | a NYCHH **sub-customer** in `Client_Nychh.Customers` | dormant, **0** sales orders |
|
|
337
|
+
| **1101** Miami-Dade Police Department | used by `Client_Miamidade` | nothing failing today |
|
|
338
|
+
| **32030** Security 101 | used by `Client_Miamidade` | nothing failing today |
|
|
339
|
+
|
|
340
|
+
All **17** other NetSuite customer ids used by `Client_Nychh` are already on **5050 AVATAX**. A scan of
|
|
341
|
+
`Core.WorkerJobs` shows this error has only ever hit the transfer-order action — 5 jobs, all on
|
|
342
|
+
2026-09-14.
|
|
343
|
+
|
|
232
344
|
## The trigger — `postPost` on the SHARED `_Model_Client_TransferOrder`
|
|
233
345
|
|
|
234
346
|
`_Model_Client_TransferOrder::postPost` queues the action. **Not** `_Model_Nychh_TransferOrder` — a
|
|
@@ -363,8 +475,11 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
|
|
|
363
475
|
`postPost` reads **zero lines** and the push aborts — the trigger would then have to move. See
|
|
364
476
|
[Transfer Orders page](../../toga25-supply/features/transfer-orders-page.md).
|
|
365
477
|
- **dev-sandbox metadata is incomplete** (record 325 + its RecordFields + both inherent-child rows).
|
|
366
|
-
-
|
|
367
|
-
|
|
478
|
+
- ~~**The ship-to fix is written but NOT deployed.**~~ **Closed 2026-09-14** — deployed, and
|
|
479
|
+
confirmed working by the developer after NetSuite customer 28908 moved off tax group -8.
|
|
480
|
+
- **The -8 tax-group list is a snapshot (2026-09-14).** Re-run the check before onboarding any new
|
|
481
|
+
client to this push; **34283 Yale New Haven Health** and **2661 Endeavor Health** are the two that
|
|
482
|
+
will fail on their first order.
|
|
368
483
|
- **Undecided: should the delivery contact go into the NetSuite `attention` field?** NetSuite ship-to
|
|
369
484
|
addresses carry a person on the first line (order **289193** shows *Frederick Roberts*, the Bellevue
|
|
370
485
|
delivery contact). `TransferOrders.contactId` exists as an FK on `_Model_Client_TransferOrder` but
|
|
@@ -390,6 +505,9 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
|
|
|
390
505
|
a lost PO number aborts the push. Do not "optimise" the depth.
|
|
391
506
|
- **This action does not retry itself**, exactly like the sales-order push: a throw records
|
|
392
507
|
`isSuccess = 0` and stops. An event-triggered push fires once.
|
|
508
|
+
- **A NetSuite HTTP 400 naming a tax code is a CUSTOMER data problem, not a payload problem.** Check
|
|
509
|
+
the customer's `taxitem` first; do not try to pin the tax fields in the payload (they are ignored)
|
|
510
|
+
and do not drop the address to make the error go away (that silently ships to the wrong site).
|
|
393
511
|
- **`git diff --stat` after any programmatic full-file write to `_underscore`.** A full-file write
|
|
394
512
|
with an editor default flipped CRLF → LF on `_underscore/Model/Nychh/TransferOrder.php` — content
|
|
395
513
|
identical, git reported it modified with no content hunks. Restored with `git checkout --`. The
|
|
@@ -397,6 +515,30 @@ Stub detail for anyone extending it: `_Model_True` is stubbed with **only** the
|
|
|
397
515
|
|
|
398
516
|
## Change history
|
|
399
517
|
|
|
518
|
+
- 2026-09-14 — **Deployed the ship-to fix, then spent the day on the 400 it exposed. Root cause was
|
|
519
|
+
NetSuite customer data.** From 2026-09-09 every transfer order create failed with *"Invalid
|
|
520
|
+
shippingtaxcode reference key -8 for subsidiary 1"* (`Core.WorkerJobs` 1534082, 1534805, 1535235,
|
|
521
|
+
1536824, 1536920). NetSuite customer **28908** still carried the retired tax group **-8**; sending
|
|
522
|
+
an inline ship-to address makes NetSuite rebuild the address and re-check the shipping tax code
|
|
523
|
+
against subsidiary 1, and -8 fails it. **NetSuite ops moved 28908's `taxitem` from -8 to 5050
|
|
524
|
+
(AVATAX)** — no code change, and the push was confirmed working end to end. Recorded three things
|
|
525
|
+
that cost real time: **pinning `taxItem`/`shippingTaxCode` in the payload is ignored** (NetSuite
|
|
526
|
+
derives both from the customer *after* the address — jobs 1534805 and 1536824 both sent -7 and both
|
|
527
|
+
still failed on -8), so the `NETSUITE_TAX_CODE__NOT_TAXABLE = '-7'` constant and its pin block were
|
|
528
|
+
added and then removed with a warning comment left behind; **stripping `state`/`country` does not
|
|
529
|
+
help either** (job 1535235) because any inline address triggers the re-check; and **-8 is not a dead
|
|
530
|
+
id** — it is active in `taxgroup` as "-Not Taxable-", while `salestaxitem` holds -7 with the same
|
|
531
|
+
name, so an earlier comment claiming it "points at nothing" was wrong. Also documented that
|
|
532
|
+
**omitting the address is a wrong-address bug, not a fix**: NetSuite silently uses the customer's
|
|
533
|
+
`defaultshipping = T` entry (28908 has 124 addresses; the default is Jacobi, id 6568575), which is
|
|
534
|
+
why 5 of the 6 orders that reached NetSuite went to the wrong site. Confirmed the line `location` is
|
|
535
|
+
the source warehouse (117) and is never a ship-to; that `resolveTransferOrderCustomer()` always
|
|
536
|
+
lands on the client's customer, so changing the destination cannot work around a customer-level
|
|
537
|
+
problem; and that `NETSUITE_READ_DEPTH = 6` really does carry
|
|
538
|
+
`destinationLocation.primaryLocationAddress.address.state.country` (verified against a real job's
|
|
539
|
+
`parameters` blob), while the neighbouring `contact.contactLocations[]` branch runs out of depth and
|
|
540
|
+
is a decoy. Added the dated list of the **61** customers still on -8, of which **34283 Yale New Haven
|
|
541
|
+
Health** and **2661 Endeavor Health** are client-level and will fail their first transfer order. (bala)
|
|
400
542
|
- 2026-09-09 — **The push had never sent a shipping address at all.**
|
|
401
543
|
`buildSalesOrderShapeFromTransferOrder()` set no `shipToAddress` key, so the `shippingAddress` block
|
|
402
544
|
in `buildNetSuiteOrder()` never ran and NetSuite substituted the **customer's default** site on every
|
package/knowledge/INDEX.md
CHANGED
|
@@ -20,8 +20,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
|
|
|
20
20
|
|
|
21
21
|
- **_underscore** (_Underscore) _(framework core)_ — 83 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
|
|
22
22
|
- **worker2** (Worker) — 68 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
|
|
23
|
-
- **api2** (API) —
|
|
24
|
-
- **dbchanges2** (Database Changes) _(framework core)_ —
|
|
23
|
+
- **api2** (API) — 26 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
|
|
24
|
+
- **dbchanges2** (Database Changes) _(framework core)_ — 19 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
|
|
25
25
|
- **toga2-supply** (TOGa Supply) — 9 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
|
|
26
26
|
- **saml** (SAML SSO Gateway) — 6 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
|
|
27
27
|
- **toga2-view** (TOGa View Frontend) — 12 doc(s) → [2.0/apps/toga2-view/INDEX.md](2.0/apps/toga2-view/INDEX.md)
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
| [Compass USA — reversing the 199-item catalog fold (MITS outage recovery)](workflows/catalog-fold-reversal.md) | 2.0 | The **root cause** of the Compass MITS transmission outage (the `assetType`-NULL and duplicate-vendor symptoms) was an ad-hoc cross-client **duplicate-items mer |
|
|
28
28
|
| [Changing what is IN a Compass kit (adding / swapping a BundleItems line)](workflows/changing-a-kit-line-item.md) | 2.0 | "Add this fee/component to every kit that has X" is a recurring Compass request, and it looks like one `INSERT` into `Client_Compass.BundleItems`. |
|
|
29
29
|
| [Compass Cross-Kit Bundle Corruption — Detection & Repair](workflows/cross-kit-bundle-corruption.md) | 2.0 | A frontend regression in `toga2-commerce`'s edit-order bundle submission mis-attributed bundle (kit) line items and **fees/warranties** to the **wrong kit**, pe |
|
|
30
|
+
| [Giving a Compass API consumer its own scoped key (one role per key)](workflows/granting-api-access.md) | 2.0 | "Give team X API access" on Compass used to mean handing out another key wired to the same two shared roles. |
|
|
30
31
|
| [Granting a Compass user the same toga25-supply navigation as another user (role vs. SurfaceOverride)](workflows/granting-navigation-access.md) | 2.0 | "Give user X the same menu items user Y has" is the **navigation analog** of the recurring [bundle/persona grant](./granting-persona-bundle-access.md) request. |
|
|
31
32
|
| [Granting a Compass user access to a bundle (persona grant) and the SuperUser role](workflows/granting-persona-bundle-access.md) | 2.0 | "Give user X sight of kit N" is a **recurring** Compass request, usually paired with "and make them a super user". |
|
|
32
33
|
| [Compass Office Depot Duplicate PO-Line Cleanup (dual-catalog SKU)](workflows/odp-duplicate-po-line-cleanup.md) | 1.0 | The NetSuite fulfillment-sales-order importer duplicated Office Depot purchase-order lines on Compass USA because it reconciled the fulfillment SO against the e |
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: compass-usa
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-
|
|
9
|
+
updated: 2026-09-11
|
|
10
10
|
owners: ["apeterson", "dfranks", "bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Compass/ApprovalDecision.php
|
|
@@ -15,6 +15,7 @@ files:
|
|
|
15
15
|
- _underscore/Model/Compass/Canada/ApprovalDecision.php
|
|
16
16
|
- _underscore/Model/Client/ApprovalTemplateStage.php
|
|
17
17
|
- _underscore/Model/Quad/SalesOrder.php
|
|
18
|
+
- dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql
|
|
18
19
|
related:
|
|
19
20
|
- mr-ma-order-approval-and-status.md
|
|
20
21
|
- ../../../2.0/apps/toga25-supply/features/action-button-rule-engine.md
|
|
@@ -241,6 +242,38 @@ break the invariant and produce a stage with two competing approvers. Both exist
|
|
|
241
242
|
paths already respect this (`_Model_Compass_SalesOrder` compares `assignedToUserId != managerId` and
|
|
242
243
|
then PUTs).
|
|
243
244
|
|
|
245
|
+
### Step-1 approval is blocked when step 2 has no assignee (guard - BUILT, NOT DEPLOYED)
|
|
246
|
+
|
|
247
|
+
An admin could approve step 1 on an order whose step-2 stage had **no `assignedToUserId`**. The
|
|
248
|
+
order then moved to *Pending Approval* assigned to nobody: no manager approval-request email is
|
|
249
|
+
sent (that email is addressed from the step-2 assignee), nobody can act on it, and **no report
|
|
250
|
+
surfaces it** - the order just goes silent. Real prod population: **SA136795, SA137088, SA137146,
|
|
251
|
+
SA137235**.
|
|
252
|
+
|
|
253
|
+
`_Model_Compass_ApprovalDecision` now carries a `prePost()` plus an extended `prePut()` that reject
|
|
254
|
+
a **step-1 approval** when the step-2 stage has no `assignedToUserId`, throwing
|
|
255
|
+
`_Exception_Validation` (api2 maps it to **HTTP 400**). Both tenants are covered automatically,
|
|
256
|
+
because the subclasses are empty.
|
|
257
|
+
|
|
258
|
+
**Every exemption is decided from the database, never from the request payload:**
|
|
259
|
+
|
|
260
|
+
- **MR/MA orders** - matched on the `SalesOrders.number` prefix; see
|
|
261
|
+
[MR/MA order approval and status](mr-ma-order-approval-and-status.md).
|
|
262
|
+
- **Step-2 decisions** - identified by the stage's step number, not by anything the caller sends.
|
|
263
|
+
- **Templates that define no step 2** - nothing can strand, so nothing is blocked.
|
|
264
|
+
|
|
265
|
+
Verified by replaying **6,000 real approvals** against a local copy of prod: 4,209 normal approvals
|
|
266
|
+
and 1,786 MR/MA auto-approvals allowed, only the genuinely stranded orders blocked. Compass Canada:
|
|
267
|
+
760 approvals, **none** blocked.
|
|
268
|
+
|
|
269
|
+
**⚠ The guard does nothing until its interceptor row exists.** A `prePost` only runs when a
|
|
270
|
+
matching `ApiPayloadInterceptors` row is registered, and `recordId 179` (approval-decisions) carried
|
|
271
|
+
only PRE/PUT, POST/PUT and POST/POST - a newly added `prePost` is **dead code with no error**.
|
|
272
|
+
`dbchanges2/Client_Compass/2026-09-10b - RegisterApprovalDecisionPrePostInterceptor.sql` (and the
|
|
273
|
+
`Client_CompassCanada` twin) insert the PRE/POST row, guarded with `NOT EXISTS` so they are safe to
|
|
274
|
+
re-run. **Run both, then deploy api2** - otherwise nothing changes. Mechanics:
|
|
275
|
+
[API Payload Interceptors](../../../2.0/apps/api2/features/api-payload-interceptors.md).
|
|
276
|
+
|
|
244
277
|
### Manager reassignment and the notification list
|
|
245
278
|
When a step-2 (Manager) approval decision is **reassigned** to a new manager,
|
|
246
279
|
`_swapManagerEmailAddress()` updates the order's CC/notification list in `SalesOrderEmailAddresses`:
|
|
@@ -436,7 +469,56 @@ requester**. All of these comparisons are case-insensitive (`strcasecmp()`), mat
|
|
|
436
469
|
could not open the order from the "Manager Approval Needed" email, so admin Kai Wong had to
|
|
437
470
|
approve as manager on his behalf. (Fixed 2026-07-23.)
|
|
438
471
|
|
|
472
|
+
- **⚠ An exemption from a server-side guard must be decided from the DATABASE, never from a
|
|
473
|
+
client-supplied field.** The step-1 guard above originally skipped itself when the request payload
|
|
474
|
+
carried `approvalDecisionType` / `approvalDecisionTypeId`, treated as a "this is a system
|
|
475
|
+
auto-approval" marker. That value comes from the **request body**, so any caller could set it and
|
|
476
|
+
skip the check entirely. The early return and its `_isSystemAutoApproval` helper were removed in
|
|
477
|
+
PR review. Checked against prod before removing: all **5,028** step-1 auto-approvals in Compass
|
|
478
|
+
history are MR/MA orders (Canada has none), so the DB-backed order-number check covers every real
|
|
479
|
+
case, and VIP auto-approvals land on **step 2** and exit at the step check. Treat this as the
|
|
480
|
+
general rule for any guard added here.
|
|
481
|
+
- **⚠ The green approve tick used to silently replace the step-2 manager with the approver.**
|
|
482
|
+
`makeApprovalDecision()` / `updateApprovalDecision()` in supply hardcoded
|
|
483
|
+
`assignedToUserId: { uuid: userUuid }` into every single-stage approve payload, so an admin
|
|
484
|
+
approving the manager stage **overwrote the real manager with themselves** - and the order
|
|
485
|
+
timeline then read "Manager Approved", making it look like the manager had acted. Confirmed on
|
|
486
|
+
**SA136901** (07:18:35 re-sent an assignee that had not changed; Dawn Anderson had already been
|
|
487
|
+
assigned by Emily at 06:50:06), and it also hit **SA136807** (Angel Almodovar replaced) and
|
|
488
|
+
**SA137080** (delegate manager Daniel Mezzanares replaced). Fixed front-end by removing the field
|
|
489
|
+
- `decidedByUserId` already records who approved. The "Approve All Stages" path was always
|
|
490
|
+
correct, which is why the bug never reproduced there. Detail:
|
|
491
|
+
[Record Modals & Nested Tables](../../../2.0/apps/toga25-supply/features/record-modals-and-nested-tables.md).
|
|
492
|
+
- **⚠ A manager swap sent in the SAME save as an approval is never logged, and the email list
|
|
493
|
+
keeps the old manager.** `_evaluateManagerVip()` returns immediately when the request carries
|
|
494
|
+
`isApproved`, on the assumption that a request is *either* an assignment *or* an approval. When
|
|
495
|
+
both arrive together the `"Manager reassigned"` note is skipped (the feature itself works - it has
|
|
496
|
+
fired **757** times since May) **and** so is the `SalesOrderEmailAddresses` swap, so the outgoing
|
|
497
|
+
manager stays on the notification list. A handler for the combined case was drafted on the
|
|
498
|
+
`_sandbox-client` branch only and is **NOT** on `TRUE-81900` - known gap, not shipped.
|
|
499
|
+
- **The upstream cause of an unassigned step 2 is a user saved with no `supervisorUserId`.** The
|
|
500
|
+
admin add-user screen allows saving a user without one, and the PEOPLE-file import may or may not
|
|
501
|
+
backfill it later. That one data hole feeds both the `!$hasManager` wipe above and the
|
|
502
|
+
never-created step-2 decision at order creation. **Not fixed** - the guard only stops the order
|
|
503
|
+
being *stranded*, it does not give it a manager.
|
|
504
|
+
|
|
439
505
|
## Change history
|
|
506
|
+
- 2026-09-11 - **Blocked step-1 approval when step 2 has no assignee** (`prePost()` + extended
|
|
507
|
+
`prePut()` on `_Model_Compass_ApprovalDecision`, `_Exception_Validation` -> HTTP 400), after four
|
|
508
|
+
prod orders (SA136795, SA137088, SA137146, SA137235) sat in *Pending Approval* assigned to nobody
|
|
509
|
+
with no email sent and no report surfacing them. Exemptions are **all DB-derived** - MR/MA by
|
|
510
|
+
`SalesOrders.number` prefix, step-2 decisions by step number, templates with no step 2 - after PR
|
|
511
|
+
review **removed a client-controlled bypass** that skipped the guard whenever the request payload
|
|
512
|
+
carried `approvalDecisionType`/`approvalDecisionTypeId` (prod check: all 5,028 historic step-1
|
|
513
|
+
auto-approvals are MR/MA, Canada none). Verified by replaying 6,000 real approvals from a local
|
|
514
|
+
prod copy (4,209 normal + 1,786 MR/MA allowed; Canada 760, none blocked). Also recorded: the green
|
|
515
|
+
approve tick was **overwriting the step-2 manager with the approver** (SA136901 / SA136807 /
|
|
516
|
+
SA137080) until `assignedToUserId` was removed from the single-stage payload, and that
|
|
517
|
+
`_evaluateManagerVip()` skips both the "Manager reassigned" note and the
|
|
518
|
+
`SalesOrderEmailAddresses` swap when an assignment and an approval arrive in one save (drafted on
|
|
519
|
+
`_sandbox-client` only, **not shipped**). Ticket TRUE-81900. **NOT DEPLOYED** - the `2026-09-10b`
|
|
520
|
+
interceptor SQL must run on `Client_Compass` **and** `Client_CompassCanada`, and api2 must be
|
|
521
|
+
deployed, before the guard does anything. (bala)
|
|
440
522
|
- 2026-08-31 — **Root-caused the "decided but untyped" step-2 approvals that no admin can recover**
|
|
441
523
|
(both tenants, via the shared parent). `_Model_Compass_SalesOrder::postPut`'s `!$hasManager` branch
|
|
442
524
|
nulls the entire step-2 decision when the "Order for" contact has no `supervisorUserId`; a
|
|
@@ -20,7 +20,7 @@ project: _Underscore
|
|
|
20
20
|
client: compass-usa
|
|
21
21
|
type: profile
|
|
22
22
|
status: active
|
|
23
|
-
updated: 2026-09-
|
|
23
|
+
updated: 2026-09-14
|
|
24
24
|
owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean]
|
|
25
25
|
files: []
|
|
26
26
|
related:
|
|
@@ -32,6 +32,7 @@ related:
|
|
|
32
32
|
- workflows/granting-persona-bundle-access.md
|
|
33
33
|
- workflows/changing-a-kit-line-item.md
|
|
34
34
|
- workflows/granting-navigation-access.md
|
|
35
|
+
- workflows/granting-api-access.md
|
|
35
36
|
- features/mits-sales-order-transmission-alerting.md
|
|
36
37
|
- features/oneuptime-ma-refresh-order-monitor.md
|
|
37
38
|
- workflows/catalog-fold-reversal.md
|
|
@@ -206,6 +207,18 @@ separate, related client (see its own profile).
|
|
|
206
207
|
the ⚠ rule that **deactivation is raw bulk SQL, so no model hook ever fires on it**.
|
|
207
208
|
|
|
208
209
|
## Notes
|
|
210
|
+
- **⚠ Compass API keys: four of the five are NOT scoped (2026-09-10).** Agilant, MITS Service Hub,
|
|
211
|
+
Compass Group and Office Depot (Cxml) all share the same two roles — Base (1) and API (3) — so each
|
|
212
|
+
of them can create, update and delete across **291 records**, whatever its published integration
|
|
213
|
+
document says (MITS's lists 5 read lookups). `appId` cannot fix this: an API key never gets
|
|
214
|
+
`$this->app` set, so every appId-scoped grant applies to it. The **'Compass Refresh Team'** key
|
|
215
|
+
(prod `Apis.id 5`) is the first with its **own read-only role** ('Refresh API', prod roleId 17,
|
|
216
|
+
9 records / 0 writable) and is the template for the next consumer:
|
|
217
|
+
[Giving a Compass API consumer its own scoped key](workflows/granting-api-access.md).
|
|
218
|
+
**Open:** the generated secret is still in plain text inside
|
|
219
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql` (strip or rotate it), the
|
|
220
|
+
negative test was never run, the script was applied straight to prod, and the four older keys are
|
|
221
|
+
not migrated.
|
|
209
222
|
- **Item modal "Restrict to Persona" needed a Surface opt-in that was never written (2026-09-04).**
|
|
210
223
|
Core seeds the item modal's More-Info `restrictToPersona` field **OFF** for every client
|
|
211
224
|
(surface 19 / element 57), expecting a per-client override — Compass USA had **none**, so the
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Giving a Compass API consumer its own scoped key (one role per key)
|
|
3
|
+
framework: "2.0"
|
|
4
|
+
repo: dbchanges2
|
|
5
|
+
project: Database Changes
|
|
6
|
+
client: compass-usa
|
|
7
|
+
type: workflow
|
|
8
|
+
status: active
|
|
9
|
+
updated: 2026-09-14
|
|
10
|
+
owners: ["bala"]
|
|
11
|
+
files:
|
|
12
|
+
- dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql
|
|
13
|
+
related:
|
|
14
|
+
- ../profile.md
|
|
15
|
+
- ./granting-navigation-access.md
|
|
16
|
+
- ../../../2.0/apps/api2/features/api-key-authentication-and-scoping.md
|
|
17
|
+
- ../../../2.0/apps/_underscore/features/acl-permission-chain.md
|
|
18
|
+
- ../../../2.0/apps/api2/features/request-logging.md
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Summary
|
|
22
|
+
|
|
23
|
+
"Give team X API access" on Compass used to mean handing out another key wired to the same two
|
|
24
|
+
shared roles. **Every pre-existing Compass key can read and write almost everything**, so the key
|
|
25
|
+
identified the caller but did not limit it. The Refresh-team key (built 2026-09-10) is the first
|
|
26
|
+
Compass key with **one dedicated, read-only role of its own**, and its migration
|
|
27
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql` is the template for the next
|
|
28
|
+
consumer: copy it and change the record list.
|
|
29
|
+
|
|
30
|
+
Why a dedicated role is the only lever: an API key's roles come **only** from `Apis_Roles`, and
|
|
31
|
+
`appId` scoping does not bind a key at all — see
|
|
32
|
+
[API-Key Authentication](../../../2.0/apps/api2/features/api-key-authentication-and-scoping.md).
|
|
33
|
+
|
|
34
|
+
## ⚠ The four older Compass keys are NOT scoped
|
|
35
|
+
|
|
36
|
+
All four pre-existing `Client_Compass.Apis` rows — **Agilant**, **MITS Service Hub**,
|
|
37
|
+
**Compass Group**, **Office Depot (Cxml)** — are wired in `Apis_Roles` to the **same two roles**:
|
|
38
|
+
|
|
39
|
+
| Role | id | Record permissions | Writable |
|
|
40
|
+
|---|---|---|---|
|
|
41
|
+
| Base | 1 | 123 | 115 |
|
|
42
|
+
| API | 3 | 291 | **all 291** |
|
|
43
|
+
|
|
44
|
+
So MITS, whose published integration document lists **5 read lookups**, can in fact create, update
|
|
45
|
+
and delete across 291 records. Nothing in the database constrains any of those four keys to its
|
|
46
|
+
documented calls. Anyone auditing Compass API access needs to start from this fact. Migrating those
|
|
47
|
+
four keys onto per-key roles (starting with MITS) is **intended but not done**.
|
|
48
|
+
|
|
49
|
+
## The Refresh-team key (the worked example)
|
|
50
|
+
|
|
51
|
+
Landed on **production** 2026-09-10:
|
|
52
|
+
|
|
53
|
+
- `Client_Compass.Apis` row **'Compass Refresh Team'** (prod `id = 5`).
|
|
54
|
+
- `Apis_Roles` binds it to exactly **one** new client role, **'Refresh API'** (prod `roleId = 17`).
|
|
55
|
+
- Read-only: **9 records**, **83 field permissions**, **18 custom-field permissions**,
|
|
56
|
+
**0 writable anywhere**.
|
|
57
|
+
- Records granted (`Core.Records.id`): **2** users, **11** locations, **37** contacts,
|
|
58
|
+
**38** contact-email-addresses, **39** contact-phone-numbers, **40** personas,
|
|
59
|
+
**201** user-personas, **273** user-roles, **274** roles.
|
|
60
|
+
- Field grants were **cloned from the shared API role (roleId 3)** for those same records, minus the
|
|
61
|
+
two fields listed below.
|
|
62
|
+
|
|
63
|
+
The consumer calls four requests: auth, cost center by number (`/locations`), user by email
|
|
64
|
+
(`/users`), and user by Compass username (`/users`).
|
|
65
|
+
|
|
66
|
+
## Steps
|
|
67
|
+
|
|
68
|
+
1. Create the `Client_Compass.Roles` row for the consumer (one role, one key). `coreRoleId` may stay
|
|
69
|
+
NULL — every record here has `Core.Records.aclDatabase = 'CLIENT'`.
|
|
70
|
+
2. Insert the `Client_Compass.Apis` row with a generated uuid and secret, then the single
|
|
71
|
+
`Apis_Roles` row binding it to that new role only.
|
|
72
|
+
3. For each record the consumer needs, build the **whole** ACL chain — `AclRecordPermissions` +
|
|
73
|
+
`AclLogicGroups` + `AclLogicGroupExpressions` + `AclRecordExpressions`. A permission row alone
|
|
74
|
+
fails the request with `No ACL Logic Groups defined for ACL Record Permissions`
|
|
75
|
+
([ACL Permission Chain](../../../2.0/apps/_underscore/features/acl-permission-chain.md)).
|
|
76
|
+
4. Clone the field grants from role 3 for those records, with `isWritable = 0`, **dropping**:
|
|
77
|
+
- `Users.password` — `recordFieldId 611`
|
|
78
|
+
- `Users.ldapDn` — `recordFieldId 1329`
|
|
79
|
+
5. Reuse `AclRecordExpressions.id = 4` on record 2 (Users). That expression is the row filter that
|
|
80
|
+
**hides every user whose email ends in `@togatech.com`**, so internal TOGa staff accounts never
|
|
81
|
+
appear in an API user lookup. Any new role reading Users should reuse it.
|
|
82
|
+
6. Verify before handover (see below), then hand the uuid and secret to the consumer out of band.
|
|
83
|
+
7. Produce the consumer's reference document (see below).
|
|
84
|
+
|
|
85
|
+
### ⚠ Do not clone `AclLogicGroup id 20` wholesale
|
|
86
|
+
|
|
87
|
+
Logic group **20** (role 3, Users) also links `AclRecordExpressions id 16` — but expression 16
|
|
88
|
+
belongs to `recordId 22`, not 2. It is a **stray mis-linked row** in the shared role. It was
|
|
89
|
+
deliberately **not** copied into the Refresh role. Read what an expression actually points at before
|
|
90
|
+
mirroring it.
|
|
91
|
+
|
|
92
|
+
## Verification
|
|
93
|
+
|
|
94
|
+
- Counts: 9 records / 83 field permissions / 18 custom-field permissions / 0 writable — all passed
|
|
95
|
+
on prod.
|
|
96
|
+
- `Apis_Roles` for the new `apiId` returns **exactly one** `roleId`.
|
|
97
|
+
- Call each intended route with the new key, then read `Logs_Compass.Api` filtered on the new
|
|
98
|
+
`apiId` to confirm the exact routes and response payloads
|
|
99
|
+
([V2 Request Logging](../../../2.0/apps/api2/features/request-logging.md)).
|
|
100
|
+
|
|
101
|
+
## Environment note — Compass Core ids matched prod and client-sandbox
|
|
102
|
+
|
|
103
|
+
Checked 2026-09-10 for this client: `Core.Records` ids, every `Core.RecordFields` id for records
|
|
104
|
+
2/11/37/38/39/40/201/273/274, `Client_Compass.AclRecordExpressions` ids and
|
|
105
|
+
`Client_Compass.CustomRecordFields` ids were **identical between production and client-sandbox**. So
|
|
106
|
+
this ACL script, written against prod ids, runs unchanged on client-sandbox for Compass.
|
|
107
|
+
|
|
108
|
+
> This contradicts the usual assumption that `RecordFields` ids are environment-specific. Treat it
|
|
109
|
+
> as a **Compass prod to client-sandbox** observation, not a general rule — re-check per client and
|
|
110
|
+
> per environment before reusing a script elsewhere. The general rule (resolve ids to names before
|
|
111
|
+
> diffing environments) still stands.
|
|
112
|
+
|
|
113
|
+
## The consumer-facing reference document
|
|
114
|
+
|
|
115
|
+
Each API consumer gets a Word reference document, mirroring the existing MITS one. It is generated
|
|
116
|
+
with `python-docx` using the MITS `.docx` as the style template, and covers the four requests, real
|
|
117
|
+
sample responses lifted from `Logs_Compass.Api`, Postman links, token lifetimes, and a plain-English
|
|
118
|
+
"what your key can access" section. **Credentials in the document are placeholders only.** The file
|
|
119
|
+
lives outside the repos (the developer's local Downloads) — the binary is not stored in git or the
|
|
120
|
+
knowledge base.
|
|
121
|
+
|
|
122
|
+
## Open items (as of 2026-09-14)
|
|
123
|
+
|
|
124
|
+
- **⚠ The generated secret is sitting in plain text inside
|
|
125
|
+
`dbchanges2/Client_Compass/2026-09-10 - RefreshTeamApiAccess.sql`, which is a git repo.** Strip it
|
|
126
|
+
before commit or rotate the secret. Never commit a secret value.
|
|
127
|
+
- The **negative test has not been run** — calling a route outside the grant (e.g. `/items`) with the
|
|
128
|
+
Refresh key to confirm `UNAUTHORIZED`.
|
|
129
|
+
- The script was run **directly on production**; the intended client-sandbox dry run was skipped.
|
|
130
|
+
- Migrating the four older keys (Agilant, MITS Service Hub, Compass Group, Office Depot (Cxml)) off
|
|
131
|
+
the shared Base + API roles onto per-key roles is **not started**.
|
|
132
|
+
|
|
133
|
+
## Change history
|
|
134
|
+
- 2026-09-14 — Created. Built the first per-key Compass API role: `Apis` row 'Compass Refresh Team'
|
|
135
|
+
(prod id 5) bound to the single read-only role 'Refresh API' (prod roleId 17) over 9 records / 83
|
|
136
|
+
field grants / 18 custom-field grants, 0 writable, cloned from role 3 minus `Users.password` (611)
|
|
137
|
+
and `Users.ldapDn` (1329). Recorded that the four older Compass keys all share Base (1) + API (3)
|
|
138
|
+
and are therefore fully writable across 291 records; that `AclRecordExpressions id 4` is the
|
|
139
|
+
`@togatech.com` staff-hiding row filter to reuse; that logic group 20 links a stray expression
|
|
140
|
+
(id 16, wrong record) not to be cloned; and that Compass Core metadata ids matched prod and
|
|
141
|
+
client-sandbox. Open: secret still in the SQL file, negative test not run, prod-only run, older
|
|
142
|
+
keys not migrated. (bala)
|
|
@@ -6,7 +6,7 @@ project: _Underscore
|
|
|
6
6
|
client: nychh
|
|
7
7
|
type: client-feature
|
|
8
8
|
status: active
|
|
9
|
-
updated: 2026-09-
|
|
9
|
+
updated: 2026-09-14
|
|
10
10
|
owners: ["bala"]
|
|
11
11
|
files:
|
|
12
12
|
- _underscore/Model/Client/TransferOrder.php
|
|
@@ -33,7 +33,13 @@ and live in
|
|
|
33
33
|
this doc holds the NYCHH-specific parts: the interceptor row, the **location → mapping-column
|
|
34
34
|
roles** (verified in production), and the data/metadata state the push still needs.
|
|
35
35
|
|
|
36
|
-
**LIVE since 2026-09-08
|
|
36
|
+
**LIVE since 2026-09-08, fully working since 2026-09-14.** Between 2026-09-09 and 2026-09-14 **every**
|
|
37
|
+
transfer order failed on a NetSuite tax error caused by customer **28908**'s data — see
|
|
38
|
+
[the tax item section](#-customer-28908-was-stuck-on-tax-group--8-and-it-blocked-every-transfer) —
|
|
39
|
+
and before that, every order shipped to the wrong hospital. Both are now fixed and the push was
|
|
40
|
+
confirmed working by the developer.
|
|
41
|
+
|
|
42
|
+
NetSuite SO **289187** (internal id 7463537) was created from this path
|
|
37
43
|
and is correct. The location-role table below still stands, but **the destination location is NOT
|
|
38
44
|
the NetSuite customer** — that was corrected the same day; see the next section.
|
|
39
45
|
|
|
@@ -118,6 +124,34 @@ rows for records 69, 12, 23, `NOT EXISTS`-guarded). **Run on prod and verified:
|
|
|
118
124
|
> Generalised on [nested FK ACL embedding](../../../2.0/apps/api2/features/nested-fk-acl-embedding.md):
|
|
119
125
|
> deepening a `depth` is an ACL change, and the branch is dropped **silently**.
|
|
120
126
|
|
|
127
|
+
## ⚠ Customer 28908 was stuck on tax group -8, and it blocked every transfer
|
|
128
|
+
|
|
129
|
+
From **2026-09-09** (the day the push started sending a ship-to address) to **2026-09-14**, every
|
|
130
|
+
NYCHH transfer order create returned NetSuite HTTP **400**:
|
|
131
|
+
|
|
132
|
+
```
|
|
133
|
+
Error while accessing a resource. Invalid shippingtaxcode reference key -8 for subsidiary 1.
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`Core.WorkerJobs` ids **1534082, 1534805, 1535235, 1536824, 1536920**.
|
|
137
|
+
|
|
138
|
+
**It was NetSuite customer data, not Toga code.** Customer **28908** (NYC Health + Hospitals — the
|
|
139
|
+
client-level customer from `Core.Clients.netsuiteCustomerInternalId`, which every NYCHH transfer order
|
|
140
|
+
uses) still carried the retired tax group **-8** as its `taxitem`. An inline ship-to address makes
|
|
141
|
+
NetSuite rebuild the address on the order and re-check the shipping tax code against subsidiary 1,
|
|
142
|
+
and -8 fails that check.
|
|
143
|
+
|
|
144
|
+
**Fix: NetSuite ops changed 28908's `taxitem` from -8 to 5050 (AVATAX).** Verified working the same
|
|
145
|
+
day. 7151 of subsidiary 1's customers were already on 5050.
|
|
146
|
+
|
|
147
|
+
**Still on -8 inside NYCHH: sub-customer 31584 "Coney Island"** (present in `Client_Nychh.Customers`,
|
|
148
|
+
0 sales orders today, so dormant). All **17** other NetSuite customer ids used by `Client_Nychh` are
|
|
149
|
+
on 5050. The mechanism, the failed workarounds, and the cross-client -8 list are on
|
|
150
|
+
[the shared push doc](../../../2.0/apps/worker2/features/netsuite-transferorder-outbound-push.md).
|
|
151
|
+
|
|
152
|
+
> **Do not try to fix this from supply.** The customer is always the client (28908), so **changing the
|
|
153
|
+
> destination location cannot change the NetSuite customer** — it only changes the ship-to address.
|
|
154
|
+
|
|
121
155
|
## Where the ship-to address comes from — and the location-2 problem
|
|
122
156
|
|
|
123
157
|
The address is the **destination Location's** `primaryLocationAddress.address`. Until 2026-09-09 all
|
|
@@ -130,6 +164,21 @@ are in [location shipping addresses + delivery contacts](./location-shipping-add
|
|
|
130
164
|
delivery site for that location — and location 2 is the destination on the large majority of NYCHH
|
|
131
165
|
transfer orders. Any transfer order to location 2 will ship to Jacobi.
|
|
132
166
|
|
|
167
|
+
### What "no address" actually did — 5 of 6 orders went to the wrong hospital
|
|
168
|
+
|
|
169
|
+
Customer **28908** holds **124** saved addresses, and the one flagged `defaultshipping = T` is
|
|
170
|
+
**“NYCHHC Jacobi Dental”** (address id **6568575**, 1400 Pelham Parkway South, Bronx NY 10461). With
|
|
171
|
+
no address in the payload NetSuite used that one **silently** on every order:
|
|
172
|
+
|
|
173
|
+
| NetSuite order | Ship-to |
|
|
174
|
+
|---|---|
|
|
175
|
+
| 7453238, 7462009, 7463537, 7467799, 7482373 | **wrong** — Jacobi Dental, whatever the real destination |
|
|
176
|
+
| 7462031 | correct, **by accident** — it predates the switch to the parent customer and was built on destination sub-customer **33674**, whose own default *is* the destination |
|
|
177
|
+
|
|
178
|
+
The line-level `location` does **not** rescue this: that is the **source** warehouse (NetSuite
|
|
179
|
+
internal id **117**) and NetSuite never uses it for delivery. So dropping the address to get past a
|
|
180
|
+
400 is a wrong-address bug, not a fix.
|
|
181
|
+
|
|
133
182
|
## Interceptor registration — record 312, and the deploy order matters
|
|
134
183
|
|
|
135
184
|
`dbchanges2/Client_Nychh/2026-09-02a - TransferOrderNetsuitePushInterceptor.sql` inserts into
|
|
@@ -164,6 +213,19 @@ switch that turns the feature on for NYCHH and nobody else.
|
|
|
164
213
|
|
|
165
214
|
## Change history
|
|
166
215
|
|
|
216
|
+
- 2026-09-14 — **Push deployed and confirmed working; the blocker was NetSuite customer 28908's tax
|
|
217
|
+
item.** Every transfer order from 2026-09-09 failed with *"Invalid shippingtaxcode reference key -8
|
|
218
|
+
for subsidiary 1"* (`Core.WorkerJobs` 1534082, 1534805, 1535235, 1536824, 1536920) because 28908
|
|
219
|
+
still carried the retired tax group **-8**; sending an inline ship-to address makes NetSuite
|
|
220
|
+
re-check the shipping tax code against subsidiary 1. **NetSuite ops moved 28908 to 5050 (AVATAX)**
|
|
221
|
+
and the push worked with no code change. Confirmed the customer is always 28908, so a destination
|
|
222
|
+
change in supply cannot work around a customer-level problem. Quantified the old no-address
|
|
223
|
+
behaviour: 28908 has **124** addresses and its `defaultshipping` entry is **Jacobi Dental (id
|
|
224
|
+
6568575)**, which is why **5 of the 6** orders that reached NetSuite (7453238, 7462009, 7463537,
|
|
225
|
+
7467799, 7482373) shipped to the wrong hospital — only 7462031 was right, and only because it was
|
|
226
|
+
built on sub-customer 33674 before the switch to the parent. Remaining NYCHH exposure: sub-customer
|
|
227
|
+
**31584 Coney Island** is still on -8 but dormant; the other 17 NetSuite customer ids used by
|
|
228
|
+
`Client_Nychh` are all on 5050. (bala)
|
|
167
229
|
- 2026-09-09 — **The push had never sent a shipping address**, so NetSuite used customer 28908's
|
|
168
230
|
default site on every order: NetSuite order **7467799** was for North Central Bronx yet shipped to
|
|
169
231
|
Jacobi's *1400 Pelham Parkway South*, and SO **289187** (Woodhull) shows the same. The fix reads the
|
package/package.json
CHANGED