toga-ai 1.0.772 → 1.0.773

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,7 @@
6
6
  | [/clickup/aliases — ClickUp Label-Alias Admin (Tools → 2.0 Team schema)](features/clickup-label-aliases-admin.md) | The human side of the NetSuite→ClickUp **Stakeholders / End Customer** labels. | tools/mvc/clickup/aliases/get.php, tools/mvc/clickup/aliases/post.php, tools/_/app/clickup/aliases.php, tools/_/app/nav.php |
7
7
  | [Developer Dashboard (ClickUp Sprint, tools /developer)](features/clickup-sprint-dashboard.md) | A native **ClickUp sprint dashboard** in the 1.0 `tools` app at route `/developer` (the **"Developer Dashboard"**, renamed from `/clickup/react` / "Clickup" — s | tools/_/app/clickup/sprint.php, tools/v2/sprints/tile/index.php, tools/v2/sprints/current/index.php, tools/v2/sprints/status-breakdown/index.php, tools/v2/sprints/worktype-breakdown/index.php, tools/v2/sprints/points-by-dev/index.php, tools/v2/sprints/burndown/index.php, tools/assets/clickup/sprint-dashboard.html, tools/mvc/developer/get.php, tools/mvc/login/get.php, tools/_/app/nav.php |
8
8
  | [CloudFront Client Setup](features/cloudfront-client-setup.md) | An SSO-gated admin tool at **`/devops/cloudfront-clients`** in the Tools 1.0 app that onboards a client onto **CloudFront + Route 53 across multiple AWS account | tools/_/app/devops/cloudfront.php, tools/mvc/devops/cloudfront-clients/get.php, tools/mvc/devops/cloudfront-clients/post.php, tools/assets/js/cloudfront-clients.js, tools/assets/css/cloudfront-clients.css, tools/_/app/nav.php, tools/_/app/frameworkindex.php, tools/config.production.ini |
9
+ | [Compass User & Persona Admin (tools /compass/users, /compass/personas)](features/compass-user-persona-admin.md) | Two pages in the Tools `developers` nav folder that let staff do, in a browser, the two Compass requests that used to mean "email a developer, who writes a `dbc | tools/_/app/compass.php, tools/mvc/compass/users/get.php, tools/mvc/compass/users/post.php, tools/mvc/compass/personas/get.php, tools/mvc/compass/personas/post.php, tools/_/app/nav.php, tools/config.production.ini |
9
10
  | [Design Demo Admin](features/design-demo-admin.md) | A self-serve admin UI at **`/design`** in the SSO-protected **Tools** app that lets the design team publish self-contained "Claude Design" HTML exports as **ver | tools/_/app/design/github.php, tools/mvc/design/get.php, tools/mvc/design/post.php, tools/assets/css/design.css, tools/assets/js/design.js, tools/_/app/frameworkindex.php, tools/_/app/nav.php, tools/composer.json |
10
11
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | The first two tools shipped in the Tools app, both under the **Developers** folder and gated to personas **Development Team** / **TOGa Technology**. | tools/mvc/developers/uuid/get.php, tools/mvc/developers/password/get.php, tools/mvc/developers/password/post.php |
11
12
  | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only triage/curation screen for the 2.0 Issue/Event error-reporting pipeline, built as a 1.0 Tools MVC page reading the **shared Core Logs DB** through | tools/mvc/errors/get.php, tools/mvc/errors/post.php, tools/mvc/errors/issue/get.php, tools/assets/css/style.css, tools/_/app/nav.php, tools/config.production.ini |
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-08-14
10
- owners: [jcardinal, kyalamarthi]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, kyalamarthi, ajean]
11
11
  files:
12
12
  - tools/index.php
13
13
  - tools/.ebextensions/020_setup_git_libraries.config
@@ -41,7 +41,11 @@ related:
41
41
  **Tools** is a standalone 1.0 (`App_`) application that houses many small internal tools behind
42
42
  simple interfaces, gated by Client_True staff persona. It is modeled on `togaview` and depends on
43
43
  the `library` core. Auth comes via the SAML gateway `?saml=` handoff; the app owns its own
44
- session and reads `Client_True` **read-only**. Adding a tool = create `mvc/<route>/get.php`
44
+ session and reads `Client_True` **read-only** but it is **no longer a read-only app**: as of
45
+ PR tools#13 (unmerged) it also **WRITES** to client tenant databases (`Client_Compass` /
46
+ `Client_CompassCanada`) through new `[database_compass]` / `[database_compasscanada]` ini
47
+ sections that reuse the `[database_true]` account. That is the first non-read-only surface in
48
+ Tools. Adding a tool = create `mvc/<route>/get.php`
45
49
  (copy `mvc/_TEMPLATE`) + add one `App_Nav::definition()` entry + `App_Auth::requireAuth([...])`.
46
50
 
47
51
  **Critical rules:** Do NOT use bare `<header>` elements in this app — they inherit the legacy
@@ -59,6 +63,11 @@ denied at the front controller / out of the web root. New CSS must use `--c-*` t
59
63
  `App_FrameworkIndex::setHeaderCode()` is the **only** hook for markup in `<head>`, and it
60
64
  **SETS rather than appends** — the theme bootstrap is its sole caller, so a second call
61
65
  silently clobbers it.
66
+ **Any page that writes to a client tenant DB must `App_Auth::requireAuth()` an explicit, narrow
67
+ persona list (`['Development Team']`) — never `App_Nav::personasForRoute()`**, which returns the
68
+ UNION of the folder's personas and the action's personas and so widens the grant straight back to
69
+ everyone who can see the nav group. Also: **persona checks read a 4-day session snapshot**, so
70
+ removing someone's persona does **not** revoke their access — their session must be killed.
62
71
 
63
72
  ## Boot & structure
64
73
 
@@ -0,0 +1,138 @@
1
+ ---
2
+ title: Compass User & Persona Admin (tools /compass/users, /compass/personas)
3
+ framework: "1.0"
4
+ repo: tools
5
+ project: Tools
6
+ client: shared
7
+ type: feature
8
+ status: draft
9
+ updated: 2026-09-04
10
+ owners: [ajean]
11
+ files:
12
+ - tools/_/app/compass.php
13
+ - tools/mvc/compass/users/get.php
14
+ - tools/mvc/compass/users/post.php
15
+ - tools/mvc/compass/personas/get.php
16
+ - tools/mvc/compass/personas/post.php
17
+ - tools/_/app/nav.php
18
+ - tools/config.production.ini
19
+ related:
20
+ - ./mvc-data-access-patterns.md
21
+ - ./persona-gated-navigation.md
22
+ - ./saml-sso-auth.md
23
+ - ../architecture.md
24
+ - ../../../2.0/apps/_underscore/features/effective-persona-resolution.md
25
+ - ../../../clients/compass-usa/features/people-file-user-lifecycle.md
26
+ - ../../../clients/compass-usa/features/persona-model-and-levy-gating.md
27
+ - ../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
28
+ ---
29
+
30
+ ## Summary
31
+
32
+ Two pages in the Tools `developers` nav folder that let staff do, in a browser, the two Compass
33
+ requests that used to mean "email a developer, who writes a `dbchanges2` SQL file": **activate /
34
+ deactivate a Compass user**, and **grant a persona**. Both tenants are covered by one tenant
35
+ switcher — **US** (`Client_Compass`) and **Canada** (`Client_CompassCanada`).
36
+
37
+ **This is the first thing in `tools` that WRITES to a client tenant database.** Until now the app
38
+ read `Client_True` read-only. That single fact drives most of the design and every open risk below.
39
+
40
+ Status: built on PR **tools#13**, **not yet merged**. `status: draft` until it ships.
41
+
42
+ ## Key files / entry points
43
+
44
+ - **`tools/_/app/compass.php`** — `App_Compass`, all data access for both pages (search, persona
45
+ resolution, activate/deactivate, grant).
46
+ - **`tools/mvc/compass/users/{get,post}.php`** — user search + activate/deactivate + grant persona.
47
+ - **`tools/mvc/compass/personas/{get,post}.php`** — inspect one persona and who reaches it.
48
+ - **`tools/_/app/nav.php`** — the two nav entries, inside the existing `developers` folder.
49
+ - **`tools/config.production.ini`** — new `[database_compass]` / `[database_compasscanada]` sections.
50
+
51
+ ## How it works
52
+
53
+ ### What the pages do
54
+
55
+ 1. **Find a user** by `c_hrEmpUsername`, `email`, or `c_hrEmpPersonnelNbr`.
56
+ 2. **Activate / deactivate** them (`Users.isActive`).
57
+ 3. **See the personas that user reaches, and WHY** — direct row, own location, or sector.
58
+ 4. **Inspect a persona** and list who reaches it, from all three sources.
59
+ 5. **Grant a persona** — one `INSERT` into `Users_Personas`.
60
+
61
+ The "why" column is the point of the tool. Answering "who has persona N" from `Users_Personas`
62
+ alone badly under-reports on Compass, because most users hold their catalogue through their
63
+ **sector**, not through a row of their own. The resolution rule the tool implements is the
64
+ framework's own — see
65
+ [Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md),
66
+ which also carries the measured prod numbers and the "reverse lookups must be set-based" rule.
67
+
68
+ ### Access gating is deliberately tighter than the folder
69
+
70
+ Both pages `requireAuth(['Development Team'])` — narrower than the `developers` folder's own
71
+ persona set. That is on purpose: these are tenant **writes**, not read-only developer utilities.
72
+ Passing `App_Nav::personasForRoute()` here would widen the grant back to the folder's set; see the
73
+ nav-narrowing gotcha in [MVC data access patterns](./mvc-data-access-patterns.md).
74
+
75
+ ### Database connections
76
+
77
+ New `[database_compass]` and `[database_compasscanada]` sections in `config.production.ini` reuse the
78
+ **existing `[database_true]` account** — same client cluster, only `dbname` differs. **No new secret
79
+ value was introduced.** See the accepted risk below.
80
+
81
+ ### Schema footprint: none
82
+
83
+ The tool creates and alters **nothing**. It reads/writes only `Users`, `Users_Personas`,
84
+ `Locations_Personas`, `Locations`, and reads `Personas` for names.
85
+
86
+ ## Decisions
87
+
88
+ - **2026-09-04 — No new columns, no new tables (Jeff Cardinal, back-end review).** The tool talks
89
+ directly to production `Client_Compass` / `Client_CompassCanada` using the existing schema only.
90
+ This killed an earlier design that added `Users.c_isKeepActive` plus expiry/audit columns, an
91
+ `AdminAuditLogs` table, and three search indexes. `dbchanges2#478` and `worker2#160` were opened
92
+ and then **closed unmerged** — do not resurrect them without re-opening that decision.
93
+ - **2026-09-04 — Reuse the `[database_true]` credential** rather than split out a least-privilege
94
+ Compass account. Raised twice in security review and consciously declined for now (see below).
95
+
96
+ ## Gotchas
97
+
98
+ - **⚠ Activating a Compass user is TEMPORARY; deactivating is durable.** Because the
99
+ `c_isKeepActive` flag was dropped from scope, the nightly PEOPLE importer will deactivate anyone
100
+ absent from that day's file — so an activation only sticks if the person is actually in the file.
101
+ Deactivation holds, because the cron never reactivates anybody. Full lifecycle:
102
+ [PEOPLE-file User Lifecycle](../../../clients/compass-usa/features/people-file-user-lifecycle.md).
103
+ - **A cross-region user still needs a code change and a deploy.** The only durable way to hold one
104
+ active is the hardcoded four-address list in `ensureCrossRegionUsersActive()` in
105
+ `worker2/Worker/Client/Compass/PeopleFile.php`. This tool does not change that.
106
+ - **Every user search is a full table scan.** `Client_Compass.Users` has no index on `email`,
107
+ `c_hrEmpUsername`, `c_hrEmpPersonnelNbr` or `isActive` (176k rows; `EXPLAIN` reports `type=ALL`,
108
+ no possible keys). Same on `Client_CompassCanada`. Keep result sets bounded and do not add
109
+ per-row lookups against those columns in a loop.
110
+ - **⚠ Accepted risk — the tool widens the blast radius of one shared credential.** It points the
111
+ `[database_true]` account at two more tenant schemas. `config.production.ini` is committed and was
112
+ web-readable until the 2026-07-24 `FilesMatch` deny blocks, so **rotation of that account is still
113
+ owed**. An inline comment in the ini records this and lists the exact grants a split-out Compass
114
+ account would need. Never paste the value anywhere.
115
+ - **⚠ Persona revocation does not take effect for up to 4 days.** Tools snapshots persona names into
116
+ `$_SESSION` at login and sessions last 4 days, so removing someone's `Development Team` persona
117
+ does not lock them out of these pages until their session dies. See
118
+ [SAML SSO & persona-gated auth](./saml-sso-auth.md).
119
+ - **No staff-actor audit trail exists for these writes.** `Logs_Compass.Record.userId` is a **tenant**
120
+ `Users.id`, so it cannot record a `Client_True` staff member — it is not usable as the audit log
121
+ for this tool, and `Logs_Compass.Record` is the client-facing activity log (3.6M rows) besides.
122
+ See
123
+ [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
124
+ Auditing internal-tool actions remains an open item.
125
+ - **Detecting a no-op UPDATE needs `App_Database::affectedRows()`** — `App_Database::query()` returns
126
+ the `App_Query` execute result, not a row count. Details in
127
+ [MVC data access patterns](./mvc-data-access-patterns.md).
128
+
129
+ ## Change history
130
+
131
+ - 2026-09-04 — Initial: built the two-page Compass user/persona admin (PR tools#13, unmerged) — the
132
+ first `tools` pages that write to a client tenant DB, gated to `Development Team` only, with a
133
+ US/Canada switcher and new `[database_compass]` / `[database_compasscanada]` ini sections reusing
134
+ the existing `[database_true]` account. Recorded Jeff Cardinal's scope reset (no schema changes;
135
+ `dbchanges2#478` and `worker2#160` closed unmerged) and its consequence — **activation is temporary
136
+ because the `c_isKeepActive` flag was dropped**, while deactivation is durable. Logged the accepted
137
+ credential-reuse risk, the 4-day session persona staleness, the missing staff-actor audit trail, and
138
+ that every user search is a full scan. (ajean)
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: [jcardinal, bala]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, bala, ajean]
11
11
  files:
12
12
  - tools/_/app/nav.php
13
13
  - tools/mvc/get.php
@@ -15,6 +15,7 @@ files:
15
15
  - library/app/database.php
16
16
  related:
17
17
  - ./persona-gated-navigation.md
18
+ - ./compass-user-persona-admin.md
18
19
  - ./saml-sso-auth.md
19
20
  - ../architecture.md
20
21
  - ../../library/features/mvc-page-pattern-and-app-skeleton.md
@@ -57,6 +58,31 @@ add one expecting the framework to validate it.
57
58
  2.0 DB read-only through its own connection (e.g. `db_true → Client_True`); adding
58
59
  `db_team → Team` is the same pattern.
59
60
 
61
+ ### Detecting a no-op UPDATE — `App_Database::affectedRows()`
62
+ `App_Database::query()` returns the `App_Query` execute result, **not** a row count, so it cannot
63
+ tell you whether an `UPDATE` actually changed anything. Use
64
+ `App_Database::affectedRows($linkRegistryString)` — pass the same alias you queried with. This is
65
+ how a page distinguishes "saved" from "the value was already that".
66
+
67
+ ### DB credentials come straight from the ini — there is no secret store
68
+ `App_Database::registerDatabaseConnect()` (`library/app/database.php:125-128`) reads
69
+ **hostname / username / password / dbname directly from the config ini**. Only
70
+ `database_reader_endpoint` goes through `App_Cloud::getParameter()`. So a 1.0 app **cannot** move a
71
+ DB credential into a secret store without changing shared `library` core that every 1.0 app boots
72
+ through. Plan around it: `config.*.ini` is where DB credentials live, and it is committed (see the
73
+ config/secrets note in [the architecture doc](../architecture.md)).
74
+
75
+ ### Input filtering — `App_Page::getVarIfSet()` allows printable ASCII
76
+ `getVarIfSet()` runs `App_String::filterCharactersValidCharactersOnly()`, which permits **ASCII
77
+ 32-126**. Emails, usernames and personnel numbers survive intact, so it is safe for those search
78
+ inputs. It is a character filter, not an escaper — still escape/cast before the value reaches SQL.
79
+
80
+ ### Production MySQL runs `US/Central`
81
+ The prod MySQL server timezone is **`US/Central`**, matching the 1.0 framework's `America/Chicago`
82
+ and worker2's Central-time cron scheduling (verified 2026-09-02). So MySQL `CURDATE()` and PHP
83
+ `date('Y-m-d')` agree — there is **no** day-boundary divergence to design around when the same date
84
+ check exists in both SQL and PHP.
85
+
60
86
  ### Gotcha — by-reference row helpers
61
87
  `buildArrayOfRows`, `fetchOne`, and `fetchRow` take `$res` **by reference**. Assign the
62
88
  `query()` result to a **variable first**, then pass it — passing the call result inline emits
@@ -142,6 +168,14 @@ set_error_handler(fn(): bool => true); // swallow non-fatal notices/deprecations
142
168
  `App_Talos_Estimator` → `_/app/talos/estimator.php`.
143
169
 
144
170
  ## Change history
171
+ - 2026-09-04 — Added four `App_Database` / input facts found building the Compass admin pages:
172
+ `affectedRows($alias)` is how you detect a no-op `UPDATE` (`query()` returns the `App_Query`
173
+ execute result, not a row count); `registerDatabaseConnect()` reads DB credentials **directly from
174
+ the config ini** (only `database_reader_endpoint` uses `App_Cloud::getParameter()`), so there is no
175
+ secret-store option without changing shared 1.0 core; `App_Page::getVarIfSet()` filters to
176
+ printable ASCII 32-126, so emails/usernames/personnel numbers pass through intact; and production
177
+ MySQL runs timezone **`US/Central`**, matching PHP's `America/Chicago`, so `CURDATE()` and
178
+ `date('Y-m-d')` agree. (ajean)
145
179
  - 2026-08-04 — Added two gotchas found rebuilding the Talos Pricing UI: (1) `App_Database::fetchOne()` is **not** an existence test — it `data_seek`s and returns `$row[$col]` with no empty check, so zero rows dereferences null → uncaught `ErrorException` → bare 500; a **library-wide** footgun affecting every 1.0 app (use `COUNT(*)` or `buildArrayOfRows` + `empty()`). (2) Tools runs **PHP 8.5**, where "using null as an array offset" is deprecated even inside `isset()` and the framework escalates deprecations to fatals — cast to `(string)` before indexing; PHP 8.2 local dev does not reproduce it. (jcardinal)
146
180
  - 2026-08-03 — Added the error-handler escalation gotcha: `App_Error::handleError` promotes ANY PHP
147
181
  notice/deprecation to a thrown `ErrorException` (ignoring `error_reporting`) and, without Sentry,
@@ -6,8 +6,8 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-30
10
- owners: [jcardinal]
9
+ updated: 2026-09-04
10
+ owners: [jcardinal, ajean]
11
11
  files:
12
12
  - tools/_/app/auth.php
13
13
  - tools/mvc/sso/initiate/get.php
@@ -20,6 +20,7 @@ files:
20
20
  - tools/config.local.ini
21
21
  related:
22
22
  - ../architecture.md
23
+ - ./compass-user-persona-admin.md
23
24
  - ../../library/features/app-sso-initiation.md
24
25
  - ../../../2.0/apps/saml/features/downstream-integration-contract.md
25
26
  - ../../../clients/true/features/users-personas-data-model.md
@@ -115,6 +116,17 @@ removed.
115
116
 
116
117
  ## Gotchas / known issues
117
118
 
119
+ - **⚠ Personas are snapshotted at login and sessions last 4 days — revoking a persona does not
120
+ revoke access.** `App_Auth` caches the user's persona **names** into `$_SESSION` at sign-in and
121
+ `requireAuth()` / `App_Nav` both read that cache, never the live `Users_Personas` rows. So removing
122
+ someone's persona leaves them able to use every page it gated for up to **4 days**, until their
123
+ session expires. This matters most for pages that write to a client tenant — see the [Compass
124
+ admin pages](./compass-user-persona-admin.md). To cut access off now, kill the session, do not just
125
+ revoke the persona.
126
+ - **The Tools session cookie has no `Secure` flag.** `App_Framework_Tools` sets `useSessions = false`,
127
+ so the parent `library` framework's `cookie_secure` setup never runs; Tools sets the cookie itself
128
+ with HttpOnly + SameSite=Lax only. Open item — do not assume the cookie is HTTPS-only.
129
+
118
130
  - **Consumer route runs mid-render → headers already sent.** The 1.0 framework runs `mvc/sso/get.php`
119
131
  **inside** the page render (`frameworkindex → body → loadFile`) after `common/header.php` has
120
132
  emitted output, so `session_regenerate_id(true)` fatals ("cannot be regenerated after headers
@@ -180,6 +192,11 @@ remains (already noted in tools knowledge) — **flag for rotation.** Document *
180
192
  never the values.
181
193
 
182
194
  ## Change history
195
+ - 2026-09-04 — Recorded two standing security gaps found while adding tenant-writing pages: personas
196
+ are **snapshotted into `$_SESSION` at login** and sessions last **4 days**, so revoking a persona
197
+ does not revoke page access until the session dies (kill the session instead); and the Tools
198
+ session cookie carries **no `Secure` flag**, because `App_Framework_Tools` sets `useSessions =
199
+ false` and the parent framework's `cookie_secure` setup therefore never runs. (ajean)
183
200
  - 2026-06-30 — Fixed SSO "Sign in failed" (`/login?error=1`) regression and **corrected the earlier wrong "config tokens are removable" note**. The prior fix moved only the consumer/decrypt leg to `Core.Parameters` and advised the `[saml] api_secret_access_token` was removable — but the **initiate** leg (`mvc/sso/initiate/get.php`) still read it from config, so removing it made `App_Sso::initiate()` throw on an empty token (`library/app/sso.php:64-65`) → `/login?error=1`. Fix: **both** legs now source the rotating secret from `Core.Parameters` — added public `App_Auth::currentApiSecret()` (reuses cached `handoffKeys()`), and `sso/initiate/get.php` passes it instead of the config value. Documented the non-empty-token requirement, the `/login?error=1` (initiate) vs "Authentication failed" (consumer) leg distinction, the `true_client_uuid`-stays-in-config rule, and the `db_toga2core`-missing→500 diagnostic. `php -l` clean on both files. (jcardinal)
184
201
  - 2026-06-30 — Fixed prod SSO login failure: handoff decrypt keys now sourced from `Core.Parameters` (rotated `API_SECRET_ACCESS_TOKEN`/`_PREVIOUS`) via new `App_Auth::handoffKeys()` over `db_toga2core` (per-request cached, current→previous fallback), instead of the stale committed `config [saml]` tokens — the stale key was the root cause. Added `[database_toga2core]` connection requirement to every config and documented the AES-256-CBC crypto format. Fixed `tools_ssoFail()` 500→intended 401: `http_response_code(401)` fataled on headers-already-sent (preloader flushes before mvc page runs); guarded with `!headers_sent()`. (jcardinal)
185
202
  - 2026-06-26 — Wired up real SSO initiation via the new 1.0 `App_Sso` library class (`/sso/initiate`), replacing the `initiation_url` placeholder; registered the fixed-uuid Core.Domains return row (dbchanges2 `2026-06-26a`); fixed the `session_regenerate_id` headers-already-sent fatal (guarded with `!headers_sent()`) and documented the mid-render failure-path limitation; collapsed home → `/login` to one-step sign-in; noted prod secrets live in `config.production.ini [saml]` (jcardinal)
@@ -19,6 +19,7 @@
19
19
  | [_Config group access — the two-argument "optional" form does NOT rescue a missing GROUP](features/config-group-access.md) | `_Config::<group>('<property>')` reads a value out of the active `Config/<ENVIRONMENT>.ini`, and **it throws when the requested group is absent from the ini.** | _underscore/Config.php, _underscore/Component/Api/Paypal/Paypal.php, _underscore/Worker.php, api2/Config/beta.ini, api2/Config/sandbox-dev.ini |
20
20
  | [Core.Domains — the app host registry (and why you cannot derive a host)](features/core-domains-app-host-registry.md) | `Core.Domains` is the **authoritative registry of which host serves which client's app in which environment**. | _underscore/Model/Core/Domain.php, api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, worker/config.beta.ini |
21
21
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
22
+ | [Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop](features/effective-persona-resolution.md) | A user's **effective** persona set is **not** `Users_Personas`. | _underscore/Model/Client/User.php |
22
23
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, _underscore/String.php, worker2/Worker/Infrastructure/Email/Send.php, _underscore/Model/Client/Logs/Email.php, _underscore/Model/Client/Logs/EmailAttachment.php |
23
24
  | [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/Quad/ApprovalDecision.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
24
25
  | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Query.php, _underscore/Database.php, _underscore/Exception/Business.php, _underscore/Exception/Database.php, _underscore/Test/DatabaseFingerprint.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, worker2/Worker/Infrastructure/Errors.php, tools/mvc/errors/issue/get.php, tools/mvc/errors/post.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
@@ -0,0 +1,88 @@
1
+ ---
2
+ title: Effective persona resolution (_Model_Client_User::_personaIds) — the three sources and the sector hop
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-09-04
10
+ owners: [ajean]
11
+ files:
12
+ - _underscore/Model/Client/User.php
13
+ related:
14
+ - ./persona-name-translation.md
15
+ - ../../../clients/compass-usa/features/persona-model-and-levy-gating.md
16
+ - ../../../clients/compass-usa/workflows/granting-persona-bundle-access.md
17
+ - ../../../clients/compass-usa/workflows/persona-population-env-comparison.md
18
+ - ../../../1.0/apps/tools/features/compass-user-persona-admin.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ A user's **effective** persona set is **not** `Users_Personas`. The framework already owns the rule:
24
+ `_Model_Client_User::_personaIds()` (`_underscore/Model/Client/User.php:48`) is the **source of
25
+ truth**. It is a `FIELD_SQL` virtual column — a `GROUP_CONCAT(personaId SEPARATOR ':')` over a
26
+ **UNION of three sources**.
27
+
28
+ Anything that answers "what can this user see?" or "who can see persona N?" must reproduce all three
29
+ sources. Reading `Users_Personas` alone badly under-reports: on Compass, the overwhelming majority of
30
+ users hold their catalogue through their **sector**, not through a row of their own.
31
+
32
+ ## How it works
33
+
34
+ ### The three sources
35
+
36
+ 1. **`Users_Personas`** on the user's own `userId` — personas granted directly.
37
+ 2. **`Locations_Personas`** on the user's **own** `locationId`.
38
+ 3. **`Locations_Personas`** on the user's **sector** — reached by **five INNER JOINs** up the
39
+ `Locations` chain, with semantic aliases:
40
+
41
+ ```
42
+ Locations -> LocationComplex -> LocationDistrict -> LocationRegion -> LocationDivision -> LocationSector
43
+ ```
44
+
45
+ Because those five hops are `INNER JOIN`s, a user whose chain is shorter than five levels simply
46
+ contributes nothing from source 3 — no error, just an empty branch.
47
+
48
+ ### Measured on production, 2026-09-02
49
+
50
+ | Tenant | Active users | Resolve at hop 5 (sector) | Resolve at hop 0 (own location) | Hops 1-4 and 6 |
51
+ |---|---|---|---|---|
52
+ | `Client_Compass` | 74,268 | **67,157** | 338 | **0** |
53
+ | `Client_CompassCanada` | 4,948 | **4,029** | — | **0** |
54
+
55
+ So in practice only **two** of the possible depths ever pay out: the user's own location, and the
56
+ five-hop sector. Nothing in between.
57
+
58
+ **But "persona locations all sit at one level" is FALSE.** The 123 persona-bearing locations in
59
+ `Client_Compass` sit at two different depths — 15 with 3 ancestors, 108 with 6 or more. The depths
60
+ that *users* pick them up at are stable; the depths the *locations* live at are not. Do not write a
61
+ query that assumes a fixed hierarchy depth for `Locations_Personas`.
62
+
63
+ ## Gotchas
64
+
65
+ - **⚠ A reverse lookup (persona → users) must be SET-BASED.** Build it as the union of the three
66
+ sources and join once. Never run an `EXISTS`-per-user check: on `Client_Compass` that is 74k
67
+ executions of a five-join chain against a `Users` table with no useful index (see below).
68
+ - **`Users_Personas` alone is the wrong answer.** On Compass it would return roughly 338 of the
69
+ 67,495 users who actually reach a sector-granted persona. Any "who has persona N" report built on
70
+ it is wrong, not merely incomplete.
71
+ - **The rule lives in the framework — do not re-derive it.** If you are writing SQL for a report, a
72
+ migration, or an internal tool, mirror `_personaIds()` rather than inventing a resolution. Where
73
+ the effective set matters for a *grant*, the same three-source check is the pre-flight step in
74
+ [Granting a Compass user access to a bundle](../../../clients/compass-usa/workflows/granting-persona-bundle-access.md)
75
+ — the user may already reach the persona, in which case the right answer is "no change needed".
76
+ - **No index backs the user side of these queries on Compass.** `Client_Compass.Users` has no index
77
+ on `email`, `c_hrEmpUsername`, `c_hrEmpPersonnelNbr` or `isActive`, so every lookup is a full scan
78
+ (`EXPLAIN` → `type=ALL`, 176,018 rows, no possible keys). Same on `Client_CompassCanada`.
79
+
80
+ ## Change history
81
+
82
+ - 2026-09-04 — Initial: documented `_Model_Client_User::_personaIds()` as the source of truth for a
83
+ user's effective persona set — a `FIELD_SQL` `GROUP_CONCAT` over a UNION of `Users_Personas`, the
84
+ user's own `Locations_Personas`, and their **sector's**, reached by five aliased INNER JOINs.
85
+ Measured on prod 2026-09-02: 67,157 of 74,268 active `Client_Compass` users resolve at hop 5 and
86
+ only 338 at hop 0, with **zero** at any other hop (Canada: 4,029 of 4,948 at hop 5). Recorded that
87
+ persona-bearing locations sit at two different depths, so "one level" assumptions are wrong, and
88
+ that reverse persona→user queries must be set-based, never EXISTS-per-user. (ajean)
@@ -6,14 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-20
10
- owners: [bala]
9
+ updated: 2026-09-04
10
+ owners: [bala, ajean]
11
11
  files:
12
12
  - _underscore/Model/Client/Logs/Record.php
13
13
  - _underscore/Model/Client/Logs/RecordField.php
14
14
  - _underscore/Model/Client/Logs/CustomRecordField.php
15
15
  related:
16
16
  - ../../api2/features/request-logging.md
17
+ - ../../../1.0/apps/tools/features/compass-user-persona-admin.md
17
18
  - ../../../../clients/compass-usa/features/approval-decision-flow.md
18
19
  - ../../../../clients/compass-usa/features/stranded-approval-reassignment.md
19
20
  - ../../api2/features/api-payload-interceptors.md
@@ -106,6 +107,15 @@ single record: the milestone notes are the chapter headings, the field changes a
106
107
  to read the client id from — resolve it from `Core` (`Clients` JOIN `Databases` on `name`). Known
107
108
  ids: `Client_Compass` = **2** (`Logs_Compass`), `Client_CompassCanada` = **43**
108
109
  (`Logs_CompassCanada`). This applies to **every** client, not just Compass.
110
+ - **⚠ `Record.userId` is a TENANT `Users.id` — it cannot record an internal staff actor.** There is
111
+ no way to express "a `Client_True` staff member did this" in this table, so it is **not** an audit
112
+ log for internal-tool actions taken against a client tenant (e.g. the [Tools Compass admin
113
+ pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md)). It is also the
114
+ **client-facing** activity log — `Logs_Compass.Record` is ~3.6M rows — so staff actions do not
115
+ belong in it even if the id could be made to fit. An internal audit trail needs its own home.
116
+ - **Tables in the `Logs_<Tenant>` schema are SINGULAR — the documented exception to the plural table
117
+ convention.** `Logs_Compass` holds `Api`, `CustomRecordField`, `Record`, `RecordField`, `Email`,
118
+ `EmailAttachment`. If you ever add a log table there, name it singular to match.
109
119
  - **⚠ `Logs_*` and `Core` are on different database clusters — you cannot join them in one query.**
110
120
  `Record.recordId` → `Core.Records` and `RecordField.recordFieldId` → `Core.RecordFields` are
111
121
  logical foreign keys across a cluster boundary. Resolve the `Core` ids in a **separate** query and
@@ -119,7 +129,12 @@ single record: the milestone notes are the chapter headings, the field changes a
119
129
  mechanism, not the people.
120
130
 
121
131
  ## Change history
122
-
132
+ - 2026-09-04 — Recorded two facts found while looking for an audit home for an internal admin tool:
133
+ `Record.userId` is a **tenant** `Users.id`, so this log **cannot** record a `Client_True` staff
134
+ actor and is not the audit trail for internal-tool writes (and `Logs_Compass.Record` is the
135
+ ~3.6M-row client-facing activity log besides); and tables in the `Logs_<Tenant>` schema are
136
+ **singular** (`Api`, `CustomRecordField`, `Record`, `RecordField`, `Email`, `EmailAttachment`) —
137
+ the documented exception to the plural table convention. (ajean)
123
138
  - 2026-08-20 — Added the **`addNote()` is a silent no-op outside api2** gotcha: `addNote()` only
124
139
  stages into the static `$_notes` array and **only api2's `Component/Api/V2/V2.php` ever flushes
125
140
  it**, so every `addNote()` call from `_underscore` or `worker2` loses its note with no error. A
@@ -6,8 +6,8 @@ project: Database Changes
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-01
10
- owners: [bala, kyalamarthi, apeterson]
9
+ updated: 2026-09-04
10
+ owners: [bala, kyalamarthi, apeterson, ajean]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
13
  - dbchanges2/Core/2026-08-27a - Insert - Netsuite Location SyncAll CronJob.sql
@@ -220,9 +220,13 @@ PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
220
220
 
221
221
  - **`TABLE_SCHEMA = DATABASE()`** keeps it cluster-safe — the file never names a database, so the
222
222
  same statement is correct in every `Client_*` schema.
223
- - Guard **indexes** the same way against `information_schema.STATISTICS` (and remember a composite
224
- unique key is only recorded against its first column see
225
- [tableview-field-metadata](../../api2/features/tableview-field-metadata.md)).
223
+ - Guard **indexes** the same way against `information_schema.STATISTICS`, but **key the guard on
224
+ `COLUMN_NAME` + `SEQ_IN_INDEX = 1`, never on `INDEX_NAME`.** An index name is arbitrary: guarding
225
+ on the name misses an equivalent index that someone already created under a different name, and
226
+ you silently add a duplicate. Guarding on "is this column already the first column of some index"
227
+ asks the question you actually mean. (A composite unique key is likewise only recorded against its
228
+ first column — see
229
+ [tableview-field-metadata](../../api2/features/tableview-field-metadata.md).)
226
230
  - The `PREPARE` shape is what makes the guard work at all: **DDL cannot sit inside an `IF`** in a
227
231
  plain script, so the condition has to select the *statement text*, not branch around it.
228
232
 
@@ -280,6 +284,11 @@ concluding a migration is production-safe.
280
284
  [FIELD_SQL calculated fields](../../_underscore/features/calculated-sql-fields.md).
281
285
 
282
286
  ## Change history
287
+ - 2026-09-04 — Sharpened the index guard: check `information_schema.STATISTICS` on **`COLUMN_NAME` +
288
+ `SEQ_IN_INDEX = 1`**, not on `INDEX_NAME` — an existing index under a different name goes
289
+ undetected by a name-based guard and you add a duplicate. Found while sizing (and then dropping)
290
+ the Compass `Users` search indexes. (ajean)
291
+
283
292
  - 2026-09-01 — Added the **fan-out rollback rule**: a `Client/` grant migration that fans out across
284
293
  every tenant must use **hardcoded v4 uuid literals**, not the generator, so its rollback
285
294
  (`DELETE … WHERE uuid IN (…)`) can only touch rows that file created. A natural-key rollback on
@@ -14,11 +14,11 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
14
14
  - **walmarttechservices** (Walmart Tech Services) — 1 doc(s) → [1.0/apps/walmarttechservices/INDEX.md](1.0/apps/walmarttechservices/INDEX.md)
15
15
  - **test** (Test) — 15 doc(s) → [1.0/apps/test/INDEX.md](1.0/apps/test/INDEX.md)
16
16
  - **toga** (TOGa) — 3 doc(s) → [1.0/apps/toga/INDEX.md](1.0/apps/toga/INDEX.md)
17
- - **tools** (Tools) — 16 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
17
+ - **tools** (Tools) — 17 doc(s) → [1.0/apps/tools/INDEX.md](1.0/apps/tools/INDEX.md)
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 79 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 80 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 59 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
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)_ — 15 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
@@ -12,12 +12,13 @@ apps:
12
12
  - worker2
13
13
  - dbchanges2
14
14
  - library
15
+ - tools
15
16
  project: _Underscore
16
17
  client: compass-canada
17
18
  type: profile
18
19
  status: active
19
- updated: 2026-09-01
20
- owners: [jcardinal, bala, tcox, apeterson]
20
+ updated: 2026-09-04
21
+ owners: [jcardinal, bala, tcox, apeterson, ajean]
21
22
  files: []
22
23
  related:
23
24
  - ../compass-usa/profile.md
@@ -6,12 +6,13 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-20
10
- owners: [bala]
9
+ updated: 2026-09-04
10
+ owners: [bala, ajean]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
13
13
  related:
14
14
  - ./persona-model-and-levy-gating.md
15
+ - ../../../1.0/apps/tools/features/compass-user-persona-admin.md
15
16
  - ./approval-decision-flow.md
16
17
  - ./stranded-approval-reassignment.md
17
18
  - ../profile.md
@@ -118,9 +119,27 @@ rediscovered, and the deactivation path has no model hooks at all (see below).
118
119
  - **`_Model_Client_Logs_Record::addNote()` is a no-op in this cron.** Staged notes are only flushed
119
120
  by api2, so a worker must write its `Record` rows itself. See
120
121
  [Record Change Audit Log](../../../2.0/apps/_underscore/features/record-change-audit-log.md).
121
- - **`Client_Compass.Users` is 176,057 rows with no index on `c_hrEmpPersonnelNbr`, `email`, or
122
- `isActive`.** Any per-row lookup you add against those columns is a full scan of ~174k rows. An
123
- index migration is recommended and **not yet done**.
122
+ - **`Client_Compass.Users` is ~176,000 rows with no index on `c_hrEmpPersonnelNbr`, `email`,
123
+ `c_hrEmpUsername`, or `isActive`.** Any per-row lookup you add against those columns is a full
124
+ scan `EXPLAIN` returns `type=ALL`, 176,018 rows, no possible keys (re-confirmed on prod
125
+ 2026-09-02, and the same on `Client_CompassCanada`). An index migration is recommended and **still
126
+ not done**. **The blank-client template is not a safe proxy for this schema**: `dbchanges2/Client/
127
+ 2026-06-03- BLANK_CLIENT_DATABASE.sql` declares `UNIQUE KEY email (email)`, and Compass has
128
+ diverged from it. Check the live tenant, never the template.
129
+ - **⚠ `executeForTenant()`'s inactivation sweep is the ONLY place in the file that writes
130
+ `isActive = 0`.** Every other `isActive` path in the ~2,200-line class only forces it back to
131
+ **1**. Two consequences: (a) **an activation made outside this cron is temporary** — the next run
132
+ deactivates the person again unless they are in that day's PEOPLE file — while (b) **a
133
+ deactivation is durable**, because nothing here ever reactivates anyone on its own. This is what
134
+ makes the [Tools Compass admin
135
+ pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md) a one-way-durable tool.
136
+ - **`ensureCrossRegionUsersActive()` runs `UPDATE Users SET isActive = 1 WHERE email = X` with no
137
+ `isActive` filter**, so it reactivates **every** row carrying one of its four hardcoded addresses,
138
+ dead duplicates included. Verified on prod 2026-09-02: each of the four addresses currently
139
+ resolves to exactly **one** active row in **both** tenants (zero inactive), so nothing is being
140
+ resurrected today — but the query would happily do it if a duplicate appeared. That hardcoded list
141
+ is also still the **only** durable way to hold a cross-region user active, and changing it needs a
142
+ code change and a deploy per request.
124
143
 
125
144
  ## Remediation — BUILT 2026-08-20
126
145
 
@@ -151,6 +170,17 @@ question is now contained rather than blocking.
151
170
 
152
171
  ## Change history
153
172
 
173
+ - 2026-09-04 — Recorded that the inactivation sweep in `executeForTenant()` is the **only**
174
+ `isActive = 0` writer in the whole class (every other path only forces it back to 1), so an
175
+ activation made outside the cron is **temporary** while a deactivation is **durable** — the
176
+ governing constraint on the new [Tools Compass admin
177
+ pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md). Noted that
178
+ `ensureCrossRegionUsersActive()` filters on email only, so it would reactivate dead duplicates
179
+ too (prod check 2026-09-02: one active row per address in both tenants, so nothing is being
180
+ resurrected today), and that its hardcoded four-address list remains the only durable
181
+ cross-region hold. Extended the missing-index gotcha with `c_hrEmpUsername`, the `EXPLAIN`
182
+ evidence, and the fact that the blank-client template's `UNIQUE KEY email` does **not** describe
183
+ this tenant. (ajean)
154
184
  - 2026-08-20 — **Built the remediation** that this doc had recorded as designed-but-unbuilt: the
155
185
  importer is now a thin trigger that collects newly created user ids and calls the new worker action
156
186
  `Client/Compass/ApprovalReassignment/Reassign` **after the tenant transaction commits**; all the
@@ -6,12 +6,13 @@ project: Worker
6
6
  client: compass-usa
7
7
  type: client-feature
8
8
  status: active
9
- updated: 2026-08-26
10
- owners: [bala]
9
+ updated: 2026-09-04
10
+ owners: [bala, ajean]
11
11
  files:
12
12
  - worker2/Worker/Client/Compass/PeopleFile.php
13
13
  related:
14
14
  - ./people-file-user-lifecycle.md
15
+ - ../../../2.0/apps/_underscore/features/effective-persona-resolution.md
15
16
  - ../profile.md
16
17
  - ../workflows/persona-refactor-migration.md
17
18
  - ../workflows/persona-population-env-comparison.md
@@ -95,6 +96,13 @@ hierarchy root**:
95
96
  └─ … (users sit ~5 levels below 55)
96
97
  ```
97
98
 
99
+ > **How a user's personas are *read* is a different rule from how this cron *assigns* them.** The
100
+ > effective set is a UNION of `Users_Personas`, the user's own `Locations_Personas`, and their
101
+ > sector's — and on prod **67,157 of 74,268** active Compass users get theirs from the **sector**,
102
+ > not from a row of their own. See
103
+ > [Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md)
104
+ > before writing any "who has persona N" query.
105
+
98
106
  ## Persona assignment, by tenant
99
107
 
100
108
  **US** (`$tenantKey === 'US'`):
@@ -138,6 +146,12 @@ VIP (35) and CDL (30) handling is orthogonal and applies to both tenants unchang
138
146
  constants from this file into tickets, docs, or chat transcripts.
139
147
 
140
148
  ## Change history
149
+ - 2026-09-04 — Linked the framework rule for **reading** effective personas
150
+ ([Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md)):
151
+ `_Model_Client_User::_personaIds()` unions `Users_Personas`, the user's own `Locations_Personas`
152
+ and their sector's, and on prod 2026-09-02 **67,157 of 74,268** active Compass users resolve
153
+ through the **sector** (Canada: 4,029 of 4,948). Assignment by this cron and resolution at read
154
+ time are separate rules — do not answer "who has persona N" from `Users_Personas`. (ajean)
141
155
  - 2026-08-26 — **Correction from a read-only prod check.** The "Non-Levy = id **38** on dev-sandbox
142
156
  *and* prod" claim is wrong: prod has **no Non-Levy persona** and id 38 is **"MyDining- KDS"**, so
143
157
  prod's auto-increment is past 38 and persona 1 is still named **"All"**. Reinforces resolve-by-name.
@@ -15,12 +15,13 @@ apps:
15
15
  - library
16
16
  - test
17
17
  - talos-backend
18
+ - tools
18
19
  project: _Underscore
19
20
  client: compass-usa
20
21
  type: profile
21
22
  status: active
22
- updated: 2026-09-02
23
- owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani]
23
+ updated: 2026-09-04
24
+ owners: [jcardinal, bala, tcox, apeterson, dfranks, akhokhani, ajean]
24
25
  files: []
25
26
  related:
26
27
  - ../../2.0/apps/_underscore/features/sales-order-po-number-sourcing.md
@@ -6,13 +6,15 @@ project: Database Changes
6
6
  client: compass-usa
7
7
  type: workflow
8
8
  status: active
9
- updated: 2026-09-02
10
- owners: [bala]
9
+ updated: 2026-09-04
10
+ owners: [bala, ajean]
11
11
  files:
12
12
  - dbchanges2/Client_Compass/2026-08-26a - CompassCreativeStudioPersona.sql
13
13
  related:
14
14
  - ./granting-navigation-access.md
15
15
  - ./changing-a-kit-line-item.md
16
+ - ../../../2.0/apps/_underscore/features/effective-persona-resolution.md
17
+ - ../../../1.0/apps/tools/features/compass-user-persona-admin.md
16
18
  - ../features/persona-model-and-levy-gating.md
17
19
  - ./persona-refactor-migration.md
18
20
  - ./persona-population-env-comparison.md
@@ -139,6 +141,11 @@ A user's **effective** persona set is three UNIONed sources, not just `Users_Per
139
141
  3. `Locations_Personas` on the user's **sector** — five `parentLocationId` hops up
140
142
  (location → complex → district → region → division → sector).
141
143
 
144
+ The framework already implements exactly this in `_Model_Client_User::_personaIds()` — mirror it
145
+ rather than re-deriving it, and note that on prod the **sector** hop is where almost every Compass
146
+ user gets their personas. Full rule and the measured numbers:
147
+ [Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md).
148
+
142
149
  **Check all three before adding anything** — the user may already reach the bundle through their
143
150
  location, in which case the right answer is "no change needed". After the grant, re-run the same
144
151
  resolution and confirm the bundle number appears; the verification block at the end of the template
@@ -167,6 +174,12 @@ tooling uses — see
167
174
  removed. A "remove access" request is a different, destructive job and needs its own review.
168
175
 
169
176
  ## Change history
177
+ - 2026-09-04 — Pointed the three-source verification step at the framework's own implementation,
178
+ `_Model_Client_User::_personaIds()`, now documented with prod measurements in
179
+ [Effective persona resolution](../../../2.0/apps/_underscore/features/effective-persona-resolution.md).
180
+ The Tools [Compass admin pages](../../../1.0/apps/tools/features/compass-user-persona-admin.md)
181
+ now do the "grant a persona to a user" step (`Users_Personas` insert) in a browser; the bundle /
182
+ item / assortment work in this file still needs SQL. (ajean)
170
183
  - 2026-09-02 — Recorded the **reverse case** and cross-linked it: adding a new line to a kit that
171
184
  personas *already* own hits the same item-ACL rule, so a fully active `BundleItems` row is omitted
172
185
  per-persona until `Personas_Items` is backfilled (item 687 `TabAdminSuppPro` reached only MITS Team
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.772",
3
+ "version": "1.0.773",
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",