toga-ai 1.0.456 → 1.0.458

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.
@@ -24,7 +24,7 @@
24
24
  | [Background Email-Template Worker (_Worker_Notification_EmailTemplate)](features/notification-email-template.md) | `_Worker_Notification_EmailTemplate::Send(...)` dispatches a **stored, client-defined `EmailTemplates` row off-thread** as a background WorkerJob. | worker2/Worker/Notification/EmailTemplate.php, worker2/Worker/Client/True.php, _underscore/Model/Client/EmailTemplate.php |
25
25
  | [DB-Driven Notification (Internal) Email](features/notification-email.md) | Internal/notification emails (merge-conflict alerts, ops notices — anything system-generated, not client-facing transactional mail) are sent through one worker | worker2/Worker/Notification/Email.php, _underscore/Model/Client/EmailTemplate.php, dbchanges2/Client/2026-06-23a - EmailTemplateWrapper.sql, dbchanges2/Client_True/2026-06-23a - EmailTemplateWrapper.sql |
26
26
  | [OneUptime push-metric monitors for 2.0 workers](features/oneuptime-worker2-monitoring.md) | A second, **OneUptime-reporting** monitoring pattern for the 2.0 worker2 tier, ported from the 1.0 `App_SystemMonitor_Compass` monitors. | worker2/Worker/Monitor/Compass.php, worker2/Worker/Client/Compass.php, worker2/composer.json, _underscore/Cloud.php |
27
- | [Platform Cache Cleanup (_Worker_Platform_Cache::Clean)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache::Clean()` is the reaper for the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-clien | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
27
+ | [Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)](features/platform-cache-cleanup.md) | `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's [multi-client data retrieval](../../api2/features/cross-client-data- | worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
28
28
  | [Startech Webhook Handler (worker2)](features/startech-webhook-handler.md) | Receives inbound webhook events from Startech (Easeedesk) and creates or updates the corresponding ticket in TOGA 2.0. | worker2/Worker/Startech.php |
29
29
  | [Talos (TOGa IQ) Meeting-Notes Integration & Token Auto-Refresh (consumer)](features/talos-meeting-notes-integration.md) | How a **dev tool / agent consumes Talos (TOGa IQ)** to query the team meeting-notes corpus programmatically. | .claude/skills/plan-ticket/scripts/talos.js |
30
30
  | [Talos Pricing Automation (worker2 Cron — AWS Actuals, Calibration, Monthly Report)](features/talos-pricing-automation.md) | The worker2 half of the **Talos Pricing Platform** (see the talos `pricing-cogs-model` and tools `talos-pricing-ui` docs for the other halves). | worker2/Worker/Talos/Pricing.php, worker2/Database/TalosPricingCrons.sql |
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-21
9
+ updated: 2026-07-28
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/Controller/Index.php
@@ -163,6 +163,37 @@ file `Worker/Team/Github.php`.
163
163
  `_Worker_Infrastructure_Worker_Cleanup`: `WorkerJobs()` deletes `isSuccess=1` rows >90 days
164
164
  (≤1000/run); `WebhookLogs()` deletes `Logs.Webhook` >90 days. Two daily CronJobs at 2 AM.
165
165
 
166
+ ## Cron actions must return a result on BOTH paths
167
+
168
+ **A new cron action is expected to return a result for a success as well as a failure.** Both
169
+ outcomes are recorded against the job, and the **successful** results are what auditing reads — a
170
+ run that completed but returned nothing is indistinguishable in the audit trail from one that never
171
+ executed.
172
+
173
+ So an action must not return only on the failure path, and must not return nothing on success
174
+ because "there was nothing to do". Return a result describing the outcome — including the no-op case
175
+ (`0 deleted, 0 dropped` is a meaningful audit record; silence is not). Make it structured and
176
+ countable rather than prose, so one run can be compared against its neighbours:
177
+
178
+ ```php
179
+ public static function Clean(): string {
180
+ // ... work ...
181
+ return json_encode([
182
+ 'isSuccess' => true,
183
+ 'deletedRowCount' => $deletedRowCount,
184
+ 'droppedTableCount' => $droppedCount,
185
+ 'failedDropCount' => $failedCount,
186
+ ]);
187
+ }
188
+ ```
189
+
190
+ Reserve throwing for genuinely **retriable** conditions, where the queue re-running the job is the
191
+ behaviour you want. A handled, non-retriable failure should return a result saying so rather than
192
+ throwing — throwing costs the full retry cycle and still records nothing useful for the audit.
193
+
194
+ See the worker contract in [2.0 framework rules](../../standards/framework-rules.md) for the
195
+ class/method shape and the named-argument parameter contract.
196
+
166
197
  ## Critical transaction pattern
167
198
 
168
199
  **Any INSERT/UPDATE that must be visible to another connection before an SQS message is
@@ -229,6 +260,12 @@ the MySQL-first design was departed from **on purpose** here.
229
260
 
230
261
  ## Change history
231
262
 
263
+ - 2026-07-28 — Added **Cron actions must return a result on BOTH paths**: a new cron action is
264
+ expected to return a structured result for a success as well as a failure, because both are
265
+ recorded against the job and the successful results are what auditing reads (a completed run that
266
+ returned nothing is indistinguishable from one that never executed). Formalises the intent of the
267
+ 2026-07-21 `output` change below. Also documents `_Worker_Platform_Cache::Truncate()`, a manual
268
+ companion to the scheduled `Clean()` action. (jcardinal)
232
269
  - 2026-07-21 — `Core.WorkerJobs.failureReason` renamed to `output` and repurposed to capture
233
270
  successful run results as well as failures (success writes the composed "Successfully Executed"
234
271
  message, return value `json_encode`d). Multi-producer rename: PHP worker (both paths), `Retry()`
@@ -1,5 +1,5 @@
1
1
  ---
2
- title: Platform Cache Cleanup (_Worker_Platform_Cache::Clean)
2
+ title: Platform Cache Cleanup (_Worker_Platform_Cache — Clean + Truncate)
3
3
  framework: "2.0"
4
4
  repo: worker2
5
5
  project: Worker
@@ -17,20 +17,28 @@ related:
17
17
  - ../../api2/features/cross-client-data-retrieval.md
18
18
  - ./creating-worker-actions.md
19
19
  - ../../_underscore/features/per-client-database-connections.md
20
+ - ../architecture.md
20
21
  ---
21
22
 
22
23
  ## Summary
23
24
 
24
- `_Worker_Platform_Cache::Clean()` is the reaper for the shared **Cache** cluster that backs api2's
25
- [multi-client data retrieval](../../api2/features/cross-client-data-retrieval.md). It deletes
26
- expired `Cache.Tables` rows and drops the per-query `TableResults_<Tables.id>` tables that no
27
- longer have a parent. Without it the Cache schema grows one table per distinct cross-client query,
28
- forever.
25
+ `_Worker_Platform_Cache` owns maintenance of the shared **Cache** cluster that backs api2's
26
+ [multi-client data retrieval](../../api2/features/cross-client-data-retrieval.md). It exposes **two**
27
+ actions on the same class (both listed in the class docblock in `Worker/Platform/Cache.php`):
29
28
 
30
- Action path `Platform/Cache/Clean`, scheduled on a **`*/5`** cron registered in
31
- `dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql`.
29
+ | Action | Path | Scheduled? | Scope |
30
+ |---|---|---|---|
31
+ | `Clean()` | `Platform/Cache/Clean` | yes — `*/5` cron | **expired** entries only, with a 2-minute grace |
32
+ | `Truncate()` | `Platform/Cache/Truncate` | **no — deliberately manual** | **everything**, no grace, spares nothing |
32
33
 
33
- ## How it works
34
+ Without `Clean()` the Cache schema grows one `TableResults_<Tables.id>` table per distinct
35
+ cross-client query, forever. `Truncate()` is the administrative "reset the whole cache" hammer.
36
+
37
+ The `Clean()` cron is registered in `dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql`.
38
+ Both actions return a summary string on **every** path (success and handled failure) — see
39
+ [cron actions must return a result on both paths](../architecture.md#cron-actions-must-return-a-result-on-both-paths).
40
+
41
+ ## How it works — `Clean()` (scheduled reaper)
34
42
 
35
43
  1. **Reap expired parents.** Delete `Cache.Tables` rows whose TTL has elapsed, with a **2-minute
36
44
  grace period** so a request that is mid-read cannot have its rows deleted out from under it.
@@ -41,6 +49,44 @@ Action path `Platform/Cache/Clean`, scheduled on a **`*/5`** cron registered in
41
49
  status is **re-verified on the write host immediately before each `DROP`** — a `DROP TABLE` is
42
50
  irreversible and non-transactional, so a stale read replica must never be the basis for one.
43
51
 
52
+ ## How it works — `Truncate()` (manual, unscheduled)
53
+
54
+ `_Worker_Platform_Cache::Truncate(): string` removes the **entire** cross-client cache. It is
55
+ deliberately **not** registered as a cron — invoke it explicitly:
56
+
57
+ ```php
58
+ _Worker::runTask('Platform/Cache/Truncate', []);
59
+ ```
60
+
61
+ 1. `_Database::useQueryCache(bool: false)` at the top, because the action re-reads tables it has
62
+ just emptied and a cached result set would make it act on stale contents.
63
+ 2. `DELETE FROM Tables` — `Tables_Clients` follows automatically via `ON DELETE CASCADE`.
64
+ 3. A **defensive** `DELETE FROM Tables_Clients` afterwards, to catch any cursor row orphaned by an
65
+ earlier partial failure (the cascade only covers rows whose parent still existed).
66
+ 4. Drop every `TableResults_*` table.
67
+
68
+ **Each delete is committed immediately** with
69
+ `_Database::transactionCommit(database: _underscore::DB_CACHE)`. This is not optional:
70
+ `_Database::register()` opens a **lazy transaction**, so anything left uncommitted is rolled back
71
+ at connection close and the truncate silently does nothing.
72
+
73
+ Drops still go through the existing `dropResultTable()` **allowlist** (`^TableResults_[1-9][0-9]*$`),
74
+ so a table that merely shares the prefix is never dropped. A drop failure is logged and counted and
75
+ does **not** abort the remaining drops.
76
+
77
+ ### `Clean()` vs `Truncate()` — the difference that matters
78
+
79
+ | | `Clean()` | `Truncate()` |
80
+ |---|---|---|
81
+ | Selects | **expired** entries only | **everything** |
82
+ | Grace period | **2 minutes** — in-flight requests keep their rows | **none** |
83
+ | Safe on a busy environment | yes, by design | **no** |
84
+
85
+ `Truncate()` gives a request that is mid-build no protection: it loses its rows and serves an empty
86
+ or short page. Nothing is *corrupted* — the cache is derived data and rebuilds on the next request —
87
+ but the visible-wrong-output window is real, so it must not be run casually against a busy
88
+ environment.
89
+
44
90
  ## Key rules
45
91
 
46
92
  - **Delete-parent-then-drop-child, and re-check on the writer.** Never drop a result table based on
@@ -51,18 +97,41 @@ Action path `Platform/Cache/Clean`, scheduled on a **`*/5`** cron registered in
51
97
  (`Databases.name = 'Cache'`) in `worker2/Controller/Index.php` — the same rule as api2. There is
52
98
  deliberately no `CACHE_DATABASE_ID`: `Databases.id` is auto-assigned per Core instance and a
53
99
  hardcoded id fails silently in every environment where it does not happen to match.
100
+ - **Commit each delete immediately in `Truncate()`.** `_Database::register()`'s lazy transaction
101
+ rolls back at connection close, so an uncommitted truncate is a no-op that *looks* like it worked.
102
+ - **Never bypass `dropResultTable()`'s allowlist.** `^TableResults_[1-9][0-9]*$` is the only thing
103
+ standing between a prefix-sharing table and an irreversible `DROP`.
104
+ - **Both actions return a summary string on every path** — success, no-op, and handled failure. See
105
+ the [worker2 architecture rule](../architecture.md#cron-actions-must-return-a-result-on-both-paths);
106
+ a run that returns nothing is indistinguishable in the audit trail from one that never executed.
54
107
 
55
108
  ## Gotchas
56
109
 
57
110
  - **Worker classes are `abstract class _Worker_<Path>` with `public static` entry methods**,
58
111
  dispatched as `$className::$functionName()`. They do **not** `extends _Worker` — that is the
59
112
  dispatcher. See [Creating Worker Actions](./creating-worker-actions.md). (The 2.0
60
- `standards/framework-rules.md` worker contract still describes the older `extends _Worker` +
61
- `run()` shape and needs reconciling.)
113
+ `standards/framework-rules.md` worker contract now matches this shape — reconciled
114
+ 2026-07-28.)
62
115
  - **Adding the cron is a dbchanges2 change, not a code change** — the schedule lives in Core, so a
63
116
  new worker action is not actually scheduled until that SQL is applied per environment.
117
+ - **`Truncate()` is unscheduled on purpose.** Do not "helpfully" add a cron for it — it has no grace
118
+ period, so on a schedule it would periodically blank live requests' cache rows.
119
+ - **`ON DELETE CASCADE` is not sufficient on its own.** The extra `DELETE FROM Tables_Clients` in
120
+ `Truncate()` exists because an earlier partial failure can leave cursor rows whose parent is
121
+ already gone — the cascade never fires for those.
122
+ - **Stale reads inside a truncate.** Without `_Database::useQueryCache(bool: false)` the action
123
+ re-reads its own just-emptied tables from the query cache and mis-decides what is left.
64
124
 
65
125
  ## Change history
126
+ - 2026-07-28 — Added second action `_Worker_Platform_Cache::Truncate()` on the same class: manual /
127
+ administrative, deliberately **not** scheduled. Removes the entire cross-client cache
128
+ (`DELETE FROM Tables` + cascade, defensive `DELETE FROM Tables_Clients` for orphaned cursors, then
129
+ drops every `TableResults_*`), committing each delete immediately because `_Database::register()`'s
130
+ lazy transaction would otherwise roll it back at connection close; `useQueryCache(false)` since it
131
+ re-reads tables it just emptied; drops still gated by the `dropResultTable()` allowlist and a
132
+ failed drop is logged/counted without aborting. Unlike `Clean()` it applies **no** grace period —
133
+ an in-flight request loses its rows and serves a short page (no corruption; cache rebuilds).
134
+ Both actions return a summary string on every path. (jcardinal)
66
135
  - 2026-07-28 — Initial capture: `_Worker_Platform_Cache::Clean()` reaps expired `Cache.Tables` rows
67
136
  (2-minute grace) and drops orphaned `TableResults_*` tables (parent deleted+committed first,
68
137
  orphan status re-verified on the write host immediately before each DROP); `DB_CACHE` registered
@@ -138,22 +138,30 @@ Do not add a worker class without registering it. Unregistered workers cannot be
138
138
  Workers must catch specific exceptions and handle them appropriately. A worker that throws an uncaught exception will be retried by the queue (up to the configured retry limit) and then dead-lettered.
139
139
 
140
140
  ```php
141
- public function run(): void {
141
+ public static function ProcessOrder(int $orderId): string {
142
142
  try {
143
- $this->processOrder($this->payload['order_id']);
143
+ self::processOrder(orderId: $orderId);
144
144
  } catch (_Exception_OrderNotFound $e) {
145
145
  // Do not retry — log and discard
146
146
  error_log('Worker: order not found, discarding job: ' . $e->getMessage());
147
- return;
147
+ return json_encode(['isSuccess' => false, 'isRetriable' => false, 'orderId' => $orderId, 'error' => $e->getMessage()]);
148
148
  } catch (_Exception_Database $e) {
149
- // Retriable — re-throw so queue retries
149
+ // Retriable — re-throw so the queue retries
150
150
  error_log('Worker: DB error, will retry: ' . $e->getMessage());
151
151
  throw $e;
152
152
  }
153
+
154
+ return json_encode(['isSuccess' => true, 'orderId' => $orderId]);
153
155
  }
154
156
  ```
155
157
 
156
- Distinguish between retriable errors (transient DB/network issues — re-throw) and non-retriable errors (bad data, missing record — log and return).
158
+ Distinguish between retriable errors (transient DB/network issues — re-throw) and non-retriable errors (bad data, missing record — log and return a failure result).
159
+
160
+ **Always return a result — on the failure path as well as the success path.** Both outcomes are
161
+ recorded against the job, and the successful results are relied on for auditing, so a worker that
162
+ returns nothing (or returns only when it succeeds) leaves a blind spot in the audit trail. Re-throw
163
+ only for genuinely retriable conditions, where the queue's retry is the intended behaviour; a
164
+ handled non-retriable failure should return a result describing what happened rather than throwing.
157
165
 
158
166
  ## Change history
159
167
  - 2026-07-28 — Corrected the **Worker contract** and **Worker dispatch** sections to match the actual codebase: worker actions are `abstract class _Worker_<Name>` with `public static` entry methods dispatched via `_Worker::runTask(string $action, array|object $parameters)` on the `Category/Sub/File/MethodName` path, with parameters spread as **named arguments** (`$className::$functionName(...$parameters)`, worker2 `Controller/Index.php:325,567`; `_underscore/Worker.php:8`). This replaces the previous `extends _Worker` / `run()` / `_Queue::dispatch()` guidance — no worker in worker2 follows it, `_Queue::dispatch()` does not exist in the codebase, and following the old text would produce a worker that never dispatches. (jcardinal)
@@ -0,0 +1,132 @@
1
+ ---
2
+ type: session
3
+ slug: multi-client-api
4
+ title: Cross-client (multi-client) API retrieval brought from untested branch to working
5
+ author: jcardinal
6
+ repos: [api2, _underscore, worker2, dbchanges2]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-07-28
11
+ updated: 2026-07-28
12
+ ---
13
+
14
+ # Session: multi-client-api
15
+ **Date:** 2026-07-28
16
+ **Project/Repo:** api2 + _underscore + worker2 + dbchanges2 (2.0), all on branch `cache-layer`
17
+ **Task:** Take the cross-client (multi-client) data-retrieval feature — which existed on the `cache-layer` branch as "code-complete + reviewer-hardened but NEVER runtime tested" — and make it actually work end to end, ready for external testing.
18
+
19
+ ---
20
+
21
+ ## What WORKED
22
+
23
+ Working end to end on local dev: multi-client fan-out across 3 clients, ACL-filtered `client` object per row, typed multi-column sorting, keyset paging to page 2+, narrowed `fields`, per-query cache isolation, full meta parity.
24
+
25
+ - **Wiring the feature so it could execute at all.** `client`, `seekValues`, `seekUuid` were never parsed into `$httpOptions` (V2 builds it from an explicit allowlist ~line 350). Added them. Evidence: `XC:` trace first showed `handleListing enter` after this.
26
+ - **Namespace fix** — `CrossClient` type-hinted `\_Component_Api_V2` (global) but the class is `api\_Component_Api_V2`. Fixed all 4 refs. Evidence: constructor TypeError gone.
27
+ - **uuid row identity** (replacing numeric primary key) — `rowUuid CHAR(36) COLLATE utf8mb4_bin`, `UNIQUE (clientId, rowUuid)`, cursor tiebreaker `seekUuid`. Evidence: cache went from 1 row/page to a full 25.
28
+ - **Typed per-query sort columns** — `TableResults_<Tables.id>` with one typed column per sort field (model field type, or `FIELDOPT_SQL_TYPE` for calculated `FIELD_SQL`). Evidence: `sort=-dateOrder` produced `sort1 date`, verified in `information_schema`.
29
+ - **Index direction baked in** — `KEY sort_idx (sort1 DESC, …, rowUuid ASC, clientId ASC)`. Any DESC sort against an all-ASC index filesorts the whole table (`primaryKeyId ASC` was a fixed tiebreaker, so even a single-column DESC sort broke it).
30
+ - **Multi-column keyset seek in V2** — `seekValues` (JSON array) + `seekUuid`, lexicographic with NULL-safe `<=>` links and typed literals; tiebreaker `CAST(uuid AS BINARY)`. Verified predicate matched 107,511 rows after a real cursor.
31
+ - **Deepen-on-demand paging** — `Tables.safeRecordCount` (watermark-safe count). Evidence: page 2 returns rows 26–50 and `TableResults_*` grows.
32
+ - **Watermark-only deepening (efficiency)** — only the least-advanced client is re-queried per pass. Verified with the `-number DESC` case: Compass holds the watermark, NYCHH is not re-queried. ~1 sub-request per pass instead of one per client.
33
+ - **ACL-filtered `client` object per row** — `getAclFieldPermissions()` against the Core `Clients` record (id 51, `aclDatabase: CORE`) using the caller's **CORE** roles (`id.core.roles`). With roles [3,1] this grants 8 fields.
34
+ - **Field injection/stripping** — with `fields=number`, the sub-request asks for `number,uuid,dateOrder` and `servePage()` strips the injected ones. Resolved per REQUEST (not per fan-out) so cache hits strip too.
35
+ - **Sub-request failure surfacing** — `meta.crossClient.failures[]` + an ERROR per failed client in `messages[]`; response no longer returns 200/success when every in-scope client failed. This is what made the remaining bugs diagnosable in one run each.
36
+ - **Origin header on the auth handshake** — `/auth/encrypted-user-uuid` is Origin-gated; the fan-out now resolves a registered `Core.Domains` row for the target client and sends it.
37
+ - **Dangling-FK tolerance** — `getFullModelData()` catches a failed relationship expansion, logs it, sets the field null. One orphaned pointer no longer 500s an entire listing. NOTE: affects every REST listing in api2.
38
+ - **worker2 `Platform/Cache/Clean` + `Truncate`**, `DB_CACHE` registration at both boot paths, `*/5` cron.
39
+ - **Cache DB resolved BY NAME** in both api2 and worker2 (deploy blocker — see decisions).
40
+ - **Knowledge captured + pushed**: commits `b478ae0`, `2e614ae`, `ab91519` on the team repo `_main`.
41
+
42
+ ---
43
+
44
+ ## What did NOT work — DO NOT RETRY THESE
45
+
46
+ - **Numeric primary key as row identity (`primaryKeyId` / `seekId`).** A V2 LIST response **never** exposes `id` — records carry `uuid` only. Every row parsed as id `0`, all 25 collided on `UNIQUE (clientId, primaryKeyId)` under `INSERT IGNORE`, so exactly **1 row** cached per page and the cursor could never advance. Do not reintroduce an id-based cursor.
47
+ - **Defaulting the sort to `id`.** Same root cause — `id` is never returned, so `sortValues` was `[null]` on every row. Cursor stored `{"sortValues":[null]}`. Default is now `uuid`.
48
+ - **Hardcoding a named collation** (`utf8mb4_0900_as_cs`) in the seek predicate/ORDER BY. `ERROR 1253: COLLATION 'utf8mb4_0900_as_cs' is not valid for CHARACTER SET 'latin1'` for `Client_Nychh`. Client DBs do **not** share a charset: `utf8mb4_unicode_ci` (Compass), `utf8mb4_0900_ai_ci` (True), `latin1_swedish_ci` (NYCHH). Use `CAST(... AS BINARY)`. I verified only Compass+Core and generalised — that is what caused this.
49
+ - **`http_build_query()` for the sub-request URL.** Encodes commas as `%2C`; V2 never url-decodes, so `fields=number,uuid,dateOrder` arrived as one field literally named `number%2Cuuid%2CdateOrder` → `EV-8` "A field specified in your request does not exist". Forward options **verbatim** from the raw QUERY_STRING; assemble the URL by hand.
50
+ - **Sending `seekValues` JSON without decoding it in V2.** `json_decode()` on the still-percent-encoded value returns `null`, the engagement check fails **silently**, V2 falls back to OFFSET and replays page 1 forever. V2 now `urldecode()`s `seekValues`/`seekUuid` specifically.
51
+ - **`empty($model->id)` on a `_Model` field.** Returned TRUE for a populated field (`id` = 49) while `$model->id ?? 'unset'` read 49 fine. Fields live in `$_model_fields[...]['value']` behind `__get()`; `empty()`/`isset()` route through `__isset()` and disagree. Cost 3 debugging rounds and presented as "not authenticated". Copy to a plain local, test the local.
52
+ - **Building a client DB name as `'Client_' . clientIdentifier`.** Diverges in practice: clientIdentifier `Compass_Usa`, database `Client_Compass`. Resolve `Core.Clients.clientDatabaseId` → `Core.Databases.name` (mirrors `_underscore/Email.php` log-DB resolution).
53
+ - **Relying on `$fieldConfig` in V2's seek-term branches.** Undefined on this path (`Undefined variable $fieldConfig`) — it is only populated on some request paths. Derive from `$model->getFieldConfig()`.
54
+ - **Hardcoding `CACHE_DATABASE_ID`** (tried 145, then 151). `Databases.id` is auto-assigned per Core instance; it would only ever match one environment and fail silently everywhere else. Both apps now resolve by `Databases.name = 'Cache'`.
55
+ - **Inferring environment ids from slug ordering** in the DatabaseHosts seed. There are **27** environments and the families are not contiguous — id 23 is `sandbox-dev` (I had labelled it `sandbox-client`), and 24–27 (`sandbox-client`, `client-beta`, `client-gamma`, `client-alpha`) were missing entirely. Query `Core.Environments`.
56
+ - **`_Model_Cache_Table` writes and column defaults.** The model writes every declared field explicitly, so a `NOT NULL DEFAULT 0` column still receives `NULL` unless assigned (`Column 'safeRecordCount' cannot be null`).
57
+ - **Treating `$decoded->data` as the record array.** A V2 LIST nests records under the route key (`data: {"salesOrders": [...]}`); requiring `is_array($decoded->data)` discarded every **successful** sub-response as unusable.
58
+ - **Counting `$collected` without dedupe.** Re-fetched rows were counted twice, inflating `safeRecordCount` to 50 against 25 real rows, so later pages skipped deepening and returned empty. Self-concealing: the first failure corrupts the bookkeeping that would trigger the retry.
59
+ - **`internalApiRequest()` for the fan-out** (rejected at design time, do not revisit without new information): it reuses the caller's auth/ACL context (defeats per-client ACL delegation, the whole security premise), is synchronous/in-process (no concurrency), and shares the outer transaction (V2.php comment at the method confirms).
60
+ - **`_Object::get($decoded, ['messages', 0, 'message'])`** — does not traverse a numeric list index; returns the whole messages array.
61
+
62
+ ---
63
+
64
+ ## Not tried yet (candidates for next session)
65
+
66
+ - **`Truncate()` has never been executed.** Written and lint-clean, reuses `Clean()`'s verified helpers, but unrun. Run it against local first.
67
+ - **No load or concurrency test.** The PHP-FPM self-call contention risk (parent holds a worker while waiting on children needing workers from the same pool) is unmeasured. Watermark-only deepening mitigates but does not remove it.
68
+ - **Multi-field sort** (`sort=-dateOrder,number`) never tested — only single-field. The N-tuple compare and multi-column seek predicate are implemented but unexercised.
69
+ - **`where` clauses over the fan-out** never tested. Given V2 never url-decodes, encoded operators are the most likely place the raw-forwarding fix is still insufficient.
70
+ - **`recordsPerPage` variations** — only 25 (the default) was used. It is part of `Tables_identity`, so a different page size forks a new cache entry.
71
+ - **`prohibitedRecordCount`** was never observed non-null; the plumbing exists but no ACL-withheld rows were produced.
72
+ - **Deep paging** (page 3+) and the `MAX_DEEPEN_ITERATIONS = 50` ceiling.
73
+ - **Deploy to any non-local environment** — all testing was localhost/XAMPP.
74
+ - **The 8 deferred concerns** now recorded in `2.0/apps/api2/architecture.md` → *Known issues / accepted risks*.
75
+
76
+ ---
77
+
78
+ ## Current file state
79
+
80
+ | File | Status | Notes |
81
+ |------|--------|-------|
82
+ | `api2/Component/Api/CrossClient/CrossClient.php` | Modified (uncommitted) | Near-total rewrite. uuid identity, typed sort columns, N-tuple merge, watermark-only deepening, field injection/stripping, ACL client object, failure capture. |
83
+ | `api2/Component/Api/V2/V2.php` | Modified (uncommitted) | Option parsing (`client`/`seekValues`/`seekUuid` + urldecode), multi-column keyset seek, uuid ORDER BY tiebreaker, cross-client delegation + failure surfacing, dangling-FK guard, `CACHE_DATABASE_ID` removed. |
84
+ | `api2/Controller/Index.php` | Modified (uncommitted) | Cache DB registration resolved by name. |
85
+ | `_underscore/Model/Cache/Table.php` | Committed on `cache-layer` | `safeRecordCount` added; no `homeClientApiId`. |
86
+ | `_underscore/Model/Cache/Tables/Client.php` | Committed on `cache-layer` | `keysetCursor` JSON replaces `lastSortValue`/`lastPrimaryKeyId`. |
87
+ | `_underscore/Model/Core/Record.php` | Committed on `cache-layer` | `ttlCache` field declared (column existed, model field did not). |
88
+ | `worker2/Worker/Platform/Cache.php` | Modified (uncommitted) | `Clean()` (scheduled) + `Truncate()` (manual, **untested**). |
89
+ | `worker2/Controller/Index.php` | Modified (uncommitted) | `DB_CACHE` registration at both boot paths, resolved by name. |
90
+ | `worker2/_.php` | Committed on `cache-layer` | `const DB_CACHE = 'Cache'`. |
91
+ | `dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql` | Modified (uncommitted) | `Tables` + `Tables_Clients`; `safeRecordCount`; `keysetCursor` JSON; no `homeClientApiId`. |
92
+ | `dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql` | Modified (uncommitted) | Cache DB + hosts for all **27** environments; `Records.ttlCache`. **3 production RDS endpoint placeholders still `<...>`.** |
93
+ | `dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql` | Untracked | `*/5` cron for `Platform/Cache/Clean`. |
94
+ | Team KB (8 docs) | **Pushed** | `b478ae0`, `2e614ae`, `ab91519` on `_main`. |
95
+
96
+ ---
97
+
98
+ ## Decisions made
99
+
100
+ - **Row identity = record `uuid`, not the numeric primary key.** Chosen because a V2 LIST response never exposes `id`. Rejected: having V2 emit `id` for internal sub-requests (would widen the API's trust surface and expose internal identifiers over HTTP); rejected page-number pagination for sub-requests (abandons the deep-pagination property the design exists for).
101
+ - **Byte-ordered comparison everywhere** (`utf8mb4_bin` in the cache, `CAST(... AS BINARY)` in client queries). PHP's string comparison is byte-wise, so the merge and MySQL agree *by construction*. Rejected: any named collation — invalid on at least one client charset.
102
+ - **Result tables are per-QUERY**, not per record type, so sort columns can be typed for that specific query. Rejected: generic pre-typed sort slots (`sortInt1`, `sortStr1`…) which avoid DDL but waste columns.
103
+ - **Cache database resolved BY NAME**, no `CACHE_DATABASE_ID` constant in either app. Deliberate deviation from `CORE_LOGS_DATABASE_ID = 11`.
104
+ - **Cross-client is USER-LEVEL ONLY.** API credentials (`Client.Apis`) have no entitlement bridge — `Users_Clients` is user-keyed, no `Apis_Clients` exists. `homeClientApiId` dropped from the schema.
105
+ - **`ttlCache = 0` means "effectively uncached" but is floored at 1 minute**, so an entry always outlives the request that built it and the cleanup worker cannot delete rows mid-read.
106
+ - **`safeRecordCount` (provably-complete count) gates deepening**, never the raw stored row count — a fast client can leave rows cached that a lagging client would displace.
107
+ - **Only the watermark-holding client is deepened per pass.** A client past the watermark cannot make any row safe.
108
+ - **Sub-request failures surface to the caller**, and an all-clients-failed result is no longer reported as 200/success — "results unknown" is not "no results".
109
+
110
+ ---
111
+
112
+ ## Blockers
113
+
114
+ None blocking further development. Two items gate deployment:
115
+
116
+ 1. **3 production RDS endpoint placeholders** (`<prod-cache-N ...>`) in `Core/2026-06-30b` need real values from DevOps. The production `regionId IS NULL` fallback row also points at the us-east-1 cluster because no Aurora Global endpoint exists for Cache yet.
117
+ 2. **Local `Core.DatabaseHosts` has a bad row**: env 23 (`sandbox-dev`) was seeded with `client.sandbox.database.togahub.com`. Re-running the migration will NOT fix it (the `NOT EXISTS` guard skips it). Delete and re-run:
118
+ ```sql
119
+ DELETE FROM Core.DatabaseHosts
120
+ WHERE databaseId = (SELECT id FROM Core.`Databases` WHERE name = 'Cache')
121
+ AND environmentId = 23;
122
+ ```
123
+ Also: the 27 host hostnames follow the stated slug convention but **none have been DNS-verified**, and existing `Logs`/`Client` rows use raw RDS endpoints for some environments — so the convention may not be universal.
124
+
125
+ ---
126
+
127
+ ## Exact next step
128
+
129
+ > Run `_Worker::runTask('Platform/Cache/Truncate', [])` against local to exercise `worker2/Worker/Platform/Cache.php::Truncate()` — the only piece written this session that has never been executed. Confirm `Cache.Tables` and `Cache.Tables_Clients` are empty and every `TableResults_*` table is dropped, then commit the 3 api2 + 2 worker2 + 2 dbchanges2 files on `cache-layer`.
130
+
131
+ ---
132
+ _Saved by /session-save on 2026-07-28_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.456",
3
+ "version": "1.0.458",
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",