toga-ai 1.0.477 → 1.0.478

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: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-24
9
+ updated: 2026-07-30
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - tools/_/app/design/github.php
@@ -19,6 +19,7 @@ files:
19
19
  - tools/composer.json
20
20
  related:
21
21
  - ../architecture.md
22
+ - ../../../standards/frontend.md
22
23
  - ../features/persona-gated-navigation.md
23
24
  - ../features/saml-sso-auth.md
24
25
  - ../workflows/deploy-to-elastic-beanstalk-al2023.md
@@ -92,6 +93,29 @@ on the next write of that project.json. Tab management via a per-pill "⋯" menu
92
93
  - **Reorder** — a single-file `tabs.json` commit, validated as a **permutation** of the
93
94
  existing list.
94
95
 
96
+ ### Text-storage invariant: JSON holds plain text, escape once at display
97
+
98
+ `tabs.json` tab names and `project.json` `title` values are stored as **plain text**. The
99
+ single correct escape happens at display time in `assets/js/design.js` `esc()` (and in
100
+ `buildStub()`, which `htmlspecialchars()` the title into the generated `index.html`).
101
+
102
+ `App_Design_Github::decodeEntities()` (one pass of `html_entity_decode` with
103
+ `ENT_QUOTES | UTF-8`) enforces that invariant at two chokepoints:
104
+
105
+ - **On read out of storage** — `loadTabs()`, `normalizeTabs()` (including the legacy scalar
106
+ `tab`), and the project read's `title`.
107
+ - **On names/titles read off a request** — `createTab()`, `renameTab()` (both old and new
108
+ name), `deleteTab()`, `reorderTabs()`, `filterKnownTabs()`, `createProject()`, and
109
+ `upload()` titles.
110
+
111
+ Both sides are required: decoding only reads would make `renameTab()` compare a decoded
112
+ `H&H` against a stored `H&H` and fail with *"No such tab."*; decoding only writes would
113
+ leave already-bad stored data permanently double-escaped on screen.
114
+
115
+ **Self-healing:** every tab write rewrites `tabs.json` and each affected `project.json` in
116
+ full, so pre-encoded stored values are re-persisted as plain text on the next
117
+ rename/delete/reorder. No manual repair of the `forward` repo is needed.
118
+
95
119
  ### Actions & UX
96
120
 
97
121
  - **Publish a version** — uploads a self-contained HTML export as the next `vN/index.html`,
@@ -147,10 +171,27 @@ None — internal/shared design-team tool.
147
171
  `curl_close()` calls. Audit ported code for other deprecated-in-8.x calls.
148
172
  - **Contents API inlines only ≤1 MB** — read exports with the raw media type (see above).
149
173
  - **Publishing pushes to `forward`'s `_main`** and triggers EB auto-deploy; no staging.
174
+ - **Double-escaped names (`H&H` shown literally, rename fails with "No such tab.")** —
175
+ caused by values that arrived **already HTML-encoded** and were then correctly escaped once
176
+ more at display. The render path was never wrong; no current code path produces the
177
+ encoding (`createTab`/`createProject` and the JS transport `window.prompt` →
178
+ `URLSearchParams` → `$postField` trim/`mb_substr` are clean), so encoded values entered from
179
+ outside — a paste from rendered HTML, or a pre-fix build. Fixed defensively with
180
+ `decodeEntities()` on both the read and request sides (see above). Generated project stubs
181
+ were affected identically, because `buildStub()`'s single correct `htmlspecialchars()` was a
182
+ *second* escape on a pre-encoded title; storing titles plain fixes the stubs too.
150
183
  - Never hardcode the token in tracked source — a leaked `ghp_`/PAT must be rotated.
151
184
 
152
185
  ## Change history
153
186
 
187
+ - 2026-07-30 — Fixed HTML-entity double-escaping of tab names and project titles (a tab named
188
+ `H&H` rendered as `H&H`, and renaming it failed with "No such tab."). Added
189
+ `App_Design_Github::decodeEntities()` and applied it on every read out of storage
190
+ (`loadTabs`, `normalizeTabs` incl. legacy scalar `tab`, project `title`) and on every
191
+ name/title read off a request (`createTab`, `renameTab`, `deleteTab`, `reorderTabs`,
192
+ `filterKnownTabs`, `createProject`, `upload`), so all comparisons are decoded-vs-decoded and
193
+ storage re-persists plain text. Defensive — no current code path produced the encoding.
194
+ Verified by the developer; not yet committed to `tools`. (jcardinal)
154
195
  - 2026-07-24 — Moved the Design Demo Admin from `forward` into the SSO-protected `tools` app
155
196
  (`App_Design_Github` + `/design` MVC route + namespaced assets + nav entry); still commits
156
197
  to `agilantsolutions/forward` `_main` and demos stay hosted at `demo.togatech.com`. Added
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: Front-End Standards
3
+ framework: "1.0"
4
+ project: Library
5
+ client: shared
6
+ type: standard
7
+ status: active
8
+ updated: 2026-07-30
9
+ owners: [jcardinal]
10
+ files: []
11
+ related:
12
+ - ./backend-php.md
13
+ - ./framework-rules.md
14
+ - ../apps/tools/features/design-demo-admin.md
15
+ - ../apps/tools/features/talos-kb-documents-admin.md
16
+ ---
17
+
18
+ # Front-End Standards (1.0 Legacy — `Browser_` / server-rendered UI)
19
+
20
+ > **Scope.** Server-rendered UI and browser assets for the legacy **1.0** framework
21
+ > (`Browser_` classes, `mvc/` get/post handlers, `assets/js`, `assets/css`). 1.0 is in
22
+ > maintenance mode and historically inconsistent — match the file you are editing, and
23
+ > prefer the cleaner pattern for new code. Do not retrofit 2.0 front-end conventions.
24
+
25
+ ## Output escaping: storage holds plain text, escape exactly once at display
26
+
27
+ **Rule.** Persisted values — database columns, JSON manifests, config — hold **plain,
28
+ unescaped text**. HTML escaping happens **exactly once**, at the moment the value is
29
+ written into an HTML context (`htmlspecialchars($v, ENT_QUOTES, 'UTF-8')` server-side, or
30
+ the view's `esc()` helper client-side).
31
+
32
+ **Never** escape on the way *in* to storage. An escaped value in storage is a latent bug:
33
+ the display layer escapes it a second time, and the user sees `H&H` instead of `H&H`.
34
+
35
+ ### Why it recurs
36
+
37
+ The failure mode is a **round trip**, not a bad escape function:
38
+
39
+ 1. A value is rendered into an edit form / prompt already escaped for HTML.
40
+ 2. The user submits that form unchanged.
41
+ 3. The escaped string is stored verbatim — storage now holds `H&H`.
42
+ 4. Display escapes correctly once more → `H&H` on screen.
43
+
44
+ Each round trip adds a level. Equality comparisons then fail too, because a freshly typed
45
+ `H&H` no longer matches the stored `H&H` — which surfaces as a confusing
46
+ "record not found" rather than as a display bug.
47
+
48
+ ### Required practice
49
+
50
+ - Pre-fill form inputs and JS prompts with the **plain** value; let the templating layer
51
+ do the one escape it owns.
52
+ - When a store may already contain encoded data, **decode on both sides** of the boundary:
53
+ a single `html_entity_decode($v, ENT_QUOTES, 'UTF-8')` on every read out of storage
54
+ **and** on every value read off a request. Decoding only one side leaves either broken
55
+ comparisons or undisplayable legacy rows.
56
+ - Prefer stores that **rewrite records in full** on update, so the decode-on-write is
57
+ self-healing and no manual data repair is needed.
58
+ - Escape for the **right context**: `htmlspecialchars` for HTML text/attributes,
59
+ `json_encode` (with `JSON_HEX_*` for inline `<script>`) for JavaScript. Never
60
+ hand-build JSON or rely on `htmlspecialchars` inside a JS context.
61
+
62
+ ### Precedents in the codebase
63
+
64
+ - `tools/mvc/talos/vocabulary/post.php:74-80` — inline comment recording the rule after
65
+ form inputs pre-filled with `htmlspecialchars()` output accumulated encoding.
66
+ - `tools/_/app/design/github.php` — `App_Design_Github::decodeEntities()` applied to tab
67
+ names and project titles on both the storage-read and request-read sides.
68
+
69
+ ## Change history
70
+
71
+ - 2026-07-30 — Created. Establishes the escape-once/plain-text-storage invariant after a
72
+ second independent occurrence in `tools` (Design Demo Admin, following the Talos
73
+ vocabulary tool). (jcardinal)
74
+ </content>
75
+ </invoke>
@@ -194,6 +194,23 @@ All non-production environments are **all-in-one**: a single cluster per environ
194
194
  holds `Core`, `Client_<Tenant>`, `Archive_<Tenant>`, and `Logs_<Tenant>` together
195
195
  (no family split). Region/reader routing still applies per cluster.
196
196
 
197
+ > **⚠ Consequence — never write a query that spans two database families.** Because
198
+ > production splits the families across the four dedicated clusters above, a single SQL
199
+ > statement that joins or sub-selects across them (e.g. `Client_Towfoundation` ↔ `Core`,
200
+ > or `Client_<Tenant>` ↔ `Archive_<Tenant>`) **cannot resolve in production** — the foreign
201
+ > schema is not on that server. And because **every non-production environment is all-in-one,
202
+ > such a query runs perfectly in local/dev/QA/stage**, so the failure surfaces only after
203
+ > release. Treat a successful non-prod run as **no evidence** that a query is cluster-safe.
204
+ > Cross-family data must be assembled in **PHP across two connections** (`_Database` named
205
+ > connections, one per family), never in one statement. Note the corollary that platform
206
+ > databases `Core`, `Forecast`, and `Team` **do** share `prod-core`, so those are same-cluster
207
+ > — but a cross-*database* query is still discouraged, and in `dbchanges2` it is forbidden
208
+ > outright.
209
+ >
210
+ > For **`dbchanges2` migrations this is a hard, mechanically-enforced rule**: a `.sql` file may
211
+ > only reference tables in the one database its folder targets. See
212
+ > [dbchanges2 → Database isolation](../dbchanges2/architecture.md).
213
+
197
214
  | Environment (aliases) | Host |
198
215
  |---|---|
199
216
  | `dev-sandbox` / `sandbox-dev` / developer sandbox | `dev.sandbox.database.togahub.com` |
@@ -39,6 +39,10 @@ in a folder, then `b`, `c`, …). One folder per database; place client changes
39
39
  `Client_<Name>` folder. Never edit an already-run migration — add a new dated file instead. The
40
40
  `Client/` **blank must carry the baseline DATA seed** (roles, full ACL, reference/lookup tables,
41
41
  UI config) — **not just schema**; a schema-only blank produces non-functional clients.
42
+ **A file may only reference tables in the ONE database its folder targets** — `Core`, `Client_*`,
43
+ `Archive_*`, `Logs*`, and `Cache` are on **separate production clusters**, so any
44
+ `OtherDatabase.Table` reference is unrunnable in production even though it works locally
45
+ (see *Database isolation* below; enforced by the `dbchanges2-cluster-isolation` hook).
42
46
 
43
47
  ## File naming convention (the execution contract)
44
48
 
@@ -90,6 +94,101 @@ Spglobal, Trividiahealth, True, Wje, Wmchealth, Ynhh).
90
94
  `Client/` and `Logs_Client/` also contain `BLANK_CLIENT_DATABASE` / `BLANK_CLIENT_LOGS_DATABASE`
91
95
  seed scripts used to provision a brand-new tenant DB from scratch.
92
96
 
97
+ ## Database isolation — never query across databases (HARD RULE)
98
+
99
+ **A `.sql` file in `dbchanges2` may only reference tables in the ONE database its folder
100
+ targets, and it must reference them UNQUALIFIED.** Any `OtherDatabase.Table` reference — in a
101
+ `JOIN`, a subquery, a `SET @var = (SELECT …)`, an `INSERT … SELECT`, a `NOT EXISTS` guard, or a
102
+ `USE` statement — is a **hard violation**.
103
+
104
+ **Why:** in **production** the 2.0 databases are **not one server**. `Core`, each
105
+ `Client_<Tenant>`, each `Archive_<Tenant>`, `Logs`/`Logs_<Tenant>`, and `Cache` live on
106
+ **entirely separate clusters** (see `2.0/apps/_underscore/architecture.md` → *Database
107
+ architecture*). A cross-database query has no way to resolve the foreign schema there.
108
+
109
+ **Why this is a trap rather than an obvious error:** **locally and in every non-prod
110
+ environment all of these databases sit behind a single endpoint**, so a cross-database query
111
+ runs perfectly, the migration looks correct, review passes — and it fails only when it reaches
112
+ production. Never treat a successful local run as evidence that a query is cluster-safe.
113
+ **Assume every database other than the file's own target is unreachable, always.**
114
+
115
+ ### Not allowed
116
+
117
+ This is the canonical violation — a `Client_<Tenant>` migration resolving `Core` ids to copy ACL
118
+ rows. `AclFieldPermissions` is in the client database, `Core.RecordFields` / `Core.Records` are
119
+ on the **core cluster**:
120
+
121
+ ```sql
122
+ -- WRONG — Core.* is a different cluster; this cannot run in production
123
+ SET @serviceAddressFieldId = (
124
+ SELECT rf.id FROM Core.RecordFields rf
125
+ JOIN Core.Records r ON r.id = rf.recordId
126
+ WHERE r.`route` = 'entitlements' AND rf.`field` = 'serviceAddressId' LIMIT 1
127
+ );
128
+ SET @saleItemFieldId = (
129
+ SELECT rf.id FROM Core.RecordFields rf
130
+ JOIN Core.Records r ON r.id = rf.recordId
131
+ WHERE r.`route` = 'entitlements' AND rf.`field` = 'saleItemId' LIMIT 1
132
+ );
133
+
134
+ INSERT INTO AclFieldPermissions (uuid, recordFieldId, roleId, isWritable)
135
+ SELECT UUID(), @serviceAddressFieldId, sibling.roleId, 0
136
+ FROM AclFieldPermissions sibling
137
+ WHERE sibling.recordFieldId = @saleItemFieldId
138
+ AND @serviceAddressFieldId IS NOT NULL
139
+ AND NOT EXISTS (
140
+ SELECT 1 FROM AclFieldPermissions existing
141
+ WHERE existing.recordFieldId = @serviceAddressFieldId AND existing.roleId = sibling.roleId
142
+ );
143
+ ```
144
+
145
+ ### Allowed — replace the foreign lookup
146
+
147
+ Use whichever fits; the goal is that **every table named in the file lives in the file's own
148
+ database**:
149
+
150
+ 1. **Hardcode the identifier.** The `Core` row's `uuid` is stable across environments, so
151
+ pre-generate/look it up **once at authoring time** and embed the literal. (Same discipline as
152
+ rule #6 — a v4 UUID literal, never `UUID()`.)
153
+
154
+ ```sql
155
+ -- CORRECT — no foreign database read; the uuid literal is resolved at authoring time
156
+ SET @serviceAddressFieldId = (
157
+ SELECT id FROM RecordFields
158
+ WHERE uuid = '<pre-resolved v4 uuid of the serviceAddressId field>' LIMIT 1
159
+ );
160
+ ```
161
+
162
+ 2. **Resolve by slug / natural key inside the target database.** If the client database carries
163
+ its own copy of the identifying column (`field`, `route`, `name`, a code), match on that
164
+ instead of joining out to `Core`.
165
+
166
+ 3. **Split the change into one file per database folder.** If work genuinely spans two
167
+ databases, write a `Core/` file **and** a `Client_<Name>/` (or `Client/`) file, each
168
+ self-contained. Order them by date/letter so the `Core` half lands first, and carry any value
169
+ between them as a **hardcoded literal**, not a query.
170
+
171
+ ### Fan-out folders: no qualifier at all
172
+
173
+ In `Client/`, `Logs_Client/`, and `_modules/<module>/` the target database **name is not fixed**
174
+ — the executor runs the same file against every tenant DB. So there is no valid database
175
+ qualifier in these folders whatsoever: **reference every table unqualified**, including tables in
176
+ the database being targeted.
177
+
178
+ ### Enforcement
179
+
180
+ This is enforced mechanically, not by convention. The `PreToolUse` hook
181
+ `.claude/hooks/toga/dbchanges2-cluster-isolation.js` inspects every `Write`/`Edit`/`MultiEdit`
182
+ of a `.sql` file under `dbchanges2`, resolves the target database from the folder, and **refuses
183
+ the write** if the SQL qualifies any table with a different database name (`Core`, `Client*`,
184
+ `Logs*`, `Archive*`, `Cache`, `Team`, including backticked and `USE` forms). Comments and string
185
+ literals are stripped first, and table **aliases** (`rf.id`, `sibling.roleId`) are never flagged.
186
+ `HISTORIC/` folders are skipped.
187
+
188
+ > **Scope:** this rule and its hook apply to **`dbchanges2` only**. The 1.0 **`dbchanges`** repo
189
+ > is **not** subject to it. If the hook ever misfires on legitimate SQL, `DBCHANGES2_ISOLATION_DISABLED=1`
190
+ > is an escape hatch **for false positives only** — never to land a cross-database query.
191
+
93
192
  ## `_modules` — reusable, opt-in change-sets
94
193
 
95
194
  Some change-sets aren't applied to every client — only to clients that use a given **module**
@@ -189,6 +288,13 @@ its own header.)
189
288
  `prePost` interceptor is registered unguarded in `Client_Rate/2026-07-15 - PreInterceptor.sql`
190
289
  (record 191, `minDepth NULL`); a second guarded registration would have double-fired it. See
191
290
  `clients/rate/features/whole-home-warranty-purchase-guard.md`.
291
+ 8. **Never reference a database other than the folder's own target.** No `Core.Table` in a
292
+ `Client_<Name>/` file, no `Client_X.Table` in a `Core/` file, and **no database qualifier at
293
+ all** in the fan-out folders (`Client/`, `Logs_Client/`, `_modules/`). These databases are on
294
+ **separate production clusters**; the query works locally and dies in production. Resolve
295
+ foreign ids with a hardcoded v4 UUID literal or an in-database slug/natural key, or split the
296
+ work into one file per database folder. See *Database isolation* above — enforced by the
297
+ `dbchanges2-cluster-isolation` hook.
192
298
 
193
299
  ## Bulk data loads — batch, and stage large sets in a temp table
194
300
 
@@ -341,6 +447,17 @@ defined in `2.0/apps/_underscore/architecture.md`, and its change files create/a
341
447
  tables that `_Model_*` classes map to.
342
448
 
343
449
  ## Change history
450
+ - 2026-07-29 — **Added *Database isolation — never query across databases* (HARD RULE) + rule #8.**
451
+ A `dbchanges2` `.sql` file may only reference tables in the one database its folder targets, and
452
+ must reference them unqualified; fan-out folders (`Client/`, `Logs_Client/`, `_modules/`) permit
453
+ no database qualifier at all. Rationale: in production `Core`, `Client_<Tenant>`,
454
+ `Archive_<Tenant>`, `Logs`/`Logs_<Tenant>`, and `Cache` are on **entirely separate clusters**,
455
+ while local/non-prod collapse them behind one endpoint — so a cross-database query passes review
456
+ and fails only in production. Replace foreign lookups with hardcoded v4 UUID literals, in-database
457
+ slugs/natural keys, or one file per database folder. Enforced mechanically by the new `PreToolUse`
458
+ hook `dbchanges2-cluster-isolation.js`, which refuses the write. Applies to `dbchanges2` **only** —
459
+ the 1.0 `dbchanges` repo is exempt. Cluster topology recorded in
460
+ `2.0/apps/_underscore/architecture.md` → *Database architecture*. (jcardinal)
344
461
  - 2026-07-29 — Added *Self-referencing INSERT...SELECT guard — wrap the existing-set subquery in
345
462
  a derived table*: an `INSERT...SELECT` guarded per-key against its own target must wrap the
346
463
  existing-set subquery in a derived table (DISTINCT/LIMIT) so MySQL materializes it once —
@@ -113,6 +113,30 @@ Never create a `.sql` file inside `worker2`, `api2`, `_underscore`, or any other
113
113
  application repo. The SQL that belongs in those repos is only query strings embedded in
114
114
  PHP code — not standalone migration files.
115
115
 
116
+ ### Database isolation in `dbchanges2` — one database per file (HARD RULE)
117
+
118
+ **A `dbchanges2` `.sql` file may only reference tables in the ONE database its folder targets,
119
+ and must reference them UNQUALIFIED.** Never write `Core.Table` in a `Client_<Name>/` file,
120
+ never `Client_X.Table` in a `Core/` file, and use **no database qualifier at all** in the
121
+ fan-out folders (`Client/`, `Logs_Client/`, `_modules/`), where the tenant database name is not
122
+ fixed. This covers `JOIN`s, subqueries, `SET @var = (SELECT …)`, `INSERT … SELECT`, `NOT EXISTS`
123
+ guards, and `USE`.
124
+
125
+ In **production** `Core`, `Client_<Tenant>`, `Archive_<Tenant>`, `Logs`/`Logs_<Tenant>`, and
126
+ `Cache` are on **entirely separate clusters**, so a cross-database statement cannot resolve
127
+ there. **Local and every non-prod environment put all of them behind one endpoint**, so the
128
+ query runs fine, passes review, and fails only in production — never trust a local run as proof
129
+ it is safe.
130
+
131
+ Instead: hardcode a pre-resolved **v4 UUID literal**, resolve rows by a **slug / natural key that
132
+ exists in the target database**, or **split the work into one file per database folder** (passing
133
+ values between them as literals, not queries).
134
+
135
+ Enforced mechanically by the `PreToolUse` hook
136
+ `.claude/hooks/toga/dbchanges2-cluster-isolation.js`, which refuses the write. Full rule,
137
+ examples, and the approved rewrites: `2.0/apps/dbchanges2/architecture.md` → *Database
138
+ isolation*. **Applies to `dbchanges2` only — the 1.0 `dbchanges` repo is exempt.**
139
+
116
140
  ## Checking dependencies before touching shared code
117
141
 
118
142
  `dependsOn` in `knowledge/registry.json` means a repo extends or depends on another repo's classes. Before modifying a class in a dependency repo (e.g. `_underscore` core):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.477",
3
+ "version": "1.0.478",
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",