toga-ai 1.0.1107 → 1.0.1108

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,10 +6,11 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-20
9
+ updated: 2026-10-22
10
10
  owners: ["jcardinal", "mhammontree", "tcox", "bala", "apeterson", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
+ - _underscore/Model/Core/Record.php
13
14
  - _underscore/Component/Acl/ReadExpression/ReadExpression.php
14
15
  - dbchanges2/Core/2026-08-27a - TransferOrderUuidSearchableIdentifier.sql
15
16
  - _underscore/Model/Core/Page.php
@@ -590,6 +591,8 @@ readable/writable fields from `AclFieldPermissions`. The row-level READ expressi
590
591
  [notification engine](./notification-engine.md); change it in one place. `_Model_Core_Page::meta()` reads the same
591
592
  tables to return per-page ACL to the frontend.
592
593
 
594
+ **Checking one row outside a V2 request:** `_Model_Core_Record::isActionPermittedOnRow(&$api, {recordId, primaryKeyId, action})` (`_underscore/Model/Core/Record.php`, 2026-10-22) is a read-only check that mirrors V2: READ / DELETE run `isPermitted` on every permission; only UPDATE has the "no logic group = plain grant" shortcut; roles come from the JWT (+ Public for Core records), plus app id and indirect access. Use it instead of re-coding the chain; change it together with V2.
595
+
593
596
  > **⚠ There is NO ACL/metadata caching layer — the load is per-request and there is nothing to bust.**
594
597
  > `buildLookups()` re-reads the ACL/metadata tables on **every** request, so an inserted/updated
595
598
  > `AclCustomFieldPermissions` / `CustomRecordFields` / `AclFieldPermissions` row takes effect on the
@@ -946,6 +949,7 @@ hardcoded `Core.RecordFields` id literals instead of a subselect.
946
949
  and every repo is on the **same branch** so the generated model matches the DB.
947
950
 
948
951
  ## Change history
952
+ - 2026-10-22 — Added `_Model_Core_Record::isActionPermittedOnRow` (one-row ACL check mirroring V2) (TRUE-82919). (jcardinal)
949
953
  - 2026-10-20 — Client→Core joins are field-checked with core roles: `settingId.uuid` needed a Core Public read grant on `Core.Settings` (TRUE-82934). (jcardinal)
950
954
  - 2026-10-18 — Row-level READ expression moved to shared `_Component_Acl_ReadExpression` (V2 + notification engine). TRUE-82907 (jcardinal)
951
955
  - 2026-10-18 — Client ACL rows now carry `capabilityId`; linked the new Capabilities doc (TRUE-82880). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-18
9
+ updated: 2026-10-21
10
10
  owners: ["bala", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model.php
@@ -41,7 +41,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
41
41
  2. **Which table:** from the model's `const DATABASE`: `DB_CORE` → `Core.RecordNumbers`; `DB_CLIENT` → that client's; anything else throws.
42
42
  3. **Which row:** walk class + parents against `Core.Records.model` (stored with leading backslash), cached per class. Client overrides (`_Model_Compass_Canada_SalesOrder`) use the base row. No row → plain `Exception` (fail loud, no self-seeding).
43
43
  4. **Allocate:** `UPDATE RecordNumbers SET nextNumber = LAST_INSERT_ID(nextNumber + 1) WHERE recordId = X` on a separate sequence connection, committed at once (`_Database::DB_CORE_SEQUENCE` `'CoreSequence'` / `DB_CLIENT_SEQUENCE` `'ClientSequence'`). Number = insertId − 1; prefix read on the same connection.
44
- 5. **Sequence connections** follow their source alias on `register()`; dropped/closed when the source is re-pointed or re-registered with other settings (a stale ClientSequence never uses another tenant's counter). `connect()` falls back to the source DB when Core/Client is not registered by alias (worker2 prod).
44
+ 5. **Sequence connections** follow their source alias on `register()`; dropped/closed when the source is re-pointed or re-registered with other settings (a stale ClientSequence never uses another tenant's counter). `connect()` falls back to the source DB when Core/Client is not registered by alias (worker2 prod). The source → sequence map is `private static function sequenceDatabases(): array` in `Database.php`; it adds only aliases the app has `defined()` (was a class constant `SEQUENCE_DATABASES` — see Gotchas).
45
45
  6. **Format:** `prefix . nextNumber` — counting in order, no padding, no hyphen. Prefix validated in PHP: 1–8 letters A–Z except O, I, B (look like 0, 1, 8); L allowed. No CHECK constraint ([dbchanges2 rule 15](../../dbchanges2/architecture.md)).
46
46
  7. **Hand-set number wins** (counter untouched). `forSearching` with null number skips the field. 1062 retry up to 5, only when the dup is the number this save generated.
47
47
 
@@ -66,6 +66,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
66
66
  - **Old `MAX()+1` race (why this exists):** concurrent creates got one number → 1062 → HTTP 400 `EV-10`, record lost (Prudential SR, TRUE-82209).
67
67
  - **Core seed fails on a local stray `RecordFields` row** for `clients.number` (one local had id 2598, not in prod/dev-sandbox/repo) → guarded insert of 2740 hits unique `recordId_field`. Delete the stray row locally; the file fails loud on drift on purpose.
68
68
  - **Reproducing a race needs distinct rows per call** (different contact + serial) and a gate-file release ([V2 request retry](../../api2/features/v2-deadlock-retry.md)); sandbox-dev `max_connections = 90` → bursts ~10.
69
+ - **⚠ The first `SEQUENCE_DATABASES` class constant took apiproxy down (fixed 2026-10-21, TRUE-83035, commit `77adb007`, PR `_underscore#793`).** It named `_underscore::DB_CORE` / `DB_CLIENT`, which apiproxy does not define → `Undefined constant _underscore::DB_CORE` (Database.php:52) on the first `_Database` static use, every request. Now a method that skips undefined aliases. Rule: [_underscore architecture](../architecture.md) Gotchas → app-only constants in core.
69
70
  - **Run `php -l Model.php Database.php` after merge conflicts on env branches** — a past merge dropped `_Database::$_aliases` → beta outage.
70
71
 
71
72
  ## Verification
@@ -80,6 +81,7 @@ Removed: `FIELDOPT_AUTOINCREMENT_PREFIX` / `_FIRSTNUMBER` / `_SUFFIX`, the `MAX(
80
81
  - [NetSuite SalesOrder outbound push](../../worker2/features/netsuite-salesorder-outbound-push.md)
81
82
 
82
83
  ## Change history
84
+ - 2026-10-21 — Sequence map moved from class constant to `sequenceDatabases()` (only defined aliases) after it fataled apiproxy. TRUE-83035 (jcardinal)
83
85
  - 2026-10-18 — `_production` merged into `toga25`; Scope + Core Client models on plain `FIELD_AUTOINCREMENT`; Scopes seeded `SC` 100020. TRUE-82907 (jcardinal)
84
86
  - 2026-10-18 — Final design: per-client `RecordNumbers` table (client DBs + Core), counting in order, old series continued under new prefixes, Compass Canada SAC; dropped the platform-wide scrambled `Core.Records` counter. TRUE-82715. (jcardinal)
85
87
  - 2026-10-13 — Platform-wide scrambled counter in `Core.Records` (dropped before release). (jcardinal)
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-28
9
+ updated: 2026-10-21
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala", "apeterson"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -629,9 +629,12 @@ cannot. The agreed design still holds on the *sink* (shared Core Logs DB, not pe
629
629
  *who escalates* (the worker2 cron owns escalation and notification only).
630
630
 
631
631
  The cost of a direct write is that **each app must have the Core Logs connection registered**.
632
- The capture path already guards this with
633
- `isset(_Database::$_registers[_underscore::DB_LOGS])` — it **skips** capture rather than
634
- fatalling when the register is absent.
632
+ The capture path **skips** capture (sets `lastCaptureFailure`, returns `null`) rather than
633
+ fatalling when the logs DB is absent — two guards, in order: `defined('_underscore::DB_LOGS')`
634
+ (the app's `_.php` may not declare it), then
635
+ `isset(_Database::$_registers[_underscore::DB_LOGS])`. Only api2, saml and worker2 define
636
+ `DB_LOGS`; every other 2.0 app (e.g. apiproxy) records **nothing** in `Logs.Issue`/`Event` — its
637
+ errors reach only the server PHP error log.
635
638
 
636
639
  #### Why api2 is in scope at all
637
640
 
@@ -687,6 +690,13 @@ read you feed it — `_Error` promotes a *"read property on int"* warning into a
687
690
 
688
691
  ## Gotchas
689
692
 
693
+ - **⚠ `captureException()` fataled in every app without `DB_LOGS` (fixed 2026-10-21, TRUE-83035).**
694
+ From 2026-07-30 (`176c35fe`/`be86f740`) to the fix, `Error.php` read `_underscore::DB_LOGS` with
695
+ only an `isset($_registers[...])` guard. `isset()` does **not** protect: evaluating an undefined
696
+ class constant is a fatal `Error`. On apiproxy every error crashed inside the handler
697
+ (`Undefined constant _underscore::DB_LOGS in .../_underscore/Error.php:373`, from
698
+ `exceptionHandler` and `shutdownHandler`), hiding the real first error. Rule: see the
699
+ `defined()` gotcha in the [_underscore architecture](../architecture.md).
690
700
  - **`Logs.Issue` starts 2026-08-03 on prod — there are no rows before it.** The table went live with
691
701
  that migration, so **any 500 older than 2026-08-03 has no Issue row at all**: looking its
692
702
  `error.id` up returns nothing, and the `Logs.Api` row is the only record you get. An empty result
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary |
4
4
  |-----|---------|
5
5
  | [API (api2 / TOGa API v2) Architecture](architecture.md) | Backend for the public TOGa 2.0 API — metadata/ACL-driven REST JSON (`/v2`) + a cXML gateway, both over `_underscore` `_Model`; open for the response-envelope c |
6
- | [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer or auditing what a key |
6
+ | [API-Key Authentication (/v2/auth/api) and what a key can actually reach](features/api-key-authentication-and-scoping.md) | How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer, auditing what a key di |
7
7
  | [API Payload Interceptors (metadata-registered prePost/postPut model hooks)](features/api-payload-interceptors.md) | How `_Model` prePost/postPost/prePut/postPut hooks are dispatched by the api2 /v2 engine only when a metadata row exists — the missing-row silent no-op, the wro |
8
8
  | [Multi-Client (Cross-Client) Data Retrieval](features/cross-client-data-retrieval.md) | The scatter-gather engine that returns one entitled user's records across many client DBs from a single GET (triggered by a `client` query option); open when wo |
9
9
  | [cXML ShipNotice Gateway (ASN ingestion, carrier resolution, per-client provisioning)](features/cxml-shipnotice-gateway.md) | How supplier cXML ShipNotices become `/v2/advance-shipping-notices` writes, plus the per-API tracking-bridge provisioning and cross-tenant carrier trap; open wh |
@@ -6,18 +6,23 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-09-16
10
- owners: ["bala"]
9
+ updated: 2026-10-22
10
+ owners: ["bala", "jcardinal"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
13
+ - _underscore/Component/Api/Toga/Toga.php
14
+ - worker2/Worker/Workflow/Run.php
15
+ - _underscore/Model/Client/TicketNote.php
13
16
  related:
17
+ - ../../standards/framework-rules.md
18
+ - ../../worker2/features/creating-worker-actions.md
14
19
  - ../architecture.md
15
20
  - ./request-logging.md
16
21
  - ../../_underscore/features/acl-permission-chain.md
17
22
  - ../../../../clients/compass-usa/workflows/granting-api-access.md
18
23
  ---
19
24
 
20
- How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer or auditing what a key did.
25
+ How a machine caller gets a bearer token from `POST /v2/auth/api` and exactly what that key can reach; open when scoping an API consumer, auditing what a key did, or making a worker job call api2.
21
26
 
22
27
  ## Summary
23
28
 
@@ -44,6 +49,19 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
44
49
 
45
50
  **The grant chain still applies.** An `AclRecordPermissions` row on its own grants nothing — the request fails with `No ACL Logic Groups defined for ACL Record Permissions`. Full rules, tables, checklist: [ACL Permission Chain](../../_underscore/features/acl-permission-chain.md).
46
51
 
52
+ **A worker2 job calling api2 as an API record** (team rule: [machine callers use an API record, never a fake user](../../standards/framework-rules.md)). Reference: `worker2/Worker/Workflow/Run.php`.
53
+
54
+ 1. Seed one `Client_<Client>.Apis` row per client (fixed `name`, random v4 `uuid` + secret, `isActive = 1`) and link it to a role through `Apis_Roles`. Set `maxAuthsPerHour` high (Workflow uses 1000): every queued job is a new process and signs in once.
55
+ 2. In the job, call `_Database::registerClientDatabases` first, then load the row by `name` + `isActive`. Never take "the first active `Apis` row" — `Worker/Startech.php` and `Netsuite/SalesOrder.php` do, and break when a client adds a second key.
56
+ 3. Call `_Component_Api_Toga::send(clientUuid, apiUuid, secret, ...)` → `POST /auth/api {client, api, secret}`.
57
+ 4. Result: the JWT carries `id.client.api.uuid`; roles = `Apis_Roles`; Activity shows `Apis.name` as the actor; `Logs_<Client>.Record` rows get `apiId`.
58
+
59
+ **What changes for an API caller.** api2 does not call `_Model::setRequestUserId()`, so `_Model::getRequestUserId()` is `null`. Every gate that needs a signed-in user behaves differently — check each one the job hits:
60
+ - `ServiceRequest` stage/reason rules and `ServiceRequestNote` `MESSAGE_NEEDS_USER` refuse or skip.
61
+ - `TicketNote` accepts an authorless note from an API caller (since 2026-10-22); it still refuses an unauthenticated request.
62
+ - Author-stamped note models (`authorUserId`, see [note authorship](../../_underscore/features/note-record-authorship.md)) store `NULL`.
63
+ - Cross-client calls reject API callers.
64
+
47
65
  **Auditing what a key actually did.** `Logs_<Client>.Api` carries `apiId`, `dtStamp`, `method`, `route`, `responseCode`, `queryString`, `requestPayload`, `responsePayload`. **Filter on `apiId`** to get one consumer's exact calls and responses. Note the client-vs-core log split (failed writes and pre-scope auth failures land in core `Logs`): [V2 Request Logging](./request-logging.md).
48
66
 
49
67
  ## Gotchas
@@ -58,6 +76,7 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
58
76
  - **Roles are replaced, not merged.** Auditing a key means reading `Apis_Roles` and nothing else. An API key never picks up any other role.
59
77
  - **A shared role across keys means the key identifies the caller but does not limit it.** If several `Apis` rows point at the same roles, every one can do everything those roles allow, regardless of the integration's published documentation.
60
78
  - **Never record a secret value in a doc or a committed SQL file.** Document only where it lives (the `Client_<Client>.Apis` row) and hand it over out of band.
79
+ - **⚠ Debug mode used to print the secret.** `_Component_Api_Toga` `authenticate` / `authenticateUser` dumped secrets, passwords and tokens in debug mode — removed 2026-10-22 (params now `#[\SensitiveParameter]`), and the `_ApiRequest` debug dump redacts `Authorization`. Still printed in debug: payloads, raw responses, `Cookie` and `X-Api-*` headers. Never share a debug-mode log.
61
80
  - **Do not copy the `authority` field into caller documentation.** The response envelope's `authority` reports the **internal** server that handled the request (observed: `api1.togahub.com`, `api-writer.togahub.com`). Callers must always use `https://api.togahub.com/v2`.
62
81
 
63
82
  ## Related
@@ -66,3 +85,7 @@ A machine caller authenticates with `POST /v2/auth/api` using a `Client_<Client>
66
85
  - [V2 Request Logging](./request-logging.md)
67
86
  - [ACL Permission Chain](../../_underscore/features/acl-permission-chain.md)
68
87
  - [Granting Compass API access](../../../../clients/compass-usa/workflows/granting-api-access.md)
88
+ - [Creating worker actions](../../worker2/features/creating-worker-actions.md)
89
+
90
+ ## Change history
91
+ - 2026-10-22 — worker2 job → api2 as an `Apis` record (lookup by name, `maxAuthsPerHour`); API caller has no request user id; `TicketNote` accepts authorless API notes; debug dump no longer prints secrets (TRUE-82919). (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-22
10
10
  owners: ["mhammontree", "dfranks", "bala", "snaredla", "jcardinal", "rgirish"]
11
11
  files:
12
12
  - api2/Component/Api/V2/V2.php
@@ -350,6 +350,8 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
350
350
  - **Registration is per-environment.** Like `RecordFields` / `RecordScripts` / ACL rows, this is metadata — "works in prod, not in beta/dev-sandbox" is the signature of drift, not of a bad deploy. See the [non-prod metadata drift repair workflow](../../dbchanges2/workflows/nonprod-metadata-drift-repair.md).
351
351
  - **⚠ An in-request read-after-write in an interceptor works on beta and fails 100% in production.** Reads default to the **reader** connection, which cannot see the request's own uncommitted row; dev-sandbox points writer and reader at the same endpoint, so it hides the bug. Pin with `setIsReadHostEnabled(false)` (restore in `finally`).
352
352
  - **`internalApiRequest` has no `data` envelope** (`$response->salesOrders`, not `$response->data->salesOrders`), and `minDepth` cannot deepen a POST interceptor's payload — the bump happens after serialization. A wrong read path here is a **silent no-queue with a clean 201**.
353
+ - **`internalApiRequest` payload shape now matches a real HTTP JSON body (since 2026-10-22).** It used `_Array::toObject`, which turned every nested LIST into an object, so `is_array($payload->ticketNotes)` failed only on internal requests (the Ticket resolve-note rule refused internal resolves). `toRequestObject` / `toRequestShape` in `V2.php` now give: assoc array → object, list stays list, empty nested `[]` stays `[]`. Callers checked (Compass, Adyen, ItemFulfillment, Growrk, Ticket merge/convert) — none relied on the old shape.
354
+ - **⚠ A post interceptor has no after-commit hook** — it runs inside the open transaction. For work that must start after the commit, add a sibling of `V2::dispatchNotifications()` (runs after data + log commit; e.g. `dispatchWorkflowEvents()`), see [notification engine](../../_underscore/features/notification-engine.md). Locally, debug-mode `runTask` is a synchronous HTTP POST to worker2, which can deadlock on locks the outer request still holds.
353
355
  - **Anything a post interceptor queues must survive the transaction boundary** — queue only the uuid; the worker reads with a bounded wait-and-retry until the commit lands. A `catch` around `runTask` must `_Error::captureException` (not only `error_log`). See [runTask parameters are ids only](../../worker2/features/creating-worker-actions.md#-runtask-parameters-are-idsuuids-only--never-record-objects).
354
356
  - **Never emit output from an interceptor.** A stray `echo`/`print_r` corrupts the `/v2` `{isSuccess,…,data}` envelope (this really happened in `ItemFulfillment.php` — see the carrier-shipping-labels doc).
355
357
  - **Interceptors are a client-behavior extension point.** Per api2's architecture doc, client specifics belong in `_Model_<Slug>_X` overrides + interceptor hooks, **not** in `V2.php`.
@@ -378,5 +380,6 @@ Refuse API-key callers when the rule is "the signed-in user" (no user on the tok
378
380
  - 2026-10-15 — PRE → POST state hand-off via `$api->passThrough`; detect a real field change, not a resent one (TRUE-82727). (bala)
379
381
  - 2026-10-16 — Command keys: strip in `prePut`, park on `passThrough`, act in `postPut`; empty body → `{}` (TRUE-82849). (jcardinal)
380
382
  - 2026-10-18 — Write intent that writes another record via internal request (caller ACL, rollback, `addNote` after the call) (TRUE-82876). (jcardinal)
383
+ - 2026-10-22 — `internalApiRequest` keeps nested lists as arrays (was all objects); no after-commit hook in post interceptors → sibling of `dispatchNotifications()` (TRUE-82919). (jcardinal)
381
384
  - 2026-10-17 — "Action table" pattern for audited actions; write intents before a stage gate; new interceptor rows ship in a file run after the code deploy (TRUE-82889). (jcardinal)
382
385
  - 2026-10-19 — Lookup de-duplicates rows per record / PRE|POST / method (highest `minDepth` wins); client overrides must call `parent::` (TRUE-82915). (jcardinal)
@@ -6,7 +6,7 @@ project: API
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-16
9
+ updated: 2026-10-22
10
10
  owners: ["bala", "mhammontree", "tcox", "apeterson", "rgirish", "jcardinal"]
11
11
  files:
12
12
  - toga25-supply/src/pages/TransferOrders/view/CreateTransferOrderModal/hooks/useCreateTransferOrder.ts
@@ -69,7 +69,7 @@ Same back-reference machinery as the UPDATE-path injection above; treat them as
69
69
  3. `PUT /v2/contacts/{uuid}` with `primaryContactEmailAddress:{uuid}` / `primaryContactPhoneNumber:{uuid}`.
70
70
  Result: duplicate email got its own row (2939 on new contact 2984); the other contact's row 2431 stayed on 2476; both pointers set. The UPDATE-path null-echo defect above did not reproduce here — still check the echo.
71
71
 
72
- **POST passes the request options (incl. `fields=`) to every nested inherent-child create** (`processRoutePairs` ~6352-6369 passes `httpOptions` through). A root-only `fields=uuid,firstName,lastName` fails on the children with **EV-8** "field does not exist" (`identifiers.field` = the root field) and the whole request rolls back. On a POST with nested children send no `fields`; use `depth=2` (`getFullModelData` returns inherent children at depth >= 2, ~9714-9760).
72
+ **POST passes the request options (incl. `fields=`) to every nested inherent-child create** (`processRoutePairs` ~6352-6369 passes `httpOptions` through). A root-only `fields=uuid,firstName,lastName` fails on the children with **EV-8** "field does not exist" (`identifiers.field` = the root field) and the whole request rolls back. On a POST with nested children send no `fields`; use `depth=2` (`getFullModelData` returns inherent children at depth >= 2, ~9714-9760). The POST response ignores `fields` anyway: it returns the full record at the default depth; only `depth=-1` returns just the `uuid`.
73
73
 
74
74
  **Matching is "link-AND-UPDATE", not "link" — a matched row is mutated from the payload.** A nested related object that **matches** an existing record does not merely link to it — the engine also **writes the payload's fields onto that existing row**. So partner-supplied data can **mutate shared catalog rows**. **Measured (beta `Client_Aig`, 2026-08-17):** the nested `unit.item` matched `Client_Aig.Items` id **1** by `partNumber`, and the payload's `modelNumber` was then written onto it — `Items` id 1 `modelNumber` went from `NULL` to a test value, `dtUpdated 2026-08-17 10:41:17`.
75
75
  > **"Link vs. create" is really "link-and-update vs. create". Matching is not read-only.**
@@ -213,6 +213,7 @@ behaviour — it bites any write to a nested child, not one feature. Worked exam
213
213
  - **`fields=` on a POST with nested children fails EV-8 and rolls back the whole request** — the options reach each child create. Use `depth=2` instead.
214
214
 
215
215
  ## Change history
216
+ - 2026-10-22 — POST response ignores `fields` (full record at default depth; `depth=-1` = uuid only) (TRUE-82919). (jcardinal)
216
217
  - 2026-10-16 — Safe contact+email/phone create pattern (child arrays, depth=2, then PUT primary `{uuid}`); why primary FKs match by value (MATCH_UPSERT on identifier only); POST options incl. `fields=` reach nested children → EV-8 + rollback (TRUE-82887). (jcardinal)
217
218
  - 2026-10-16 — Corrected: a sent identifier field (contacts.customerId 1651) joins the match, so a mismatched contact is duplicated, not overwritten; nested-write ACL = top-level create + per-field write, EV-12 on a gap (TRUE-82841). (jcardinal)
218
219
  - 2026-10-16 — Listed the parent FKs whose nested contact can CREATE a Contact (each must send `customer`); MATCH_UPSERT overwrites a matched contact's customer (TRUE-82841). (jcardinal)
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-21
10
10
  owners: [jcardinal, ajean, rgirish, bala]
11
11
  files:
12
12
  - worker2/Controller/Index.php
@@ -48,6 +48,10 @@ Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** app that pro
48
48
  | API Gateway `WorkerWebhookIngestion` | `webhook.togahub.com` → Lambda. Lambda Function URLs are blocked by an AWS Org SCP, so API Gateway is the only public entry point. |
49
49
  | Outbound (egress) IP | All prod outbound traffic leaves from **`34.232.23.158`** (same IP is the `sourceIp` on api2 `Logs_<Tenant>.Api` rows for worker2-made calls). Prod instances have **no public IP**. They run in the shared subnet `subnet-0e135d4093ab60b2f` (`true-production-private-web-1c`, VPC `vpc-08030daf1c2dad280`), owned by the network AWS account `339712893798`. Its `0.0.0.0/0` route goes to NAT `nat-0e2c874a7a3740e7a`, which is **not visible** from the `toga-production` SSO profile (`describe-nat-gateways` returns NotFound); changing it needs network-account access (Jeff). Any third party that allowlists worker2 needs this IP. |
50
50
 
51
+ ## Calling api2 from worker2
52
+
53
+ Worker jobs call api2 as an **API record** (`Apis` + API-key auth), never as a fake `Users` row. Rule: [framework-rules](../../standards/framework-rules.md) → *Machine callers use an API record, never a fake user*.
54
+
51
55
  ## No SQL migration files in this repo
52
56
 
53
57
  **This repo contains PHP and Python only. Never create `.sql` schema-migration files here.** All table creation, `ALTER` statements, index changes, and seed data — including for `Core.WorkerJobs` and `Core.CronJobs` — go in the `dbchanges2` repo using its `YYYY-MM-DD<letter> - <Description>.sql` naming convention.
@@ -6,7 +6,7 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-10-19
9
+ updated: 2026-10-22
10
10
  owners: ["kyalamarthi", "ajean", "mhammontree", "jcardinal", "bala"]
11
11
  files:
12
12
  - worker2/.gitignore
@@ -49,6 +49,7 @@ the publish on a credential literal.
49
49
  | AWS access key (`[talos]` group) | `worker2/Config/*.ini` `[talos]` | Open — not part of TRUE-82705; rotate + replace with the instance role. |
50
50
  | Compass PEOPLE-file SFTP password (`cgsftp`) | `worker2/Worker/Client/Compass/PeopleFile.php` constant `SFTP_PASSWORD` | Open — move to Secrets Manager, rotate. |
51
51
  | Other accounts on the **same** SFTP server (1.0 copies) | NYCDOE `FTP_PASSWORD` in `library/app/asnprocessor/{apple,lenovo,lexmark,acer}.php`; Staples in `worker/crons/sync/staples/{sync_staples_cxml.php,staples_asn_netsuite.php}` (worker2's copy is the `[sftp_staples]` ini group) | Open — move to Secrets Manager, rotate. The 2.0 NYCDOE import already reads `sftp/nycdoe/<mfr>`; rotate each account in all its places at once. Server: [Shared SFTP server](./shared-sftp-server-source-ip-ban.md). |
52
+ | Elite api2 API secret (`Client_Elite.Apis` row) | `worker2/Worker/Elite.php:7` (hardcoded) | Open (found 2026-10-22, TRUE-82919) — rotate the `Apis` secret and load the row by name at run time, as `Worker/Workflow/Run.php` does. Ticket not yet decided. |
52
53
  | PayPal webhook signature verification **off** in production | `worker2/Config/production.ini:182` | Impact already written up — a caller who knows the URL can create a paid SalesOrder. See [Rate PayPal subscription webhook](../../../../clients/rate/features/paypal-subscription-purchase-webhook.md). |
53
54
 
54
55
  ### Secrets stored at runtime in `Core.WorkerJobs` (not committed, same exposure)
@@ -97,6 +98,7 @@ the publish on a credential literal.
97
98
  - [Rate PayPal Subscription Purchase & Webhook Pipeline](../../../../clients/rate/features/paypal-subscription-purchase-webhook.md)
98
99
 
99
100
  ## Change history
101
+ - 2026-10-22 — Added the hardcoded Elite API secret in `Worker/Elite.php` (TRUE-82919). (jcardinal)
100
102
  - 2026-10-19 — Added the shared SFTP server passwords: Compass `PeopleFile.php` (worker2) and the 1.0 NYCDOE/Staples copies (TRUE-82898). (bala)
101
103
  - 2026-10-12 — Moved the `[cloud]` AWS key to Fixed (deleted, TRUE-82705); listed the `[talos]` AWS key as still open (jcardinal)
102
104
  - 2026-10-06 — WorkerJobs runtime exposures: trace arguments (`zend.exception_ignore_args` devops option), NYCDOE FTP passwords in old job params (mhammontree)
@@ -369,6 +369,7 @@ Debug-path guards (`_underscore/Worker.php`, 2026-10-20):
369
369
  - **Non-2xx throws** (the worker answers 200 on every path; 5xx = the job fataled). Timeout 300 s per hop; nested waits stack, so the top can time out while lower levels still run — check `WorkerJobs`. Two related debug-mode side effects to expect: `_Component_Api_Toga` calls
370
370
  `$api->debug(true)`, and `ApiRequest.php` **echoes an HTML dump** — which produces
371
371
  *"http_response_code(): Cannot set response code - headers already sent"* inside the worker.
372
+ - **False 500:** a job whose child job prints output before `worker2/Controller/Index.php` (~L130) sets `http_response_code(200)` is reported as HTTP 500 even when it worked — and the non-2xx makes the parent `runTask` throw. Check `WorkerJobs` / the data before chasing the "failure". The dump no longer prints auth secrets (see [API-key auth](../../api2/features/api-key-authentication-and-scoping.md#gotchas)).
372
373
 
373
374
  ### An ABSENT `WorkerJobs` row is ambiguous — don't read it as "never queued"
374
375
 
@@ -552,6 +553,7 @@ Rules: rename **both sides in one PR and one deploy**; grep every repo for the o
552
553
  commit-before-SQS transaction pattern that the worker relies on.
553
554
 
554
555
  ## Change history
556
+ - 2026-10-22 — Debug mode: child-job output before `http_response_code(200)` reads as a false 500 (TRUE-82919). (jcardinal)
555
557
  - 2026-10-22 — Gotcha: vendor named args must match vendor param names; PHP 8.5 vendor tree. (snaredla)
556
558
  - 2026-10-20 — Debug-mode `runTask` capped at depth 5 (`X-Toga-Worker-Depth`), throws on non-2xx, 300 s timeout; local ini needs `[api] worker` (TRUE-82934). (jcardinal)
557
559
  - 2026-10-08 — Direct POST needs the worker key in `Authorization`, runs synchronously, writes no `WorkerJobs` row. (rgirish)
@@ -5,7 +5,7 @@ project: _Underscore
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-10-18
8
+ updated: 2026-10-21
9
9
  owners: [jcardinal, ajean, apeterson, mhammontree]
10
10
  files: []
11
11
  related:
@@ -78,6 +78,21 @@ match a parameter name on the target method.
78
78
  Direct calls bypass retry logic, error handling, SQS visibility timeout, WorkerJobs tracking and
79
79
  monitoring. Even in development, dispatch through the queue — do not invoke the method directly.
80
80
 
81
+ ## Machine callers use an API record, never a fake user
82
+
83
+ Machine / system callers (worker2 jobs, crons, engines, integrations) authenticate to api2 as an
84
+ **API record** (`Apis` table + API-key auth), never as a `Users` row. Users are only for real people.
85
+
86
+ - Never create a "system" / "service" / fake `Users` record (no-password user, `@system.invalid`
87
+ email, etc.) to run background work.
88
+ - Grants for machine work go to the API record (`Apis_Roles`), not to a user or to a role a fake
89
+ user holds.
90
+ - Incident: TRUE-82919's first draft added a `workflow@system.invalid` `Users` row per client for
91
+ the workflow run engine. It was replaced with an API record.
92
+
93
+ How API-key auth and scoping work:
94
+ [api-key-authentication-and-scoping](../apps/api2/features/api-key-authentication-and-scoping.md).
95
+
81
96
  ## API response envelope (api2 only)
82
97
 
83
98
  Every action method in `api2` controllers must return an array with exactly these keys:
@@ -20,11 +20,12 @@ apps:
20
20
  - toga25-desk
21
21
  - toga25-blox
22
22
  - toga25-iq
23
+ - apiproxy
23
24
  project: _Underscore
24
25
  client: true
25
26
  type: profile
26
27
  status: active
27
- updated: 2026-10-18
28
+ updated: 2026-10-21
28
29
  owners: [jcardinal, kyalamarthi, tcox, rgirish, mhammontree, akhokhani]
29
30
  files: []
30
31
  related:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.1107",
3
+ "version": "1.0.1108",
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",