toga-ai 1.0.194 → 1.0.196

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,6 +6,8 @@
6
6
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
7
7
  | [Carrier Shipping Labels (UPS/FedEx) & NetSuite Item Fulfillment](features/carrier-shipping-labels.md) | Backend mechanics behind TOGa Supply's Fulfill & Ship: buying a carrier label (UPS/FedEx), persisting it, and creating the NetSuite Item Fulfillment with tracki | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillments/TrackingNumber.php, _underscore/Component/Library/LabelPdf/LabelPdf.php, _underscore/Component/Library/Carriers/Ups/Ups.php, _underscore/Component/Library/Carriers/Fedex/Fedex.php, _underscore/Trait/Netsuite/ItemFulfillment.php, _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Component/Library/NetSuite/NetSuite.php, _underscore/Model/Client/TrackingNumber.php, _underscore/Model/Client/ShippingMethod.php, _underscore/Model.php, _underscore/Cloud.php |
8
8
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
9
+ | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | _underscore/Component/Forecast/SaleImport/SaleImport.php, _underscore/Component/Forecast/Db/Db.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php |
10
+ | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php |
9
11
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
10
12
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
11
13
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql |
@@ -0,0 +1,157 @@
1
+ ---
2
+ title: Forecast.Sales NetSuite import engine (real-time webhook)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-24
10
+ owners: [dfranks]
11
+ files:
12
+ - _underscore/Component/Forecast/SaleImport/SaleImport.php
13
+ - _underscore/Component/Forecast/Db/Db.php
14
+ - worker2/Worker/Netsuite/Invoice.php
15
+ - worker2/Worker/Netsuite/CashSale.php
16
+ - worker2/Worker/Netsuite/CreditMemo.php
17
+ - worker2/Worker/Netsuite/CashRefund.php
18
+ - worker2/Worker/Netsuite/Opportunity.php
19
+ - worker2/Worker/Netsuite/SalesOrder.php
20
+ related:
21
+ - ../architecture.md
22
+ - ../../worker2/architecture.md
23
+ ---
24
+
25
+ ## Summary
26
+ Real-time importer that takes a NetSuite **sale** record and writes its lines into
27
+ `Forecast.Sales` (the Forecast2 revenue table). Covers the four NetSuite sale record
28
+ types: **invoice, cashSale, creditMemo, cashRefund**. It is the webhook-driven replacement
29
+ for the SALES section of the legacy 5-minute pull cron
30
+ (`worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php`), and mirrors the
31
+ already-shipped SalesOrder (open-orders) and Opportunity webhook handlers.
32
+
33
+ The shared engine is `_Component_Forecast_SaleImport`; four thin worker2 handlers
34
+ (`_Worker_Netsuite_{Invoice,CashSale,CreditMemo,CashRefund}`) just delegate to it.
35
+
36
+ **Critical, non-obvious facts (read before touching this):**
37
+ - **Sign convention differs from the cron** — the raw REST record is all-positive, so the
38
+ webhook applies the sign factor **uniformly to every line**, not just shipping. Copying
39
+ the cron's sign handling reintroduces the credit-memo sign bug. (See Sign convention.)
40
+ - The engine is a `_Component_*` class and therefore **must live in `_underscore`**, not in
41
+ worker2 — the autoloader rejects a `_Component_*` loaded from a project path. (See Gotchas.)
42
+
43
+ ## Key files / entry points
44
+ | File | Role |
45
+ |---|---|
46
+ | `_underscore/Component/Forecast/SaleImport/SaleImport.php` | the engine (shared body for all four types) |
47
+ | `_underscore/Component/Forecast/Db/Db.php` | shared NS-REST + sublist-pagination helpers |
48
+ | `worker2/Worker/Netsuite/Invoice.php` etc. | four thin handlers; `post`/`put` → `sync`, `delete` → `removeAll` |
49
+
50
+ Each handler is `abstract class _Worker_Netsuite_<Type> implements
51
+ _Interface_Static_Webhook_Netsuite`. The router (`_Worker_NetSuite::Webhook`) PascalCases
52
+ the inbound `recordType` into the class name; `invoice`/`cashSale`/`creditMemo`/`cashRefund`
53
+ map 1:1, so **no router change is needed** to add these.
54
+
55
+ Engine public surface: `sync(int $internalId, string $type)` and
56
+ `removeAll(int $internalId)`. post/put → `sync`; delete → `removeAll`.
57
+
58
+ ## How it works
59
+ Per-record flow in `sync`:
60
+ 1. REST GET the record (`expandSubResources=true`) via `_Component_Forecast_Db::fetchRecord`.
61
+ 2. **Per-type status gate** → if excluded, call `removeAll` and stop. Exclusions: invoice
62
+ `Voided`/`Rejected`; cashSale `Unapproved Payment`; creditMemo `Voided`; cashRefund has
63
+ no gate.
64
+ 3. If `shippingCost != 0`, append a **synthetic SHIPPING line at lineNumber 0** using
65
+ NetSuite item internalId **13500** (a real `Forecast.Items` row).
66
+ 4. Per line: resolve the item (with self-heal — see below); skip `itemGroup` items
67
+ (no line revenue).
68
+ 5. Guarded upsert keyed on `(netsuiteTransactionInternalId, lineNumber)`.
69
+ 6. Reconcile-delete any lines NetSuite no longer returns.
70
+ 7. Commit `DB_FORECAST`.
71
+
72
+ `removeAll(internalId)` deletes all rows for that transaction (used by delete events and by
73
+ the status gate).
74
+
75
+ ### Shared helpers on `_Component_Forecast_Db` (dedup)
76
+ - `fetchRecord(route, label)` — NS REST GET + decode.
77
+ - `collectItemLines(record)` + `nextPageRoute(links)` — `rel=next` sublist pagination.
78
+
79
+ `Opportunity.php` and `SalesOrder.php` were refactored onto these shared versions; their
80
+ private `fetchRecord`/`collectAllItemLines`/`nextPageRoute` copies (and SalesOrder's
81
+ orphaned `MAX_SUBLIST_PAGES` const) were deleted.
82
+
83
+ ## Sign convention (the load-bearing decision)
84
+ The **raw REST record** returns each line `amount`/`costEstimate` **POSITIVE for all four
85
+ types** (verified by live probe 2026-06-24 against NS account 1095849: creditMemo lines
86
+ +350/+400/+700/…, cashRefund +885, invoice all positive). The engine therefore applies the
87
+ sign factor **uniformly**:
88
+ - `factor = -1` for **creditMemo + cashRefund**, `+1` for **invoice + cashSale**
89
+ - `revenue = amount * factor`
90
+ - `profit = (amount - costEstimate) * factor`
91
+ - shipping line `amount = shippingCost * factor`
92
+
93
+ This is the **opposite of the legacy cron**, which reads the pre-signed listSales 1.0 shim
94
+ (already `-foreignamount`) and so applies the factor **only** to the synthetic shipping
95
+ line. **Do not copy the cron's sign handling into the webhook** — it reintroduces the
96
+ ~$470K credit-memo sign bug (credits stored positive). The cron is not authoritative for
97
+ the raw-REST path. Confirmed end-to-end against the local Forecast mirror (creditMemo
98
+ revenue stored -350..-600; cashRefund revenue -885 / profit -78; invoice positive).
99
+
100
+ > A reviewer may flag "profit double-signs the factor" — false positive:
101
+ > `(amount*factor) - (costEstimate*factor) == (amount - costEstimate)*factor`.
102
+
103
+ ## Item self-heal
104
+ When a sale line references a NetSuite item not yet in `Forecast.Items`, the engine pulls it
105
+ on demand via `_Worker_Netsuite_Item::syncItem()` (the same method the open-orders handler
106
+ uses) and then writes the line — instead of the cron's silent skip. Failure semantics:
107
+ - `syncItem` throws **`RuntimeException`** for a genuinely unmappable item (no Forecast enum
108
+ value / absent from NetSuite) → engine catches it and **skips that one line** (logging it),
109
+ matching cron tolerance.
110
+ - A real infrastructure failure is raised by `_Component_Api_Netsuite::send` as a **plain
111
+ `Exception`** (superclass of `RuntimeException`) → **not caught** → propagates and fails
112
+ the job for retry.
113
+ - `itemGroup` items carry no line revenue and are skipped.
114
+
115
+ ## Data model — Forecast.Sales schema facts
116
+ Prod `Forecast.Sales` writable columns: `netsuiteTransactionType`
117
+ (enum `'invoice','cashSale','creditMemo','cashRefund'`), `netsuiteTransactionInternalId`,
118
+ `tranDate`, `tranNumber`, `customerId`, `leadSource`, `salesRepEmployeeId`, `itemId`,
119
+ `lineNumber`, `revenue`/`profit` `decimal(14,2)`, `createdFromNetsuiteTransactionInternalId`.
120
+ - **No `amountDue` and no `dtPendingBilling` column** in prod (no staged dbchanges2 adds
121
+ them) → this importer does **not** write amountDue. The engine is structured so adding it
122
+ later is a one-line change (value map + tracked-columns + change-detection SELECT).
123
+ - **No `classificationId` column** (unlike OpenOrderItems) → classification is not resolved
124
+ or written for Sales.
125
+ - **No unique index** on `(netsuiteTransactionInternalId, lineNumber)`; the composite
126
+ `Sales_netsuiteTransactionType_IDX` leads with `netsuiteTransactionType`. Because there is
127
+ no unique key, inserts use a guarded `INSERT ... SELECT ... FROM DUAL WHERE NOT EXISTS(...)`
128
+ for idempotency. This reduces but does not fully eliminate concurrent-duplicate risk;
129
+ serialized-per-internalId + reconcile-delete close the gap. A real unique index is
130
+ **deliberately deferred**.
131
+
132
+ ## Integration mechanism — the NetSuite AMQ enqueuer is per-record-type
133
+ The User Event enqueuer `customscript_ue_amq_enqueue`
134
+ (`test/@dave/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js`) must be **deployed on
135
+ each record type** for that type's create/edit/delete to enqueue a webhook. Inbound sale
136
+ events therefore require this one script deployed on **Invoice, Cash Sale, Credit Memo, and
137
+ Cash Refund**. The drainer (`customscript_ss_amq_drain`) is record-type-agnostic and needs
138
+ no per-type deployment.
139
+
140
+ The enqueuer's `RECORD_TYPE_MAP` (lowercase NS `record.type` → camelCase `recordType` the
141
+ worker2 router PascalCases) already maps the four sale types 1:1. Note: NetSuite emits item
142
+ events as specific **subtypes** (`inventoryItem`, `nonInventoryResaleItem`, `kitItem`,
143
+ `itemGroup`, …) with no generic `item` type — collapsing those subtype VALUES to `'item'`
144
+ would be needed only for a future real-time item webhook, **not** for this Sales importer
145
+ (which keeps items fresh via the hourly item pull cron plus the inline self-heal).
146
+
147
+ ## Gotchas / known issues
148
+ - **`_Component_*` classes must live under the `_underscore` framework root, never a project
149
+ repo.** The autoloader rejects a `_Component_*`/`_Model_*` loaded from a project path
150
+ (e.g. `./` under worker2) with "namespace … has not been defined" — at **class-load
151
+ (runtime), not `php -l`**. So this engine lives in `_underscore` next to
152
+ `_Component_Forecast_Db` even though only worker2 uses it; it references the worker2 class
153
+ `_Worker_Netsuite_Item`, resolved lazily at runtime in the worker2 context.
154
+ - The cron's sign handling is not portable here — see Sign convention.
155
+
156
+ ## Change history
157
+ - 2026-06-24 — Built the Forecast.Sales real-time webhook importer (shared `_Component_Forecast_SaleImport` engine + four thin worker2 handlers), replacing the legacy cron SALES section; decided the uniform-factor sign convention against the raw REST record; deduped `fetchRecord`/sublist pagination onto `_Component_Forecast_Db`. (dfranks)
@@ -0,0 +1,57 @@
1
+ ---
2
+ title: _Model magic-field access (__get without __isset)
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-06-25
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - _underscore/Model/Core/Model.php
13
+ related:
14
+ - ../../api2/features/language-translation-layer.md
15
+ ---
16
+
17
+ ## Summary
18
+
19
+ `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`.
20
+ Because PHP does not route `isset()` / null-coalescing through `__get()`, any `isset($model->field)` or
21
+ `$model->field ?? null` on a magic (DB) field is **always** `false` / `null` — even when the column
22
+ exists and has a value. And a bare `$model->field` read for a column that is **not** on the model
23
+ **throws**. This bites any code that probes for an optional/conditional field on a generic model.
24
+
25
+ ## How it works
26
+
27
+ - `_Model::__get($name)` resolves a configured DB field and returns its value; for an unconfigured
28
+ field it throws (not a silent null).
29
+ - There is **no** `_Model::__isset()`, so PHP's `isset()`/`empty()`/`??` short-circuit to "not set"
30
+ for every magic field before `__get` is ever consulted. This is a PHP language rule, not a bug in
31
+ the value.
32
+
33
+ **Safe pattern for a possibly-absent field on a generic model:**
34
+
35
+ ```php
36
+ if (array_key_exists('uuid', $model->getFieldConfig())) {
37
+ $uuid = $model->uuid; // safe: __get won't throw, field is known to exist
38
+ }
39
+ ```
40
+
41
+ Check `array_key_exists('field', $model->getFieldConfig())` first, then read via `__get`. Do **not**
42
+ gate on `isset($model->field)` or `$model->field ?? $default` — both silently misbehave.
43
+
44
+ ## Gotchas / known issues
45
+
46
+ - `isset($model->magicField)` / `$model->magicField ?? null` are **always** false/null for DB-backed
47
+ magic fields. Use `getFieldConfig()` + `array_key_exists` to test presence.
48
+ - A bare `__get` on an unconfigured field **throws** — never read a maybe-absent field without the
49
+ `getFieldConfig()` guard.
50
+ - This surfaced as a missing `uuid` in the API translation-layer fallback warning — see
51
+ [Language Translation Layer](../../api2/features/language-translation-layer.md).
52
+
53
+ ## Change history
54
+
55
+ - 2026-06-25 — Documented the `__get`-without-`__isset` gap and the `getFieldConfig()` +
56
+ `array_key_exists` safe-read pattern, discovered while debugging a missing `uuid` in the API2
57
+ translation fallback warning. (jcardinal)
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
- | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, expanding later) in multiple languages without forking the schema or breaking English consume | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql |
6
+ | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, expanding later) in multiple languages without forking the schema or breaking English consume | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql |
7
7
  | [POST + JSON-body args for scripted APIs](features/scripted-api-post-body-args.md) | The V2 engine can run a Record Script (scripted API) for a **POST** request, and a scripted API can receive its arguments from the **JSON request body** instead | api2/Component/Api/V2/V2.php |
8
8
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
9
9
  | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | |
@@ -6,10 +6,11 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-23
9
+ updated: 2026-06-25
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
+ - api2/Component/Api/V2/Response/Response.php
13
14
  - _underscore/Model/Core/Setting.php
14
15
  - _underscore/Model/Core/RecordField.php
15
16
  - _underscore/Model/Core/DefaultGlobalSetting.php
@@ -20,6 +21,7 @@ files:
20
21
  - dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql
21
22
  related:
22
23
  - ./acl-permission-chain.md
24
+ - ../../_underscore/features/model-magic-field-access.md
23
25
  ---
24
26
 
25
27
  ## Summary
@@ -55,6 +57,19 @@ persona** (in the user's persona order) that sets it wins. The resolved code is
55
57
  response as `audience.language`. API-credential auth gets no language. Token refresh copies the claim,
56
58
  so a language change requires re-authentication.
57
59
 
60
+ **Invalid-language hard failure (auth time).** The resolved code is validated against the client's
61
+ `Languages` table. An **unset/empty** setting defaults to `en` (no error). But a **non-empty** code
62
+ that does **not** exist in `Languages` now **fails authentication** with HTTP 400
63
+ `EV-16 DEFINED_MESSAGE_ERROR_INVALID_LANGUAGE_SETTING` (validation group, defined in `Response.php`),
64
+ with the offending `language` code in the message `identifiers` — instead of silently falling back to
65
+ `en`. A misconfigured language must surface loudly, not silently degrade.
66
+
67
+ **Auth-mint status finalization (subtle).** The auth success path sets `status = 201` and attaches the
68
+ tokens. EV-16 is signalled by a local flag `$invalidLanguageSetting` set at the resolution site; the
69
+ finalization branches `if (empty($invalidLanguageSetting))` → `201` + tokens, else
70
+ `addDefinedMessage(EV-16)` + `data = null`. See Gotchas — do **not** convert this to an
71
+ `is_null($this->response->status)` guard.
72
+
58
73
  **Which fields are translatable (metadata-driven).** `Core.RecordFields.translationRecordFieldId`
59
74
  (new column, positioned after `recordId`) points a source field's RecordField at the sidecar field's
60
75
  RecordField. `buildLookups`/RecordFields-load resolves this into
@@ -65,7 +80,10 @@ serialization site (top-level full-model, custom-fields, FK child, both inherent
65
80
  `getTranslatedFieldValue($record, $field, $sourceModel, $defaultValue)` is called. When a non-base
66
81
  language is active and the field is translatable, it loads the sidecar row for that source row +
67
82
  `languageId` (cached per row so multiple fields = one load) and returns the sidecar value if non-null;
68
- otherwise it returns the English default and queues a deduped `W*` warning.
83
+ otherwise it returns the English default and queues a fallback warning. The warning now carries the
84
+ missing record's `uuid` in its `identifiers` and dedupes per-field-**and**-per-record
85
+ (key `recordFieldId:uuid`) rather than once-per-field — so every record lacking a translation for a
86
+ field is reported individually.
69
87
 
70
88
  **Write path.** On create and update, `extractTranslationWrites()` pulls translatable fields out of
71
89
  the write set for a non-base language (so the English source is never overwritten), and after the
@@ -96,6 +114,26 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
96
114
  per-field fallback warning signals the English fallback.
97
115
  - The base language is `en`; when the resolved language is `en` (or API auth) all translation logic is
98
116
  skipped and responses are byte-identical to pre-feature.
117
+ - **"Why isn't my language setting applying?"** A `ClientGlobalSettings` row with `isOverridable = 0`
118
+ **locks** the value against all downstream persona/user layers — a `UserGlobalSettings` (or persona)
119
+ override never wins while the client-global layer is locked. This is by design (the 8-layer cascade
120
+ with `isOverridable` locking). To let user/persona overrides apply, the locking layer must be
121
+ `isOverridable = 1` (or its value cleared). The cascade itself is correct — this is a data-config
122
+ gotcha, not a code bug.
123
+ - **Namespace gotcha in `V2.php` (`namespace api;`).** The api-namespaced classes
124
+ (`_Component_Api_V2_Response`, `_Component_Api_V2_Response_Message`) must be referenced **unqualified**
125
+ (resolves to `api\…`) or with the full `\api\_Component_Api_V2_Response_Message`. A bare leading
126
+ backslash (`\_Component_Api_V2_Response_Message`) forces the global namespace and throws
127
+ `EO-1 "Could not find required file"` via the autoloader. The translation fallback-warning code hit this.
128
+ - **Do NOT guard the auth-mint finalization with `is_null($this->response->status)`.** The auth response
129
+ auto-adds client `name` + user `firstName`/`lastName` and runs `processRoutePairs()`, which sets
130
+ `$this->response->status` to `200` **before** finalization. An `is_null` guard there suppresses the
131
+ `201` + tokens and yields a broken `200`/`data:null` login. Branch on the explicit
132
+ `$invalidLanguageSetting` flag instead.
133
+ - Reading a possibly-absent magic field off a generic `_Model` needs `array_key_exists(...)` +
134
+ `__get` — `isset()`/`?? null` always read false/null. See
135
+ [_Model magic-field access](../../_underscore/features/model-magic-field-access.md); this was the
136
+ cause of the uuid not appearing in the fallback warning.
99
137
  - Migrations not yet executed at time of writing; needs live verification.
100
138
 
101
139
  ## Change history
@@ -103,6 +141,14 @@ None — uniform across all clients. The sidecar table + ACL ship via `dbchanges
103
141
  - 2026-06-23 — Initial build: audience.language + JWT embedding, ItemTranslations sidecar + metadata +
104
142
  full ACL chain, and translation-aware read/write at the API layer. Also fixed a latent autoload bug
105
143
  by renaming `DefaultFlobalSetting.php` → `DefaultGlobalSetting.php`. (jcardinal)
144
+ - 2026-06-25 — Added defined error `EV-16 INVALID_LANGUAGE_SETTING` (HTTP 400, in `Response.php`): a
145
+ non-empty language code absent from `Languages` now fails auth instead of silently falling back to
146
+ `en` (empty still defaults to `en`). Documented the auth-mint `201` finalization (branch on the
147
+ `$invalidLanguageSetting` flag, never an `is_null(status)` guard). Fixed the fallback-warning
148
+ namespace bug (no bare leading backslash on api-namespaced classes). Fallback warning now includes the
149
+ record `uuid` and dedupes per-record (`recordFieldId:uuid`). Documented the `isOverridable = 0`
150
+ client-global lock as a config gotcha. Discovered the `_Model` `__isset` gap behind the missing-uuid
151
+ bug (see linked _underscore doc). Live-tested on the local Compass DB. (jcardinal)
106
152
 
107
153
  ## Related docs
108
154
 
@@ -15,7 +15,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
15
15
 
16
16
  ## 2.0 framework
17
17
 
18
- - **_underscore** (_Underscore) _(framework core)_ — 12 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
18
+ - **_underscore** (_Underscore) _(framework core)_ — 14 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
19
19
  - **worker2** (Worker) — 14 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
20
20
  - **api2** (API) — 6 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
21
21
  - **dbchanges2** (Database Changes) _(framework core)_ — 2 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.194",
3
+ "version": "1.0.196",
4
4
  "description": "TOGA Technology Team Claude Knowledge System — shared AI coding harness with skills, knowledge base CLI, and project installer for Claude Code.",
5
5
  "keywords": [
6
6
  "claude",