toga-ai 1.0.454 → 1.0.455

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: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-06-25
9
+ updated: 2026-07-28
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - _underscore/Model/Core/Model.php
@@ -43,7 +43,15 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
43
43
 
44
44
  ## Gotchas / known issues
45
45
 
46
- - `isset($model->magicField)` / `$model->magicField ?? null` are **always** false/null for DB-backed
46
+ - **Treat `empty()` / `isset()` / `??` on a magic field as simply UNRELIABLE — the exact behavior
47
+ is not uniform.** Fields live in `$_model_fields[...]['value']` behind `__get()`. In the 2026-06-25
48
+ case `??` short-circuited to null; in the 2026-07-28 cross-client case the opposite mix appeared —
49
+ `empty($model->id)` reported a **populated** field (value `49`) as absent, while
50
+ `$model->id ?? $default` read it fine. Do not reason about which way it will fail. **Read the
51
+ value into a plain local variable first and test that local.** This cost three debugging rounds
52
+ because it failed in the "looks unauthenticated" direction — a populated identity read as empty.
53
+ A codebase-wide sweep for `empty(`/`isset(` on model fields is warranted.
54
+ - `isset($model->magicField)` / `$model->magicField ?? null` cannot be trusted for DB-backed
47
55
  magic fields. Use `getFieldConfig()` + `array_key_exists` to test presence.
48
56
  - A bare `__get` on an unconfigured field **throws** — never read a maybe-absent field without the
49
57
  `getFieldConfig()` guard.
@@ -52,6 +60,12 @@ gate on `isset($model->field)` or `$model->field ?? $default` — both silently
52
60
 
53
61
  ## Change history
54
62
 
63
+ - 2026-07-28 — Broadened the rule after the cross-client work: `empty()` can report a **populated**
64
+ field as absent while `??` reads it correctly — the inverse of the 2026-06-25 symptom. The safe
65
+ rule is now "copy to a plain local, then test the local," and a codebase-wide sweep is warranted
66
+ because it fails in the "looks unauthenticated" direction. Also noted the sibling trap: `_Model`
67
+ writes every **declared** field explicitly, so a DB column not declared on the model both makes
68
+ `__get` throw and means the column's SQL default never applies on insert. (jcardinal)
55
69
  - 2026-06-25 — Documented the `__get`-without-`__isset` gap and the `getFieldConfig()` +
56
70
  `array_key_exists` safe-read pattern, discovered while debugging a missing `uuid` in the API2
57
71
  translation fallback warning. (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-07-28
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "apeterson", "kyalamarthi"]
11
11
  files:
12
12
  - _underscore/Database.php
@@ -50,8 +50,11 @@ The three per-client aliases and their concrete local schemas:
50
50
  | `ClientLogs` (`DB_CLIENT_LOGS`) | `Logs_<Id>` | **API transaction logging** |
51
51
  | `Archive` | `Archive_<Id>` | historical snapshots |
52
52
 
53
- `<Id>` is the client's `clientIdentifier` from `Core.Clients` (e.g. `Growrk` → `Client_Growrk`,
54
- `Logs_Growrk`, `Archive_Growrk`). A `Logs_<Id>` schema has 5 tables:
53
+ `<Id>` is *usually* the client's `clientIdentifier` from `Core.Clients` (e.g. `Growrk` →
54
+ `Client_Growrk`, `Logs_Growrk`, `Archive_Growrk`) — but that is a naming convention, **not** a rule
55
+ you may rely on in code. **Always resolve the real schema name through
56
+ `Core.Clients.clientDatabaseId` → `Core.Databases.name`** (see the gotcha below). A `Logs_<Id>`
57
+ schema has 5 tables:
55
58
  `Api, CustomRecordField, Error, Record, RecordField`.
56
59
 
57
60
  ## Data model
@@ -63,18 +66,28 @@ Engine InnoDB, `utf8mb4_unicode_ci`.
63
66
 
64
67
  ## Client variations
65
68
 
66
- None — uniform across clients; only the `<Id>` suffix differs.
69
+ Structurally uniform, but two per-client values genuinely differ and must never be assumed:
70
+
71
+ - **The schema NAME** — resolve `Core.Clients.clientDatabaseId` → `Core.Databases.name`. Observed
72
+ divergence: `clientIdentifier` = `Compass_Usa` but the database is `Client_Compass`.
73
+ - **The CHARSET/COLLATION** — client databases do **not** share one. `utf8mb4_unicode_ci`,
74
+ `utf8mb4_0900_ai_ci` and `latin1_swedish_ci` were all observed on the same logical column across
75
+ tenants.
67
76
 
68
77
  ## Related shared clusters (not per-client)
69
78
 
70
79
  Alongside the three per-client aliases and the shared **Core** cluster, a dedicated shared
71
- **Cache** cluster was added (2026-07-07): `Core.Databases` id **145**, `_underscore` alias
72
- `DB_CACHE`, `CACHE_DATABASE_ID = 145`. It is modeled like Core (a single shared cluster, **not**
80
+ **Cache** cluster was added (2026-07-07), alias `DB_CACHE`. It is resolved **by name**
81
+ (`Databases.name = 'Cache'`) in both api2 and worker2 — there is deliberately **no
82
+ `CACHE_DATABASE_ID` constant**, because `Databases.id` is auto-assigned per Core instance and
83
+ differs per environment, so a hardcoded id works only where it happens to match and fails silently
84
+ elsewhere. It is modeled like Core (a single shared cluster, **not**
73
85
  per-client), region-aware (1=us-east-1, 2=us-west-2, 3=eu-west-1) with per-region `DatabaseHosts`
74
86
  (`hostCluster` DevOps-TBD). It exists to keep cache churn off Core and backs the api2
75
87
  [multi-client data retrieval](../../api2/features/cross-client-data-retrieval.md) engine. Boot
76
- registration is region-aware in `api2/Controller/Index.php`; the alias const is in `api2/_.php`.
77
- `Records.ttlCache` (TINYINT UNSIGNED, default 15) governs cache TTL.
88
+ registration is name-based in `api2/Controller/Index.php` and `worker2/Controller/Index.php`.
89
+ `Records.ttlCache` (TINYINT UNSIGNED, default 15) governs cache TTL; `0` means "effectively
90
+ uncached" but is **floored at 1 minute** so an entry always outlives the request that built it.
78
91
 
79
92
  ### The `Team` schema rides the Core cluster (`DB_TEAM`)
80
93
 
@@ -94,6 +107,15 @@ here — they live in `Config/*.ini`.)
94
107
 
95
108
  ## Gotchas / known issues
96
109
 
110
+ - **NEVER build a client's database name as `'Client_' . $clientIdentifier`.** They diverge in
111
+ production (`Compass_Usa` → `Client_Compass`). Resolve
112
+ `Core.Clients.clientDatabaseId` → `Core.Databases.name`. This mirrors the log-database resolution
113
+ already done in `_underscore`'s `Email.php`. A string-built name fails only for the clients whose
114
+ names happen to diverge, so it passes local testing and breaks in production.
115
+ - **Client DBs do not share a charset, so any hardcoded `COLLATE` is invalid somewhere.** Emitting
116
+ e.g. `COLLATE utf8mb4_unicode_ci` against a `latin1` column errors with `COLLATION ... is not
117
+ valid for CHARACTER SET 'latin1'`. For cross-tenant string comparison use `CAST(expr AS BINARY)`
118
+ (byte ordering) rather than naming a collation.
97
119
  - **The "logs DB write trap" (local dev).** A laptop usually imports only `Client_<Id>`, not
98
120
  `Logs_<Id>`/`Archive_<Id>`. Because logging is **on by default**, the first request 500s on
99
121
  `Unknown database 'logs_<id>'` from `ApiRequest.php`'s logging write — *after* the real work
@@ -130,6 +152,14 @@ here — they live in `Config/*.ini`.)
130
152
 
131
153
  ## Change history
132
154
 
155
+ - 2026-07-28 — **Corrected a wrong rule:** a client's schema name is *not* reliably
156
+ `'Client_' . clientIdentifier` (`Compass_Usa` → `Client_Compass`); resolve
157
+ `Clients.clientDatabaseId` → `Databases.name`. Recorded that client DBs do **not** share a
158
+ charset (utf8mb4_unicode_ci / utf8mb4_0900_ai_ci / latin1_swedish_ci observed on one column), so
159
+ hardcoded `COLLATE` is invalid somewhere — use `CAST(... AS BINARY)`. Replaced the Cache
160
+ cluster's hardcoded id 145 / `CACHE_DATABASE_ID` with **name-based resolution**
161
+ (`Databases.name = 'Cache'`), since ids are per-Core-instance and a hardcoded id fails silently
162
+ per environment. (jcardinal)
133
163
  - 2026-07-27 — Recorded that the three per-client aliases key **all** live connection state, so
134
164
  re-pointing an alias mid-request does not reconnect on its own; split the detail into
135
165
  [Re-pointing a DB alias mid-request](./database-alias-repointing.md). (jcardinal)
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | `api2` is the backend powering the public **TOGa 2.0 API**. | api2/Controller/Index.php, api2/Component/Api/V2/V2.php, api2/Component/Api/Cxml/Cxml.php, api2/Component/Api/V2/Response/Response.php, api2/Config/ |
6
- | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, api2/_.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql |
6
+ | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | A single authenticated V2 GET listing can return records across **many** clients (designed for 1000+) that the caller is entitled to, honoring **each target cli | api2/Component/Api/CrossClient/CrossClient.php, api2/Component/Api/V2/V2.php, api2/Controller/Index.php, _underscore/Model/Cache/Table.php, _underscore/Model/Cache/Tables/Client.php, _underscore/Model/Core/Record.php, worker2/Worker/Platform/Cache.php, worker2/Controller/Index.php, worker2/_.php, dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql, dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql, dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql |
7
7
  | [Encrypted-User-UUID Auth Handoff (/auth/encrypted-user-uuid)](features/encrypted-user-uuid-auth-handoff.md) | `POST /auth/encrypted-user-uuid` is the intended **cross-client / SSO-handoff identity mechanism**: given an encrypted `{client, user}` UUID pair, it mints a fr | api2/Component/Api/CrossClient/CrossClient.php |
8
8
  | [Health-check endpoint (/health liveness short-circuit)](features/health-check-endpoint.md) | `_Controller_Index::api()` short-circuits **liveness/health-probe** requests to an HTTP 200 **before** any routing, DB bootstrap, or V2 engine work runs. | api2/Controller/Index.php |
9
9
  | [Language Translation Layer (audience.language + sidecar tables)](features/language-translation-layer.md) | Serves the same TOGa data (Item title/description/longDescription, plus item **feature** text — `Features.name`, `ItemCategoryFeatureGroups.name`, `ItemFeatures | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, _underscore/Model/Core/Setting.php, _underscore/Model/Core/RecordField.php, _underscore/Model/Core/DefaultGlobalSetting.php, _underscore/Model/Client/ItemTranslation.php, _underscore/Model/Client/FeatureTranslation.php, _underscore/Model/Client/ItemCategoryFeatureGroupTranslation.php, _underscore/Model/Client/ItemFeatureTranslation.php, dbchanges2/Client/2026-06-23a - ItemTranslations.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql, dbchanges2/Client/2026-07-13a - FeatureTranslations.sql, dbchanges2/Client/2026-07-13b - FeatureTranslationsAcl.sql, dbchanges2/Core/2026-06-23a - RecordFieldsTranslationColumn.sql, dbchanges2/Core/2026-06-23b - ItemTranslationsRecord.sql, dbchanges2/Core/2026-07-13 - FeatureTranslationsRecord.sql |
@@ -5,23 +5,29 @@ repo: api2
5
5
  project: API
6
6
  client: shared
7
7
  type: feature
8
- status: draft
9
- updated: 2026-07-27
8
+ status: active
9
+ updated: 2026-07-28
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/CrossClient/CrossClient.php
13
13
  - api2/Component/Api/V2/V2.php
14
14
  - api2/Controller/Index.php
15
- - api2/_.php
16
15
  - _underscore/Model/Cache/Table.php
17
16
  - _underscore/Model/Cache/Tables/Client.php
18
- - dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql
17
+ - _underscore/Model/Core/Record.php
18
+ - worker2/Worker/Platform/Cache.php
19
+ - worker2/Controller/Index.php
20
+ - worker2/_.php
19
21
  - dbchanges2/Cache/2026-06-30a - MultiClientCacheTables.sql
22
+ - dbchanges2/Core/2026-06-30b - CacheClusterRegistrationAndRecordTtl.sql
23
+ - dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql
20
24
  related:
21
25
  - ./encrypted-user-uuid-auth-handoff.md
26
+ - ../../worker2/features/platform-cache-cleanup.md
22
27
  - ../../_underscore/features/per-client-database-connections.md
23
28
  - ../../_underscore/features/acl-permission-chain.md
24
29
  - ../../_underscore/features/database-alias-repointing.md
30
+ - ../../_underscore/features/model-magic-field-access.md
25
31
  ---
26
32
 
27
33
  ## What it is
@@ -33,13 +39,13 @@ single-client path is byte-for-byte unchanged.
33
39
 
34
40
  Because client DBs live on **different clusters**, a cross-DB JOIN/UNION is impossible. The feature
35
41
  is a **scatter-gather** engine: it fans out to each entitled client's own V2 API, merges the
36
- streams, and caches the result per-user on a dedicated Cache cluster.
42
+ streams with a watermark, and caches the merged result per-user/per-query on a dedicated Cache
43
+ cluster.
37
44
 
38
- > **Status: code-complete + reviewer-hardened, NOT yet runtime-tested.** Result rows are stored as
39
- > a JSON `rowData` column (deliberate v1 deviation from real typed columns). Phases 4 (single-record
40
- > cross-client), 5 (worker2 page-ahead prefetch), and 7 (live load test / verify `_Query`/`_Database`
41
- > method names against a running instance) are open. Per-client token hardening (§0) is an
42
- > owner-accepted deferred risk.
45
+ > **Status: working end to end on local dev** (multi-client fan-out, ACL-filtered `client` object,
46
+ > typed multi-column sort, keyset paging, narrowed `fields`, per-query cache isolation).
47
+ > **Not yet deployed** — next stop is external testing. See
48
+ > [api2 architecture](../architecture.md) for the accepted risks carried into that test.
43
49
 
44
50
  ## How it works
45
51
 
@@ -47,84 +53,168 @@ Entry point is `_Component_Api_CrossClient` (`api2/Component/Api/CrossClient/Cro
47
53
  delegated to from a guard at the **top** of the `ACTION__LIST` case in
48
54
  `_Component_Api_V2::processRoutePairs()`: when `isCrossClientRequest($httpOptions)` is true (a
49
55
  non-empty `client` option), it calls `handleListing()`, merges result rows into `$outData` and meta
50
- into `$this->response->meta`, sets status 200, and breaks. The guard is **inert** on ordinary
51
- single-client GETs.
56
+ into `$this->response->meta`, and breaks. The guard is **inert** on ordinary single-client GETs.
52
57
 
53
58
  1. **resolveScope** — the caller's entitled clients resolve via the `Users_Clients` bridge
54
59
  (`Users.homeClientUserId` maps one home user to per-client user records across tenant DBs); each
55
- target yields a minted per-client identity.
60
+ target yields a minted per-client identity. This is **serial** — one DB context switch + query
61
+ per permitted client, on every request including cache hits.
56
62
  2. **queryHash** — SHA-256 of the normalized query; the cache key.
57
- 3. **Build lock** — a named `GET_LOCK` (`tablecache:<clientId>:<userId>:<hash>`) prevents duplicate
58
- concurrent builds.
63
+ 3. **Build lock** — a named `GET_LOCK` over a **fixed-width digest** of client/user/hash.
59
64
  4. **Fan out** — a two-phase concurrent HTTP handshake per client (see
60
65
  [encrypted-user-uuid-auth-handoff](./encrypted-user-uuid-auth-handoff.md)): Phase A mints an
61
66
  access token, Phase B issues the authenticated GET.
62
- 5. **Watermark k-way merge** — client streams are merged into a globally-correct sort order using a
63
- watermark (`safeRowCount`/`keyBeyond`); a `deepen` loop pulls more from lagging streams, capped
64
- by a 50-iteration backstop.
65
- 6. **Cache build/serve** — rows are cached on the Cache cluster; `servePage` emits a keyset page.
66
-
67
- ### Total order + keyset pagination (V2 query builder, Phase 0a/0b)
68
-
69
- Cross-client global sort correctness requires each client's stream to be a **total order**:
70
- - **0a** — the LIST query builder always appends a **primary-key ASC tiebreaker** to `ORDER BY`
71
- (also improves single-client determinism).
72
- - **0b** — a new keyset/seek pagination mode runs alongside offset mode; it engages **only** when
73
- the caller passes `seekValue` + `seekId` options. It injects a seek `WHERE` predicate (V2.php
74
- ~line 3782) and switches `LIMIT` from offset to `recordsPerPage` (~line 3861). The composite
75
- unique sort key is `(sortfield, clientId, primaryKeyId)`.
76
-
77
- ### Query-string forwarding (load-bearing gotcha)
78
-
79
- V2 parses list options (`fields`/`where`/`sort`/`join`/`group`…) from the **raw** query-string
80
- values into structured `$httpOptions` — re-serializing the parsed array does **not** round-trip.
81
- The fan-out therefore forwards the caller's **original** `$_SERVER['QUERY_STRING']` through an
82
- explicit **allowlist** of forwardable keys (`fields, where, sort, group, distinct, join, ojoin,
83
- depth, calcDepth, _`), re-stamping `recordsPerPage`, `seekValue`/`seekId`, and a fresh
84
- `transactionId` per client. The allowlist (not a denylist) prevents internal flags like
85
- `surface`/`debug` from leaking into sub-requests.
86
-
87
- ### Cache cluster
88
-
89
- A new dedicated **Cache** cluster (Core-style shared cluster, `Databases` id **145**, alias
90
- `DB_CACHE`, `CACHE_DATABASE_ID = 145`) keeps cache churn off Core. It is region-aware
91
- (1=us-east-1, 2=us-west-2, 3=eu-west-1) with per-region `DatabaseHosts`; `hostCluster` values are
92
- DevOps TBD. Boot registration lives in `api2/Controller/Index.php`; the alias const in `api2/_.php`.
93
- See [per-client-database-connections](../../_underscore/features/per-client-database-connections.md)
94
- for how it sits among the shared/per-client clusters.
67
+ 5. **Watermark k-way merge** — client streams merge into a globally-correct sort order behind a
68
+ watermark; a `deepen` loop pulls more from the lagging stream, capped by a 50-iteration backstop
69
+ plus a **stall guard** (a pass that returns no new rows with nobody exhausted logs and breaks).
70
+ 6. **Cache build/serve** — merged rows are written to a per-query result table on the Cache
71
+ cluster; `servePage` emits a keyset page.
72
+
73
+ ### Row identity is the record `uuid` — never the numeric id
74
+
75
+ A V2 LIST response **never exposes `id`**. The original design keyed cached rows on
76
+ `primaryKeyId` + `seekId`, which could not work: every row arrived with `id = 0`, collided on the
77
+ unique key, and the cache retained exactly one row per page. Result rows are now keyed on
78
+ **`rowUuid CHAR(36)`** and the keyset cursor tiebreaker is **`seekUuid`**. The default sort is
79
+ `uuid` (it used to be `id`, a field the API never returns → null sort values and a cursor that
80
+ could not advance).
81
+
82
+ ### Per-query result tables with TYPED sort columns
83
+
84
+ Each cached query gets its own `TableResults_<Tables.id>` table carrying **one typed column per
85
+ requested sort field** — type taken from the model field, or `FIELDOPT_SQL_TYPE` for calculated
86
+ `FIELD_SQL` fields, defaulting to `CHAR`. Tables are per-**query**, not per record type, precisely
87
+ so the sort columns can be typed for that query. The index declares per-column **ASC/DESC
88
+ directions matching the ORDER BY**; without that, any DESC sort filesorts the entire table.
89
+
90
+ ### Multi-column keyset seek (V2)
91
+
92
+ The seek generalised from single-column `seekValue` + `seekId` to **`seekValues`** (a JSON array)
93
+ + **`seekUuid`**: a lexicographic predicate with NULL-safe `<=>` links and typed literals, tie-broken
94
+ on `CAST(uuid AS BINARY)`. The fan-out must send `sort` **explicitly** (rebuilt from the resolved
95
+ sort specs) — V2 only engages its keyset when the sub-request itself carries sort terms, and the
96
+ term count must match the `seekValues` tuple.
97
+
98
+ ### Deepen-on-demand paging + watermark-only deepening
99
+
100
+ `Tables.safeRecordCount` records how deep the cache is **provably complete**. The raw stored row
101
+ count must **never** be used for that decision: a fast client can leave rows cached that a lagging
102
+ client would displace. Duplicate rows are de-duplicated by `clientId|rowUuid` before counting —
103
+ double-counting inflated `safeRecordCount` (25 real rows reported as 50), so later pages skipped
104
+ deepening and returned empty pages.
105
+
106
+ **Only the watermark-holding client is deepened per pass.** A client whose cursor is already past
107
+ the watermark cannot make any row safe, so querying it is a wasted round trip. At 1000 clients that
108
+ is ~1 sub-request per pass instead of 1000 — the single biggest efficiency property of the design.
109
+
110
+ ### Per-row `client` object (ACL-filtered)
111
+
112
+ Every returned row carries a `client` object filtered by the caller's **CORE** roles
113
+ (`id.core.roles`) against the Core `Clients` record. `getAclFieldPermissions()` interprets role ids
114
+ "in the perspective of `$record->aclDatabase`", so a CORE record requires **core** role ids — client
115
+ role ids silently yield nothing.
116
+
117
+ ### Narrowed `fields`
118
+
119
+ The fan-out injects `uuid` + the sort fields into each sub-request (the merge cannot identify or
120
+ order rows without them) and strips them back out of the returned rows. This resolves **per
121
+ request**, not per fan-out, so cache hits strip too.
122
+
123
+ ### Failure surfacing
124
+
125
+ Sub-request failures reach the caller: `meta.crossClient.failures[]` plus an ERROR per failed client
126
+ in `messages[]`, and the response no longer reports 200/success when **every** in-scope client
127
+ failed. Previously a 500 inside a sub-request returned 200 with an empty page.
128
+
129
+ Cross-client `meta` now mirrors the single-client LIST meta exactly — `depth`, `calcDepth`, `page`,
130
+ `prevPage`, `nextPage`, `totalPageCount`, `recordsPerPage`, `pageRecordCount`, `totalRecordCount`,
131
+ `prohibitedRecordCount` — plus the `crossClient` block.
132
+
133
+ ### Cache cluster & cleanup
134
+
135
+ The dedicated **Cache** cluster is registered for **all** environments, and both api2 and worker2
136
+ resolve it **by name** (`Databases.name = 'Cache'`). There is deliberately **no
137
+ `CACHE_DATABASE_ID` constant** — `Databases.id` is auto-assigned per Core instance and differs per
138
+ environment, so a hardcoded id works only where it happens to match and fails silently everywhere
139
+ else. This was caught as a deploy blocker.
140
+
141
+ Expired `Tables` rows and orphaned `TableResults_*` tables are reaped by
142
+ [`_Worker_Platform_Cache::Clean()`](../../worker2/features/platform-cache-cleanup.md) on a `*/5`
143
+ cron.
95
144
 
96
145
  ## Key rules
97
146
 
98
147
  - **Triggered only by a non-empty `client` option** — single-client GETs are unaffected.
99
- - **Each target client's ACL is honored** via a minted per-client identity, not a global aggregate
100
- view. This preserves ACL fidelity across tenants.
101
- - **Every outbound sub-request needs its own globally-unique `transactionId`** — the API enforces
102
- uniqueness; it is the load-bearing idempotency/audit key across Core + Client logs.
103
- - **Forward the original query string, not the parsed options** — parsed `$httpOptions` do not
104
- round-trip through re-serialization.
105
- - `MAX_CONCURRENT_CLIENT_FETCHES = 25` bounds fan-out concurrency (PHP-FPM has no in-process
106
- concurrency, so transport is an HTTP pool via `curl_multi`).
148
+ - **Row identity is the `uuid`**, never the numeric primary key. Chosen over exposing internal ids
149
+ over HTTP, consistent with the platform never emitting `id`.
150
+ - **Cross-client is USER-LEVEL ONLY.** API credentials (`Client.Apis`) have no cross-client
151
+ entitlement bridge — `Users_Clients` is user-keyed and there is no `Apis_Clients` equivalent.
152
+ `homeClientApiId` was dropped from the schema entirely.
153
+ - **`Records.ttlCache = 0` means "effectively uncached" but is FLOORED at 1 minute**, so a cache
154
+ entry always outlives the request that built it and the cleanup worker cannot delete rows
155
+ mid-read.
156
+ - **String/uuid comparison uses BYTE ordering** — `utf8mb4_bin` in the cache, `CAST(... AS BINARY)`
157
+ in client queries — because the PHP-side watermark merge compares bytes. Any linguistic collation
158
+ would order differently and the merge could certify rows complete that MySQL then re-orders.
159
+ - **Never use the raw stored row count** to decide paging depth; only `safeRecordCount`.
160
+ - **Every outbound sub-request needs its own globally-unique `transactionId`.**
161
+ - **Forward the caller's original query string verbatim** — parsed `$httpOptions` do not round-trip
162
+ (see the url-decode gotcha below). `MAX_CONCURRENT_CLIENT_FETCHES = 25` bounds fan-out.
107
163
 
108
164
  ## Gotchas
109
165
 
110
- - **Cross-client output row shape must match single-client.** Served rows are cast to `(object)` in
111
- `servePage` so payload interceptors reading `$row->prop` don't throw — the single-client path
112
- emits `(object)$outRow`.
113
- - **The X-Cross-Client / X-Cross-User custom-header transport was a dead stub** — the V2 engine
114
- never reads such headers. The real transport is the two-phase encrypted-UUID auth handshake.
115
- - **Switching the `Client` DB alias mid-request used to silently not switch.** `CrossClient.php`
116
- (~lines 193/201/207) uses `_Database::registerClientDatabases()` + `_underscore::DB_CLIENT`, and
117
- `_Database` keys its live connection state by **alias**, not physical schema — so cross-client
118
- reads were executing against the *home* client's DB and returning 0 rows. Fixed in
119
- `_underscore/Database.php` (no api2 edit needed); see
166
+ - **V2 NEVER url-decodes its query string.** It splits `QUERY_STRING` on `&` and `=` and consumes
167
+ the raw values. Anything containing `,`, `[`, `"`, `%`, `+` or a space arrives mangled — this bit
168
+ the feature twice (the JSON `seekValues` array, and a comma-separated `fields` list corrupted into
169
+ a single field literally named `number%2Cuuid`). Corollary: a fan-out must forward options
170
+ **verbatim**, never `parse_str` → `http_build_query`.
171
+ - **V2 builds `$httpOptions` from an explicit allowlist.** `client`, `seekValues` and `seekUuid`
172
+ were never parsed into it, so the cross-client branch was unreachable and the keyset could never
173
+ engage. Adding an option means adding it to that allowlist.
174
+ - **Client databases do NOT share a charset** — `utf8mb4_unicode_ci`, `utf8mb4_0900_ai_ci` and
175
+ `latin1_swedish_ci` were all observed on the same column across tenants. Any hardcoded `COLLATE`
176
+ is invalid somewhere (`COLLATION ... is not valid for CHARACTER SET 'latin1'`). Use
177
+ `CAST(... AS BINARY)`.
178
+ - **A client's database NAME must come from `Core.Clients.clientDatabaseId` → `Core.Databases.name`**
179
+ — never built as `'Client_' . clientIdentifier`. They diverge in practice (identifier
180
+ `Compass_Usa`, database `Client_Compass`).
181
+ - **MySQL `GET_LOCK` names cap at 64 chars and ERROR rather than truncate.** The original lock name
182
+ was 80 chars.
183
+ - **`_Query::fetchRows()` returns OBJECTS**, not arrays. CrossClient read them with array syntax
184
+ throughout.
185
+ - **A V2 LIST nests records under the route key** (`data: {"salesOrders": [...]}`);
186
+ `parseClientResponse` required `data` to be a flat array and discarded every successful
187
+ sub-response as unusable.
188
+ - **`_Model` writes every declared field explicitly, so column defaults never apply.**
189
+ `Tables.safeRecordCount` is NOT NULL and `buildCache` never assigned it. Likewise a Core column
190
+ (`Records.ttlCache`) that exists in SQL but is not declared on the model makes `__get` throw.
191
+ - **Dangling foreign keys used to fail an entire listing.** `getFullModelData()` threw out of
192
+ `_Model::initialize()` when a referenced row did not exist; one orphaned pointer killed the whole
193
+ page. It now logs and expands the relationship to `null`. **This affects every REST listing/GET in
194
+ api2, not just cross-client.**
195
+ - **`empty()`/`isset()` on a `_Model` field is unreliable** and fails in the "looks unauthenticated"
196
+ direction — see [_Model magic-field access](../../_underscore/features/model-magic-field-access.md).
197
+ - **Switching the `Client` DB alias mid-request** needs the park/restore path — `_Database` keys
198
+ live connection state by **alias**, not physical schema. See
120
199
  [Re-pointing a DB alias mid-request](../../_underscore/features/database-alias-repointing.md).
121
200
  - **`curl_multi` busy-spin guard** — both multi loops `usleep(100)` when
122
201
  `curl_multi_select() === -1`.
123
202
 
124
203
  ## Change history
204
+ - 2026-07-28 — Took the feature from never-executed to working end to end. Row identity moved from
205
+ the numeric id to `rowUuid`/`seekUuid` (a V2 LIST never returns `id`); per-query
206
+ `TableResults_<id>` tables with typed, direction-indexed sort columns; multi-column `seekValues`
207
+ keyset; `safeRecordCount` watermark with de-dup by `clientId|rowUuid` and watermark-only
208
+ deepening; ACL-filtered per-row `client` object (CORE roles); narrowed `fields`; sub-request
209
+ failures surfaced in `meta.crossClient.failures[]` + `messages[]`; Cache cluster registered for
210
+ all environments and resolved **by name** (no `CACHE_DATABASE_ID`); worker2 cache reaper on a
211
+ `*/5` cron. Cross-client declared **user-level only** (`homeClientApiId` dropped). Fixed a dozen
212
+ latent runtime bugs (namespace, option allowlist, 80-char `GET_LOCK`, undeclared `ttlCache` field,
213
+ unassigned `safeRecordCount`, route-key-nested `data`, `id` default sort, missing explicit `sort`,
214
+ duplicate counting, object-vs-array `fetchRows`, dangling-FK listing failure). (jcardinal)
125
215
  - 2026-07-27 — Root-caused cross-client reads hitting the home client's DB: `_Database` keys live
126
216
  connection state by alias, so re-registering `DB_CLIENT` never swapped the open link. Fixed in
127
217
  `_underscore/Database.php`; CrossClient needed no edit. (jcardinal)
128
218
  - 2026-07-07 — Initial capture: scatter-gather cross-client retrieval engine (orchestrator, watermark
129
- k-way merge, keyset pagination Phase 0a/0b, `client`-option delegation, Cache cluster id 145).
219
+ k-way merge, keyset pagination, `client`-option delegation, Cache cluster).
130
220
  Code-complete + reviewer-hardened, not yet runtime-tested. (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-07
9
+ updated: 2026-07-28
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - api2/Component/Api/CrossClient/CrossClient.php
@@ -22,6 +22,12 @@ without the caller holding the target client's credentials. It is what the scatt
22
22
  [cross-client data retrieval](./cross-client-data-retrieval.md) engine uses to authenticate each
23
23
  per-client sub-request.
24
24
 
25
+ > **Correction (2026-07-28): the route is ORIGIN-GATED and browser-shaped.** It resolves the
26
+ > `Origin` header against `Core.Domains` to establish `appId`/`clientId` *before* minting, and
27
+ > rejects an unknown origin with **EN-6**. It therefore **cannot** be used server-to-server without
28
+ > presenting a domain registered for the target client. Earlier revisions of this doc described it
29
+ > as the cross-client engine's auth mechanism without noting this constraint — plan for it.
30
+
25
31
  ## How it works
26
32
 
27
33
  - The encrypted `{client, user}` pairs are produced during **normal** auth: `/auth/api` and
@@ -41,6 +47,12 @@ per-client sub-request.
41
47
 
42
48
  ## Key rules
43
49
 
50
+ - **Origin gating is mandatory.** The `Origin` header must resolve to a `Core.Domains` row for the
51
+ target client; that lookup — not the caller's own session — establishes the `appId`/`clientId` the
52
+ token is minted under. Unknown origin → **EN-6**.
53
+ - **The minted token's `appId` is the resolved DOMAIN's app, not the caller's app.** Observed in
54
+ cross-client fan-out: sub-request tokens carried `appId 1` (TOGa Supply) while the caller was
55
+ Talos. Harmless for reads today; it must be revisited before anything ACLs on `appId`.
44
56
  - **Identity source of truth is the `Users_Clients` bridge** — `Users.homeClientUserId` maps one
45
57
  home user to its per-client user records across tenant DBs; that is where the per-client UUIDs
46
58
  come from.
@@ -50,5 +62,10 @@ per-client sub-request.
50
62
  tolerates the rotation set.
51
63
 
52
64
  ## Change history
65
+ - 2026-07-28 — Corrected: the route is **origin-gated** (resolves `Origin` against `Core.Domains`
66
+ to establish `appId`/`clientId`, EN-6 on unknown origin), so it is browser-shaped and cannot be
67
+ used server-to-server without a registered domain for the target client. Also recorded that the
68
+ minted token's `appId` follows the resolved domain's app, not the caller's. Found while getting
69
+ the cross-client fan-out running end to end. (jcardinal)
53
70
  - 2026-07-07 — Documented `/auth/encrypted-user-uuid` as the cross-client/SSO identity-handoff route
54
71
  and its two-phase use by the cross-client fan-out engine. (jcardinal)
@@ -24,10 +24,11 @@
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
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 |
28
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 |
29
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 |
30
- | [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | `_Worker_Team_Transcripts` runs a fully automated, cron-driven pipeline that ingests raw Teams transcripts into the **Talos / TOGa IQ** AWS Bedrock knowledge ba | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a |
31
+ | [Talos Transcript Ingestion Pipeline (worker2 → AWS Bedrock KBs)](features/talos-transcript-ingestion.md) | `_Worker_Team_Transcripts` runs a fully automated, cron-driven pipeline that ingests raw Teams transcripts into the **Talos / TOGa IQ** AWS Bedrock knowledge ba | worker2/Worker/Team/Transcripts.php, worker2/bin/sync-knowledge-bases.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Team/2026-06-30a, dbchanges2/Team/2026-06-30b, dbchanges2/Team/2026-06-30c, dbchanges2/Team/2026-06-30d, dbchanges2/Team/2026-06-30e, dbchanges2/Core/2026-06-30a, dbchanges2/Core/2026-07-02a, dbchanges2/Team/2026-07-02a, dbchanges2/Team/2026-07-08a, dbchanges2/Team/2026-07-09a, dbchanges2/Team/2026-07-10a, dbchanges2/Team/2026-07-28a - TranscriptProcessingRetryAttempts.sql, dbchanges2/Team/2026-07-28b - TranscriptPromptTemplateConverseModel.sql, dbchanges2/Core/2026-07-28a - TeamsTranscriptRetryCron.sql |
31
32
  | [Team Sprint Management & Reporting](features/team-sprint-management.md) | `_Worker_Team_Sprint` (file `Worker/Team/Sprint.php`) is the engine behind TOGA's internal **development-sprint process and reporting**. | worker2/Worker/Team/Sprint.php, _underscore/Model/Team/Sprint.php, dbchanges2/Core/CronJobs (SprintLockScheduled seed) |
32
33
  | [Teams Meeting Transcript Export](features/teams-transcript-export.md) | > **SUPERSEDED (2026-07-09) — the S3-staging model below is history.** `Export` is now a thin > **GRAPH-DIRECT** cron poller: it no longer archives raw VTT to ` | worker2/Worker/Team/Transcripts.php, worker2/Config/production.ini, worker2/Database/TeamsTranscriptExports.sql, dbchanges2/Core/2026-06-18a - Teams Transcript Export schedule.sql |
33
34
  | [VAPI Webhook Handler (worker2 — AI-BDR end-of-call processing)](features/vapi-webhook-handler.md) | `_Worker_Vapi` ([worker2/Worker/Vapi.php](worker2/Worker/Vapi.php)) is the **PHP side of the AI-BDR call loop** — the webhook that receives VAPI's end-of-call r | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php |
@@ -0,0 +1,69 @@
1
+ ---
2
+ title: Platform Cache Cleanup (_Worker_Platform_Cache::Clean)
3
+ framework: "2.0"
4
+ repo: worker2
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-28
10
+ owners: [jcardinal]
11
+ files:
12
+ - worker2/Worker/Platform/Cache.php
13
+ - worker2/Controller/Index.php
14
+ - worker2/_.php
15
+ - dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql
16
+ related:
17
+ - ../../api2/features/cross-client-data-retrieval.md
18
+ - ./creating-worker-actions.md
19
+ - ../../_underscore/features/per-client-database-connections.md
20
+ ---
21
+
22
+ ## Summary
23
+
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.
29
+
30
+ Action path `Platform/Cache/Clean`, scheduled on a **`*/5`** cron registered in
31
+ `dbchanges2/Core/2026-07-27a - PlatformCacheCleanCron.sql`.
32
+
33
+ ## How it works
34
+
35
+ 1. **Reap expired parents.** Delete `Cache.Tables` rows whose TTL has elapsed, with a **2-minute
36
+ grace period** so a request that is mid-read cannot have its rows deleted out from under it.
37
+ This pairs with the api2 side flooring `Records.ttlCache = 0` at 1 minute — together they
38
+ guarantee a cache entry outlives the request that built it.
39
+ 2. **Drop orphans.** Enumerate `TableResults_*` tables and drop the ones with no surviving parent
40
+ row. Ordering is load-bearing: the parent row is **deleted and committed first**, and orphan
41
+ status is **re-verified on the write host immediately before each `DROP`** — a `DROP TABLE` is
42
+ irreversible and non-transactional, so a stale read replica must never be the basis for one.
43
+
44
+ ## Key rules
45
+
46
+ - **Delete-parent-then-drop-child, and re-check on the writer.** Never drop a result table based on
47
+ a list read earlier in the run or from a reader host.
48
+ - **Never shorten the grace period below the longest plausible in-flight request.** The whole
49
+ correctness argument for reaping while requests are live rests on it.
50
+ - The Cache DB is registered as `DB_CACHE` in `worker2/_.php` and resolved **by name**
51
+ (`Databases.name = 'Cache'`) in `worker2/Controller/Index.php` — the same rule as api2. There is
52
+ deliberately no `CACHE_DATABASE_ID`: `Databases.id` is auto-assigned per Core instance and a
53
+ hardcoded id fails silently in every environment where it does not happen to match.
54
+
55
+ ## Gotchas
56
+
57
+ - **Worker classes are `abstract class _Worker_<Path>` with `public static` entry methods**,
58
+ dispatched as `$className::$functionName()`. They do **not** `extends _Worker` — that is the
59
+ 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.)
62
+ - **Adding the cron is a dbchanges2 change, not a code change** — the schedule lives in Core, so a
63
+ new worker action is not actually scheduled until that SQL is applied per environment.
64
+
65
+ ## Change history
66
+ - 2026-07-28 — Initial capture: `_Worker_Platform_Cache::Clean()` reaps expired `Cache.Tables` rows
67
+ (2-minute grace) and drops orphaned `TableResults_*` tables (parent deleted+committed first,
68
+ orphan status re-verified on the write host immediately before each DROP); `DB_CACHE` registered
69
+ by name in worker2 boot; `*/5` cron added in dbchanges2 `Core/2026-07-27a`. (jcardinal)
@@ -24,6 +24,9 @@ files:
24
24
  - dbchanges2/Team/2026-07-08a
25
25
  - dbchanges2/Team/2026-07-09a
26
26
  - dbchanges2/Team/2026-07-10a
27
+ - dbchanges2/Team/2026-07-28a - TranscriptProcessingRetryAttempts.sql
28
+ - dbchanges2/Team/2026-07-28b - TranscriptPromptTemplateConverseModel.sql
29
+ - dbchanges2/Core/2026-07-28a - TeamsTranscriptRetryCron.sql
27
30
  related:
28
31
  - ./teams-transcript-export.md
29
32
  - ./notification-email-template.md
@@ -56,7 +59,7 @@ JSON that PHP renders to an inline-styled HTML recap email, enqueued to the orga
56
59
 
57
60
  ## Key files / entry points
58
61
 
59
- - `Worker/Team/Transcripts.php` — actions `Export`, `Process`, `SyncKb`,
62
+ - `Worker/Team/Transcripts.php` — actions `Export`, `Process`, `Retry`, `SyncKb`,
60
63
  `SyncKnowledgeBases`. **`Scan` was removed.**
61
64
  - `bin/sync-knowledge-bases.php` — CLI entry for the Bedrock→`Team.KnowledgeBases` registry
62
65
  sync (`--dry-run` supported).
@@ -111,6 +114,53 @@ the Graph date filter and gets stuck "observing" forever.
111
114
  `alreadyExported()` and `recordExport()` were **removed** (superseded by the observation
112
115
  helpers).
113
116
 
117
+ ### `Retry(?int $limit = null, array $cc = [], array $bcc = [])` (cron) — bounded re-drive of failures
118
+ Added 2026-07-28. Before it existed, a failed `Process()` was **permanently terminal** (see
119
+ Gotchas). `Retry` sweeps `Team.TranscriptProcessing` for `status='failed'` and re-enqueues
120
+ `Team/Transcripts/Process` **directly from the `Team.TranscriptExports` ledger row**, which
121
+ already holds every Graph id `Process` needs.
122
+
123
+ Two deliberate non-behaviors:
124
+ - It does **not** clear `dtEnqueued`, and it does **not** route back through `Export`. Going
125
+ through `Export` would re-download every VTT purely to re-measure its size and would perturb
126
+ the observe-until-stable state; the ledger is sufficient.
127
+ - Nothing "resets" a failure — the row stays `failed` until a `Process` run succeeds.
128
+
129
+ **Bounded on three axes** so a permanently unprocessable transcript *parks itself* instead of
130
+ retrying forever (budget sized for a multi-day AI-endpoint outage, not a blip):
131
+ - `RETRY_MAX_ATTEMPTS = 20`
132
+ - doubling backoff: `RETRY_BACKOFF_MINUTES = 15`, doubling up to `RETRY_BACKOFF_CAP_MINUTES = 360`
133
+ - `RETRY_MAX_AGE_DAYS = 30`
134
+
135
+ **Atomic claim (do not "simplify" this).** `claimRetryAttempt()` increments the attempt counter
136
+ with a status-guarded UPDATE — `... WHERE transcriptIdentifier = ? AND status = 'failed'` — reads
137
+ `_Query::getAffectedRows()` **before** the commit (affected-rows reflects the last statement on
138
+ the connection), and **skips the enqueue when zero rows were claimed**. `loadRetryCandidates()`
139
+ takes no locks and `Process()` only short-circuits when status is already `'synced'`, so an
140
+ unconditional increment let two overlapping sweeps both run the full pipeline for one meeting:
141
+ two AI passes, two Bedrock syncs, and **two recap emails to the organizer**. Claim-then-enqueue is
142
+ the pattern for making any sweep-and-requeue worker safe without locking.
143
+
144
+ **Unprocessable rows are excluded in the candidate SQL, not skipped in the loop.** A ledger row
145
+ with no `meetingIdentifier` can never succeed (`Process()` throws without one); leaving such rows
146
+ selectable let a handful fill the `LIMIT` on every sweep and starve rows that could succeed. They
147
+ are surfaced separately by `countUnprocessableFailures()` in the sweep summary.
148
+
149
+ Private helpers: `loadRetryCandidates()`, `claimRetryAttempt()`, `countUnprocessableFailures()`,
150
+ `toIsoUtc()`.
151
+
152
+ **Schema (`Team/2026-07-28a`):** `Team.TranscriptProcessing.retryAttempts`
153
+ (`TINYINT UNSIGNED NOT NULL DEFAULT 0`), `dtLastRetried` (`DATETIME NULL`), plus a
154
+ `(status, retryAttempts)` index. The defaults make every already-failed row eligible on the first
155
+ sweep, so **no backfill or one-off UPDATE is needed** (an explicit requirement). The retry join
156
+ needs no new index — `TranscriptExports.transcriptIdentifier` already has the `uq_transcript`
157
+ UNIQUE index.
158
+
159
+ **Cron (`Core/2026-07-28a`): `10,40 9-18 * * 1-5`** — deliberately offset from the `Export` cron
160
+ (`15,45 9-18 * * 1-5`, `Core.CronJobs` id 18) so the two never contend for the Graph token or the
161
+ AI endpoint in the same minute, and kept inside Export's business-hours window because a re-drive
162
+ emails a recap to the meeting organizer.
163
+
114
164
  ### `Process(int $transcriptId, string $graphUserId, string $meetingId, string $organizerEmail, string $subject, string $meetingStartIso, array $cc = [], array $bcc = [])`
115
165
  1. Download the VTT **straight from Graph** (no S3 round trip).
116
166
  2. `stripAndGroupVtt()` — strips WebVTT timestamps and **merges consecutive same-speaker cues
@@ -225,6 +275,48 @@ part of the ingestion loop** (raw reads removed). Credential values live only in
225
275
 
226
276
  ## Gotchas / known issues
227
277
 
278
+ - **THE AI MODEL FOR THIS PIPELINE COMES FROM THE DATABASE, NOT `production.ini`.** `Process()`
279
+ resolves it as `($template->model ?? _Config::talos('model'))` against the **active
280
+ `Team.TranscriptPromptTemplate` row**, and because that table's `model` column is
281
+ `VARCHAR(255) NOT NULL`, the ini value is **unreachable — dead fallback on this path**. Editing
282
+ `worker2/Config/production.ini` does nothing here. Both AI passes (`callAICleaningAPI`
283
+ clean/classify and `buildRecapEmailBody` recap) read the **same row**, so one row update fixes
284
+ both. There is **no admin screen** for the prompt template in `tools/mvc/talos/`, so a model
285
+ change belongs in a dbchanges2 migration like its original seed (`Team/2026-06-30c`). Write such
286
+ a migration to match on the **OLD model value** (not id/uuid) so it no-ops in an environment
287
+ already hand-corrected and cannot clobber one deliberately pinned to a different model.
288
+ This was the root cause of a five-day outage: Talos `/api/ai/generate` returned
289
+ `500 {"error":"internal_error"}` for `bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0`
290
+ because the endpoint now requires the **`bedrock_converse/` prefix** (verified against
291
+ `Logs_True.Api` id 5581286, `dtStamp 2026-07-28 09:45:54`, route `/api/ai/generate`).
292
+ Fixed by `Team/2026-07-28b`.
293
+ - **A failed `Process()` used to be PERMANENTLY TERMINAL — "just re-run it" never recovered it.**
294
+ Three mechanisms combined: (a) workers return **HTTP 200 even on failure** by framework
295
+ contract, so SQS never redelivers; (b) `Export()` stamps `TranscriptExports.dtEnqueued`
296
+ **before** the SQS handoff and **nothing ever clears it**, while Export's first check skips any
297
+ transcript whose `dtEnqueued` is set — so `dtEnqueued` is a **one-way latch**; (c) nothing else
298
+ re-read `status='failed'` rows. Observed impact: **25 meetings stranded 2026-07-22 → 2026-07-27**
299
+ (last successful sync 2026-07-22 16:15) while `Export` reported
300
+ `found: 106, enqueued: 0, skipped: 106` — i.e. fixing the underlying error alone would **not**
301
+ have recovered them. **Diagnostic tell:** Export/Process pairs *do* appear in
302
+ `Core.WorkerJobs`; an `Export` with `enqueued: 0` simply has no `Process` after it, which is
303
+ easy to misread as a missing dispatch. The `Retry` action is the fix.
304
+ - **Losing the `'synced'` status write is uniquely dangerous now that `Retry` exists.**
305
+ `setStatus()` catches `Throwable` and only `error_log`s. If the failure hits the **`'synced'`**
306
+ write specifically, the pipeline **completed** and the recap was **already sent**, but the row
307
+ still reads `failed` — so the `Retry` sweep re-runs everything and re-sends the recap,
308
+ repeatedly. It now logs a distinct **CRITICAL** line naming the uuid and telling the operator to
309
+ set the row to `synced` manually. Residual risk **accepted** rather than adding a
310
+ `dtRecapSent` column.
311
+ - **DEPLOY ORDER (2026-07-28 change): Team migration → worker2 code → Core cron row.** Any other
312
+ order is safe but inert. Also note: draining the 25-meeting backlog **sends 25 recap emails** to
313
+ the original organizers for meetings up to six days old.
314
+ - **Two of the 25 stranded meetings are not the outage and will not self-heal** — they will
315
+ exhaust the 20-attempt budget and park, which is the intended behavior of the bound.
316
+ "Agilant & The Tow Foundation Sync" (2026-07-22, 179,779 bytes, the largest in the set) failed
317
+ **HTTP 422**, not 500, against an active-template `maxTokens` of 40000 — it likely exceeds the
318
+ model's input budget and needs **chunking**. "Tow Receipt Project Update" is **86 bytes**, an
319
+ effectively empty transcript.
228
320
  - **DEPLOY ORDER: apply migration `2026-07-10a` BEFORE deploying the new worker2 code.** The
229
321
  code reads/writes `dtEnqueued`/`dtLastChanged`; deploying code first errors on every poll.
230
322
  The migration also backfills every existing row as handed-off (`dtEnqueued=dtExported`) so
@@ -294,6 +386,23 @@ part of the ingestion loop** (raw reads removed). Credential values live only in
294
386
 
295
387
  ## Change history
296
388
 
389
+ - 2026-07-28 — **Added `Team/Transcripts/Retry`, a bounded automatic re-drive of failed
390
+ transcripts, and fixed the five-day 500 outage.** Discovered a failed `Process()` was
391
+ *permanently* terminal (HTTP-200 workers + the `dtEnqueued` one-way latch + nothing re-reading
392
+ `status='failed'`), stranding 25 meetings from 2026-07-22. `Retry` re-enqueues `Process` straight
393
+ from the `TranscriptExports` ledger, bounded by `RETRY_MAX_ATTEMPTS=20`, a 15→360-minute doubling
394
+ backoff, and `RETRY_MAX_AGE_DAYS=30`. Review findings addressed: made the attempt increment an
395
+ **atomic status-guarded claim** (read `getAffectedRows()` pre-commit, skip enqueue on 0 rows) —
396
+ the unconditional version let concurrent sweeps double-run the pipeline and send **two recap
397
+ emails**; excluded `meetingIdentifier`-less unprocessable rows in the candidate SQL so they can't
398
+ starve the `LIMIT`; added a CRITICAL log when the `'synced'` status write is lost. Outage root
399
+ cause was a stale model id **in `Team.TranscriptPromptTemplate`, not `production.ini`** (the ini
400
+ is dead fallback) — Talos now requires the `bedrock_converse/` prefix. dbchanges2:
401
+ `Team/2026-07-28a` (retryAttempts/dtLastRetried, defaults make failures eligible with **no
402
+ backfill**), `Team/2026-07-28b` (model, matched on the old value so it no-ops if hand-fixed),
403
+ `Core/2026-07-28a` (cron `10,40 9-18 * * 1-5`, offset from Export's `15,45`). `php -l` clean;
404
+ php-reviewer 1 critical + 1 high + 1 medium all addressed; sql-reviewer SAFE TO MERGE.
405
+ **Nothing deployed or applied yet.** (jcardinal)
297
406
  - 2026-07-28 — **`callTalosEndpoint()` migrated from raw curl to `_ApiRequest`** so both AI
298
407
  passes are recorded in the api log; `initialize()` now also registers `Logs_True` under
299
408
  `_underscore::DB_CLIENT_LOGS` (without it the logging is a silent no-op). Deliberately kept
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
  ## 2.0 framework
19
19
 
20
20
  - **_underscore** (_Underscore) _(framework core)_ — 39 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
- - **worker2** (Worker) — 31 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
21
+ - **worker2** (Worker) — 32 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 18 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
24
24
  - **toga2-supply** (TOGa Supply) — 3 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.454",
3
+ "version": "1.0.455",
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",