toga-ai 1.0.968 → 1.0.970

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.
@@ -18,6 +18,15 @@
18
18
  "timeout": 3000
19
19
  }
20
20
  ]
21
+ },
22
+ {
23
+ "hooks": [
24
+ {
25
+ "type": "command",
26
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/session-files.js\"",
27
+ "timeout": 3000
28
+ }
29
+ ]
21
30
  }
22
31
  ],
23
32
  "PreToolUse": [
@@ -182,6 +191,16 @@
182
191
  "timeout": 3000
183
192
  }
184
193
  ]
194
+ },
195
+ {
196
+ "matcher": "Edit|Write|MultiEdit|NotebookEdit",
197
+ "hooks": [
198
+ {
199
+ "type": "command",
200
+ "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/toga/session-files.js\"",
201
+ "timeout": 3000
202
+ }
203
+ ]
185
204
  }
186
205
  ],
187
206
  "SessionStart": [
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-09-23
9
+ updated: 2026-10-07
10
10
  owners: [jcardinal, kyalamarthi, ajean, mhammontree]
11
11
  files:
12
12
  - tools/index.php
@@ -61,6 +61,16 @@ No per-host `stylePath` subfolder — one `assets/css/style.css`. Uses TOGA Tech
61
61
 
62
62
  **All colour comes from a semantic token layer, `assets/css/theme.css`**, registered **first** in `App_FrameworkIndex_Tools::__construct()` so every other sheet resolves through it. The app supports light / dark / auto; the resolved theme is stamped on `<html>` as `data-theme` by an inline pre-paint script and the user's choice lives in `localStorage` (`toga-tools-theme`) — **not** the DB. The left sidebar and public header stay dark in **both** themes, which is why `toga-brandmark-white.png` still works everywhere. Login shows `toga-horizontal-colored.png` in light and `toga-horizontal-white.png` in dark. See `features/theme-light-dark.md`.
63
63
 
64
+ ## UI standard (internal tools)
65
+
66
+ Developer standard (jcardinal, 2026-09): the team works on wide screens, so tool pages use the space.
67
+
68
+ - **Wide-screen first (~1600px+).** Split-pane / master-detail (list left, detail right), one-row toolbars, meta laid out in grids. Never a vertical stack of full-width cards (the first `/emails` page was rejected for this).
69
+ - **Real gaps + padding between cards**, clear hierarchy, easy-to-reach actions. Opt out of the 960px cap with `.tool--wide` (see Gotchas).
70
+ - **Visible progress for anything that takes more than a moment:** a loading state, a real progress bar (N of total, %), and a live count of the work consumed (API calls, rows, budget left). Prefer honest per-step progress over a bare spinner.
71
+ - **Polished controls:** style buttons/inputs with the theme tokens (`assets/css/theme.css`), never default gray buttons.
72
+ - Check it in the browser (light + dark) before showing it.
73
+
64
74
  ## Home route
65
75
 
66
76
  `/` (`mvc/get.php`) renders a persona-filtered dashboard of available tools (tiles grouped by folder) when logged in, via `App_Nav::visibleGroups()`. When **not** logged in, `/` immediately routes to `/login` (one-step SSO sign-in — no intermediate welcome card). A persona-less but authenticated user gets the dashboard's explicit empty-state (see `features/persona-gated-navigation.md`).
@@ -93,4 +103,5 @@ Create `mvc/<folder>/<tool>/get.php` (copy `mvc/_TEMPLATE/get.php`), add one ent
93
103
  - **Framework-level Sentry error reporting is an open gap.** Prod printed "Sentry is not installed" because `sentry/sentry` was missing from `composer.json` (added `^4.10`; sibling apps use `^4.10`/`^4.19`). But even with Sentry in `vendor/`, the 1.0 framework does **not** auto-require `vendor/autoload.php` (the `App_Talos_S3` pattern `require_once`s it explicitly), so framework-level Sentry reporting for Tools still does not load. Any code path that needs a composer package must `require_once` the autoloader itself.
94
104
 
95
105
  ## Change history
106
+ - 2026-10-07 — Added "UI standard (internal tools)": wide-screen split-pane layout, real gaps, visible progress + live counts, themed controls. (jcardinal)
96
107
  - 2026-09-23 — Added gotchas from the /emails build: input :not chain, 960px cap/.tool--wide, red btn-secondary, Origin/Referer CSRF. (jcardinal)
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-25
9
+ updated: 2026-10-08
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/_/app/developers/centralids.php
@@ -28,7 +28,7 @@ Replaces tracking next central ids in the developer chat. Branches hold hardcode
28
28
  ## How it works
29
29
  - Storage: prod `Core.Parameters` (`id`, `uuid` char36 unique, `key` varchar64 unique, `value` varchar255), keys `NEXT_CORE_RECORDS_ID`, `NEXT_CORE_RECORD_FIELDS_ID`. Read via config `[database_toga2core]` (link `db_toga2core`; tools is prod-only).
30
30
  - Access: Developers folder, **Development Team** persona only — hardcoded `requireAuth` in `get.php` + `post.php`, narrowed in `nav.php`.
31
- - UI: one card per key showing stored value + table's current `MAX(id)`; number box + Save; "Take 1" saves shown value + 1.
31
+ - UI: one card per key showing stored value + table's current `MAX(id)`; number box + Save; "Take 1" saves shown value + 1. "Copy for Claude" copies `Next Core.Records.id = N` / `Next Core.RecordFields.id = N` (labels from `KEYS`) from latest loaded/saved state; disabled until load; unset key → "not set"; `navigator.clipboard`, falls back to textarea + `execCommand('copy')` off secure context.
32
32
  - `App_Developers_CentralIds`: `all()`, `set($key, $newValue, $expected)`. `post.php` actions `load`, `set`.
33
33
  - Save rules: refused if value <= `MAX(id)` of the tracked table or > INT UNSIGNED max. Compare-and-set: `UPDATE ... WHERE value = expected`; missing row → `INSERT IGNORE` with `App_String::generateUuidV4()`. Loser of a race gets "someone changed this value". Table names only from hardcoded `KEYS` const. Each change `error_log`'d with user uuid.
34
34
  - First Save creates the Parameters row — no dbchanges2 file needed.
@@ -54,7 +54,7 @@
54
54
  | [Shipping Carriers & Carrier Account Numbers (records, matching, duplication, deletion)](features/shipping-carriers-and-accounts.md) | Per-client `ShippingCarriers` / `ShippingCarrierAccountNumbers` model, how the syncs match/create carrier rows, and the duplication + blank-carrier + pagination |
55
55
  | [_String helpers — ASCII-safe HTML entity encoding (and the parseBetween trap)](features/string-html-entity-helpers.md) | `_String` static string utilities: use `encodeToAsciiHtmlEntities` for any dynamic text going into a 2.0 email; open when escaping email text or parsing substri |
56
56
  | [Surface FIELD ↔ RecordField binding (maxLength / validationType / isWritable fold-in)](features/surface-field-recordfield-binding.md) | Resolver capability that folds `Core.RecordFields` input-contract metadata (maxLength/validationType/isWritable) onto a surface FIELD element; open when a 2.5 s |
57
- | [DEFERRED — re-key Client.SurfaceOverrides from numeric Core ids to uuids](features/surface-overrides-uuid-rekey.md) | Deferred: `Client.SurfaceOverrides.surfaceId`/`surfaceElementId` still point at Core rows by numeric id; open before a client's first override rows on an env wh |
57
+ | [DECLINED 2026-10-08 — re-key Client.SurfaceOverrides from numeric Core ids to uuids](features/surface-overrides-uuid-rekey.md) | **Decision 2026-10-08 (jcardinal): keep numeric ids; no uuid re-key.** The plan below is not scheduled; see the Layer boundary in [surface-resolver](surface-res |
58
58
  | [Surface Resolver (_Model_Core_Surface::resolve — replaces Page::meta)](features/surface-resolver.md) | Runtime for the platform Surface UI layer (`_Model_Core_Surface::resolve`/`metaGroup`, replaces `Page::meta`); open for surface resolution, the override cascade |
59
59
  | [Table-View Hyperlink Columns (meta → ACL → computed URL → render)](features/tableview-hyperlink-columns.md) | How a 2.0 table-view column renders its value as a clickable link (`hyperlinkRecordFieldId` → ACL field-read → computed URL → render); open when a configured hy |
60
60
  | [TableView joins (TableViewJoins → SQL) — aliasing, chained multi-hop joins, ACL](features/tableview-joins.md) | How `TableViewJoins` rows become a view's join SQL — aliasing, chained multi-hop joins, ACL gating, and the three ways a view breaks; open before adding/rebasin |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-24
9
+ updated: 2026-10-08
10
10
  owners: [tcox, bala, jcardinal, apeterson]
11
11
  files:
12
12
  - _underscore/Model/Core/Page.php
@@ -41,7 +41,7 @@ So a currency/dollar icon is a **type** problem (fix the field's `type` — Core
41
41
 
42
42
  **⚠ A CUSTOM record field's label row is UNREACHABLE from the grid header (legacy path only).** `Page.php` emits `ClientCustomRecordFieldSettings` in its own block (~L900–935) and keys it by a record route resolved through `$lookupRecordIdFromCustomRecordFieldId` → `$lookupRecordFromRecordId[...]->route`. For a column pointed at a `CustomRecordFields` row, that resolves to an **empty** record-route key — the row lands at `settings.fields[""][<field>][…]`, a bucket `convertColumnTitles` never reads. **A `ClientCustomRecordFieldSettings` `label-singular` row for a custom column is therefore dead metadata**, and no amount of adding/correcting those rows moves the header. (Measured 2026-08-26: two hours lost adding label rows that could never be read.)
43
43
 
44
- > **⚠ SCOPE — this "dead metadata" is the legacy `Page.php` / `toga2-supply` path ONLY. On the `toga25-supply` table path it WORKS.** `toga25-supply` reads its grid headers from `/table-views/meta` (`_Model_Client_TableView::meta()`), which fetches `ClientCustomRecordFieldSettings` directly — so a **base** custom-field column (both context columns NULL) IS labeled by a `ClientCustomRecordFieldSettings` row with `settingId = 5` (`Core.Settings.slug = 'label-singular'`), `isOverridable = 1`. Verified live 2026-09-10: exactly how `Client_Elite` labels its custom unit columns (`c_grade` → "Grade", `c_chargingBrick` → "Charging Brick", …), and how NYCHH's `_transferLocation` column on `units-for-items-for-purchase-orders` gets its "Location" header (`Client_Nychh/2026-09-10c - TransferLocationColumnLabel.sql`). Do **not** move a custom field to a Core `RecordField` just to get a header on a `toga25-supply` grid — the client custom-field settings row is enough. The dead-metadata trap above still holds for the old `Page.php` consumers.
44
+ > **⚠ CORRECTION (2026-10-08): `/table-views/meta` does NOT carry labels.** `_Model_Client_TableView::meta()` reads the settings tables only to decide the context columns (`settings.contextTable` / `contextField`); it returns no label values. Grid header text comes from `/pages/meta` `settings.fields[<record route>][<field>]['label-singular']`. An earlier version of this note said `table-views/meta` fetches `ClientCustomRecordFieldSettings` and labels base custom-field columns directly. That is wrong. The Elite / NYCHH custom-column headers (2026-09-10 seeds, e.g. `Client_Nychh/2026-09-10c - TransferLocationColumnLabel.sql`) were seen live, but the label path that served them is not proven; re-check against `Page::meta` before relying on a client custom-field settings row for a header. Boundary for new apps: see "Layer boundary (2026-10-08)" in [surface-resolver](surface-resolver.md).
45
45
 
46
46
  Where the label must come from instead, for such a column:
47
47
  - **`Core.DefaultRecordFieldSettings`** on the underlying **Core** `RecordField`, or
@@ -103,3 +103,4 @@ Pages --< PageRecords >-- Records --< RecordFields
103
103
 
104
104
  ## Change history
105
105
  - 2026-09-24 — Documented the Core-only label chain (`Pages`→`PageRecords`→`RecordFields`→`DefaultRecordFieldSettings` `label-singular`, setting id 5) and that `Sections` are a separate path listing pages never have. (apeterson)
106
+ - 2026-10-08 — Corrected: `/table-views/meta` returns no labels (header text = `/pages/meta` `settings.fields`). Frozen for old apps per the Layer boundary in surface-resolver (jcardinal)
@@ -1,12 +1,12 @@
1
1
  ---
2
- title: "DEFERRED — re-key Client.SurfaceOverrides from numeric Core ids to uuids"
2
+ title: "DECLINED 2026-10-08 — re-key Client.SurfaceOverrides from numeric Core ids to uuids"
3
3
  framework: "2.0"
4
4
  repo: _underscore
5
5
  project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
- status: deferred
9
- updated: 2026-09-29
8
+ status: deprecated
9
+ updated: 2026-10-08
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - _underscore/Model/Core/Surface.php
@@ -16,7 +16,7 @@ related:
16
16
  - surface-resolver.md
17
17
  ---
18
18
 
19
- Deferred: `Client.SurfaceOverrides.surfaceId`/`surfaceElementId` still point at Core rows by numeric id; open before a client's first override rows on an env whose Core can be rebuilt.
19
+ **Decision 2026-10-08 (jcardinal): keep numeric ids; no uuid re-key.** The plan below is not scheduled; see the Layer boundary in [surface-resolver](surface-resolver.md). Was: `Client.SurfaceOverrides.surfaceId`/`surfaceElementId` still point at Core rows by numeric id; open before a client's first override rows on an env whose Core can be rebuilt.
20
20
 
21
21
  ## Summary
22
22
 
@@ -35,3 +35,4 @@ Planned fix (do before first client use on such an env):
35
35
 
36
36
  ## Change history
37
37
  - 2026-09-29 — Recorded as deferred after the nav-gate uuid fix exposed the same numeric-id risk (jcardinal)
38
+ - 2026-10-08 — Declined: keep numeric ids, no uuid re-key (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
9
+ updated: 2026-10-08
10
10
  owners: [jcardinal, apeterson, bala]
11
11
  files:
12
12
  - _underscore/Model/Client/Language.php
@@ -313,6 +313,20 @@ Same discipline as the reserved-id blocks: **read the id out of the target envir
313
313
  - **The meta-group dispatch grant is per-client-DB too.** A 403 EZ-1 on `meta-group` on dev-sandbox was the same mechanism as the original `meta` 403: the `meta-group` RecordScript needs its own per-role `Client.AclRecordScripts` dispatch grant. Verified via toga-db — `meta` script id 25 was granted roles 1/3/4 but `meta-group` (id 27) had **none** until the grant ran. Any new surfaces scripted-API method repeats this: record READ never authorizes dispatch.
314
314
  - **The meta-group grant is seeded per-tenant, not globally — new tenants repeat the 403.** When `meta-group` shipped, the `AclRecordScripts` grant was seeded **only** for `Client_Compass` (recordScriptId → roles 1,3,4). `Client_Quad` and `Client_CompassCanada` had **no** grant for the meta-group recordScriptId at all — their `meta` was granted to client role 1 (why `meta` worked but `meta-group` 403'd for the same user/token). Same-user-different-script + same-script-different-tenant is the fingerprint of this gap. Fix (2026-07-01): two `dbchanges2/Client_<tenant>/` migrations granting meta-group to **client role 1** (parity with each tenant's existing `meta` grant), id-agnostic (resolve `Core.RecordScripts` id by route/method/phpMethod, cross-DB read of `Core.*` per the 2026-06-29c ACL pattern) and re-runnable via `NOT EXISTS`. Surface-layer DB changes ship to the **beta** environment only. ⚠ **Root-cause not yet closed:** the meta-group grant should be added to the `dbchanges2/Client/` **baseline (blank) seed** so newly-cut clients get it automatically — otherwise the next migrated tenant hits the identical 403. Only Compass (already correct), Quad, and CompassCanada are addressed. Also note **role breadth**: Compass exposes surfaces to roles 1,3,4; Quad/CC got role 1 only (parity with their `meta`). If Quad/CC should match Compass, a follow-up migration aligning both `meta` and `meta-group` to roles 1,3,4 is needed.
315
315
 
316
+ ## Layer boundary (2026-10-08)
317
+
318
+ Developer decision (jcardinal, 2026-10-08), platform apps (Desk, IQ, template). Which layer owns what:
319
+
320
+ 1. **Surface owns what users SEE.** `Core.Surfaces` / `SurfaceElements` / `Messages` + Client `MessageTranslations` + Client `SurfaceOverrides`: copy, labels, order, visibility, required, icons, colours. Client changes = `SurfaceOverrides` (client / persona / role); translations = `MessageTranslations`.
321
+ 2. **`*Settings` keeps ONLY preferences:** `language`, `date-format`, `time-format`, `time-zone`, `currency`, `persona`. One shared preferences resolver (to move from api2 `V2::resolveSettingValue` into `_underscore`), honouring `isOverridable`. UI keys are FROZEN, new apps never read them: `label`, `label-singular`, `icon`, `tooltip`, `placeholder`, `sort-order`, `is-required`, `default-value`, `min-required-siblings`, `max-required-siblings`.
322
+ 3. **Record + field names move to `Core.Messages`** (translatable) via message-id link columns on `Core.Records` / `Core.RecordFields`, first seeded from `DefaultRecordSettings` / `DefaultRecordFieldSettings` labels; until seeded, fall back to the settings label.
323
+ 4. **No per-user text overrides.** User choices = preferences, saved views, column order. Add a Surface lock only when a real need appears.
324
+ 5. **`SurfaceOverrides` keep numeric Core ids** in client DBs (no uuid re-key) - see [surface-overrides-uuid-rekey](surface-overrides-uuid-rekey.md).
325
+ 6. **Permissions** (record CRUD + field writable) get their own once-per-session call for new apps instead of `GET /pages/meta`. Shape TBD; table-based preferred; a scripted API needs the developer's OK.
326
+ 7. **Old apps** (`toga2-supply`, `toga2-commerce`) keep `GET /pages/meta` frozen: bug + security fixes only. `toga25-supply` (uses both) moves with the platform apps.
327
+
328
+ Precedence is per layer and never mixed on one value: Surface = base < client < persona < role (language = filter); Settings = Default < Client < Persona < User (< Language where the reader applies it). Rejected: settings absorbs Surface; keep both + a rule. Detail: Desk ADR 0140 (Settings holds preferences only).
329
+
316
330
  ## Related
317
331
 
318
332
  - [surface-layer-schema](../../dbchanges2/features/surface-layer-schema.md)
@@ -322,3 +336,6 @@ Same discipline as the reserved-id blocks: **read the id out of the target envir
322
336
  - [sales-order-status-filter-surface](sales-order-status-filter-surface.md)
323
337
  - [surface-field-recordfield-binding](surface-field-recordfield-binding.md)
324
338
  - [side-navigation-and-default-route](../../toga25-supply/features/side-navigation-and-default-route.md)
339
+
340
+ ## Change history
341
+ - 2026-10-08 — Added "Layer boundary": Surface owns what users see, `*Settings` = preferences only (jcardinal decision; see Desk ADR 0140) (jcardinal)
@@ -6,12 +6,14 @@ project: SAML SSO Gateway
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-02
9
+ updated: 2026-10-07
10
10
  owners: ["rgirish", "mhammontree"]
11
11
  files:
12
12
  - saml/Controller/Index.php
13
13
  - _underscore/String.php
14
14
  - _underscore/Model/True/ClientAuthentication.php
15
+ - _underscore/Model/Compass/ClientAuthentication.php
16
+ - _underscore/Model/Core/ClientAuthentication.php
15
17
  related:
16
18
  - ../architecture.md
17
19
  - ./gateway-environments-and-redirect-safety.md
@@ -78,6 +80,40 @@ the window. App-side fix: a used-payload table. Gateway-side fix: nonce + iat +
78
80
  payload. Changing `encryptWithKey()` affects every caller — it is also
79
81
  the `?saml=` handoff and the api2 encrypted-user-uuid auth, so this is not a local change.
80
82
 
83
+ ## Planned fix (deferred 2026-09-25)
84
+
85
+ Proposed, **not approved and not built** — deferred by the developer (jcardinal) as too large for
86
+ that moment. Needs a `cto` pass (schema) and a `cso` review before any code.
87
+
88
+ **Rollout — per client, LOG_ONLY then ENFORCE:**
89
+
90
+ 1. Add to `Core.ClientAuthentications_Sso`: `idpEntityIdentifier`, `idpSigningCertificate`,
91
+ `signatureMode` ENUM(`LOG_ONLY`, `ENFORCE`).
92
+ 2. Ship every SSO client in `LOG_ONLY` (verify the IdP signature, log the result, still allow).
93
+ 3. Flip a client to `ENFORCE` after one real login passes verification. No cert on file = refuse.
94
+
95
+ **Turn on for everyone at once (no per-client stage):** reject unsigned assertions; check the
96
+ assertion time window; Audience = our `/meta`; Destination = our `/acs`; RelayState expiry; bind
97
+ RelayState's `domainUuid` to the SSO client (today `_underscore/Model/Core/ClientAuthentication.php`
98
+ accepts any `domainUuid`).
99
+
100
+ **Remove now-known unsafe code in the same change:**
101
+
102
+ - **Compass backdoor:** `_underscore/Model/Compass/ClientAuthentication.php` (~lines 17-20) maps any
103
+ `@togatech.com` NameID straight to one fixed internal user. Combined with Gap 1, any trusted IdP can
104
+ sign in as that user. Remove it.
105
+ - A comment near line ~235 of `saml/Controller/Index.php` holds a **real customer SAMLResponse**
106
+ (personal data). Delete it; never copy its content anywhere.
107
+
108
+ **Inputs still needed (IdP metadata / signing certs):** Compass (Shibboleth IdP — the old commented
109
+ code carries a cert valid to 2031), Rate (Okta metadata URLs — today stored in `entityIdentifier`),
110
+ True + Compass Sandbox Client (Microsoft Entra — app metadata URL needed from the developer).
111
+ `entityIdentifier` is used two ways today (our SP id for Compass/True, the IdP metadata URL for Rate)
112
+ — settle that before adding the new columns.
113
+
114
+ Prod SSO clients in scope (`Core.ClientAuthentications`, type SSO): Compass Group (Production,
115
+ Sandbox Client), CompassCanada, Rate (Production, Beta), True.
116
+
81
117
  ## Gotchas
82
118
 
83
119
  - **Do not describe the gateway as "authenticating" anyone to a client or an auditor.** Until Gap 1
@@ -89,5 +125,6 @@ the `?saml=` handoff and the api2 encrypted-user-uuid auth, so this is not a loc
89
125
  citing this doc as sign-off.
90
126
 
91
127
  ## Change history
128
+ - 2026-10-07 — Added "Planned fix (deferred 2026-09-25)": per-client LOG_ONLY → ENFORCE signature rollout, IdP metadata still needed, Compass `@togatech.com` backdoor + stored SAMLResponse comment to remove (jcardinal)
92
129
  - 2026-10-02 — Added Tools + TOGa Desk as affected consumers; recorded handoff replay (Gap 3) (mhammontree)
93
130
  - 2026-09-25 — Created; recorded that `/acs` verifies no IdP signature and no Audience/Destination/InResponseTo/NotBefore/NotOnOrAfter, and that RelayState uses unauthenticated AES-256-CBC with an attacker-supplied IV (rgirish)
@@ -6,7 +6,7 @@ project: TOGa Commerce
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-05
9
+ updated: 2026-10-07
10
10
  owners: [tcox, bala, apeterson]
11
11
  files:
12
12
  - src/api/sessionTokens.ts
@@ -96,11 +96,13 @@ Seeds a long-lived `ADMIN_ACCESS_JWT`; stubs `/auth/delegator` → `mock-delegat
96
96
 
97
97
  - On a 401 logout the in-memory zustand stores are not reset, so a store write in the ≤1.5s before the redirect can write `zu-cart` / `order-for-zu` back. Low risk: login runs `resetClientState` (cso NIT, 2026-09-30).
98
98
  - `SENTRY_AUTH_TOKEN` is committed in 25 tracked `.env*` files — treat it as compromised and rotate it. (Never store the value in this KB.)
99
+ - **`GET /users/encryptedUser` permission gap (found 2026-09-25, deferred).** The RecordScript (`Core.RecordScripts` id 16; `_underscore/Model/Client/User.php` ~line 353, `@TODO` with no check) was granted in prod to ordinary roles across many clients (e.g. Compass Base + Manager), likely from `dbchanges2/Core/HISTORIC/2025/2025-01-13 - 55324 - GetEncryptedUser.sql`. Fix = a real permission check in code, not only pulling grants. The `canUserOrderForOtherUser` gate (Gotchas) covers it on beta / client-sandbox — confirm it is live in prod before closing this item.
99
100
  - ~~Confirm api2 authorizes `/auth/delegator` **server-side**~~ — **answered 2026-09-21:** it now re-checks `_Model_Client_User::canUserOrderForOtherUser` at mint time (and `/auth/refresh` re-checks it), so a captured encrypted pair cannot be replayed. See [order-for delegation](../../_underscore/features/order-for-delegation.md).
100
101
  - ~~The parked-token slot is shared with "Order For" — decide precedence~~ — **settled 2026-09-22:** one slot, no nesting; the mode is told apart by the Order For marker, not the slot.
101
102
 
102
103
  ## Change history
103
104
 
105
+ - 2026-10-07 — Open item: `/users/encryptedUser` permission gap found 2026-09-25 (prod grants to ordinary roles); confirm the delegation gate is live in prod. (jcardinal)
104
106
  - 2026-09-30 — Order For merged onto the TRUE-81991 session layer on `_sandbox-client`: parked pair cleared via `AUTH_STORAGE_KEYS`; return and switch use `clearPersistedQueryCache`; a refresh that lost the identity race now returns the current token instead of `null` (stopped a false logout on return); View As `onError` restores the admin's language and logs out if the parked pair is gone. (tcox)
105
107
  - 2026-09-22 — Refresh identity binding upgraded to an epoch bumped on the view-as swap; `Bearer undefined` swap bug fixed. TRUE-81991 (`e546db3d`). (apeterson)
106
108
  - 2026-09-22 — Return moved to the shared `useReturnToOriginalUser` hook (state cleared before any await); mode now tested with `useIsViewAsActive` because Order For parks the same user; View As start calls `endDelegation()` and its cart-clear effect is gated; recorded the 403 EZ-1 when the target is not on the delegation list. (tcox)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.968",
3
+ "version": "1.0.970",
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",
@@ -113,20 +113,24 @@ Keep the description short (3–5 words). Use hyphens, not underscores.
113
113
 
114
114
  ## Commit messages
115
115
 
116
- Format: `type: short description`
116
+ Format: `<TICKET>: <Subject>` — the same in every repo. It matches api2's CI check.
117
117
 
118
- Types: `feat`, `fix`, `refactor`, `docs`, `test`, `chore`
118
+ - `TRUE-79868: Use client default tax code when invoice tax code is blank`
119
+ - `TRUE-80112: Add retry limit to NetSuite item sync`
119
120
 
120
- - `feat: add batch order processing worker`
121
- - `fix: null check on customer email before dispatch`
122
- - `refactor: extract order validation to dedicated class`
123
- - `docs: document recursive item fulfillment feature`
124
- - `test: add regression test for empty batch edge case`
125
- - `chore: update composer dependencies`
121
+ Rules:
126
122
 
127
- The description is lowercase, present tense, under 72 characters. No period at the end.
123
+ 1. **Ticket prefix from the branch name.** ClickUp names the branch (e.g. `TRUE-79868`); the
124
+ ticket id matches `^[A-Z]+-\d+`. If the branch has no ticket id, ask the developer.
125
+ 2. **Subject starts with a capital letter.**
126
+ 3. **Imperative mood** — "Use", "Add", "Fix". Not past tense ("Used", "Added", "Fixed", "Updated").
127
+ 4. **80 characters max** for the whole subject line.
128
+ 5. **No period at the end.**
129
+ 6. **Not a single word** — the subject says what the commit does.
128
130
 
129
- If a commit is substantial, add a blank line after the subject and a body paragraph explaining the why.
131
+ If a commit is substantial, add a blank line after the subject and a body explaining the why.
132
+
133
+ Claude's work is committed with `/commit`, which commits only the files this session changed and writes the session notes into the message body (`Claude-Notes: v1`). Changes made by hand outside Claude may be committed the normal way.
130
134
 
131
135
  ## What never goes in commits
132
136
 
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * pr-risk.js — deterministic PR risk score for the TOGA PR summary reviewer.
6
+ *
7
+ * The model must NOT pick the risk level. This script does, from git facts only.
8
+ *
9
+ * Usage:
10
+ * node pr-risk.js --base <sha> --head <sha> --repo <owner/name> [--out risk.json]
11
+ *
12
+ * Output JSON:
13
+ * { level, reasons[], stats{...}, readFirstCandidates[], manualCommitList[] }
14
+ *
15
+ * No dependencies. Read-only git commands only.
16
+ */
17
+
18
+ const { execFileSync } = require('child_process');
19
+ const fs = require('fs');
20
+
21
+ // ---- Thresholds -------------------------------------------------------------
22
+ const HIGH_LINES_THRESHOLD = 800;
23
+ const MEDIUM_LINES_THRESHOLD = 300;
24
+ const MEDIUM_FILES_THRESHOLD = 15;
25
+ const READ_FIRST_LIMIT = 8;
26
+ const GIT_MAX_BUFFER = 256 * 1024 * 1024;
27
+
28
+ // ---- Repo / path rules ------------------------------------------------------
29
+ const DB_REPOS = ['dbchanges2', 'dbchanges'];
30
+ const CORE_REPOS = ['_underscore', 'library'];
31
+ // Whole words only (after splitting camelCase and non-letters), so "author" does not match.
32
+ const SENSITIVE_WORDS = new Set([
33
+ 'acl', 'acls', 'auth', 'authn', 'authz', 'authentication', 'authorization', 'authorize',
34
+ 'login', 'logout', 'token', 'tokens', 'saml', 'permission', 'permissions', 'oauth', 'sso',
35
+ 'password', 'credential', 'credentials',
36
+ ]);
37
+ const SHA_RE = /^[0-9a-f]{7,40}$/i;
38
+ const SQL_FILE_RE = /\.sql$/i;
39
+ const TEST_PATH_RE = /(^|\/)(test|tests|__tests__|spec)(\/|$)|\.(test|spec)\.[^/]+$/i;
40
+ const LOCKFILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|bun\.lockb)$/i;
41
+ const GENERATED_RE = /(^|\/)(dist|build)\/|\.min\.[^/]+$|\.map$|(^|\/)INVENTORY\.md$/i;
42
+ const PACKAGE_MANIFEST_RE = /(^|\/)(package\.json|composer\.json)$/i;
43
+ const CORE_DB_REF_RE = /\b(Core|Forecast|Team)`?\.`?\w/;
44
+ const CLIENT_DB_REF_RE = /\bClient_\w/;
45
+ const NOTES_TRAILER_RE = /^Claude-Notes:\s*v1\s*$/m;
46
+
47
+ function parseArgs(argv) {
48
+ const args = {};
49
+ for (let i = 0; i < argv.length; i++) {
50
+ const key = argv[i];
51
+ if (!key.startsWith('--')) continue;
52
+ const name = key.slice(2);
53
+ const next = argv[i + 1];
54
+ if (next === undefined || next.startsWith('--')) {
55
+ args[name] = true;
56
+ continue;
57
+ }
58
+ args[name] = next;
59
+ i++;
60
+ }
61
+ return args;
62
+ }
63
+
64
+ function git(args) {
65
+ // core.quotePath=false: non-ASCII paths come back as-is, not octal-quoted.
66
+ return execFileSync('git', ['-c', 'core.quotePath=false'].concat(args), {
67
+ encoding: 'utf8',
68
+ maxBuffer: GIT_MAX_BUFFER,
69
+ });
70
+ }
71
+
72
+ /** Split a path into lowercase words: camelCase, digits and any non-letter are breaks. */
73
+ function pathWords(path) {
74
+ return path
75
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
76
+ .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
77
+ .split(/[^A-Za-z]+/)
78
+ .filter(Boolean)
79
+ .map((w) => w.toLowerCase());
80
+ }
81
+
82
+ function isSensitivePath(path) {
83
+ return pathWords(path).some((w) => SENSITIVE_WORDS.has(w));
84
+ }
85
+
86
+ function isTest(path) {
87
+ return TEST_PATH_RE.test(path);
88
+ }
89
+
90
+ function isGenerated(path) {
91
+ return LOCKFILE_RE.test(path) || GENERATED_RE.test(path);
92
+ }
93
+
94
+ /** Resolve rename notation like "dir/{old => new}/file" or "old => new" to the new path. */
95
+ function resolveRenamePath(raw) {
96
+ if (!raw.includes('=>')) return raw;
97
+ const braced = raw.match(/^(.*)\{(.*) => (.*)\}(.*)$/);
98
+ if (braced) return (braced[1] + braced[3] + braced[4]).replace(/\/\//g, '/');
99
+ return raw.split(' => ').pop();
100
+ }
101
+
102
+ /** Per-file line counts. ignoreWhitespace=true uses -w, so CRLF/LF or re-indent churn is not counted. */
103
+ function readFileStats(range, ignoreWhitespace) {
104
+ const flags = ignoreWhitespace ? ['--numstat', '-M', '-w'] : ['--numstat', '-M'];
105
+ const out = git(['diff'].concat(flags, [range]));
106
+ const files = [];
107
+ for (const line of out.split('\n')) {
108
+ if (!line.trim()) continue;
109
+ const [added, deleted, ...rest] = line.split('\t');
110
+ const path = resolveRenamePath(rest.join('\t'));
111
+ const isBinary = added === '-' || deleted === '-';
112
+ const lines = isBinary ? 0 : Number(added) + Number(deleted);
113
+ files.push({ path, lines });
114
+ }
115
+ return files;
116
+ }
117
+
118
+ function readDeletedPaths(range) {
119
+ const out = git(['diff', '--name-only', '--diff-filter=D', range]);
120
+ return out.split('\n').filter(Boolean);
121
+ }
122
+
123
+ function readAddedLines(range) {
124
+ const out = git(['diff', '-U0', '-w', '--no-color', range]);
125
+ return out
126
+ .split('\n')
127
+ .filter((line) => line.startsWith('+') && !line.startsWith('+++'))
128
+ .map((line) => line.slice(1));
129
+ }
130
+
131
+ function readCommits(base, head) {
132
+ const out = git(['log', '--no-merges', '--format=%H%x00%B%x1e', `${base}..${head}`]);
133
+ return out
134
+ .split('\x1e')
135
+ .map((chunk) => chunk.replace(/^\s+/, ''))
136
+ .filter(Boolean)
137
+ .map((chunk) => {
138
+ const [sha, body = ''] = chunk.split('\x00');
139
+ const subject = body.split('\n')[0].trim();
140
+ return { sha: sha.trim(), subject, hasNotes: NOTES_TRAILER_RE.test(body) };
141
+ });
142
+ }
143
+
144
+ function computeRisk({ repoName, files, rawLines, deletedPaths, addedLines, commits }) {
145
+ const high = [];
146
+ const medium = [];
147
+
148
+ const countedFiles = files.filter((f) => !isTest(f.path) && !isGenerated(f.path));
149
+ const linesChanged = files.reduce((sum, f) => sum + f.lines, 0);
150
+ const linesCounted = countedFiles.reduce((sum, f) => sum + f.lines, 0);
151
+ const notesCommits = commits.filter((c) => c.hasNotes);
152
+ const manualCommits = commits.filter((c) => !c.hasNotes);
153
+
154
+ if (DB_REPOS.includes(repoName) || files.some((f) => SQL_FILE_RE.test(f.path))) {
155
+ high.push('database change');
156
+ }
157
+ if (CORE_REPOS.includes(repoName)) {
158
+ high.push('edit to shared core — affects every app');
159
+ }
160
+ if (files.some((f) => isSensitivePath(f.path))) {
161
+ high.push('touches login or permissions');
162
+ }
163
+ const deletedTests = deletedPaths.filter(isTest);
164
+ if (deletedTests.length > 0) {
165
+ high.push(`${deletedTests.length} test file(s) deleted`);
166
+ }
167
+ if (addedLines.some((l) => CORE_DB_REF_RE.test(l) && CLIENT_DB_REF_RE.test(l))) {
168
+ high.push('a query may mix Core and a Client database — they are on different servers in production');
169
+ }
170
+ if (linesCounted > HIGH_LINES_THRESHOLD) {
171
+ high.push(`large change: ${linesCounted} lines (not counting tests and generated files)`);
172
+ }
173
+
174
+ if (linesCounted > MEDIUM_LINES_THRESHOLD && linesCounted <= HIGH_LINES_THRESHOLD) {
175
+ medium.push(`medium-size change: ${linesCounted} lines (not counting tests and generated files)`);
176
+ }
177
+ if (files.some((f) => PACKAGE_MANIFEST_RE.test(f.path) || LOCKFILE_RE.test(f.path))) {
178
+ medium.push('new or changed package');
179
+ }
180
+ if (manualCommits.length > 0) {
181
+ const noun = manualCommits.length === 1 ? 'commit has' : 'commits have';
182
+ medium.push(`${manualCommits.length} ${noun} no session notes`);
183
+ }
184
+ if (files.length > MEDIUM_FILES_THRESHOLD) {
185
+ medium.push(`${files.length} files changed`);
186
+ }
187
+ const whitespaceOnly = rawLines - linesChanged;
188
+ if (whitespaceOnly > MEDIUM_LINES_THRESHOLD) {
189
+ medium.push(`${whitespaceOnly} lines are whitespace or line-ending only (ignored)`);
190
+ }
191
+
192
+ let level = 'LOW';
193
+ let reasons = [commits.length > 0
194
+ ? 'small change with session notes on every commit'
195
+ : 'small change (no commits found in the range)'];
196
+ if (high.length > 0) {
197
+ level = 'HIGH';
198
+ reasons = high.concat(medium);
199
+ } else if (medium.length > 0) {
200
+ level = 'MEDIUM';
201
+ reasons = medium;
202
+ }
203
+
204
+ const readFirstCandidates = countedFiles
205
+ .slice()
206
+ .sort((a, b) => b.lines - a.lines || a.path.localeCompare(b.path))
207
+ .slice(0, READ_FIRST_LIMIT)
208
+ .map((f) => ({ path: f.path, linesChanged: f.lines }));
209
+
210
+ return {
211
+ level,
212
+ reasons,
213
+ stats: {
214
+ filesChanged: files.length,
215
+ linesChanged,
216
+ rawLines,
217
+ linesChangedExcludingTestsAndGenerated: linesCounted,
218
+ commits: commits.length,
219
+ commitsWithNotes: notesCommits.length,
220
+ manualCommits: manualCommits.length,
221
+ },
222
+ readFirstCandidates,
223
+ manualCommitList: manualCommits.map((c) => ({ sha: c.sha.slice(0, 7), subject: c.subject })),
224
+ };
225
+ }
226
+
227
+ function main() {
228
+ const args = parseArgs(process.argv.slice(2));
229
+ if (!args.base || !args.head || !args.repo || args.base === true || args.head === true) {
230
+ process.stderr.write('Usage: node pr-risk.js --base <sha> --head <sha> --repo <owner/name> [--out risk.json]\n');
231
+ process.exit(2);
232
+ }
233
+ if (!SHA_RE.test(String(args.base)) || !SHA_RE.test(String(args.head))) {
234
+ process.stderr.write('pr-risk.js: --base and --head must be commit shas (7-40 hex characters)\n');
235
+ process.exit(2);
236
+ }
237
+ const repoName = String(args.repo).split('/').pop();
238
+ const range = `${args.base}...${args.head}`;
239
+
240
+ const result = computeRisk({
241
+ repoName,
242
+ files: readFileStats(range, true),
243
+ rawLines: readFileStats(range, false).reduce((sum, f) => sum + f.lines, 0),
244
+ deletedPaths: readDeletedPaths(range),
245
+ addedLines: readAddedLines(range),
246
+ commits: readCommits(args.base, args.head),
247
+ });
248
+ result.repo = String(args.repo);
249
+
250
+ const json = JSON.stringify(result, null, 2) + '\n';
251
+ if (args.out && args.out !== true) {
252
+ fs.writeFileSync(args.out, json);
253
+ }
254
+ process.stdout.write(json);
255
+ }
256
+
257
+ try {
258
+ main();
259
+ } catch (err) {
260
+ process.stderr.write(`pr-risk.js failed: ${err.message}\n`);
261
+ process.exit(1);
262
+ }