toga-ai 1.0.740 → 1.0.741

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.
@@ -32,6 +32,7 @@
32
32
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
33
33
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, _underscore/Trait/Netsuite/SalesOrder.php, worker2/Worker/Netsuite/SalesOrder.php, worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Worker/Netsuite/Opportunity.php |
34
34
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
35
+ | [Outbound third-party API rate budget (_Component_Api_RateBudget) — designed + staged, NOT merged](features/outbound-api-rate-budget.md) | A **shared outbound rate budget for every third-party API** we call from 2.0 (ClickUp, NetSuite, FedEx, UPS, …): one small component plus one `Core` table, keye | _underscore/Component/Api/RateBudget/RateBudget.php, _underscore/Component/Api/Clickup/Clickup.php, _underscore/Route.php, _underscore/Query.php, dbchanges2/Core/2026-09-01a - Add ApiRateBudget table.sql, worker2/Worker/Clickup/Api.php |
35
36
  | [Legacy page meta (Page::meta) & context-scoped ClientRecordFieldSettings](features/page-meta-context-field-settings.md) | `_Model_Core_Page::meta()` is the **legacy** page-meta resolver behind `GET /pages/meta?slug=<slug>` — still the live path for `toga2-supply` and other pre-Surf | _underscore/Model/Core/Page.php, _underscore/Model/Client/TableView.php, toga2-supply/src/components/ui/Tables/PrimaryTable/PrimaryTable.tsx, toga2-supply/src/components/ui/Tables/PrimaryTable/helperFunctions/formatTableData.tsx |
36
37
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/Model.php, _underscore/Query.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php, api2/Controller/Index.php |
37
38
  | [Persona Name Translation (PersonaTranslations sidecar)](features/persona-name-translation.md) | Serves Persona **names** in multiple languages by adding a per-language **sidecar** table `PersonaTranslations`, reusing the platform's existing metadata-driven | _underscore/Model/Client/PersonaTranslation.php, dbchanges2/Client/2026-07-22b - PersonaTranslations.sql, dbchanges2/Core/2026-07-22a - PersonaTranslationsRecord.sql, dbchanges2/Client/2026-07-22c - PersonaTranslationsAcl.sql, dbchanges2/Client_CompassCanada/2026-07-22 - PersonaTranslationsFrench.sql, toga2-commerce/src/pages/Account/view/MySettingsView.tsx |
@@ -0,0 +1,190 @@
1
+ ---
2
+ title: Outbound third-party API rate budget (_Component_Api_RateBudget) — designed + staged, NOT merged
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: draft
9
+ updated: 2026-09-02
10
+ owners: [ajean]
11
+ files:
12
+ - _underscore/Component/Api/RateBudget/RateBudget.php
13
+ - _underscore/Component/Api/Clickup/Clickup.php
14
+ - _underscore/Route.php
15
+ - _underscore/Query.php
16
+ - dbchanges2/Core/2026-09-01a - Add ApiRateBudget table.sql
17
+ - worker2/Worker/Clickup/Api.php
18
+ related:
19
+ - ../architecture.md
20
+ - ../../worker2/features/creating-worker-actions.md
21
+ - ../../worker2/features/clickup-project-routing.md
22
+ - ./async-query-execution.md
23
+ - ./model-save-vs-query-atomic-update.md
24
+ ---
25
+
26
+ ## Summary
27
+
28
+ A **shared outbound rate budget for every third-party API** we call from 2.0 (ClickUp,
29
+ NetSuite, FedEx, UPS, …): one small component plus one `Core` table, keyed by a
30
+ `bucketKey` string. It counts outbound calls per fixed 60-second window so we can *measure*
31
+ our real request rate before enforcing any cap.
32
+
33
+ **Status — nothing is shipped.** The component and its migration exist only as local staged
34
+ files; they are not committed to `_underscore` or `dbchanges2`, and no PR contains them. They
35
+ replace a rejected ClickUp-only design (533-line worker class + 106-line migration = 639 lines)
36
+ that lives in the still-open PRs worker2 **#137** and dbchanges2 **#449**. Read this doc as a
37
+ design record, not as live behavior.
38
+
39
+ ## Key files / entry points
40
+
41
+ - `_underscore/Component/Api/RateBudget/RateBudget.php` — `_Component_Api_RateBudget`
42
+ (staged; 222 lines, ~103 lines of code).
43
+ - `take(string $bucketKey, int $limit): ?int` — **the entire public surface.** Increments the
44
+ current window's counter and returns the post-increment count, or `null` if the store is
45
+ unreachable. It never blocks, never sleeps, never throws on saturation.
46
+ - `prune()` — deletes old windows; call from an existing cron, never on the hot path.
47
+ - `dbchanges2/Core/2026-09-01a - Add ApiRateBudget table.sql` — staged, 19 lines. Creates
48
+ `Core.ApiRateBudget`: `id`, `uuid`, `dtCreated`, `dtUpdated`, `bucketKey varchar(64)`,
49
+ `windowStart int UNSIGNED`, `requests int UNSIGNED`, with `UNIQUE KEY (bucketKey, windowStart)`.
50
+ - Rejected predecessor (in PR #137 / #449, ClickUp-only): `worker2/Worker/Clickup/Api.php`.
51
+
52
+ ## How it works
53
+
54
+ 1. A caller about to hit a provider calls `take('clickup', $limit)`.
55
+ 2. The component upserts `(bucketKey, windowStart)` incrementing `requests`, then reads the row
56
+ back by primary key, and returns the count.
57
+ 3. The caller decides what to do with the number. **Counting and enforcement are deliberately
58
+ separate.**
59
+
60
+ Design decisions worth keeping:
61
+
62
+ - **One table, one class, keyed by `bucketKey`** (`'clickup'`, `'netsuite'`, `'fedex'`, `'ups'`).
63
+ Never a second per-provider table or class. The table lives in `Core/`, not `Team/`, because it
64
+ is platform infrastructure, not team tooling.
65
+ - **Observe-only deployment is the point.** Run it for a week, read the real aggregate rate out of
66
+ `Core.ApiRateBudget`, then pick a threshold from measurement instead of guesswork. The
67
+ measurement *is* the test.
68
+ - **Fixed 60s window, boundary computed by MySQL** (`FLOOR(UNIX_TIMESTAMP()/N)*N`) and **never by
69
+ PHP** — instance clock drift across the fleet would split the budget. Accepted trade-off: a burst
70
+ straddling a boundary is bounded at 2x nominal. If that ever matters, move to a 10s window and
71
+ `SUM()` the last 6 rows.
72
+ - **Fail open.** If the store is unreachable: log once, return `null`, proceed with the call.
73
+ worker2 has **no DLQ and no redelivery**, so a fail-closed limiter outage would permanently
74
+ destroy production events — strictly worse than briefly exceeding a provider's cap.
75
+
76
+ ### Framework constraint: a cross-process counter needs its OWN DB alias, committed immediately
77
+
78
+ This is the one piece of essential complexity carried over from the rejected design, and it
79
+ applies to **any** shared counter or rollup in 2.0, not just this one.
80
+
81
+ `_underscore/Route.php` opens a transaction on the **default** connection and holds it open until
82
+ the job ends. So a counter written on the default alias is:
83
+
84
+ 1. **invisible to every other process** until the job ends — a fleet-wide total can never be read; and
85
+ 2. **a write lock held on one hot row** for up to the full 300s job budget — every worker that
86
+ touches the counter serialises behind it.
87
+
88
+ The only escape is to register a **second connection under its own alias** and call
89
+ `_Database::transactionCommit($alias)` immediately after the write. A "simplification" that drops
90
+ the alias silently breaks correctness *and* adds a fleet-wide lock.
91
+
92
+ ### Why not the one-statement `LAST_INSERT_ID(expr)` upsert-and-read
93
+
94
+ The tempting single statement —
95
+ `... VALUES (… LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE requests = LAST_INSERT_ID(requests + 1)`
96
+ — is **not safe on a table with an `AUTO_INCREMENT id`**: `LAST_INSERT_ID()` returns the *new row
97
+ id* on the INSERT path but the *assigned expression* on the UPDATE path. The 2.0 table standard
98
+ mandates a leading `id AUTO_INCREMENT`, so the idiom would need empirical proof against the target
99
+ MySQL 8 before use.
100
+
101
+ **Decision:** upsert, then read the row back by primary key. One extra indexed lookup, obviously
102
+ correct. The resulting read-after-write skew is **upward-only** under concurrency (another process
103
+ may increment between our write and our read), so the number is conservative — it can cause an
104
+ early yield but never an overrun.
105
+
106
+ ### Enforcement policy (once there is data) — never sleep-wait
107
+
108
+ `MAX_EXECUTION_TIME_WORKER_ACTION` hard-caps any non-CronScheduler job at **300s**, and the
109
+ JobScheduler watchdog only *writes the failure row* — it does not stop the PHP process. Sleeping to
110
+ wait for budget therefore burns the cap and gets the job marked failed with **no retry and no DLQ**.
111
+ See [Creating Worker Actions](../../worker2/features/creating-worker-actions.md).
112
+
113
+ | Traffic class | Examples | Policy at the threshold |
114
+ |---|---|---|
115
+ | Bulk / sweep | sprint sync, stakeholder backfill, discovery | **Yield cleanly and reschedule the remainder.** Already chunked and resumable, so pausing costs nothing. This is where a cap gets real teeth. |
116
+ | Event-driven / escalation | Sentry→ClickUp, webhooks, design review | **Proceed over budget, counted and alarmed.** Low volume, irreplaceable. |
117
+
118
+ ## Verified findings about ClickUp metering today
119
+
120
+ - **The "500/min is worker2's deliberate half of ClickUp's 1000/min" premise is FALSE.** Both
121
+ `worker2/Worker/Clickup/Api.php` and the #449 migration assert that api2 and tools also call the
122
+ ClickUp API. Verified by grep across the `api2`, `tools` and `_underscore` checkouts: **zero**
123
+ references to `api.clickup.com` and **zero** `_Component_Api_Clickup::send()` calls in api2 or
124
+ tools. `tools` reads the `Team` database that worker2 populates; it does not call ClickUp.
125
+ worker2's realistic share is therefore **~900/min, not 500** — the limit must be re-derived with
126
+ the reviewer before any enforcement.
127
+ - **~24 of ~116 ClickUp call sites would be unmetered** (measured on the merged tree via
128
+ `git merge-tree --write-tree` + grep): 92 metered, 6 raw `_Component_Api_Clickup::send()`, and 18
129
+ hand-built `api.clickup.com` endpoints — `Worker/Team/Sprint.php` (6),
130
+ `Worker/Netsuite/Opportunity.php` (6), `Worker/Sentry.php` (4),
131
+ `Worker/Netsuite/OpportunityStakeholderBackfill.php` (2). The **burstiest** loops (paginated
132
+ sprint sweeps, the stakeholder backfill) are among the unmetered ones, so a limiter as built
133
+ misses exactly the traffic most likely to breach a cap. **Call-site consolidation is the real
134
+ work** — any limiter is only as good as that.
135
+ - **The rejected design's own enforcement test is blind to the biggest bypass.**
136
+ `TestNoDirectApiCalls()` greps only for `_Component_Api_Clickup::send(`, so it cannot see the 18
137
+ raw `api.clickup.com` sites, and it reports `RESULT: FAIL (2)` immediately post-merge because
138
+ `Worker/Monitor/Oneuptime.php` (5 unmetered calls) landed on `_production` after the branch was
139
+ cut. **A guard that passes while 18 bypasses exist manufactures false confidence.** This belongs
140
+ in CI as a ~10-line grep for `api.clickup.com` outside the component — not as a curl-invokable
141
+ worker action.
142
+
143
+ ## What was deliberately dropped from the rejected design
144
+
145
+ 45-vs-50 rolling-minute arithmetic, a 0.25s window-edge guard, batch reserve plus
146
+ `register_shutdown_function` slot refunds, `beginBulk()`, `isBudgetTight()`, and a `CHECK`
147
+ constraint hand-synced to a PHP constant. Rationale: that precision existed to guarantee
148
+ ≤495 requests per rolling minute — yet on saturation the limiter logged and **proceeded unmetered
149
+ anyway**, so the exact bound was unenforceable by design and the arithmetic bought nothing.
150
+
151
+ ## Gotchas / known issues
152
+
153
+ - `_Query::setQuery()` **executes non-SELECT queries immediately** — `new _Query($writeSql, $alias)`
154
+ runs the write eagerly, with no `execute()` call; `__destruct()` calls `execute()` as a backstop.
155
+ This is why any async flag must be set *before* `setQuery()`
156
+ (see [Asynchronous Query Execution](./async-query-execution.md)).
157
+ - `_Query::fetchRow()` returns an **object**, not an array — `$row->requests`, never
158
+ `$row['requests']`. The affected-rows accessor is **`getAffectedRows()`** (there is no
159
+ `affectedRows()`). `fetchOne()` returns the first column of the first row, or `false` — the
160
+ cleanest single-value read.
161
+ - **Security (location only):** `_underscore/Component/Api/Clickup/Clickup.php` declares a
162
+ hardcoded shared ClickUp workspace access token as a class constant next to `ENDPOINT`. Every
163
+ consumer authenticates as the same workspace identity, so per-consumer attribution and isolation
164
+ are impossible at the provider end — which also means a budget can never be attributed per caller
165
+ upstream. Needs rotation, a move to config/SSM, and a `/cso` pass. Already noted in
166
+ [ClickUp Design Sprint Automation](../../worker2/features/clickup-design-sprint-automation.md).
167
+
168
+ ## Ticket mapping (so the next reader does not conflate these)
169
+
170
+ - **TRUE-80662 "ClickUp - Fix Project Hub Routing"** (Sprint 83, back-end review; reviewer Jeff
171
+ Cardinal) — contains nothing about rate limiting. The limiter arrived *inside* its PRs (#137,
172
+ #449). The outbound budget work has **no ticket of its own yet**.
173
+ - **TRUE-80659** "formal rate limiting feature for the API … return a 429 with a Retry-Again
174
+ header" (filed by Jeff Cardinal; Backlog/On Bench, unassigned, unbuilt) — that is the **inbound**
175
+ 429 + `Retry-After` feature, a **different** concern from this outbound budget.
176
+ - **TRUE-4193** — an older per-IP **inbound** variant.
177
+
178
+ ## Change history
179
+ - 2026-09-02 — Created. Designed `_Component_Api_RateBudget` + `Core.ApiRateBudget` (staged, not
180
+ merged) as a provider-agnostic replacement for the rejected 639-line ClickUp-only limiter;
181
+ recorded the false 500/min premise, the ~24 unmetered ClickUp call sites, the blind enforcement
182
+ test, the dedicated-alias requirement for any cross-process counter, and the no-sleep-wait
183
+ enforcement tiering. (ajean)
184
+
185
+ ## Related docs
186
+ - [_underscore Framework Architecture](../architecture.md)
187
+ - [Creating Worker Actions](../../worker2/features/creating-worker-actions.md) — the 300s cap
188
+ - [ClickUp Project & Opportunity Multi-List Routing](../../worker2/features/clickup-project-routing.md) — PR #137's actual subject
189
+ - [Asynchronous Query Execution](./async-query-execution.md)
190
+ - [_Model::save() vs raw _Query](./model-save-vs-query-atomic-update.md)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-31
9
+ updated: 2026-09-02
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - worker2/Worker/Clickup/Project.php
@@ -17,6 +17,7 @@ related:
17
17
  - ./netsuite-opportunity-sync.md
18
18
  - ./clickup-connectivity-watchdog.md
19
19
  - ./clickup-subtask-activity.md
20
+ - ../../_underscore/features/outbound-api-rate-budget.md
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -166,6 +167,14 @@ None — uniform across all clients (shared internal automation for Agilant's Cl
166
167
  needs **`taskCreated` on LS + A&M** and a **workspace-wide `taskMoved`**, registered via
167
168
  `RegisterHubGuardWebhooks()` (which skips any already covered to avoid double-delivery).
168
169
 
170
+ - **PR #137 also carries a ClickUp rate limiter that is NOT part of this feature — and it was
171
+ rejected in review.** The open branch `fix/clickup-project-hub-routing` bundles a 533-line
172
+ `Worker/Clickup/Api.php` limiter (plus dbchanges2 #449) whose premise, coverage, and self-test are
173
+ all wrong: api2 and tools do **not** call ClickUp (verified by grep), ~24 of ~116 call sites stay
174
+ unmetered, and its `TestNoDirectApiCalls()` guard cannot see the 18 hand-built `api.clickup.com`
175
+ sites. Treat rate limiting as a separate subject with its own design —
176
+ [Outbound third-party API rate budget](../../_underscore/features/outbound-api-rate-budget.md).
177
+
169
178
  ## Diagnostics
170
179
 
171
180
  Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/` with
@@ -176,6 +185,8 @@ Read-only / setup actions, invoked via `curl -X POST https://worker.togahub.com/
176
185
  `RESULT: PASS`), `RegisterOpportunityWebhook` and `RegisterHubGuardWebhooks` (one-time setup).
177
186
 
178
187
  ## Change history
188
+ - 2026-09-02 — Noted that PR #137 bundles a rejected ClickUp rate limiter unrelated to routing, and
189
+ split that subject out to its own doc. No routing behavior changed. (ajean)
179
190
  - 2026-08-31 — Recorded the triage consequence of the hybrid split (read-only investigation, no code
180
191
  changed): worker-created opportunity tasks were observed **homed in `901111987451`
181
192
  "Quote/Proposal/SOW/MSA"** rather than the `901111987449` list they were POSTed to, with the mover
@@ -17,6 +17,7 @@ related:
17
17
  - ../architecture.md
18
18
  - ./netsuite-opportunity-client-labels.md
19
19
  - ../../api2/features/api-payload-interceptors.md
20
+ - ../../_underscore/features/outbound-api-rate-budget.md
20
21
  ---
21
22
 
22
23
  ## Summary
@@ -232,6 +233,13 @@ production (2026-08-28, job `1075223`, a full `OpportunityStakeholderBackfill` d
232
233
  So an over-budget action loses its output **twice**: once to a false failure row, once to a dropped
233
234
  connection. Design for 300s.
234
235
 
236
+ **Corollary — never sleep-wait for an external rate budget inside an action.** Pausing to wait for
237
+ a third-party API slot burns the 300s cap, gets the job stamped failed by the watchdog, and there is
238
+ no retry and no DLQ to recover it. Instead: **bulk/sweep** actions yield at the threshold and
239
+ re-enqueue the remainder (they are already chunked and resumable, so pausing is free), while
240
+ **event-driven/escalation** actions proceed over budget, counted and alarmed. See
241
+ [Outbound third-party API rate budget](../../_underscore/features/outbound-api-rate-budget.md).
242
+
235
243
  ### Self-chunking: the pattern for work that cannot finish in one job
236
244
 
237
245
  An action that may exceed the budget works for a **sub-budget slice**, then **re-enqueues itself**
@@ -456,6 +464,9 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
456
464
  commit-before-SQS transaction pattern that the worker relies on.
457
465
 
458
466
  ## Change history
467
+ - 2026-09-02 — Added the corollary that an action must **never sleep-wait for a third-party API
468
+ rate budget** (it burns the 300s cap and the failure is unrecoverable — no retry, no DLQ); bulk
469
+ work yields and re-enqueues, event-driven work proceeds over budget counted and alarmed. (ajean)
459
470
  - 2026-09-02 — Added two authoring rules learned on the End Customer label work (worker2 PR #158/#159):
460
471
  **gate any code that names a column from a hand-applied dbchanges2 migration** on an
461
472
  `information_schema.COLUMNS` check memoised in a `static` (an ungated write fails the whole query
@@ -5,8 +5,8 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-09-01
9
- owners: [jcardinal, mhammontree, dfranks, bala]
8
+ updated: 2026-09-02
9
+ owners: [jcardinal, mhammontree, dfranks, bala, ajean]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/_underscore/architecture.md
@@ -521,6 +521,33 @@ unapproved for 6 days because the approval was stamped with a dead user id. Prod
521
521
  See: clients/compass-usa/features/approval-decision-flow.md and
522
522
  clients/compass-usa/features/stranded-approval-reassignment.md
523
523
 
524
+ ### A cross-process counter or shared rollup MUST use its own DB alias, committed immediately
525
+
526
+ `Route.php` opens a transaction on the **default** connection and holds it open until the request
527
+ or job ends. So anything you write on the default alias for other processes to read is broken twice
528
+ over:
529
+
530
+ 1. **Nobody else can see it** until your job ends — a fleet-wide total is unreadable by design.
531
+ 2. **You hold a write lock on one hot row** for up to the full 300s job budget, serialising every
532
+ other process that touches that counter.
533
+
534
+ So any shared counter, rate budget, sequence, or rollup must:
535
+
536
+ - register a **second connection under its own alias**, and
537
+ - call `_Database::transactionCommit($alias)` **immediately after the write** — not at end of job.
538
+
539
+ Removing the extra alias looks like a simplification. It is not: it silently breaks correctness and
540
+ adds a fleet-wide lock.
541
+
542
+ **Read the value back by primary key after the upsert.** Do not use the one-statement
543
+ `LAST_INSERT_ID(expr)` upsert-and-read idiom on a table with an `AUTO_INCREMENT id` (which our
544
+ table standard mandates): `LAST_INSERT_ID()` returns the new row id on the INSERT path but the
545
+ assigned expression on the UPDATE path, so the result is path- and version-dependent. One extra
546
+ indexed lookup is obviously correct. Under concurrency the read-after-write skew is upward-only,
547
+ so the number is conservative.
548
+
549
+ See [Outbound third-party API rate budget](../apps/_underscore/features/outbound-api-rate-budget.md).
550
+
524
551
  ## PHP
525
552
 
526
553
  ### PHP Tags
@@ -997,6 +1024,15 @@ See: 2.0/apps/worker2/features/cross-account-aws-access.md
997
1024
 
998
1025
  ## Change history
999
1026
 
1027
+ - 2026-09-02 — Database/SQL: any **cross-process counter or shared rollup needs its own DB alias,
1028
+ committed immediately**. `Route.php` holds a transaction open on the default connection until the
1029
+ request/job ends, so a counter written there is invisible to every other process (a fleet-wide
1030
+ total is unreadable) and holds a write lock on one hot row for up to the 300s job budget,
1031
+ serialising the fleet. Register a second connection under its own alias and
1032
+ `_Database::transactionCommit($alias)` right after the write. Also: read the value back by
1033
+ primary key instead of the one-statement `LAST_INSERT_ID(expr)` upsert-and-read idiom — with the
1034
+ mandated `AUTO_INCREMENT id` it returns the new row id on INSERT but the assigned expression on
1035
+ UPDATE, so it is path- and version-dependent. (ajean)
1000
1036
  - 2026-09-01 — TRUE-81576. Three additions: (1) Security — never ship a hardcoded-credential
1001
1037
  fallback; the FedEx `initialize()` "production fallback" holds a live `client_id`/`client_secret`
1002
1038
  byte-identical to `api2/Config/production.ini`, so it must throw on missing `[fedex]` config, the
@@ -18,7 +18,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 75 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 76 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 58 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
23
  - **api2** (API) — 25 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 15 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.740",
3
+ "version": "1.0.741",
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",