toga-ai 1.0.994 → 1.0.995

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: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-08
9
+ updated: 2026-10-09
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -25,6 +25,8 @@ files:
25
25
  - dbchanges2/Client_Compass/2026-08-06 - RemoveBrokenSalesOrderItemPostPostInterceptor.sql
26
26
  - dbchanges2/Client_Compass/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
27
27
  - dbchanges2/Client_CompassCanada/2026-08-14a - AddSalesOrderInactiveStandaloneItemPreInterceptors.sql
28
+ - _underscore/Model/Client/UserGlobalSetting.php
29
+ - dbchanges2/Core/2026-10-09a - UserGlobalSettingsInterceptors.sql
28
30
  related:
29
31
  - ../../worker2/features/netsuite-transferorder-outbound-push.md
30
32
  - ../../../clients/rate/features/aig-contract-creation.md
@@ -40,6 +42,8 @@ related:
40
42
  - ../../_underscore/features/email-template-sending.md
41
43
  - ../../../clients/elite/features/salesorder-netsuite-push.md
42
44
  - ../../../clients/compass-usa/features/sales-order-line-renumbering.md
45
+ - ./language-translation-layer.md
46
+ - ../../_underscore/features/persona-nav-visibility-gate.md
43
47
  ---
44
48
 
45
49
  How `_Model` prePost/postPost/prePut/postPut hooks are dispatched by the api2 /v2 engine only when a metadata row exists — the missing-row silent no-op, the wrong-row hard fatal, and the persistence/transaction traps; open before writing, activating, or debugging any interceptor.
@@ -73,7 +77,7 @@ The lookup predicate is **`apiId IS NULL OR apiId = <the caller's api>`**, so a
73
77
  ### The interceptor sees payload keys verbatim (no `c_` stripping, no rename)
74
78
 
75
79
  **Verified empirically** by a live HTTP probe against local dev (`test/@dave/Junk Drawer/probe_c_prefix_payload_mapping.php`):
76
- - `$payload` carries the keys **exactly as the client sent them**. Sending `mitsSalesOrder` yields `$payload->mitsSalesOrder`; sending `c_mitsSalesOrder` yields `$payload->c_mitsSalesOrder`. **Column mapping happens AFTER the interceptor runs.**
80
+ - `$payload` carries the keys **exactly as the client sent them**. Sending `mitsSalesOrder` yields `$payload->mitsSalesOrder`; sending `c_mitsSalesOrder` yields `$payload->c_mitsSalesOrder`. **Column mapping happens AFTER the interceptor runs** — including alias keys: a PRE hook sees `user` / `setting`, and api2 maps them onto `userId` / `settingId` only afterwards (security impact: [own-row guards](#own-row-guards-in-a-pre-interceptor)).
77
81
  - The rename in **`Client_<X>.Apis_CustomRecordFields.overrideFieldName`** governs only which field names the API **accepts on input**. Without a rename row the renamed name is rejected with *"a field specified in your request does not exist"*; the **raw model field name is always accepted**. Rename rows are themselves `apiId`-scoped, so two APIs writing the same record can legitimately use different names for one field.
78
82
 
79
83
  > **⚠ Do not re-derive this from the source.** Static reading of `api2/Component/Api/V2/V2.php` around **L7384** — where `$modelVarsToSet` is cast to an object after a `$flippedRenamedFields` flip — *looks* like the interceptor receives MODEL-level field names. It does not. The probe result above is authoritative; the code path is misleading.
@@ -243,6 +247,23 @@ The reverse direction: not "queue a worker from an interceptor" but **"run the p
243
247
  - **Do not use the interceptor path just to send an email.** For a plain send there is a documented non-API entry point: `_Model_Client_EmailTemplate::send(string $clientIdentifier, ...)`, versus the `$api`-based `sendEmail()`. See [Email Template Sending](../../_underscore/features/email-template-sending.md). Faking `$api` is for reusing *business logic*, not for reaching the mailer.
244
248
  - **Notes will not be logged.** `_Model_Client_Logs_Record::addNote()` is flushed only by `Component/Api/V2/V2.php`, so notes staged by the interceptor you just ran are **dropped** in a worker. See [Record Change Audit Log](../../_underscore/features/record-change-audit-log.md).
245
249
 
250
+ ### Own-row guards in a PRE interceptor
251
+
252
+ A PRE hook that limits a user to their own rows must close four bypasses — each found by review on the `user-global-settings` guard (TRUE-82613):
253
+ 1. **Alias keys.** api2 maps `user` / `setting` (FK objects) onto `userId` / `settingId` **after** PRE runs, so a hook that only checks `$payload->userId` is skipped by sending `user: {uuid}`. **Allowlist the payload keys** and reject anything else.
254
+ 2. **Parent routes.** On `POST /v2/users/<uuid>/user-global-settings` api2 overwrites the FK the hook stamped with the parent's id **after** the hook. Refuse any POST with a parent segment.
255
+ 3. **PRE runs BEFORE the field-level write ACL check** — the hook cannot count on the ACL to have rejected a field yet.
256
+ 4. **PUT/PATCH carry no owner in the body.** Load the row by `$api->recordUuid`, refuse not-own rows, refuse changes to the owner/type columns, then `unset` them before the save.
257
+
258
+ Refuse API-key callers when the rule is "the signed-in user" (no user on the token). Throw `_Exception_Validation` (400); use **one** message for "not yours" and "not allowed" so the response does not reveal whether a row exists.
259
+
260
+ **Reference implementation:** `_Model_Client_UserGlobalSetting::prePost/prePut/prePatch` (Core.Records **279**), rows in `dbchanges2/Core/2026-10-09a - UserGlobalSettingsInterceptors.sql` (`clientId` + `apiId` NULL → **every tenant, every API**). Keys: POST `settingId, value`; PUT/PATCH `userId, settingId, value`. Only the language setting (Core.Settings id 2) is writable; `value` must be a code in the **client** `Languages` table (`_Model_Client_Language` — there is no Core Languages table and no active flag). POST stamps `userId` to the caller and refuses a second language row. No ACL grant ships with it yet.
261
+
262
+ **Known own-row holes (open, not fixed — 2026-10-09):**
263
+ - `Client_CompassCanada/2026-01-15 - UserGlobalSettingsAcl.sql` grants UserGlobalSettings writes with no own-row check.
264
+ - `UserNavPreferences` checks the user on create, not on PUT/PATCH.
265
+ - `TableViewPresets` has no PATCH check.
266
+
246
267
  ### Worked example — the EV-10 that was not a code bug
247
268
 
248
269
  `_Model_Client_ItemFulfillment::prePost()` defaults `itemFulfillmentStageId` to the shipped stage on a `POST /v2/item-fulfillments`. In **dev-sandbox** the `(recordId 28, PRE, POST)` row was missing, so `prePost` never ran, `itemFulfillmentStageId` stayed `NULL`, and the write failed with **`EV-10` "Column 'itemFulfillmentStageId' cannot be null"** — while the identical code worked in production. Fix was purely metadata: apply the existing `dbchanges2/Core/2026-06-30a - ItemFulfillmentStageDefaultInterceptor.sql`. **Diagnostic that works:** do a **full-table compare of `ApiPayloadInterceptors` between production and the failing environment**. It is a small table, and the drift is usually exactly one row.
@@ -262,6 +283,7 @@ The reverse direction: not "queue a worker from an interceptor" but **"run the p
262
283
  - **⚠ Validate that a payload ARRAY's elements are objects before touching them — `property_exists()` THROWS.** `property_exists()` raises a `TypeError` when argument 1 is an `array`, `int`, `null` or `bool` (a *string* merely returns `false`). So a `foreach` over e.g. `$payload->purchaseOrderItems` that calls `property_exists($item, …)` turns a malformed payload into an **HTTP 500** where the interceptor was supposed to answer **400**. Reject non-array containers and non-object elements **first**, with a plain `_Exception_Validation` (a sender coding fault is not a business condition — it should not raise an Issue or page anyone). Worked example: [Compass MITS PO zero-line-item rejection](../../../clients/compass-usa/features/mits-po-zero-line-item-rejection.md).
263
284
  - **⚠ A throw from a PRE interceptor leaves NO `Logs_<Client>.Api` row.** The transaction log is written near the END of `_Component_Api_V2::execute()` (~L2225-2271), *after* route processing (~L1742-2043), so an exception raised from `prePost` never reaches it and the rejected request looks like it never arrived. See [V2 request logging](./request-logging.md#-a-throw-from-a-pre-interceptor-leaves-no-api-log-row-at-all).
264
285
  - **⚠ Client behaviour never goes on `_Model_Client_X`.** Every tenant with a row for that record inherits it through `parent::` calls. Put it on `_Model_<Client>_X`.
286
+ - **⚠ An own-row PRE guard that checks only `userId` is bypassable** — alias keys (`user`) map after PRE, and a parent-route POST overwrites the stamped FK. Allowlist keys + refuse parent routes. See [own-row guards](#own-row-guards-in-a-pre-interceptor).
265
287
  - **A client's rows may be in `Core` (with `clientId`), not in `Client_<X>`** — check both before concluding a client has no interceptors.
266
288
  - **⚠ The same hook in BOTH `Core` (with `clientId`) and `Client_<X>` fires TWICE** — both tables are read, nothing dedupes. Adyen (2026-10-08): Core ids 15–17 duplicated client rows 1, 2, 5 → double worker jobs and double NetSuite queueing; fixed by deactivating the Core rows. Keep each `(record, phase, method)` in one table only. See [Adyen reclaim flow](../../../clients/adyen/features/service-request-salesorder-netsuite.md#gotchas).
267
289
  - **The method name is derived, so a typo'd enum silently misses.** A row with `prePostProcessing = 'PRE'`, `httpMethod = 'PUT'` resolves to `prePut`, not `prePost`; the engine finds no method and moves on.
@@ -291,3 +313,4 @@ The reverse direction: not "queue a worker from an interceptor" but **"run the p
291
313
 
292
314
  ## Change history
293
315
  - 2026-10-08 — `internalApiRequest` "[]" throw also fires on the OUTER request's warnings (e.g. WO-1); throw site now ~L2436; Core + client duplicate rows fire twice. (rgirish)
316
+ - 2026-10-09 — Own-row guards in PRE hooks (alias keys + parent-route bypasses, PRE before field ACL); user-global-settings reference; known open holes. (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-09
10
10
  owners: ["jcardinal", "bala"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -18,9 +18,12 @@ files:
18
18
  - _underscore/Model/Client/FeatureTranslation.php
19
19
  - _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php
20
20
  - _underscore/Model/Client/ItemFeatureTranslation.php
21
+ - _underscore/Model/Client/UserGlobalSetting.php
22
+ - dbchanges2/Client/2026-10-08l - LanguageOverridable.sql
21
23
  related:
22
24
  - ./acl-permission-chain.md
23
25
  - ../../_underscore/features/model-magic-field-access.md
26
+ - ./api-payload-interceptors.md
24
27
  ---
25
28
 
26
29
  How api2 serves per-language item/feature text via `audience.language` + per-table sidecar tables resolved in the API layer (ORM untouched); open when adding a translatable field, debugging a missing translation, or a collation-mismatch SQL error.
@@ -38,20 +41,18 @@ Uniform across all clients (no client variations). The sidecar table + ACL ship
38
41
  - `api2/Component/Api/V2/V2.php`: `resolveSettingValue($slug, $context)` (the 8-layer Settings-cascade resolver, private); `resolveValidLanguageCode()` (validates against `Languages.code`, default `en`); `getTranslatedFieldValue()` / `loadTranslationSidecar()` / `shouldTranslate()` / `addTranslationFallbackWarning()` (read path); `extractTranslationWrites()` / `saveTranslationWrites()` (write path); `lookupTranslatableFieldByRecordFieldId` built in the RecordFields load (~line 200).
39
42
  - `_underscore/Model/Client/ItemTranslation.php` — the first sidecar model.
40
43
 
41
- **Language resolution (auth time, user auth only).** `resolveSettingValue('language', …)` walks the Settings Matrix in order — DefaultGlobal, DefaultApp, ClientGlobal, ClientApp, PersonaGlobal, PersonaApp, UserGlobal, UserApp — each overriding the previous when set. A Client/Persona layer with `isOverridable = 0` **locks** the value against all later layers. For the persona layers, the **first persona** (in the user's persona order) that sets it wins. The resolved code is validated against `Languages.code` (fallback `en`), embedded in the JWT `id.language` claim, and echoed on every response as `audience.language`. API-credential auth gets no language.
44
+ **Language resolution (auth time, user auth only).** `resolveSettingValue('language', …)` walks the Settings Matrix in order — DefaultGlobal, DefaultApp, ClientGlobal, ClientApp, PersonaGlobal, PersonaApp, UserGlobal, UserApp — each overriding the previous when set. A Client/Persona layer with `isOverridable = 0` **locks** the value against all later layers. For the persona layers, the **first persona** (in the user's persona order) that sets it wins. The resolved code is validated against the client `Languages.code` (see *Invalid saved language* below), embedded in the JWT `id.language` claim, and echoed on every response as `audience.language`. API-credential auth gets no language.
42
45
 
43
46
  **Language re-resolution on token refresh (user tokens only).** `/auth/refresh` re-resolves the user's language and embeds it in the newly minted tokens, so a language switch takes effect on the next hourly refresh **without a full re-login** (previously the refresh path copied the decoded JWT payload verbatim and only reset `exp`/`iat`, so a language change was invisible until re-auth). Runs **only** for user tokens — reads `id.client.user.uuid` off the token and skips entirely for API-credential and public tokens (no language claim). Rebuilds the resolution context from the existing token — loads `_Model_Client_User` by `uuid` (→ `userId`), `_Model_Core_App` by `id.app.uuid` (→ `appId`), takes `personaIds` from `id.client.personas` — then calls the **same** private `resolveSettingValue(slug: _Model_Core_Setting::SLUG_LANGUAGE, context: {appId, personaIds, userId})` helper the login path uses (same 8-layer cascade; the client DB is already registered). The resolved code is validated against `_Model_Client_Language` (load by `code`):
44
47
  - **Valid** → sets `$this->jwtPayload->id->language` to the resolved code.
45
48
  - **Unset/empty** → defaults to `'en'`.
46
- - **Invalid** (non-empty but absent from `Languages`) → **keeps the token's current language** and adds a `WZ-4` warning. It deliberately does **not** fail the refresh — a bad setting must not log an active user out on their hourly refresh (contrast the login path, which hard-fails with EV-16).
49
+ - **Invalid** (non-empty but absent from `Languages`) → **keeps the token's current language** and adds a `WZ-4` warning. Never fails the refresh.
47
50
 
48
51
  Placement is load-bearing: the `id.language` mutation happens **after** client-DB registration and **before** both the access and refresh tokens are signed, so both new tokens carry the updated language.
49
52
 
50
- **Invalid-language hard failure (auth time).** The resolved code is validated against the client's `Languages` table. An **unset/empty** setting defaults to `en` (no error). But a **non-empty** code that does **not** exist in `Languages` now **fails authentication** with HTTP 400 `EV-16 DEFINED_MESSAGE_ERROR_INVALID_LANGUAGE_SETTING` (validation group, defined in `Response.php`), with the offending `language` code in the message `identifiers` — instead of silently falling back to `en`.
53
+ **Invalid saved language — warn, never block (login + refresh).** Since 2026-10-09 (TRUE-82613) `/v2/auth/login` no longer fails with EV-16 when the user's resolved code is missing from the client `Languages` table. It falls back via private `resolveClientFallbackLanguageCode($appId)` — the same cascade with **no persona/user layers** (default + client layers only), validated again — else `_Model_Client_Language::CODE_BASE` (`en`), and adds `WZ-4 DEFINED_MESSAGE_WARNING_INVALID_LANGUAGE_SETTING` (WARNING, `{language}` identifier, `Response.php`). Status stays `201` with tokens. OAuth sign-in unsets `messages`, so OAuth callers never see WZ-4. Refresh keeps the token's current language instead (above). `EV-16` is still defined in `Response.php` but nothing raises it. Unset/empty → `en`.
51
54
 
52
- **Invalid-language soft warning (refresh time).** On the refresh path an invalid code is handled with a distinct, non-fatal WARNING instead of EV-16: `WZ-4 DEFINED_MESSAGE_WARNING_INVALID_LANGUAGE_SETTING` (type WARNING, group VALIDATION, `{language}` identifier placeholder; defined in `Response.php`, added via `addDefinedMessage(...)` so the warning has a stable catalogued code). Because it is a WARNING it carries no `status` key and does not change the response status — the refresh succeeds and the token keeps its current language. Deliberately softer than EV-16 (status 400), which would fail the refresh and log the user out.
53
-
54
- **Auth-mint status finalization (subtle).** The auth success path sets `status = 201` and attaches the tokens. EV-16 is signalled by a local flag `$invalidLanguageSetting` set at the resolution site; finalization branches `if (empty($invalidLanguageSetting))` → `201` + tokens, else `addDefinedMessage(EV-16)` + `data = null`. See Gotchas — do **not** convert this to an `is_null($this->response->status)` guard.
55
+ **Writing a user's language.** `user-global-settings` writes (Core.Records 279) are guarded by PRE interceptors on `_Model_Client_UserGlobalSetting` — own row, language setting only, value must exist in client `Languages`. Rules + the bypasses they close: [API Payload Interceptors → own-row guards](./api-payload-interceptors.md#own-row-guards-in-a-pre-interceptor). Clients get the new language on the next refresh as `audience.language`.
55
56
 
56
57
  **Which fields are translatable (metadata-driven).** `Core.RecordFields.translationRecordFieldId` (new column, positioned after `recordId`) points a source field's RecordField at the sidecar field's RecordField. `buildLookups`/RecordFields-load resolves this into `lookupTranslatableFieldByRecordFieldId[sourceRecordFieldId] => {sidecarRecordId, sidecarField}`.
57
58
 
@@ -88,9 +89,9 @@ Placement is load-bearing: the `id.language` mutation happens **after** client-D
88
89
  - The resolver/read/write deliberately use `_Model::load()` per row, not a SQL JOIN/COALESCE — less SQL-efficient but the team's chosen approach; the sidecar is cached per row to avoid N-per-field loads.
89
90
  - `audience.language` reflects the *requested* language even if a given field lacks a translation; the per-field fallback warning signals the English fallback.
90
91
  - The base language is `en`; when the resolved language is `en` (or API auth) all translation logic is skipped and responses are byte-identical to pre-feature.
91
- - **"Why isn't my language setting applying?"** A `ClientGlobalSettings` row with `isOverridable = 0` **locks** the value against all downstream persona/user layers — a `UserGlobalSettings` (or persona) override never wins while the client-global layer is locked. By design (the 8-layer cascade with `isOverridable` locking). To let user/persona overrides apply, the locking layer must be `isOverridable = 1` (or its value cleared). A data-config gotcha, not a code bug.
92
+ - **"Why isn't my language setting applying?"** A `ClientGlobalSettings` row with `isOverridable = 0` **locks** the value against all persona/user layers — a `UserGlobalSettings` override never wins. `dbchanges2/Client/2026-10-08l - LanguageOverridable.sql` (fan-out) set the language row (`settingId 2`) to `isOverridable = 1` on every existing client. **New-client onboarding seeds still create it locked** (left as-is by decision) — a new tenant whose users cannot change language needs the same `UPDATE`.
92
93
  - **Namespace gotcha in `V2.php` (`namespace api;`).** The api-namespaced classes (`_Component_Api_V2_Response`, `_Component_Api_V2_Response_Message`) must be referenced **unqualified** (resolves to `api\…`) or with the full `\api\_Component_Api_V2_Response_Message`. A bare leading backslash (`\_Component_Api_V2_Response_Message`) forces the global namespace and throws `EO-1 "Could not find required file"` via the autoloader. The translation fallback-warning code hit this.
93
- - **Do NOT guard the auth-mint finalization with `is_null($this->response->status)`.** The auth response auto-adds client `name` + user `firstName`/`lastName` and runs `processRoutePairs()`, which sets `$this->response->status` to `200` **before** finalization. An `is_null` guard there suppresses the `201` + tokens and yields a broken `200`/`data:null` login. Branch on the explicit `$invalidLanguageSetting` flag instead.
94
+ - **Do NOT guard the auth-mint finalization with `is_null($this->response->status)`.** The auth response runs `processRoutePairs()` (client `name`, user `firstName`/`lastName`), which sets `status = 200` **before** finalization, so an `is_null` guard yields a broken `200`/`data:null` login. Set `201` unconditionally.
94
95
  - **The all-fields (expand-everything) serializer branch must also translate.** `V2.php` has two nested-child serialization paths: when the caller requests **specific** fields it already ran each value through `getTranslatedFieldValue()`, but when the caller requested **no** specific fields (empty `$varOptions`, i.e. "return everything I have access to") the code copied raw child values straight into `$outData` and never translated — so any field returned via the all-fields expansion stayed in base English even for a non-base-language user. Both branches must route through `getTranslatedFieldValue($record, $field, $sourceModel, $defaultValue)` (preserving the `renamedFields` alias). Symptom: on the Compass Canada French storefront item-detail page (loaded via `/bundles`), feature-spec **values** were French but the feature-group **headers** (`itemCategoryFeatureGroup.name`, e.g. "Processeur"/"Mémoire") stayed English, because the group object serialized through the all-fields branch. **Not a query-depth problem** — a commerce-side `depth:6` theory was wrong and reverted; the fix returns French headers at the default `meta.depth 3`.
95
96
  - **Sidecar/source collation mismatch — and the collations DIFFER PER CLIENT DATABASE.** The sidecar tables use `utf8mb4_unicode_ci` (matching `ItemTranslations`) while the source field's collation varies by tenant: verified in prod **and** dev-sandbox, `Client_CompassCanada.Items.title` is `utf8mb4_0900_ai_ci` but `Client_Compass.Items.title` is `utf8mb4_unicode_ci`. So a hardcoded `COLLATE utf8mb4_bin` "fix" is brittle — works in one client DB, unnecessary or wrong in another. Consequences:
96
97
  - **Never build a mixed-collation fallback expression in SQL.** `COALESCE(NULLIF(ItemTranslations.title, ''), Items.title)` produces a result whose collation is forced to `utf8mb4_bin` (MySQL cannot reconcile the two inputs). Plain `SELECT`ing it works, so it looks fine — but **any** comparison, `ORDER BY`, `GROUP BY` or `DISTINCT` on that expression throws **MySQL error 1267 "Illegal mix of collations"**. Reproduced against prod. A latent landmine: the query ships green and fails the first time someone sorts or dedupes on it.
@@ -110,3 +111,6 @@ Placement is load-bearing: the `id.language` mutation happens **after** client-D
110
111
  - [api2 Architecture](../architecture.md)
111
112
  - [Persona Name Translation](../../_underscore/features/persona-name-translation.md) — metadata-only worked example (no api2 change).
112
113
  - [_Model magic-field access](../../_underscore/features/model-magic-field-access.md)
114
+
115
+ ## Change history
116
+ - 2026-10-09 — Login no longer fails on an unknown saved language (EV-16 retired): client-language fallback + WZ-4; language row made overridable on all clients; user-global-settings write guard linked. (jcardinal)
@@ -19,7 +19,7 @@ project: _Underscore
19
19
  client: compass-canada
20
20
  type: profile
21
21
  status: active
22
- updated: 2026-10-07
22
+ updated: 2026-10-09
23
23
  owners: [jcardinal, bala, tcox, apeterson, ajean, mhammontree, rgirish]
24
24
  files: []
25
25
  related:
@@ -175,6 +175,9 @@ to but distinct from Compass USA. Like Compass USA it spans the **2.0** commerce
175
175
  block, and entity-escaped because the send is iso-8859-1. See
176
176
  [French Order Email Localization](features/french-order-email-localization.md). French
177
177
  `EmailTemplates` bodies must be pure ASCII.
178
+ - **⚠ Open security hole (2026-10-09, not fixed):** `Client_CompassCanada/2026-01-15 - UserGlobalSettingsAcl.sql`
179
+ grants UserGlobalSettings writes with **no own-row check** — a user could write another user's row. See
180
+ [own-row guards](../../2.0/apps/api2/features/api-payload-interceptors.md#own-row-guards-in-a-pre-interceptor).
178
181
  - **Approval decisions use the shared Compass parent.** `_Model_Compass_Canada_ApprovalDecision`
179
182
  is an empty subclass of `_Model_Compass_ApprovalDecision`; the entire approval/notification/
180
183
  manager-reassignment/VIP-auto-approve flow lives in the parent and branches at runtime on
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.994",
3
+ "version": "1.0.995",
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",