toga-ai 1.0.669 → 1.0.671

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-26
9
+ updated: 2026-08-27
10
10
  owners: [bala]
11
11
  files:
12
12
  - _underscore/Model/Client/SalesOrder.php
@@ -47,8 +47,11 @@ This makes the *picked/packed* portion of the fulfillment lifecycle visible (pre
47
47
  - **Compass USA + Compass Canada:** all IF stages are *imported*, but order status counts
48
48
  **shipped only** — picked/packed never advance a Compass order's status. Compass walks
49
49
  Pending → Partially Fulfilled → Fulfilled by shipped quantity alone. **The shipped-only *stage*
50
- policy is shared, but since 2026-08-26 the fulfilled-vs-partiallyFulfilled *rule* is per region:**
51
- the fulfillment branch lives in `_fulfillmentStatus()` and Canada overrides it. See
50
+ policy is shared, but the fulfilled-vs-partiallyFulfilled *rule* is now per region.** **Only Canada
51
+ actually changed:** the fulfillment branch lives in `_fulfillmentStatus()`, Canada overrides it with
52
+ a pure per-line rule, and **Compass USA still runs the original order-total logic on purpose**
53
+ (its ASN feed is not trustworthy at line level). The seam was reverted in git on 2026-08-26 and
54
+ rebuilt uncommitted. See
52
55
  [Compass USA](../../../clients/compass-usa/features/order-fulfillment-status-per-line.md) and
53
56
  [Compass Canada](../../../clients/compass-canada/features/order-fulfillment-status-per-line.md).
54
57
 
@@ -67,7 +70,8 @@ shipped-only for ALL clients** — picked/packed quantities never count as inven
67
70
  `COUNT` and the shipped-qty `SUM` subqueries `INNER JOIN ItemFulfillmentStages →
68
71
  ItemFulfillmentStatuses` filtered to the shipped status slug. Shared by Compass USA + Canada.
69
72
  Its **fulfillment half is a separate `protected static _fulfillmentStatus()`** called via
70
- `static::` — that is the per-region seam (see the Compass docs above). The approval gate stays in
73
+ `static::` — that is the per-region seam (see the Compass docs above), and its **body is the original
74
+ order-total logic that USA still runs**. The approval gate stays in
71
75
  `_status()` and is region-independent.
72
76
  - **`Model/Compass/Canada/SalesOrder.php`** — Canada's `_fulfillmentStatus()` override (per-line,
73
77
  ASN-bridge only).
@@ -177,11 +181,15 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
177
181
  the Compass order-lifecycle workflow.
178
182
  - **⚠ An order-level "ordered total vs shipped total" comparison is not a fulfillment test.** A
179
183
  grand total lets one line's surplus cancel another line's gap, so an order with one line short and
180
- another over-shipped reads **Fulfilled**. This is exactly what the Compass `_status` did until
181
- 2026-08-26 (its two sides did not even count the same lines — the ordered side filtered
182
- `parentSalesOrderItemId IS NULL`, the shipped side did not), and it mislabeled **5,234** USA and
183
- **115** Canada orders on prod. Any client `_status` that judges completeness must compare **per
184
- line**. See the two Compass docs linked above.
184
+ another over-shipped reads **Fulfilled**. This is what the Compass `_status` does for **USA
185
+ today** — and its two sides do not even count the same lines (the ordered side filters
186
+ `parentSalesOrderItemId IS NULL`, the shipped side does not). Measured on prod: **90** Canada orders
187
+ were mislabeled and have been corrected by a per-line rule, and **5,223** USA orders would move —
188
+ but the USA change was **deliberately not made**, because duplicate Office Depot ASNs make its
189
+ per-line shipped quantity unreliable. Any client `_status` that judges completeness must compare
190
+ **per line**, and must not do so on a feed that double-credits lines. **⚠ Earlier figures of 5,234
191
+ USA / 115 Canada are void** (the first attempt was reverted in git). See the two Compass docs
192
+ linked above.
185
193
  - **A `self::`-qualified call to a calculated field inside the same model always uses the parent's
186
194
  logic.** `_Model_Compass_SalesOrder` calls `self::_status()` in its ApprovalDecision notification
187
195
  query, so that one call site never sees a region subclass override. Use `static::` unless you
@@ -191,6 +199,15 @@ Fan-out (`Client/`, every client) + a Compass-Canada-specific set. Execution is
191
199
  picked/packed constant would let the literal be removed.
192
200
 
193
201
  ## Change history
202
+ - 2026-08-27 — **Corrected the 2026-08-26 entry below: that work was reverted in git (`90c964f1`
203
+ reverted by `43049d1d`) and the "5,234 USA / 115 Canada" figures are void.** Rebuilt state: the
204
+ `_fulfillmentStatus()` per-region seam exists again but is **uncommitted**, `_fulfillmentStatus()`'s
205
+ body is the **original order-total logic that Compass USA still runs deliberately**, and **only
206
+ Compass Canada overrides it** — with a pure per-line rule, no order-total condition, correcting **90**
207
+ prod orders (not 115). A USA per-line rule was measured (5,223 orders would move) and rejected
208
+ because duplicate Office Depot ASNs make USA's per-line shipped quantity unreliable. The
209
+ order-total-is-not-a-fulfillment-test gotcha now records that a per-line test also needs a
210
+ trustworthy line-level feed. (bala)
194
211
  - 2026-08-26 — Recorded that the Compass **fulfilled-vs-partiallyFulfilled** rule is now **per
195
212
  region**: the fulfillment branch of `_Model_Compass_SalesOrder::_status()` was extracted into a
196
213
  `protected static _fulfillmentStatus()` invoked via `static::` (approval gate untouched, emitted
@@ -6,3 +6,4 @@
6
6
  | [SAML Downstream Integration Contract](features/downstream-integration-contract.md) | The contract a **downstream TOGa app** implements to authenticate users through the SAML gateway (`saml.togahub.com`). | saml/Controller/Index.php, _underscore/Model/Core/ClientAuthentication.php, _underscore/Model/True/ClientAuthentication.php |
7
7
  | [Rate SAML User Provisioning](features/rate-user-provisioning.md) | When a Rate user authenticates via SSO, `_Model_Rate_ClientAuthentication::getAuthenticatedSsoUser()` is called by the saml gateway. | _underscore/Model/Rate/ClientAuthentication.php, _underscore/Model/Rate/User.php, _underscore/Model/Rate/Customer.php, _underscore/Model/Rate/Contact.php |
8
8
  | [Onboarding a New SSO Client with Just-In-Time User Provisioning](workflows/onboarding-a-new-sso-client.md) | The repeatable procedure for adding a new client to the 2.0 `saml` gateway when that client's users do **not** pre-exist in our database and must be created on | saml/Controller/Index.php, _underscore/Model/Core/ClientAuthentication.php, _underscore/Model/Rate/ClientAuthentication.php, _underscore/Database.php |
9
+ | [Rotating the SAML SP Certificate](workflows/rotating-the-saml-sp-certificate.md) | The repeatable procedure for replacing the TOGa **SAML Service Provider (SP) credential** — the X.509 cert + private key at `_underscore/Assets/ssl/togahub_com. | _underscore/Assets/ssl/togahub_com.crt, _underscore/Assets/ssl/togahub_private_key.key, _underscore/Model/Core/ClientAuthentication.php, saml/Controller/Index.php |
@@ -0,0 +1,109 @@
1
+ ---
2
+ title: Rotating the SAML SP Certificate
3
+ framework: "2.0"
4
+ repo: saml
5
+ project: SAML SSO Gateway
6
+ client: shared
7
+ type: workflow
8
+ status: active
9
+ updated: 2026-08-27
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - _underscore/Assets/ssl/togahub_com.crt
13
+ - _underscore/Assets/ssl/togahub_private_key.key
14
+ - _underscore/Model/Core/ClientAuthentication.php
15
+ - saml/Controller/Index.php
16
+ related:
17
+ - 2.0/apps/saml/architecture.md
18
+ - 2.0/apps/saml/workflows/onboarding-a-new-sso-client.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ The repeatable procedure for replacing the TOGa **SAML Service Provider (SP) credential** — the
24
+ X.509 cert + private key at `_underscore/Assets/ssl/togahub_com.crt` and
25
+ `_underscore/Assets/ssl/togahub_private_key.key`. This credential is the SP's own identity, used
26
+ to **sign outgoing AuthnRequests** and to **decrypt inbound assertions**. It is a bilateral,
27
+ partner-pinned trust artifact, **not** the TLS cert that terminates browser HTTPS. There was no
28
+ rotation runbook before this (git shows two prior undocumented swaps); this closes that gap.
29
+
30
+ For *why* a self-signed, long-lived cert is the correct choice here (rather than a public-CA TLS
31
+ cert), see the SSL certificate trust-model strategy standard.
32
+
33
+ ## How the SP credential is used
34
+
35
+ - **Private key — signs outgoing SP AuthnRequests.** `_Model_Core_ClientAuthentication`
36
+ (`_underscore/Model/Core/ClientAuthentication.php`) points its `PATH_TO_PRIVATE_KEY` constant
37
+ at `_underscore/Assets/ssl/togahub_private_key.key` and signs with XMLSecLibs `RSA_SHA256`
38
+ (around lines 11 / 95 / 107).
39
+ - **Public cert — published in SP metadata and used to decrypt assertions.** The `saml` repo's
40
+ `Controller/Index.php` uses the cert for assertion decryption; the `/meta` route publishes it
41
+ in SP metadata via `file_get_contents` read fresh on each request (no server-side cache).
42
+ - **IdPs cache metadata up to 7 days** (`cacheDuration PT604800S`), so a cert change is not
43
+ guaranteed to be picked up by every IdP immediately after deploy.
44
+
45
+ ## Current credential (as of 2026-08-27, staged — cutover pending)
46
+
47
+ A dedicated **self-signed** replacement was generated to decouple the SP credential from the
48
+ short-lived DigiCert/Thawte TLS wildcard that had been reused as the SP cert (see Background):
49
+
50
+ - Self-signed X.509, RSA 2048, SHA-256.
51
+ - Subject/Issuer: `C=US, ST=Illinois, L=Naperville, O=TOGA Technology, Inc., CN=togahub.com`
52
+ (reflects the new company name TOGA Technology, Inc. / Naperville, IL — formerly Agilant
53
+ Solutions, Inc.). CN only, **no SANs** — SANs are meaningless for bilateral SAML/AS2 trust.
54
+ - Validity: **2026-08-27 → 2036-08-27** (10 years). Capped at 10y because RSA 2048 is
55
+ NIST-assured only to ~2030; a longer term would call for RSA 4096, and client compatibility
56
+ with 4096 is unconfirmed.
57
+ - SHA-256 fingerprint:
58
+ `0E:1E:9E:11:35:B9:74:79:6F:07:2B:1F:E7:05:B8:B6:E9:07:00:0B:9E:A2:36:94:04:F6:AC:65:AC:B0:03:78`
59
+
60
+ ### Where the material lives (never paste the key)
61
+
62
+ - **AWS Secrets Manager**, account **654654170868**, region **us-east-1**.
63
+ - Secret name: **`toga/ssl/togahub_com-selfsigned-2036`**.
64
+ - ARN: `arn:aws:secretsmanager:us-east-1:654654170868:secret:toga/ssl/togahub_com-selfsigned-2036-S2r3SR`.
65
+ - JSON keys: `{certificate, privateKey}`. Retrieve the private key from here at cutover — it is
66
+ **not** stored anywhere else and must never be committed or pasted into chat/docs.
67
+ - The public cert only was staged locally at `C:\TEMP\toga-ssl-2026\togahub_com.crt`; the local
68
+ private key was deleted.
69
+
70
+ ## Cutover procedure
71
+
72
+ 1. **PRE-CUTOVER SAFETY CHECK — confirm this cert is not serving live TLS.** Before replacing a
73
+ real CA cert with a self-signed one, verify the `.crt`/`.key` at `_underscore/Assets/ssl/` is
74
+ **not** also terminating browser/API TLS anywhere (check the `saml` repo + its EB/Apache
75
+ config). TLS is normally ACM-terminated at the ALB, so this repo file should only be the
76
+ SAML/AS2 credential — but verify against the checked-out `saml` repo first.
77
+ 2. **Distribute the new public cert to every pinning partner** (SAML IdPs, AS2 partners, any
78
+ PunchOut buyer that pins our leaf) and let them import it. Include the SHA-256 fingerprint
79
+ above as the verification line.
80
+ 3. **Coordinate a cutover date/time** with those partners.
81
+ 4. **Replace both files in `_underscore` on the `_production` branch** — via a feature branch +
82
+ PR, **never a direct commit to `_production`**. No code change is needed: the path constants
83
+ already point at these filenames.
84
+ 5. **Redeploy `saml` after the `_underscore` merge.** `saml` pulls `_underscore` from
85
+ `_production` at EB build time (`.ebextensions/git.php` + prebuild hook), so a saml-only
86
+ deploy will **not** pick up the new cert. Deploy target: `saml-production` EB env, us-east-1,
87
+ DNS `saml.togahub.com`.
88
+ 6. **Deploy the cert+key to the other bilateral services** that use the same SP credential —
89
+ AS2 (AWS Transfer Family) and any other pinned endpoint — importing both cert and key.
90
+
91
+ ## Background — why the change
92
+
93
+ The old `_underscore/Assets/ssl/togahub_com.crt` was a **reused DigiCert/Thawte multi-domain
94
+ wildcard TLS cert** (issuer "Thawte TLS RSA CA G1"; SANs for togahub.com, togacommerce.com,
95
+ togadesk.com, togaretail.com, togasupply.com, togaview.com, togaiq.com plus `*.` of each; RSA
96
+ 2048; validity Mar 2 2026 → Sep 16 2026, ~6.5 months). Reusing a short-lived public-CA TLS cert
97
+ as the SAML SP credential accidentally coupled SAML to a ~6-month renewal treadmill and forced
98
+ every pinning partner to reinstall on each renewal. The dedicated self-signed 10-year cert
99
+ **decouples the SP credential from TLS renewals** — that is the whole point of the change.
100
+
101
+ ## Change history
102
+ - 2026-08-27 — Created while replacing the SAML SP credential. Documented the SP cert's dual use
103
+ (signing AuthnRequests / decrypting assertions), the new self-signed 10-year cert identity and
104
+ its AWS Secrets Manager location, the pre-cutover TLS-usage safety check, and the
105
+ `_underscore`-then-`saml` cutover ordering. Discovered the prior cert was a reused short-lived
106
+ DigiCert wildcard TLS cert, which is the reason for the switch. Cutover itself still pending.
107
+ (jcardinal)
108
+ </content>
109
+ </invoke>
@@ -0,0 +1,82 @@
1
+ ---
2
+ title: SSL Certificate Trust-Model Strategy
3
+ framework: "2.0"
4
+ project: _Underscore
5
+ client: shared
6
+ type: standard
7
+ status: active
8
+ updated: 2026-08-27
9
+ owners: [jcardinal]
10
+ files: []
11
+ related:
12
+ - ../apps/saml/workflows/rotating-the-saml-sp-certificate.md
13
+ - ../apps/saml/architecture.md
14
+ ---
15
+
16
+ ## Summary
17
+
18
+ Which kind of SSL/TLS certificate to use is decided by the **trust model** of the endpoint, not
19
+ by habit. Pick wrong and you land on a renewal treadmill; pick right and renewals become either
20
+ invisible or irrelevant. This standard is platform-wide (1.0, 2.0, standalone, and pure-AWS
21
+ infra). It was established while replacing the 2.0 SAML SP credential — see the
22
+ [SAML SP certificate rotation runbook](../apps/saml/workflows/rotating-the-saml-sp-certificate.md).
23
+
24
+ **Critical rule:** if a partner installs/pins OUR specific cert, use a **self-signed, long-lived**
25
+ cert. If AWS terminates the TLS for browsers/generic clients, use **ACM public, auto-renewing**.
26
+ Never reuse a public-CA TLS cert as a pinned bilateral credential.
27
+
28
+ ## The decision
29
+
30
+ ### 1. Bilateral / pinned trust → self-signed, long-lived
31
+ The partner installs and pins our specific certificate: SAML SP credential, AS2 (AWS Transfer
32
+ Family), PunchOut endpoints a buyer pins. Use a **self-signed** cert with a multi-year validity
33
+ (we generate 10-year). Distribute the public cert to the partner; import cert + key into the
34
+ service. Because trust is by pinning, no public CA is involved and validity can be long.
35
+
36
+ ### 2. Standard HTTPS, AWS-terminated → ACM public, non-exportable, auto-renewing
37
+ Browsers / generic clients reach the endpoint through an ALB, CloudFront, or API Gateway that AWS
38
+ terminates. Use an **ACM public certificate** (non-exportable, auto-renewing). Distribute nothing;
39
+ renewal is invisible.
40
+
41
+ ### 3. Standard HTTPS, NOT AWS-terminated → ACM exportable, or move behind an ALB
42
+ A server terminates its own TLS (e.g. the legacy Vision box). Options: an **ACM *exportable*
43
+ public cert** (a real 2025 AWS feature) — but still ~13-month validity, so you must redeploy on
44
+ every renewal — OR move the endpoint behind an ALB to get free auto-renew (option 2).
45
+
46
+ ## Why long-lived ⇒ self-signed
47
+
48
+ **Every publicly-trusted cert, exportable or not, is capped at ~398 days** by the CA/Browser
49
+ Forum, dropping toward ~47 days by 2029. Only **self-signed / private-CA** certs can be
50
+ multi-year. So "long-lived AND distributable" necessarily means self-signed (or AWS Private CA at
51
+ ~$400/mo — rejected here as overkill).
52
+
53
+ The pain of "shrinking public-cert validity" is really a **distribution problem**: automate
54
+ distribution via ACM and it disappears; a partner who pins the leaf breaks on every rotation.
55
+ That is exactly why bilateral, pinned credentials should be self-signed and long-lived — and why
56
+ reusing a short-lived public-CA TLS cert as a pinned SP/AS2 credential (as we accidentally did for
57
+ SAML) couples an otherwise stable credential to a ~6-month treadmill.
58
+
59
+ ## Key sizing
60
+
61
+ Self-signed long-lived certs use RSA 2048 / SHA-256 with validity capped at ~10 years, because
62
+ RSA 2048 is NIST-assured only to ~2030. A longer term would want RSA 4096, but partner
63
+ compatibility with 4096 is often unconfirmed — verify before choosing it.
64
+
65
+ ## Worked example — Vision / Canon cXML PunchOut (a split-trust endpoint)
66
+
67
+ The Vision cXML PunchOut endpoint (`vision.asisystem.com`, a separate domain/cert from
68
+ togahub.com) is a single cert serving **two legs with different trust models**, which is why its
69
+ renewals are painful:
70
+
71
+ - **Server-to-server cXML leg** — auth is a `<Credential>` **SharedSecret** (localVerifyIdentity),
72
+ not a client cert; our cert there is only the endpoint's TLS wrapper. The buyer reinstalling our
73
+ cert on every renewal indicates they **pin our leaf** — effectively bilateral trust → a
74
+ self-signed long-lived cert fits.
75
+ - **PunchOut portal StartPage** (`punchoutSetupResponseUrlPrefix`) is a real **browser** session →
76
+ needs a public-CA cert (option 2).
77
+
78
+ Bundling both on one cert is the root of the pain. The fix is to **split** them: ACM for the
79
+ browser portal, self-signed for the pinned cXML endpoint. StartPage host is per-identity
80
+ configurable, so the split is feasible. Canon is slated to move from Vision to TOGa Commerce (2.0,
81
+ already ALB+ACM), which auto-solves the browser leg.
82
+ </content>
@@ -23,7 +23,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 13 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
- - **saml** (SAML SSO Gateway) — 4 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
26
+ - **saml** (SAML SSO Gateway) — 5 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)
28
28
  - **toga2-hub** (TOGa Hub) — 2 doc(s) → [2.0/apps/toga2-hub/INDEX.md](2.0/apps/toga2-hub/INDEX.md)
29
29
  - **talos** (TOGa IQ) — 7 doc(s) → [2.0/apps/talos/INDEX.md](2.0/apps/talos/INDEX.md)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: compass-canada
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-26
9
+ updated: 2026-08-27
10
10
  owners: ["bala"]
11
11
  files:
12
12
  - _underscore/Model/Compass/Canada/SalesOrder.php
@@ -25,43 +25,62 @@ TOGa Supply showed **Fulfilled** on Compass Canada orders that were only partly
25
25
  `SAC100664` (`SalesOrders.id 665`): line 1 the ZBOOK `C40QYUC#ABA` ordered 1 / shipped 0, line 2 the
26
26
  mouse `9VA80AA#ABA` ordered 1 / shipped 1, and the badge read **Fulfilled**.
27
27
 
28
- Root cause was in the shared parent `_Model_Compass_SalesOrder::_status()`, which compared two
29
- **order-level grand totals** whose two sides did not count the same lines: the ordered side filtered
30
- `SalesOrderItems.parentSalesOrderItemId IS NULL` (dropping bundle child lines) while the shipped side
31
- summed **every** ASN quantity on the order with no such filter. On `SAC100664` the mouse is a child
32
- of the ZBOOK (`parentSalesOrderItemId = 1131`), so ordered = 1 and shipped = 1 and it read fulfilled.
33
- Confirmed by running the exact expression on prod: orderedSide `1.00`, shippedSide `1`.
34
-
35
- Canada's fix is a clean **per-line** rule in its own subclass. Prod impact: **115 Canada orders
36
- corrected Fulfilled to Partially Fulfilled, zero orders moving the other way**, and
37
- pendingFulfillment / canceled / pendingApproval / pendingInitialApproval counts all unchanged.
38
-
39
- > **Canada's rule is NOT the USA rule, and the two must not be merged.** Compass USA needs a second,
40
- > aggregate condition on top of the per-line check; porting Canada's clean rule to USA creates
41
- > thousands of *new* false Fulfilleds. See
28
+ Root cause is in the shared parent `_Model_Compass_SalesOrder`, which decided fulfilled vs.
29
+ partiallyFulfilled by comparing two **order-level grand totals** whose two sides did not count the
30
+ same lines: the ordered side filtered `SalesOrderItems.parentSalesOrderItemId IS NULL` (dropping
31
+ bundle child lines) while the shipped side summed **every** ASN quantity on the order with no such
32
+ filter. On `SAC100664` the mouse is a child of the ZBOOK (`parentSalesOrderItemId = 1131`), so
33
+ ordered = 1 and shipped = 1 and it read fulfilled. Confirmed by running the exact expression on prod:
34
+ orderedSide `1.00`, shippedSide `1`.
35
+
36
+ Canada's fix is a **pure per-line rule** in its own subclass, with **no order-total check at all**.
37
+ Measured on prod after the rule: **489 fulfilled / 184 pendingFulfillment / 109 partiallyFulfilled**,
38
+ which is **90 orders corrected** from Fulfilled to Partially Fulfilled, plus 3 corrected from
39
+ partiallyFulfilled to pendingFulfillment.
40
+
41
+ > **⚠ Read this before trusting any older note.** An earlier version of this work was **reverted in
42
+ > git** and its published numbers were wrong — see *History: the first attempt was reverted* below.
43
+ > The rule described on this page was rebuilt from scratch and is **uncommitted working-tree code**.
44
+
45
+ > **Canada's rule is NOT the USA rule, and the two must not be merged.** Compass **USA is
46
+ > deliberately unchanged** and still runs the order-total logic, because the Office Depot ASN feed is
47
+ > not trustworthy at line level. See
42
48
  > [the USA doc](../../compass-usa/features/order-fulfillment-status-per-line.md).
43
49
 
44
50
  ## Key files / entry points
45
51
 
46
52
  - **`_underscore/Model/Compass/Canada/SalesOrder.php`** — `_fulfillmentStatus()`, a `protected static`
47
- override of the shared parent method. This is the whole Canada rule.
48
- - **`_underscore/Model/Compass/SalesOrder.php`** — the parent `_status()` (approval gate untouched)
49
- now calls `static::_fulfillmentStatus()`, which is what makes the Canada override reachable. It
50
- also supplies the `_qtyShippedByAsn()` helper Canada reuses.
53
+ override. **Self-contained:** it builds its own expected-line set and its own per-line shipped
54
+ quantity and borrows nothing conditional from the parent. This is the whole Canada rule.
55
+ - **`_underscore/Model/Compass/SalesOrder.php`** — the parent. The fulfillment branch of `_status()`
56
+ was extracted into `protected static _fulfillmentStatus()` and is called via `static::`, which is
57
+ what makes the Canada override reachable. **The parent body is the ORIGINAL order-total logic,
58
+ moved verbatim** — the seam exists only so Canada can override. Proved behaviour-neutral by
59
+ diffing the emitted `_status` SQL: whitespace-identical, **7,548 normalized characters** both ways.
60
+ - `const ITEM_TYPES__NOT_SHIPPABLE` (Canada) — `SERVICE`, `SERVICES`, `CONSULTING`, `WARRANTY`.
51
61
 
52
62
  ## How it works
53
63
 
54
64
  ```
55
- pendingFulfillment WHEN the order has no ASN at all
56
- partiallyFulfilled WHEN any line with price > 0 has quantity > that line's own shipped quantity
65
+ pendingFulfillment WHEN every expected-to-ship line has received ZERO quantity
66
+ partiallyFulfilled WHEN any expected-to-ship line has received LESS than its ordered quantity
57
67
  fulfilled otherwise
58
68
  ```
59
69
 
60
- - **No ASN** is `COUNT(*) = 0` over `AdvanceShippingNotices` joined to the order through
61
- `SalesOrders_PurchaseOrders`.
62
- - **Per-line shipped quantity** comes from `_qtyShippedByAsn()` on the shared parent, correlated to
63
- the individual `SalesOrderItems.id`.
64
- - No parent/child line filter, so bundle children are judged on their own merits — that is the fix.
70
+ **Both branches read the same expected-line set and the same per-line quantity**, so the gate and
71
+ the shortfall test can never disagree. That symmetry is the design; keep it if you edit the method.
72
+
73
+ ### "Expected to ship" — all four conditions
74
+
75
+ A `SalesOrderItems` row counts only when:
76
+
77
+ 1. `price > 0`;
78
+ 2. it has a `SalesOrderItems_PurchaseOrderItems` link (it was actually purchased);
79
+ 3. its `ItemTypes.name` is **not** in `ITEM_TYPES__NOT_SHIPPABLE`; and
80
+ 4. `COALESCE(Items.isFulfillable, 1) = 1`.
81
+
82
+ No parent/child line filter, so bundle children are judged on their own merits — that is the
83
+ original fix.
65
84
 
66
85
  ### The per-line shipped source (ASN bridge, and only the ASN bridge)
67
86
 
@@ -74,57 +93,136 @@ AdvanceShippingNoticeItems.purchaseOrderItemId
74
93
  This mapping is **100% complete in `Client_CompassCanada`**: all **947** ASN items have a
75
94
  `purchaseOrderItemId` and every one resolves to a sales-order line. Both join columns are indexed.
76
95
  Canada ships through Grand & Toy ASNs only (see
77
- [G&T ASN Import](grand-and-toy-asn-import.md)), so this single source is sufficient here — Canada
78
- needs none of USA's TOGa Tech / Office Depot fulfillment chain.
96
+ [G&T ASN Import](grand-and-toy-asn-import.md)), so this single source is sufficient — Canada needs
97
+ none of USA's TOGa Tech / Office Depot fulfillment chain.
98
+
99
+ ## Why each condition is there (measured, prod)
100
+
101
+ - **Condition 3 (item type) is what corrected the over-correction.** Canada has SERVICE and
102
+ CONSULTING lines that **do carry PO links but never ship**: `COMPASS-IPAD-ABM` (Apple Business
103
+ Manager enrolment — 53 lines, only 9 ever on an ASN), `SDU42Z/A` and `SDRU2Z/A` (AppleCare for
104
+ Enterprise), `CN-COMP-DED-IN` / `CN-CONE-SHR-WS1` (Profile SKUs), and `COMPASS-MACBOOK-ABM-ACE`.
105
+ Requiring them to ship held **25 fully-delivered orders** at partiallyFulfilled — e.g. `SAC100041`,
106
+ `SAC100088`, `SAC100523`. Excluding the item types fixed all 25. **This is the 115 to 90 correction.**
107
+ - **Condition 2 (purchased line) is still required.** Canada has **100 priced lines with no PO link
108
+ across 48 orders**; without the link test those lines can never ship and would strand their orders.
109
+ - **Condition 4 (`isFulfillable`) is a no-op in Canada today.** `Items.isFulfillable` is **NULL on
110
+ every Canada item**, so the `COALESCE(...,1)` always passes. It is in the expression so the rule
111
+ starts honouring the flag automatically once Canada is stamped — the **item-type list does all the
112
+ real work right now**. See
113
+ [isFulfillable — Data Quality & the Type-Derived Rule](../../compass-usa/features/isfulfillable-data-quality-and-type-rule.md).
114
+ - **`ItemTypes` is the only usable column in Canada.** Canada's `AssetTypes` are only
115
+ `Equipment` / `Hardware` / `Tablets` — **there is no `FEE` asset type in Canada at all**, so
116
+ `AssetTypes` gives zero signal here. USA is the opposite case; see the USA doc.
117
+ - **Keep the `price > 0` filter.** Only **9** of Canada's **69** zero-price lines ever receive an
118
+ ASN, so requiring them to ship would strand orders in partiallyFulfilled forever.
119
+
120
+ ## Why per-line QUANTITY is the right unit (the counter-examples)
121
+
122
+ - **Order totals fail.** `SAC100664`: the mouse's shipment paid for the ZBOOK's quantity.
123
+ - **Line counts fail.** `SAC100108`: both expected lines had an ASN, so a line-count check matched —
124
+ but only 1 of 2 iPads and 1 of 2 accessories actually shipped. Also **96 Canada lines carry 2 to 3
125
+ ASN lines each**, so an ASN-line count can exceed the order-line count while the order is still
126
+ short.
127
+ - **There is no line-coverage gap to catch.** Across **601** Canada orders past approval with
128
+ shipments, **zero** read fulfilled while an expected line had no ASN line — a line with no ASN has
129
+ shipped 0 and is always caught as short. A line-count check would add nothing and only weaken the
130
+ rule.
131
+ - **Going per line also neutralises duplicate ASNs for the verdict.** A surplus stays on its own line
132
+ and can no longer cover another line's gap, so no clamping or de-duplication is needed.
133
+
134
+ ## The pendingFulfillment gate reads quantity, not "an ASN exists"
135
+
136
+ The gate is **not** `COUNT(ASNs on the order) = 0`. Three orders (`SAC100131`, `SAC100509`,
137
+ `SAC100522`) had an ASN raised only against an AppleCare / ABM line while **zero physical goods had
138
+ shipped**, and therefore read partiallyFulfilled. Reading the expected-line quantity instead makes
139
+ them correctly read **pendingFulfillment**.
79
140
 
80
141
  ## Design decisions worth keeping
81
142
 
82
143
  - **⚠ Do NOT switch Canada to the local `ItemFulfillments` / `ItemFulfillmentItems` tables**, even
83
144
  though the line-level `_qtyFulfilled` UI column does read them. They are **incomplete** in Canada:
84
145
  **47** orders have ASN shipments with no local `ItemFulfillment` at all and **52** more disagree on
85
- quantity, so moving the order-level rule onto the base `_Model_Client_SalesOrder` logic would have
86
- regressed roughly **99** orders.
87
- - **Keep the `price > 0` filter.** Only **9** of Canada's **69** zero-price lines ever receive an ASN,
88
- so requiring them to ship would strand orders in partiallyFulfilled forever.
146
+ quantity, so moving this rule onto the base `_Model_Client_SalesOrder` logic would regress roughly
147
+ **99** orders.
148
+ - **Canada deliberately has NO order-total condition.** USA keeps one; Canada does not need it,
149
+ because its ASN bridge is complete and its expected-line set is never empty in the way USA's is.
89
150
  - **Canada deliberately omits any Compass-vendor exclusion.** `VENDOR_ID__COMPASS = 26` on the parent
90
151
  is the **US** Compass vendor id. In `Client_CompassCanada` all **901** POs are GRAND & TOY
91
- (`vendorId 1`), and "COMPASS CANADA" is `vendorId 4` with **zero** POs — so the inherited constant
92
- was a latent wrong-id no-op. Do not "restore" it here.
93
- - **Canada needs no purchased-line filter** (unlike USA) precisely because the ASN bridge is complete
94
- and the vendor set is a single vendor.
152
+ (`vendorId 1`), and "COMPASS CANADA" is `vendorId 4` with **zero** POs — the inherited constant is
153
+ a latent wrong-id no-op. Do not "restore" it here.
95
154
 
96
155
  ## Gotchas / known issues
97
156
 
157
+ - **⚠ Uncommitted.** Both touched files pass `php -l`, nothing was committed or pushed, and the repo
158
+ is sitting on **`_production`** — a branch is required before committing. Canada has **not** been
159
+ exercised through the Supply UI yet.
98
160
  - **The badge is display-only and never stored.** The status is computed live, so there is no
99
- backfill and reverting the code fully reverts the behaviour. Canada's in-transit email cron
161
+ backfill and reverting the code fully reverts the behaviour. **No cron, email or report reads the
162
+ fulfilled vs partiallyFulfilled distinction.** Canada's in-transit email cron
100
163
  (`worker/crons/toga2/compasscanada/update_salesorder_status_from_odp.php`) keeps its **own
101
164
  duplicated copy** of the `_status` CASE, but it only ever produces canceled /
102
165
  pendingApprovalUnknown / pendingFulfillment / shipped and filters on
103
- `computedStatusSlug = 'shipped'` — and the pendingFulfillment gate was left byte-identical, so it
104
- is unaffected.
166
+ `computedStatusSlug = 'shipped'`. Note the Canada pendingFulfillment gate is **no longer
167
+ byte-identical** to that copy (it now reads quantity, not ASN existence). The cron's copy is
168
+ unchanged and still only acts on `'shipped'`, so behaviour is unaffected, but the two are now a
169
+ genuine divergence.
105
170
  - **⚠ Canada's order status is no longer identical to Compass USA.** Older notes (and the profile)
106
171
  said "order status is shipped-only, same as Compass USA". The *stage* policy is still shared
107
- (shipped-only), but the **fulfilled vs partiallyFulfilled rule now diverges by region.**
172
+ (shipped-only), but the **fulfilled vs partiallyFulfilled rule now diverges by region** — and it
173
+ diverges because **only Canada changed**.
108
174
  - **The ApprovalDecision notification query on the parent calls `self::_status()`**, not `static::`,
109
- so it always evaluates the **parent** fulfillment rule even for Canada. Harmless today (it only
110
- branches on pendingApproval / canceled / pendingFulfillment, and pendingFulfillment is identical
111
- in both regions) but it is a trap if Canada ever changes those.
175
+ so it always evaluates the **parent** fulfillment rule even for Canada. Because Canada's
176
+ pendingFulfillment gate now differs from the parent's, that query sees the **parent's** gate for
177
+ Canada orders. Harmless today (it only branches on pendingApproval / canceled / pendingFulfillment
178
+ and acts on approvals) but it is now a real divergence, not a theoretical one.
179
+ - **Canada's approval gate is healthy**, unlike USA's: 733 approved, 30 denied, only **19** stuck at a
180
+ pending-approval status. Canada also has all ten `SalesOrderStages` rows, where USA has only ids
181
+ 5 to 9.
182
+
183
+ ## History: the first attempt was reverted
184
+
185
+ - The original fix, commit **`90c964f1`** "Updating the status logic as per the client", was
186
+ **reverted by commit `43049d1d`** ("Revert …", kmaramreddy08, 2026-08-26 16:19). The revert is on
187
+ **`#sprint85`, `TRUE-81284`, `_beta`, `_production` and `_sandbox-client`**; only **`_sandbox-dev`**
188
+ still carries the original fix.
189
+ - Every refinement made after `90c964f1` was **never committed** and is gone from git. The rule on
190
+ this page was therefore **rebuilt from scratch** on `_production`.
191
+ - The ticket branch for this work is **`TRUE-81284`**, not `TRUE-80431`.
192
+ - **Any statement that this shipped is wrong**, including the previously published "115 Canada
193
+ orders corrected" figure.
112
194
 
113
195
  ## Change history
114
- - 2026-08-26 — Fixed the false **Fulfilled** on partly-shipped Canada orders (reported via
115
- `SAC100664`): the shared parent compared two order-level totals whose ordered side dropped bundle
116
- child lines while the shipped side did not. Added a `_fulfillmentStatus()` override in
117
- `_Model_Compass_Canada_SalesOrder` — pendingFulfillment with no ASN, otherwise partiallyFulfilled
118
- if any `price > 0` line is short of its **own** ASN-bridge shipped quantity. Prod: 115 orders
119
- corrected Fulfilled to Partially Fulfilled, zero promoted, all other status counts unchanged.
120
- Recorded why the ASN bridge (100% complete, 947 items) is the source and the local
121
- `ItemFulfillments` tables are not (would regress ~99 orders), why `price > 0` stays, and that the
122
- inherited `VENDOR_ID__COMPASS = 26` is a US-only id and a no-op here. Not committed or pushed; not
123
- yet exercised through the Supply UI. (bala)
196
+ - 2026-08-27 — **Corrects the 2026-08-26 entry below, which described work that was reverted in git
197
+ (`90c964f1` reverted by `43049d1d`) and published a wrong count.** Canada's `_fulfillmentStatus()`
198
+ rebuilt from scratch as a self-contained pure per-line rule with **no order-total check**:
199
+ pendingFulfillment when every expected-to-ship line has received zero, partiallyFulfilled when any
200
+ expected line is short, else fulfilled — both branches reading the same expected-line set and the
201
+ same per-line quantity. "Expected to ship" now also requires `ItemTypes.name NOT IN
202
+ ITEM_TYPES__NOT_SHIPPABLE` (SERVICE/SERVICES/CONSULTING/WARRANTY) and
203
+ `COALESCE(Items.isFulfillable,1) = 1`, which released **25 fully-delivered orders** wrongly held at
204
+ partiallyFulfilled by PO-linked-but-never-shipped AppleCare/ABM/Profile SKUs — so the real impact is
205
+ **90 orders corrected, not 115**. The pendingFulfillment gate now reads expected-line quantity
206
+ instead of "any ASN exists", correcting `SAC100131` / `SAC100509` / `SAC100522`. Prod after:
207
+ 489 fulfilled / 184 pendingFulfillment / 109 partiallyFulfilled. Recorded that `isFulfillable` is
208
+ NULL on every Canada item (so the item-type list does the work), that Canada has no `FEE` asset
209
+ type, and why per-line **quantity** beats order totals (`SAC100664`) and line counts (`SAC100108`).
210
+ Uncommitted; repo on `_production`; not yet exercised through the Supply UI. (bala)
211
+ - 2026-08-26 — **REVERTED IN GIT — do not rely on this entry.** Original per-line
212
+ `_fulfillmentStatus()` override for Canada (pendingFulfillment when the order had no ASN at all,
213
+ otherwise partiallyFulfilled if any `price > 0` line was short of its own ASN-bridge quantity),
214
+ reported as 115 orders corrected. Commit `90c964f1` was reverted by `43049d1d` on 2026-08-26; the
215
+ rule over-corrected by 25 orders and its gate tested ASN existence rather than quantity. Superseded
216
+ by the 2026-08-27 entry. Still valid from this work: the ASN bridge is the correct source (100%
217
+ complete, 947 items), the local `ItemFulfillments` tables are not (would regress ~99 orders),
218
+ `price > 0` stays, and the inherited `VENDOR_ID__COMPASS = 26` is a US-only id and a no-op here.
219
+ (bala)
124
220
 
125
221
  ## Related docs
126
- - [Compass USA — order aggregate AND per-line](../../compass-usa/features/order-fulfillment-status-per-line.md)
127
- — the shared parent rule, and why USA cannot use this page's rule.
222
+ - [Compass USA — order aggregate, deliberately unchanged](../../compass-usa/features/order-fulfillment-status-per-line.md)
223
+ — the parent rule, and why USA was not touched.
128
224
  - [Grand & Toy ASN Import](grand-and-toy-asn-import.md) — where Canada's ASN rows come from.
129
225
  - [IF Stage Lifecycle & Order Status](../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md)
130
226
  — the shipped-only stage policy shared by both Compass regions.
227
+ - [FIELD_SQL calculated fields](../../../2.0/apps/_underscore/features/calculated-sql-fields.md)
228
+ — why a region-subclass override of a calculated field is picked up at all.
@@ -16,7 +16,7 @@ project: _Underscore
16
16
  client: compass-canada
17
17
  type: profile
18
18
  status: active
19
- updated: 2026-08-26
19
+ updated: 2026-08-27
20
20
  owners: [jcardinal, bala, tcox, apeterson]
21
21
  files: []
22
22
  related:
@@ -148,14 +148,20 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
148
148
  lifecycle stages (picked/packed/shipped) + shipped backfill are seeded by
149
149
  `dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql`.
150
150
  See [IF stage lifecycle & order status](../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md).
151
- - **⚠ Order status is no longer identical to Compass USA — the fulfilled-vs-partiallyFulfilled rule
152
- diverged 2026-08-26.** Canada overrides `_fulfillmentStatus()` in
153
- `_Model_Compass_Canada_SalesOrder` with a clean **per-line** rule sourced from the **ASN bridge
154
- only** (`AdvanceShippingNoticeItems.purchaseOrderItemId` -> `SalesOrderItems_PurchaseOrderItems`,
155
- 100% complete here). Do not read Canada's local `ItemFulfillments` tables for this (incomplete:
156
- ~99 orders would regress), and do not port USA's rule or its `VENDOR_ID__COMPASS = 26` (a US
157
- vendor id; all 901 Canada POs are Grand & Toy `vendorId 1`). Fixed 115 falsely-Fulfilled prod
158
- orders. See
151
+ - **⚠ Order status is no longer identical to Compass USA — Canada is the only region whose
152
+ fulfilled-vs-partiallyFulfilled rule changed (2026-08-27).** Canada overrides
153
+ `_fulfillmentStatus()` in `_Model_Compass_Canada_SalesOrder` with a **pure per-line** rule and **no
154
+ order-total condition**, sourced from the **ASN bridge only**
155
+ (`AdvanceShippingNoticeItems.purchaseOrderItemId` -> `SalesOrderItems_PurchaseOrderItems`, 100%
156
+ complete here). A line counts as expected-to-ship only when `price > 0`, it has a PO-item link, its
157
+ `ItemTypes.name` is not in `ITEM_TYPES__NOT_SHIPPABLE` (SERVICE/SERVICES/CONSULTING/WARRANTY) and
158
+ `COALESCE(Items.isFulfillable,1) = 1` — the item-type list matters because Canada has PO-linked
159
+ AppleCare/ABM SKUs that never ship, and `isFulfillable` is NULL on every Canada item. Do not read
160
+ Canada's local `ItemFulfillments` tables for this (incomplete: ~99 orders would regress), and do
161
+ not port USA's rule or its `VENDOR_ID__COMPASS = 26` (a US vendor id; all 901 Canada POs are Grand
162
+ & Toy `vendorId 1`). **Corrects 90 prod orders, not the 115 previously published** — the first
163
+ attempt was reverted in git and over-corrected by 25 orders. **Uncommitted** (repo on
164
+ `_production`). See
159
165
  [Fulfilled vs Partially Fulfilled](features/order-fulfillment-status-per-line.md).
160
166
  - **Item titles in customer emails** come from the `ItemTranslations` sidecar (prod: 220 fr-CA rows,
161
167
  100% coverage of every item ever ordered). Resolve the English/translated fallback **in PHP**, never