toga-ai 1.0.504 → 1.0.505

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.
@@ -8,6 +8,7 @@
8
8
  | [Diagnostic Dialog — View Recommended Services Routing](features/diagnostic-dialog-view-recommended-services.md) | Two "View Recommended Services" buttons exist in the TOGa Refresh 2026 SR view: 1. | library/app/model/toga/diagnostic.php, library/app/model/servicerequest.php |
9
9
  | [Elite Freshservice Sync (library)](features/elite-freshservice-sync.md) | `App_Api_Toga2` in `library/app/api/toga2.php` orchestrates bidirectional sync between TOGA 2 and TOGaDesk. | library/app/api/toga2.php |
10
10
  | [Branded HTML Email Templates (App_Email_Template)](features/email-templates.md) | `App_Email_Template` (`app/email/template.php`) is the base class for branded HTML emails in the 1.0 (`App_`) framework. | library/app/email/template.php, library/app/email/agilant.php |
11
+ | [Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)](features/error-capture-1-0.md) | The 1.0 side of the platform error-reporting pipeline (TRUE-78188). | library/app/error/capture.php, library/app/error.php, library/app/exception/business.php, library/app/cloud.php, worker/config.worker.ini, worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php |
11
12
  | [1.0 MVC Page Pattern & New-App Skeleton](features/mvc-page-pattern-and-app-skeleton.md) | This is the **reusable recipe for standing up a new 1.0 (`App_`) application** and for adding pages to one — the folder-based MVC routing, the page lifecycle, t | library/app/framework.php, library/app/frameworkindex.php, library/app/mvc.php, library/app/database.php, library/app/model.php, library/app/config.php |
12
13
  | [isFulfillable from NetSuite during Item Sync (Phase 1)](features/netsuite-item-isfulfillable-sync.md) | This is the **1.0 (Phase 1)** half of the `isFulfillable` feature: reading the NetSuite `isfulfillable` flag during item sync and stamping it onto the **Agilant | library/app/netsuite.php, library/app/api/toga2.php, worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/backfill_isfulfillable_jul5.php |
13
14
  | [NetSuite SuiteQL/REST API Reference](features/netsuite-suiteql-api-reference.md) | General working reference for the Agilant NetSuite integration: how to authenticate, how SuiteQL behaves, and the confirmed schema of the tables/columns/codes w | library/app/api/netsuite/rest.php, library/ssl/netsuite_ec_key.pem, test/@dave/Junk Drawer/nsq.php |
@@ -0,0 +1,165 @@
1
+ ---
2
+ title: "Error Capture in 1.0 (App_Error_Capture → shared 2.0 Logs DB)"
3
+ framework: "1.0"
4
+ repo: library
5
+ project: Library
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-04
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - library/app/error/capture.php
13
+ - library/app/error.php
14
+ - library/app/exception/business.php
15
+ - library/app/cloud.php
16
+ - worker/config.worker.ini
17
+ - worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php
18
+ related:
19
+ - ../architecture.md
20
+ - ../../../2.0/apps/_underscore/features/error-reporting-issue-event.md
21
+ - ../../../2.0/apps/worker2/features/error-escalation-cron.md
22
+ - ../../tools/features/errors-curation-console.md
23
+ - ../../standards/backend-php.md
24
+ ---
25
+
26
+ ## Summary
27
+
28
+ The 1.0 side of the platform error-reporting pipeline (TRUE-78188). `App_Error_Capture`
29
+ (`library/app/error/capture.php`) records 1.0 errors as occurrences in the **same shared 2.0 Logs
30
+ database** (`Logs.Issue` / `IssueFingerprint` / `Event`) that 2.0 writes to, so 1.0 and 2.0 feed
31
+ **one issue list, one Tools console, one escalation cron**. Before this, 1.0 errors existed only
32
+ in Sentry and were invisible to the issue system.
33
+
34
+ `App_Error_Capture::captureException()` is the single 1.0 capture entry point and returns the
35
+ quotable `Issue.reference`, mirroring `_Error::captureException()` in 2.0. Add new 1.0 capture
36
+ paths by calling it — never by re-implementing persistence.
37
+
38
+ Read the 2.0 doc
39
+ ([error-reporting-issue-event](../../../2.0/apps/_underscore/features/error-reporting-issue-event.md))
40
+ for the data model, fingerprint scheme, reference encoding, and the MySQL constraints. This doc
41
+ covers only what is **different or additional in 1.0**.
42
+
43
+ ## Opt-in by config section
44
+
45
+ Capture requires a **`[database_toga2logs]`** section in the app's config (added to
46
+ `worker/config.worker.ini`). **An app with no such section silently does not capture.** That is
47
+ deliberate: `library` is shared by every 1.0 app, and a shared library must not fatal or spam an
48
+ app that has not been wired to the Logs DB yet. Wiring a new 1.0 app into error reporting is
49
+ therefore purely a config change.
50
+
51
+ > `dbname` is **not** necessarily `Logs` — 2.0 resolves the Core Logs database name at runtime
52
+ > from `Core.Databases` id 11. Read the real name per environment (same caveat as the Tools
53
+ > console).
54
+
55
+ ## How it works
56
+
57
+ ### Hand-written, fully escaped SQL
58
+
59
+ 1.0 has **no `_Model`**, so every statement in `capture.php` is hand-written against
60
+ `App_Database`, with every interpolated value escaped (`App_Database::sqlEscape()`) or cast. There
61
+ is no model layer to absorb a schema change here: a rename in the shared Logs DB must be applied
62
+ to this file in the same release as the migration (the same exposure the Tools console has).
63
+
64
+ ### `App_Exception_Business` — one issue across both frameworks
65
+
66
+ `library/app/exception/business.php` mirrors 2.0's `_Exception_Business`: a stable `issueKey` plus
67
+ a `minimumUrgency` floor. **The fingerprint text is byte-identical to 2.0's** (`'issueKey: ' .
68
+ KEY`), so the *same key* thrown from either framework resolves to **one** Issue with one
69
+ occurrence count and one set of recipients. Do not "tidy" that string on either side — the
70
+ cross-framework identity is the whole point.
71
+
72
+ Business issues are titled by their key: `Logs.Issue.subject` holds the `issueKey` (e.g.
73
+ `COMPASS_SALES_ORDER_MITS_TRANSMIT_REJECTED`) while `Issue.errorMessage` and each
74
+ `Event.errorMessage` keep the per-occurrence text. Otherwise the issue ends up named after
75
+ whichever occurrence happened to be seen first — i.e. one arbitrary order number.
76
+
77
+ Compass USA's sales-order → MITS transmit rejection was the first business-exception use case.
78
+
79
+ ### Client attribution
80
+
81
+ `App_Error::setCurrentClientId()` exists in 1.0 for parity with 2.0's ambient current-client, but
82
+ **1.0 has no central hook** equivalent to `_Database::registerClientDatabases()`. Client-scoped
83
+ 1.0 crons therefore **set it explicitly** (see
84
+ `worker/crons/toga2/compass/workflow/1_transmit_compass_sales_orders_to_mits.php`). Capture
85
+ prefers an explicit `clientId` argument and falls back to the ambient value.
86
+
87
+ ### Fingerprint fallback (no usable stack)
88
+
89
+ Frames are keyed on **file + function**, not `file:line`, so inserting lines above a throw site
90
+ does not split an issue. When there is no usable application stack (fatals, OOM), identity is
91
+ **exception class + entry point (script basename) + message with digits masked** — never the
92
+ exception's own file/line, which for an OOM is wherever the allocation happened to cross the
93
+ limit. Same scheme as 2.0; keep the two implementations in lockstep.
94
+
95
+ ### The 1.0 "red box" error page
96
+
97
+ The rendered error page now headlines the **issue reference** (e.g. `1A-1`). Sentry's event id is
98
+ a fallback shown only when our own capture produced nothing. The `sleep(13)` that existed purely
99
+ to let Sentry ingest before the link was clicked is gone. Business exceptions get a different
100
+ headline and **do** render their message — safe for that class only, because a business message is
101
+ written for a reader and carries no file, line, or trace.
102
+
103
+ ### `Logs.Event.context` shape
104
+
105
+ Identical in both frameworks: **every top-level key holds an object**, and scalars are grouped
106
+ under a `Reference` object. Top-level keys are the display names — `Reference`, `GET`, `POST`,
107
+ `Runtime`, `AWS`, `SERVER`. `GET`/`POST` are cast to objects so an empty one serializes as `{}`,
108
+ not `[]`. `$_POST` is captured and redacted on the same key-name basis as `$_GET` (note PHP only
109
+ populates `$_POST` for form encodings, so JSON API bodies deliberately do not appear). The
110
+ over-length shed list was renamed in lockstep and its marker is an object too.
111
+
112
+ ### Elastic Beanstalk environment name on AL1
113
+
114
+ 1.0 runs **"64bit Amazon Linux/2.8.8" (AL1)**, which predates
115
+ `/opt/elasticbeanstalk/bin/get-config`, and the `042-write-eb-environment.sh` deploy hook only
116
+ exists in `api2`/`worker2`. So 1.0 reads the AL1 container config at
117
+ `/opt/elasticbeanstalk/deploy/configuration/containerconfiguration`, searching **recursively** for
118
+ `environment_name` (its position moved between platform versions), then falls back to
119
+ `aws ec2 describe-tags`.
120
+
121
+ ## Gotchas / known issues
122
+
123
+ - **Any PHP warning inside capture code would kill the request.** 1.0's
124
+ `App_Error::handleError()` routes **every** PHP warning into `handleException()`, which renders
125
+ the red box and calls `exit()` — so a single warning raised while capturing (an unreachable IMDS
126
+ endpoint is the everyday case: `file_get_contents` warns before returning `false`) terminates
127
+ the request or cron mid-run while reporting an unrelated error. 2.0's equivalent handler
128
+ *throws*, which a `catch` absorbs. `captureException()` therefore sets
129
+ `App_Error::setThrowExceptionsEnabled(false)` for its own duration and restores it in
130
+ `finally`. This disables the **handler**, not real throws — `App_Query` raises a plain
131
+ `throw new Exception` on DB errors, so genuine failures still reach the catch. **This is a
132
+ general 1.0 trap for any code that must not escalate**, not just error capture.
133
+ - **No typed properties anywhere in `library/`.** 1.0 runs **PHP 7.2**; a deploy failed with
134
+ `syntax error, unexpected '?'` on `private static ?string $memoryReserve`. Nine typed properties
135
+ across `error.php`, `error/capture.php`, and `exception/business.php` were converted to untyped
136
+ with `@var` docblocks. Parameter and return types are fine (7.0/7.1).
137
+ - **An early `return` can make a fallback unreachable.** Two bugs in the first EB-environment
138
+ attempt: a `return` when `get-config` was not executable skipped the `describe-tags` fallback
139
+ entirely — i.e. the fallback was dead on the only platform that needed it — and the CLI call
140
+ omitted `App_Cloud::$preCommands`, so it did not authenticate the way every other 1.0 AWS call
141
+ does. **Unverified:** whether that IAM identity actually holds `ec2:DescribeTags`.
142
+ - **Fingerprint scheme changes re-group existing issues once.** Moving to file+function frames and
143
+ the class+entryPoint fallback changes hashes, so open issues re-fingerprint on first occurrence
144
+ after deploy. Deliberate; merge old hashes onto the curated Issue with the Tools
145
+ merge-fingerprint box.
146
+ - **Pre-existing committed secrets (location only — not fixed, out of scope).**
147
+ `library/app/cloud.php` holds a hardcoded **AWS access key id and secret access key** as
148
+ class constants/statics (~lines 23–24), used by `App_Cloud::$preCommands` for every 1.0 AWS CLI
149
+ call. Unrelated to this work. Treat as compromised: move to SSM like the other parameters and
150
+ rotate.
151
+
152
+ ## Change history
153
+
154
+ - 2026-08-04 — Built the 1.0 side of TRUE-78188: `App_Error_Capture` writing occurrences into the
155
+ shared 2.0 `Logs` DB (opt-in via `[database_toga2logs]`, hand-written escaped SQL);
156
+ `App_Exception_Business` with a fingerprint string byte-identical to 2.0's so one `issueKey`
157
+ spans both frameworks; business issues titled by `issueKey`; the red-box page headlining the
158
+ issue reference and dropping the Sentry `sleep(13)`; the uniform object-per-top-level-key
159
+ `Event.context` shape with `$_POST` capture; AL1 container-config + `describe-tags` resolution of
160
+ the EB environment name; explicit `setCurrentClientId()` for client-scoped crons. Recorded two
161
+ framework-level traps: PHP 7.2 forbids typed properties in `library/`, and
162
+ `App_Error::handleError` escalates any warning to a rendered page + `exit()`, which capture
163
+ suppresses with `setThrowExceptionsEnabled(false)`/`finally`. Also fixed a latent `$this->errStr`
164
+ in a static branch of `error.php` and noted the pre-existing hardcoded AWS keys in
165
+ `app/cloud.php`. (jcardinal)
@@ -7,7 +7,7 @@
7
7
  | [CloudFront Client Setup](features/cloudfront-client-setup.md) | An SSO-gated admin tool at **`/devops/cloudfront-clients`** in the Tools 1.0 app that onboards a client onto **CloudFront + Route 53 across multiple AWS account | tools/_/app/devops/cloudfront.php, tools/mvc/devops/cloudfront-clients/get.php, tools/mvc/devops/cloudfront-clients/post.php, tools/assets/js/cloudfront-clients.js, tools/assets/css/cloudfront-clients.css, tools/_/app/nav.php, tools/_/app/frameworkindex.php, tools/config.production.ini |
8
8
  | [Design Demo Admin](features/design-demo-admin.md) | A self-serve admin UI at **`/design`** in the SSO-protected **Tools** app that lets the design team publish self-contained "Claude Design" HTML exports as **ver | tools/_/app/design/github.php, tools/mvc/design/get.php, tools/mvc/design/post.php, tools/assets/css/design.css, tools/assets/js/design.js, tools/_/app/frameworkindex.php, tools/_/app/nav.php, tools/composer.json |
9
9
  | [Tools — Developers Folder (UUID & Password Generators)](features/developer-tools.md) | The first two tools shipped in the Tools app, both under the **Developers** folder and gated to personas **Development Team** / **TOGa Technology**. | tools/mvc/developers/uuid/get.php, tools/mvc/developers/password/get.php |
10
- | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only triage/curation screen for the 2.0 Issue/Event error-reporting pipeline, built as a 1.0 Tools MVC page reading the **shared Core Logs DB** through | tools/mvc/errors/get.php, tools/mvc/errors/post.php, tools/_/app/nav.php, tools/config.production.ini |
10
+ | [/errors Curation Console (Tools → shared Core Logs DB)](features/errors-curation-console.md) | Internal-only triage/curation screen for the 2.0 Issue/Event error-reporting pipeline, built as a 1.0 Tools MVC page reading the **shared Core Logs DB** through | tools/mvc/errors/get.php, tools/mvc/errors/post.php, tools/mvc/errors/issue/get.php, tools/assets/css/style.css, tools/_/app/nav.php, tools/config.production.ini |
11
11
  | [Legacy Email Notifier (tools /email-migration/notify)](features/legacy-email-notifier.md) | An SSO-gated admin tool at **`/email-migration/notify`** (nav group **Email Migration** > **Legacy Notifier**, personas `['TOGa Technology','Development Team']` | tools/mvc/email-migration/notify/get.php, tools/mvc/email-migration/notify/post.php, tools/_/app/nav.php |
12
12
  | [Tools MVC — Routing, CSRF & App_Database Access Patterns](features/mvc-data-access-patterns.md) | The load-bearing 1.0 (`App_`) framework conventions a developer needs when adding a page to the Tools app — URL routing, CSRF, and DB access through `App_Databa | tools/_/app/nav.php, tools/mvc/get.php, library/app/error.php |
13
13
  | [Tools Persona-Gated Navigation (App_Nav)](features/persona-gated-navigation.md) | `App_Nav` is the Tools app's two-level, **persona-gated** navigation. | tools/_/app/nav.php, tools/mvc/get.php |
@@ -6,11 +6,13 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-01
9
+ updated: 2026-08-04
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - tools/mvc/errors/get.php
13
13
  - tools/mvc/errors/post.php
14
+ - tools/mvc/errors/issue/get.php
15
+ - tools/assets/css/style.css
14
16
  - tools/_/app/nav.php
15
17
  - tools/config.production.ini
16
18
  related:
@@ -18,6 +20,7 @@ related:
18
20
  - ./mvc-data-access-patterns.md
19
21
  - ../../../2.0/apps/_underscore/features/error-reporting-issue-event.md
20
22
  - ../../../2.0/apps/worker2/features/error-escalation-cron.md
23
+ - ../../library/features/error-capture-1-0.md
21
24
  ---
22
25
 
23
26
  ## Summary
@@ -41,6 +44,35 @@ happening and were deliberately never ticketed. Nobody had that view before.
41
44
  - **Any save sets `isManaged`**, which does double duty: it protects the curated text from being
42
45
  overwritten by later occurrences, **and** it exempts the Issue from the cron's GC sweep.
43
46
 
47
+ ### Routing and pages
48
+
49
+ Filesystem routing (1.0 Tools MVC): `mvc/errors/` = **`/errors`** (the listing) and
50
+ `mvc/errors/issue/` = **`/errors/issue`** (the detail page).
51
+
52
+ **Listing** — 25 per page with a filtered `COUNT` (it was previously a silent `LIMIT 200`, so
53
+ older issues simply did not exist as far as a curator could tell); bold monospace Issue ID; an
54
+ **EB Environment** column read from the newest event's context; a **Type** column showing
55
+ `BUSINESS` or `TECHNICAL`; and search + three dropdown filters (including EB Environment) laid out
56
+ as a **3-column grid** — the original wrapping flex row broke to one control per line.
57
+
58
+ **`Type` needs no new column.** The presence of `Issue.issueKey` *is* the business/technical
59
+ signal. Do not add a `type` column for this (see the 2.0 doc's "proposed and not built" note).
60
+
61
+ **Issue detail** — a two-column split: technical evidence left, business curation (recipients,
62
+ curated text, affected clients) right, with an always-present Type chip.
63
+
64
+ - **Stack trace and context come from the MOST RECENT event**, not the Issue's first-seen
65
+ snapshot. The first occurrence is rarely the one you are debugging.
66
+ - **Context renders as cards**, one per top-level context group, ordered largest-first by recursive
67
+ node count, each a collapsible JSON tree built on native `<details>`/`<summary>` — **no
68
+ JavaScript** — with typed value colouring and `[REDACTED]` called out. This only works because
69
+ capture now emits a uniform object-per-top-level-key shape.
70
+ - A **view-layer** key humanizer (`elasticBeanstalkEnvironment` → "Elastic Beanstalk Environment";
71
+ ALL_CAPS `$_SERVER` keys left verbatim). It is presentation only — never rename keys at capture
72
+ time.
73
+ - Hourly-occurrence chart; fingerprints table re-sized for the narrow column with an icon-only
74
+ Move button.
75
+
44
76
  **This page notifies nobody.** Escalation, ClickUp ticketing, and email are owned entirely by
45
77
  `_Worker_Infrastructure_Errors::Escalate`. Adding a notify action here would create a second,
46
78
  untracked alerting path.
@@ -80,11 +112,32 @@ They were renamed from plural on 2026-08-01, after the pipeline was already in p
80
112
  - **Cross-tenant surface.** The Core Logs DB is shared by every client, so this console can show
81
113
  one client's captured context to a viewer looking at another client's problem. It is
82
114
  internal-only and persona-gated for that reason — do not expose any part of it to a client.
115
+ - **⚠ Never `JSON_EXTRACT` `Event.context`.** The column is `TEXT` and a context truncated at
116
+ `CONTEXT_MAX_LENGTH` is **invalid JSON** — `JSON_EXTRACT` would raise and take the whole page
117
+ down. The EB Environment filter therefore matches with **`SUBSTRING_INDEX`**, uses `EXISTS` (has
118
+ this issue *ever* occurred on that deployment), and validates the submitted value against the
119
+ discovered list — an allowlist, which also rules out `LIKE` wildcards.
120
+ - **The distinct-EB-environment query is a full scan of `Event`.** Acceptable only because that
121
+ table is bounded by the 7-day purge. At volume the proper fix is denormalising the EB environment
122
+ onto its own `Event` column **at capture time**.
123
+ - **Read both the old and new `Event.context` key positions.** The context shape was restructured
124
+ on 2026-08-04 (one object per top-level key, scalars under `Reference`); with 7-day retention,
125
+ both shapes coexist for a week after any such change.
83
126
  - **Curated text is authoritative.** Once `isManaged` is set, later occurrences no longer update
84
127
  subject/description, so a stale curated subject stays stale until someone edits it again.
85
128
 
86
129
  ## Change history
87
130
 
131
+ - 2026-08-04 — Console build-out: the listing gained real pagination (25/page with a filtered
132
+ `COUNT`, replacing a silent `LIMIT 200`), an EB Environment column **and filter**, a
133
+ BUSINESS/TECHNICAL Type column derived from `issueKey` (no new DB column), and a 3-column filter
134
+ grid. New `/errors/issue` detail page split technical/curation into two columns, showing the
135
+ **most recent** event's trace and context, with context rendered as largest-first cards over a
136
+ JavaScript-free `<details>` JSON tree, a view-layer key humanizer, a Type chip, and an
137
+ hourly-occurrence chart. Recorded the load-bearing gotcha that `Event.context` must be matched
138
+ with `SUBSTRING_INDEX` and never `JSON_EXTRACT` (a truncated context is invalid JSON and would
139
+ take the page down), that the filter value is allowlisted against the discovered list, and that
140
+ the distinct-values scan is only safe because `Event` is bounded by the 7-day purge. (jcardinal)
88
141
  - 2026-08-01 — Updated every hardcoded table name in `tools/mvc/errors/get.php` (25+ refs) and
89
142
  `post.php` (15+ refs) for the Core Logs singular rename (`Issues`→`Issue`, `Events`→`Event`,
90
143
  `IssueFingerprints`→`IssueFingerprint`, `IssueClickupTasks`→`IssueClickupTask`,
@@ -6,7 +6,7 @@ project: Tools
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-29
9
+ updated: 2026-08-04
10
10
  owners: [jcardinal, ajean]
11
11
  files:
12
12
  - tools/mvc/talos/kb-documents/get.php
@@ -303,6 +303,13 @@ Refactor from a single hard-coded `development-team` KB to per-AI-model, data-dr
303
303
  idempotent, **fails the deploy loudly** if `pgsql` won't load) and
304
304
  `hooks/postdeploy/01-restart-php.sh` (restarts php-fpm/httpd). **EB runs `.platform/hooks`
305
305
  regardless of the git exec bit** — worker2's equivalents are committed `100644`.
306
+ > **Rewritten 2026-08-04 (in `tools` and `worker2`) — it failed on PHP 8.5.** The old hook tried
307
+ > the **generic** `php-pgsql` first, which resolves against the default 8.1 stream and can
308
+ > conflict; it also discarded `dnf` output and only checked that `pgsql` loaded, not
309
+ > **`pdo_pgsql`** (the extension `App_Pg` actually needs). It now tries the **versioned**
310
+ > candidate first, captures and prints `dnf` output so a failure is diagnosable from
311
+ > `eb-hooks.log`, checks **both** extensions, and honours a `PGSQL_HOOK_OPTIONAL=1` escape hatch
312
+ > for environments that must deploy without Postgres.
306
313
  - Apply the dbchanges2 `Team/2026-06-30a..e` + `Core/2026-06-30a` migrations in order.
307
314
  - Verify the Bedrock KB region / data-source name against the live account (TODOs in code).
308
315
 
@@ -397,6 +404,11 @@ Refactor from a single hard-coded `development-team` KB to per-AI-model, data-dr
397
404
 
398
405
  ## Change history
399
406
 
407
+ - 2026-08-04 — Rewrote the `prebuild/01-install-php-pgsql.sh` hook (here and in `worker2`), which
408
+ failed on PHP 8.5: it tried the generic `php-pgsql` (resolving against the default 8.1 stream),
409
+ swallowed `dnf` output, and checked only `pgsql` rather than `pdo_pgsql`. Now tries the versioned
410
+ candidate first, prints `dnf` output, verifies both extensions, and supports
411
+ `PGSQL_HOOK_OPTIONAL=1`. (jcardinal)
400
412
  - 2026-07-29 — **Fixed the empty Vocabulary page: the per-model scoping shipped with no data
401
413
  backfill.** `vocabulary/get.php` reads strictly `WHERE c_trueAiModelId = <model>` (no NULL
402
414
  fallback), but `Team/2026-07-27a` added the column `DEFAULT NULL` and nothing populated it —
@@ -8,6 +8,7 @@
8
8
  | [Elite TOGA 2.0 → TOGaDeskSupport Standalone Attachment Sync](features/elite-togadesk-attachment-sync.md) | `sync_togadesk_elite_attachments.php` is a standalone cron (every 5 minutes) that syncs file attachments from TOGA 2.0 into TOGaDeskSupport for Elite. | worker/crons/toga2/elite/sync_togadesk_elite_attachments.php, worker/crons/toga2/elite/test_sync_togadesk_elite_attachments.php |
9
9
  | [Forecast2 ↔ NetSuite Reconciliation & Trueup Tooling](features/forecast2-netsuite-reconciliation.md) | CLI tools to **audit** and **repair** drift between the production `Forecast` DB (core2) and NetSuite. | test/@dave/checker.php, worker2/Component/Forecast/SaleImport/SaleImport.php, test/@dave/looper.php, test/@dave/reconcile_netsuite_totals.php, test/@dave/fixer.php, test/@dave/analyze_netsuite_forecast_diff.php, test/@dave/trueup_sales.php, test/@dave/reconcile_drift_2023plus.php, test/@dave/probe_invoice_gap_2026.php, test/@dave/probe_creditmemo_gap_detail.php, test/@dave/trueup_open_orders.php, test/@dave/loop_trueup_open_orders.php, test/@dave/trueup_opportunities.php, test/@dave/probe_sales_gap_direct.php, test/@dave/probe_missing_oo_timing.php, test/@dave/probe_missing_oo_createdby.php, test/@dave/probe_drift_so_dates.php, test/@dave/probe_profit_invoices.php, test/@dave/probe_profit_gap.php, worker/crons/toga2/forecast2/common_import_sales_from_netsuite.php, worker/crons/toga2/forecast2/periodic_forecast_discrepancy_fix_open_orders.php, worker/crons/toga2/forecast2/import_open_orders.php, worker/schedules/cron.worker.infrastructure.json |
10
10
  | [NetSuite → TOGa Supply Per-Client Sync (thin wrappers)](features/netsuite-togasupply-per-client-sync.md) | Syncs NetSuite transactions (sales orders, purchase orders, invoices, item receipts, item fulfillments, inventory adjustments) into each TOGa Supply (2.0) clien | worker/crons/toga2/netsuite/common_sync_togasupply.php, worker/crons/toga2/netsuite/sync_togasupply_canon.php, worker/schedules/cron.worker.sync.json, dbchanges2/_modules/netsuite/2026-04-01 - Parameters.sql, library/app/api/netsuite/rest.php, library/app/systemmonitor/netsuiteintegration.php |
11
+ | [OneUptime Server monitor + disk/memory hygiene on the 1.0 worker EB host](features/oneuptime-server-monitor-host-hygiene.md) | The 1.0 `agilant-worker` EB environment runs on the **legacy Amazon Linux 1 PHP 7.2 platform** (Apache httpd/prefork, s3fs mounts, cron) and repeatedly went dow | worker/.ebextensions/040_disk_memory_hygiene.config, worker/.ebextensions/045_oneuptime_agent.config, worker/ebs/cron.worker.php, worker/ebs/mount-s3fs-folders.php, worker/ebs/apache_settings.php, worker/ebs/setup_phpini.php |
11
12
  | [OneUptime external uptime monitoring for 1.0 workers](features/oneuptime-worker-uptime-monitoring.md) | Every 1.0 worker box self-reports its liveness to an external OneUptime monitor once per minute by curl-POSTing to a per-worker "Incoming Request" heartbeat URL | library/app/worker.php, worker/crons/worker/worker_heartbeat.php |
12
13
  | [Prudential: Send Shipments for the Day report (daily cron)](features/send-shipments-for-the-day.md) | Daily cron (9:00 PM) that emails Prudential and Dell stakeholders an Excel report of all devices shipped that day, including tracking number, serial number, emp | worker/crons/notifications/reports/send_shipments_for_the_day.php |
13
14
  | [Diagnosing frozen 1.0 worker cron check-ins (Sentry "missed" flood)](workflows/diagnosing-frozen-cron-checkins.md) | When 1.0 worker cron timestamps freeze and Sentry project `worker1` fills with **`missed`** check-ins, the intuitive diagnosis — a wedged `App_Framework::isProc | worker/.ebextensions/cron.config, library/app/worker.php |
@@ -0,0 +1,204 @@
1
+ ---
2
+ title: OneUptime Server monitor + disk/memory hygiene on the 1.0 worker EB host
3
+ framework: "1.0"
4
+ repo: worker
5
+ project: Worker
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-03
10
+ owners: ["jcardinal"]
11
+ files:
12
+ - worker/.ebextensions/040_disk_memory_hygiene.config
13
+ - worker/.ebextensions/045_oneuptime_agent.config
14
+ - worker/ebs/cron.worker.php
15
+ - worker/ebs/mount-s3fs-folders.php
16
+ - worker/ebs/apache_settings.php
17
+ - worker/ebs/setup_phpini.php
18
+ related: ["oneuptime-worker-uptime-monitoring"]
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ The 1.0 `agilant-worker` EB environment runs on the **legacy Amazon Linux 1 PHP 7.2
24
+ platform** (Apache httpd/prefork, s3fs mounts, cron) and repeatedly went down from the EC2
25
+ instance running **out of memory and out of disk**. Two things address it:
26
+
27
+ 1. **Host-level observability** — a OneUptime **Server monitor** per worker role, fed by the
28
+ OneUptime **Infrastructure Agent** running on the box (CPU / memory / disk / network /
29
+ process metrics every 30s). This is *not* a heartbeat — see
30
+ [[oneuptime-worker-uptime-monitoring]] for the separate Incoming-Request heartbeat layer.
31
+ 2. **Disk/memory hygiene** — a `.ebextensions` config that reaps stale files and rotates
32
+ logs (safe/additive), plus a set of **behavior-changing fixes that are documented but
33
+ NOT applied** (Apache prefork sizing, PHP `memory_limit`, s3fs `ensure_diskfree`,
34
+ cron `flock`).
35
+
36
+ ## Key files / entry points
37
+
38
+ | File | Purpose | Risk |
39
+ |---|---|---|
40
+ | `.ebextensions/040_disk_memory_hygiene.config` | Deletes stale junk (s3fs `/tmp` cache, `/var/www/cache` staging, orphaned `WORKER_ERROR_*`, deploy litter) + rotates logs | Safe / additive — deletes only stale files |
41
+ | `.ebextensions/045_oneuptime_agent.config` | Installs + supervises the OneUptime infrastructure-agent (static Go binary, v11.7.4) | Safe / additive — no app behavior change |
42
+
43
+ ## How the Server monitor works
44
+
45
+ - The agent POSTs metrics every **30s** to
46
+ `{oneuptime_url}/server-monitor/response/ingest/{secret_key}`; OneUptime evaluates the
47
+ monitor's **Criteria** against them to decide Operational / Degraded / Offline.
48
+ - **AL1 has no systemd**, so `045_…` deliberately skips the agent's `configure`/`start`
49
+ subcommands (they register a systemd/SysV service and fail here). It writes
50
+ `/etc/oneuptime-infrastructure-agent/config.json` directly and runs the foreground **`run`**
51
+ subcommand under a **1-minute cron watchdog** — the same systemd-bypass the ODP Voice
52
+ container monitor uses.
53
+ - Metrics reported: CPU (aggregate + per-core + user/system/idle/iowait/steal breakdown),
54
+ load average 1/5/15, memory + swap, **disk per mount** (total/used/free/%, device, fstype,
55
+ I/O counters — criteria target a specific path), network (aggregate + per-interface bytes/
56
+ packets/errors/drops, TCP connection counts; cumulative since boot, OneUptime derives
57
+ throughput from deltas), host info (platform/kernel/uptime/virtualization), and
58
+ **per-process** pid/name/command/cpu%/mem%/status/threads/user (enables
59
+ "process is executing" criteria).
60
+
61
+ ## Provisioning — already done, per-role model
62
+
63
+ `agilant-worker` is one EB Worker tier whose instances each claim ONE stable role
64
+ (`ebs/cron.worker.php`), so there is **one Server monitor per role (7)**, grouped under the
65
+ OneUptime label **`Worker 1.0`**. Each monitor already carries the criteria below, and its
66
+ secret is **hardcoded in `045_oneuptime_agent.config`**, selected at runtime by the role in
67
+ `/etc/worker-role`. Nothing to create — you only need to deploy.
68
+
69
+ | Role | Monitor name |
70
+ |------|--------------|
71
+ | notification | Notification Worker 1.0 - System Health |
72
+ | database | Database Worker 1.0 - System Health |
73
+ | infrastructure | Infrastructure Worker 1.0 - System Health |
74
+ | toga | TOGa Worker 1.0 - System Health |
75
+ | togadesk | TOGa Desk Worker 1.0 - System Health |
76
+ | sync | Sync Worker 1.0 - System Health |
77
+ | catalog | Catalog Worker 1.0 - System Health |
78
+
79
+ **Key rotation:** regenerate that monitor's secret in OneUptime and update the matching
80
+ `case` arm in `045_…`. Instance-ids churn but roles are stable, so a recycled box
81
+ re-reports to the same role monitor automatically.
82
+
83
+ **Creating a monitor from scratch** (only if a new role is added): Monitors → Create Monitor
84
+ → type **Server / VM** → the secret key appears on the monitor's Settings tab after save.
85
+ Via REST it is `POST /api/monitor` with `{"data":{"projectId":…,"name":…,"monitorType":"Server"}}`
86
+ and an `ApiKey` header — but the secret is server-generated and **not** returned in the
87
+ create body; read it from Settings.
88
+
89
+ **Verify after deploy**, on the instance:
90
+
91
+ ```bash
92
+ pgrep -fa oneuptime-infrastructure-agent # process running
93
+ tail -n 40 /var/log/oneuptime-agent.log # agent log
94
+ ```
95
+
96
+ Last Ping updates within ~1 min.
97
+
98
+ ## Criteria (order matters — first match wins, catch-all Operational last)
99
+
100
+ | Order | Result state | Check | Condition | Threshold |
101
+ |---|---|---|---|---|
102
+ | 1 | Offline | CPU Usage % | Greater Than | 90 |
103
+ | 2 | Offline | Memory Usage % | Greater Than | 90 |
104
+ | 3 | Degraded | Disk Usage % (path `/`) | Greater Than | 90 |
105
+ | 4 | Degraded | CPU Usage % | Greater Than | 75 |
106
+ | 5 | Degraded | Memory Usage % | Greater Than | 75 |
107
+ | 6 | Operational (fallback) | CPU Usage % | Less Than Or Equal To | 100 |
108
+
109
+ Each criterion's filter condition is **All**. Recommended additions for this worker
110
+ specifically: Degraded if disk `/` > **80%** (earlier warning — disk is this box's actual
111
+ failure mode), Degraded if swap % used > 50% (early OOM signal once swap exists), and
112
+ Offline if the Apache process is not executing (Server Process → Command contains `httpd`
113
+ → Is Not Executing).
114
+
115
+ Via API, criteria live in `monitorSteps.monitorCriteria.criteriaInstances[]`; each instance
116
+ has `filters[]` (`checkOn`, operator, `value`, plus `serverMonitorOptions.diskPath` for disk
117
+ checks), a `filterCondition` (`All`/`Any`), and a `monitorStatusId`.
118
+
119
+ ## Root cause of the outages
120
+
121
+ ### Disk
122
+
123
+ | Cause | Evidence | Status |
124
+ |---|---|---|
125
+ | s3fs `use_cache=/tmp` **unbounded**, never evicted | `ebs/mount-s3fs-folders.php:53`, `ebs/s3fs.json` (3 buckets) | **Not fixed** — needs `ensure_diskfree` (below) |
126
+ | `/var/www/cache` 0777 with no reaper; DB dumps up to **12 GB/part**, deleted only on successful upload | `ebs/setup_export_cache_folders.php:11-13`, `crons/database/database_backup.php:75,314,337` | Reaper cron shipped in `040_…` |
127
+ | `WORKER_ERROR_*` reaper never scheduled | `crons/worker/check_worker_errors.php` — present in **no** `schedules/*.json` | Reaper cron shipped in `040_…` |
128
+ | Deploy litter (`/tmp/phpredis*`, s3fs source) | `010_setup_redis.config:3-6`, `006_mount-s3fs.config:16-29` | One-off cleanup shipped in `040_…` |
129
+
130
+ ### Memory
131
+
132
+ | Cause | Evidence | Status |
133
+ |---|---|---|
134
+ | Apache prefork massively over-provisioned: `MaxRequestWorkers = ceil(3200*3/37) = 260` while PHP `memory_limit = 2G` | `ebs/apache_settings.php:3,8` + `ebs/setup_phpini.php:22` | **Not fixed** |
135
+ | Long CLI cron jobs overlap (minute-ly schedules, no `flock`), each entitled to 2 GB | `ebs/cron.worker.php`, `schedules/cron.worker*.json` (26–103 jobs) | **Not fixed** |
136
+ | Standing band-aid: Apache `graceful` every 20 min "to close db connections" | `schedules/cron.worker.json:3-7` | **Not fixed** — would be retired by `MaxConnectionsPerChild` |
137
+
138
+ ## Fixes documented but NOT applied — review before applying
139
+
140
+ These change app behavior. Apply deliberately.
141
+
142
+ **Bound the s3fs cache** — `ebs/mount-s3fs-folders.php`, after the `use_cache` option is
143
+ appended (line 53):
144
+
145
+ ```php
146
+ $mount_command .= ' -o ' . escapeshellarg('ensure_diskfree=2000'); // MB; s3fs evicts its own cache to keep >=2GB free
147
+ ```
148
+
149
+ **Right-size Apache** — `ebs/apache_settings.php`: measure the real child size (the command
150
+ is in the file at line 4 — with `memory_limit=2G`, heavy endpoints are far bigger than the
151
+ assumed 37 MB), then replace the `× 3` overcommit on line 8 with
152
+ `MaxRequestWorkers = floor(availableRAM_MB * 0.7 / measuredChildMB)` (≈20–25 on a 4 GB box,
153
+ not 260) and lower `StartServers`/`MinSpareServers` to 4–8. Add `MaxConnectionsPerChild 500`
154
+ inside the `<IfModule prefork.c>` block so children recycle and return leaked memory to the
155
+ OS — this is what retires the 20-minute graceful-restart hack.
156
+
157
+ **Lower cron memory pressure** — `ebs/setup_phpini.php`: drop the global
158
+ `memory_limit = 2G` (line 22) to `512M` and raise it per-script via `ini_set()` only in the
159
+ few CLI batch jobs that need it; set `display_errors = off` (line 20) in production, logging
160
+ to a rotated file instead.
161
+
162
+ **Stop cron overlap** — where `ebs/cron.worker.php` builds each cron command, prefix
163
+ minute-ly/2-minute jobs with a non-blocking lock:
164
+ `flock -n /tmp/lock.<jobname> php /var/www/html/crons/<jobname>`.
165
+
166
+ **Swap (optional stopgap)** — only *after* the disk reapers are in place; this box is
167
+ disk-constrained and a 2 GB swapfile on a near-full root volume makes disk worse:
168
+
169
+ ```yaml
170
+ commands:
171
+ 02_swap:
172
+ test: "[ ! -f /var/swapfile ]"
173
+ command: "dd if=/dev/zero of=/var/swapfile bs=1M count=2048 && chmod 600 /var/swapfile && mkswap /var/swapfile && swapon /var/swapfile"
174
+ ```
175
+
176
+ ## Gotchas
177
+
178
+ - **Disk path `/` works here** — this is a real EC2 host, unlike the ODP Voice container
179
+ case where "Disk metrics for path / were unavailable" appears because the container root
180
+ isn't in the reported disk list. s3fs (fuse) mounts may **not** be reported as normal
181
+ partitions, so prefer alerting on `/` rather than the s3fs mount.
182
+ - The agent is kept alive by a **cron watchdog, not a service** — if it stops reporting,
183
+ check the watchdog and `/var/log/oneuptime-agent.log`, not `service`/`systemctl`.
184
+ - Monitor secrets are hardcoded per-role in `045_…`; treat that file as a credential
185
+ location. Do not copy secrets into docs, logs, or other files.
186
+ - **Security follow-up (urgent, out of scope of the monitoring work):** hardcoded live AWS
187
+ credentials and DB passwords are committed in plaintext in `ebs/cron.worker.php:14-15`,
188
+ `crons/worker/worker_heartbeat.php:12`, `crons/database/database_backup*.php`, and
189
+ `config.worker.ini`. Rotate and move to EB environment properties / an IAM instance role.
190
+
191
+ ## References
192
+
193
+ - Agent + subcommands — https://github.com/OneUptime/oneuptime/blob/master/InfrastructureAgent/README.md
194
+ - Server monitor — https://oneuptime.com/docs/monitor/server-monitor
195
+ - Criteria instance API — https://oneuptime.com/reference/monitor-criteria-instance
196
+ - Monitor REST API — https://oneuptime.com/reference/monitor
197
+ - Agent releases (v11.7.4) — https://github.com/OneUptime/oneuptime/releases
198
+
199
+ ## Change history
200
+ - 2026-08-03 — Captured from `worker/docs/oneuptime-server-monitor.md` (that in-repo doc is
201
+ being deleted; this is now the source of truth). Documents the per-role OneUptime Server
202
+ monitors + infrastructure agent (`045_…`), the shipped disk/memory hygiene reapers
203
+ (`040_…`), the diagnosed disk/memory root causes, and the reviewed-but-unapplied
204
+ behavior-changing fixes. (jcardinal)
@@ -11,7 +11,7 @@ owners: ["jcardinal"]
11
11
  files:
12
12
  - library/app/worker.php
13
13
  - worker/crons/worker/worker_heartbeat.php
14
- related: []
14
+ related: ["oneuptime-server-monitor-host-hygiene"]
15
15
  ---
16
16
 
17
17
  ## Summary
@@ -5,11 +5,12 @@ project: Library
5
5
  client: shared
6
6
  type: standard
7
7
  status: active
8
- updated: 2026-07-24
8
+ updated: 2026-08-04
9
9
  owners: [jcardinal, rgirish, mhammontree]
10
10
  files: []
11
11
  related:
12
12
  - ../apps/library/architecture.md
13
+ - ../apps/library/features/error-capture-1-0.md
13
14
  - ../../2.0/standards/backend-php.md
14
15
  ---
15
16
 
@@ -259,6 +260,49 @@ if ($condition) {
259
260
  $piece = substr($value, 0, 8);
260
261
  ```
261
262
 
263
+ ### No typed properties (PHP 7.2)
264
+
265
+ * 1.0 runs **PHP 7.2**, so **typed properties (7.4+) are forbidden anywhere in `library/` or a 1.0
266
+ app.** Declare class attributes untyped with an `@var` docblock:
267
+
268
+ ```php
269
+ // WRONG in 1.0 — deploy fails with "syntax error, unexpected '?'"
270
+ private static ?string $memoryReserve = null;
271
+
272
+ // CORRECT in 1.0
273
+ /** @var string|null */
274
+ private static $memoryReserve = null;
275
+ ```
276
+
277
+ * **Parameter and return types ARE fine** (PHP 7.0/7.1) — this rule is only about *properties*.
278
+ * This is the opposite of the 2.0 standard, which requires typed properties. The universal
279
+ `rules/toga/common/coding-style.md` rule is already **scoped to 2.0** and cross-references this
280
+ standard for the 1.0 `@var` docblock form — so there is no conflict to resolve, just two different
281
+ rules for two different runtimes.
282
+ * Real failure: a deploy of `library/app/error/capture.php` broke on
283
+ `private static ?string $memoryReserve`; nine typed properties across three files had to be
284
+ converted.
285
+
286
+ ### Any PHP warning terminates the request — how to opt out locally
287
+
288
+ `App_Error::handleError()` routes **every** PHP warning into `handleException()`, which renders the
289
+ error page and calls **`exit()`**. (2.0's equivalent *throws*, which a `catch` can absorb — 1.0 does
290
+ not give you that.) So a warning raised inside code that must not escalate — error capture, a
291
+ monitoring ping, an IMDS probe where `file_get_contents` warns before returning `false`, a retry
292
+ loop — **kills the request or cron mid-run while reporting an unrelated error.**
293
+
294
+ Two sanctioned patterns, in order of preference:
295
+
296
+ 1. **A local handler around the risky call** — `set_error_handler()` throwing an `ErrorException`,
297
+ then `restore_error_handler()`. Use this when you want to *catch and retry* (the NetSuite
298
+ SSL-drop retry loop does this).
299
+ 2. **`App_Error::setThrowExceptionsEnabled(false)` with a `finally` restore** — use this when the
300
+ code must never escalate at all (`App_Error_Capture::captureException()` does this for its whole
301
+ duration). This disables the **handler**, not real `throw`s: `App_Query` raises a plain
302
+ `throw new Exception` on a DB error, so genuine failures still reach your `catch`.
303
+
304
+ Never leave either one in effect beyond the block that needs it.
305
+
262
306
  ### Documentation
263
307
 
264
308
  * **DocBlocks:** framework (`library`) classes/methods are well documented with a description and `@param`/`@return`/`@throws`. Application code is less consistent — document new and modified methods to the framework standard.
@@ -17,7 +17,7 @@
17
17
  | [Re-pointing a DB alias mid-request (_Database::register park/restore)](features/database-alias-repointing.md) | `_Database` keys **all live per-database runtime state by the connection ALIAS** (`Client` / `_underscore::DB_CLIENT`, `ClientLogs`, `Archive`), **not** by the | _underscore/Database.php, _underscore/Query.php, api2/Component/Api/V2/V2.php, api2/Component/Api/CrossClient/CrossClient.php |
18
18
  | [2.0 Email Send Pipeline (queue + Send worker)](features/email-send-pipeline.md) | In 2.0, `_Email::send()` **does not transmit** — it queues the message. | _underscore/Email.php, worker2/Worker/Infrastructure/Email/Send.php |
19
19
  | [Client Email Template Sending](features/email-template-sending.md) | `_Model_Client_EmailTemplate` sends a stored, client-defined email template by UUID. | _underscore/Model/Client/EmailTemplate.php, _underscore/Model/Client/EmailTemplateOutgoingEmailAddress.php, _underscore/Email.php |
20
- | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Exception/Business.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
20
+ | [Error Reporting — Issue/Event Capture, Fingerprinting & Aggregation](features/error-reporting-issue-event.md) | Platform-wide error reporting for TOGA 2.0, built on an **Issue / Event** aggregation model in the **shared Core Logs DB**. | _underscore/Error.php, _underscore/Database.php, _underscore/Exception/Business.php, api2/Controller/Index.php, worker2/Controller/Index.php, _underscore/Model/Core/Logs/Issue.php, _underscore/Model/Core/Logs/Event.php, _underscore/Model/Core/Logs/IssueFingerprint.php, _underscore/Model/Core/Logs/IssueClickupTask.php, _underscore/Model/Core/Logs/IssueEmailAddress.php, _underscore/Model/Core/Logs/IssueAreaOwner.php, dbchanges2/Logs/2026-07-30a - Error reporting Issues and Events.sql, dbchanges2/Logs/2026-08-01a - Rename tables to singular.sql, dbchanges2/Logs_Client/2026-08-01a - Drop Error table.sql, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
21
21
  | [Record-Changed Event Publishing (_Event::publish to SQS)](features/event-publish-sqs.md) | `_Event::publish()` (in `_underscore/Event.php`) is the PHP side of the real-time event pipeline. | _underscore/Event.php |
22
22
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
23
23
  | [isFulfillable Propagation Up the SO↔PO Chain](features/fulfillable-item-propagation.md) | `Items.isFulfillable` is a boolean that gates whether a storefront line's **Qty Fulfilled** cell is actionable. | _underscore/Model/Client/Item.php, _underscore/Model/Compass/Item.php, dbchanges2/Core/2026-07-17 - Items isFulfillable RecordField.sql, dbchanges2/Core/2026-07-17 - RegisterItemIsFulfillableInterceptors.sql, dbchanges2/Client/2026-07-17 - ItemsisFulfillable.sql |
@@ -6,11 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-03
9
+ updated: 2026-08-04
10
10
  owners: ["dfranks", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Error.php
13
+ - _underscore/Database.php
13
14
  - _underscore/Exception/Business.php
15
+ - api2/Controller/Index.php
16
+ - worker2/Controller/Index.php
14
17
  - _underscore/Model/Core/Logs/Issue.php
15
18
  - _underscore/Model/Core/Logs/Event.php
16
19
  - _underscore/Model/Core/Logs/IssueFingerprint.php
@@ -27,6 +30,8 @@ related:
27
30
  - ./email-send-pipeline.md
28
31
  - ../../worker2/features/error-escalation-cron.md
29
32
  - ../../../1.0/apps/tools/features/errors-curation-console.md
33
+ - ../../../1.0/apps/library/features/error-capture-1-0.md
34
+ - ../../api2/features/v2-api-error-codes.md
30
35
  - ../../dbchanges2/architecture.md
31
36
  ---
32
37
 
@@ -76,9 +81,25 @@ Issue itself. Anything that reads like "this client's issue" is a modelling erro
76
81
 
77
82
  ### Fingerprinting (identity)
78
83
 
79
- The fingerprint is the normalized top-N **application** frames (repo-relative `path:line`) plus
80
- the exception class; framework/vendor plumbing is filtered out. The exception **message is
81
- deliberately excluded** — it carries per-occurrence data (ids, values) and would defeat dedup.
84
+ The fingerprint is the normalized top-N **application** frames plus the exception class;
85
+ framework/vendor plumbing is filtered out. The exception **message is deliberately excluded** — it
86
+ carries per-occurrence data (ids, values) and would defeat dedup.
87
+
88
+ **A frame is FILE + FUNCTION, never `file:line`** (changed 2026-08-04). Line numbers made the
89
+ identity hostage to unrelated edits: a 10-line insertion above a throw site in
90
+ `Component/Api/V2/V2.php` split one failure into two Issues (`1E` and `1I`).
91
+
92
+ **Fallback when there is no usable application stack** (fatals, OOM): **exception class + entry
93
+ point (script basename) + the message with digits masked.** It must *not* use the exception's own
94
+ file/line — for an OOM that is wherever the allocation happened to cross the limit, so issues `1F`
95
+ and `1G` were the same memory exhaustion one line apart in `_underscore/Model.php`, a file that
96
+ **is** in the skip list but which the old fallback never consulted.
97
+
98
+ > Both changes alter existing hashes, so open Issues **re-fingerprint once** on their next
99
+ > occurrence after deploy. That one-time re-grouping is deliberate; merge the old hashes onto the
100
+ > curated Issue with the Tools merge-fingerprint box. The 1.0 implementation
101
+ > ([error-capture-1-0](../../../1.0/apps/library/features/error-capture-1-0.md)) uses the same
102
+ > scheme — keep the two in lockstep.
82
103
 
83
104
  This replaced `md5(serialize(debug_backtrace()))`, which serialized **call arguments** and so
84
105
  produced a different hash on every request — dedup never actually worked.
@@ -92,7 +113,7 @@ from the Tools console to merge them.
92
113
  | Thrown class | HTTP | Sentry | `Logs.Issue` | Fingerprint / routing |
93
114
  |---|---|---|---|---|
94
115
  | `_Exception_Validation` | **400** | no | **no** | n/a — bad client input. Still leaves a full `Logs.Api` row (see [api2 request logging](../../api2/features/request-logging.md)) |
95
- | `_Exception_Business` | **500** | yes | **yes** | stable `issueKey` (refactor-immune) → business recipients configured in the Tools console |
116
+ | `_Exception_Business` | **4xx** (was 500 before 2026-08-04) | yes | **yes** | stable `issueKey` (refactor-immune) → business recipients configured in the Tools console. **Now a subclass of `_Exception_Validation` — must be caught FIRST, see below** |
96
117
  | plain global `Exception` | **500** | yes | **yes** | **trace**-based fingerprint (moves when code moves) → developer ClickUp task |
97
118
  | `_Exception` | — | — | — | **never throw it deliberately** (below) |
98
119
 
@@ -113,12 +134,62 @@ exception is ever constructed, so the real signal is destroyed. Jeff states this
113
134
 
114
135
  `_underscore/Exception/Business.php` carries a stable `issueKey` plus a `minimumUrgency` floor.
115
136
  When an `issueKey` is declared, **the key is the fingerprint**, so the Issue's identity does not
116
- depend on line numbers at all. It extends `Exception` (not `_Exception`), matching
117
- `_Exception_Validation`.
137
+ depend on line numbers at all. It extends **`_Exception_Validation`** as of 2026-08-04 (never
138
+ `_Exception`) — see the ordering trap below.
118
139
 
119
140
  It **deliberately never throws on a bad argument** — it normalizes and `error_log()`s instead.
120
141
  Replacing the caller's business error with an argument error destroys the real signal.
121
142
 
143
+ **A business Issue is titled by its key.** `Issue.subject` holds the `issueKey` (e.g.
144
+ `COMPASS_SALES_ORDER_MITS_TRANSMIT_REJECTED`); `Issue.errorMessage` and each `Event.errorMessage`
145
+ keep the per-occurrence text. Otherwise the Issue is named after whichever occurrence was seen
146
+ first — i.e. one arbitrary order number.
147
+
148
+ `library`'s `App_Exception_Business` is the 1.0 twin and builds a **byte-identical** fingerprint
149
+ string (`'issueKey: ' . KEY`), so one key thrown from either framework is **one** Issue with one
150
+ occurrence count and one recipient set. Do not reformat that string on either side.
151
+
152
+ #### ⚠ `_Exception_Business` now extends `_Exception_Validation` (decided 2026-08-04)
153
+
154
+ It previously extended `Exception`. Extending `_Exception_Validation` makes api2 answer **4xx**
155
+ instead of 500 for a business condition while **still recording it**.
156
+
157
+ **The ordering trap:** `instanceof _Exception_Validation` is now **true** for a business
158
+ exception. Any handler must test **business first**, or business exceptions silently stop being
159
+ captured. This applies to api2's front-controller catch chain *and* to the three existing
160
+ `catch (_Exception_Validation $e)` blocks in `_Model_Prudential_ServiceRequest` — and to any new
161
+ `catch (_Exception_Validation …)` anyone writes. api2's `INTERNAL_SERVER_ERROR` message is also
162
+ now guarded behind `!$isClientError`, so a business exception no longer stacks a 500 on its 400.
163
+
164
+ ### Ambient "current client" for attribution
165
+
166
+ `_Error::setCurrentClientId()` / `getCurrentClientId()` hold an ambient client id, set
167
+ automatically inside **`_Database::registerClientDatabases()`** — the one place in 2.0 that always
168
+ knows the client, called with an already-resolved id from every client-scoped path (all
169
+ JWT-authenticated V2 sites, cXML, per-client workers). `captureException()` prefers an explicit
170
+ `clientId` argument and falls back to the ambient one. **No JWT decoding is needed:** by the time
171
+ `registerClientDatabases()` runs, the JWT's client uuid has already been resolved to an id.
172
+
173
+ This fixed Issue `1I`: a Compass PUT whose log went to `Logs_Compass` recorded `clientId` NULL,
174
+ because api2's catch only had `$api->client->id`, which is populated on the login/refresh paths
175
+ but **not** on an ordinary authenticated request. Never re-derive the client in a catch block —
176
+ read the ambient value.
177
+
178
+ ### `Event.context` shape (uniform, both frameworks)
179
+
180
+ **Every top-level key holds an object**; scalars are grouped under a `Reference` object. The
181
+ top-level keys *are* the display names: `Reference`, `GET`, `POST`, `Runtime`, `AWS`, `SERVER`.
182
+ `GET`/`POST` are cast to objects so an empty one serializes as `{}` rather than `[]`. `$_POST` is
183
+ now captured (redacted on the same key-name basis as `$_GET`; PHP only populates `$_POST` for form
184
+ encodings, so JSON API bodies deliberately do not appear). The duplicate top-level
185
+ `peakMemoryMb` was dropped, the over-length shed list was renamed in lockstep with its marker now
186
+ an object, and 2.0 gained `framework` / `isBusiness` / `issueKey` / `errorFile` / `errorLine` for
187
+ 1.0 parity.
188
+
189
+ A uniform shape is what lets a viewer render context **generically** (see the Tools console's
190
+ card/JSON-tree renderer). Consumers must read **both** the old and new key positions during the
191
+ 7-day `Event` retention overlap.
192
+
122
193
  ### The quotable reference
123
194
 
124
195
  `Issues.reference` is a Sentry-style base-26 encoding of `Issues.id`: 1 → `1A`, 26 → `1Z`,
@@ -172,9 +243,11 @@ error reporting with no overlap means going blind. Three findings make removal i
172
243
  warnings, but that promotion must be verified **empirically** — if it does not survive,
173
244
  code that currently 500s would silently continue with a null value, which is worse.
174
245
 
175
- Also: this pipeline is **2.0-only**. Sentry must remain for the 1.0 apps (desk1, retail1,
176
- forecast1, worker1, view1, commerce1) until a `library` / `App_Error` port lands, so the
177
- Sentry-removal ticket cannot complete until the 1.0 port does.
246
+ **The 1.0 port has landed (2026-08-04)** — `App_Error_Capture` writes into this same shared `Logs`
247
+ DB, so the pipeline is no longer 2.0-only; see
248
+ [error-capture-1-0](../../../1.0/apps/library/features/error-capture-1-0.md). Each 1.0 app still
249
+ has to opt in by adding a `[database_toga2logs]` config section, so Sentry must remain for any 1.0
250
+ app that has not been wired up yet.
178
251
 
179
252
  ## Gotchas / known issues
180
253
 
@@ -221,6 +294,23 @@ Sentry-removal ticket cannot complete until the 1.0 port does.
221
294
  `varchar(255)` (`errorMessage`, `subject`) **throws** on `->save()`. Inside the handler's
222
295
  `catch(Throwable)` the throw is swallowed and the row is silently dropped — precisely when a
223
296
  long message matters most. Truncate to column width before `save()`.
297
+ - **Hand-written SQL is what a table rename actually breaks — and it fails *after* the model
298
+ succeeds.** The singular rename left plural literals in hand-written statements across four
299
+ files, so `$issue->save()` succeeded and then `UPDATE Issues …` threw **1146** before the
300
+ fingerprint insert — creating a **new Issue per occurrence**: 70 duplicate `Logs.Issue` rows in
301
+ production. After any rename in a shared DB, grep for the literal name in *every* repo
302
+ (`_underscore`, `worker2`, `tools`), not just the models.
303
+ - **Capture must force reads to the writer for its whole duration.** `_Model::save()` re-reads the
304
+ row, which inside an open transaction throws *"Exactly 1 row was expected to be returned but 0
305
+ were"* when the read goes to the read host. Capture sets
306
+ `_Database::setIsReadHostEnabled(false)` for the entire block (the same read-your-writes fix
307
+ `_Email::send()` uses, TRUE-80448).
308
+ - **`Issue.reference` cannot be a MySQL generated column** (error 3109 — a generated column cannot
309
+ refer to an auto-increment column), which is exactly why the column is nullable and written by a
310
+ follow-up `UPDATE`. Do not "improve" it into a generated column.
311
+ - **Unrelated, open (found via this pipeline, 2026-08-04):** `Logs.Api` inserts are failing in
312
+ production with *"Column 'isAuthRequest' cannot be null"*, surfaced as issue `2G` from
313
+ `worker-production`. Not fixed — needs its own ticket.
224
314
  - **`const TABLE` must track a table rename, and nothing warns you.** A `_Model` subclass whose
225
315
  `const TABLE` points at a renamed table fails only at query time. When a `Logs` table is
226
316
  renamed, the matching `_underscore/Model/Core/Logs/*.php` `const TABLE` must change in the
@@ -289,6 +379,22 @@ clientUserId). **Neither was built.** As built instead:
289
379
 
290
380
  ## Change history
291
381
 
382
+ - 2026-08-04 — **Decided: `_Exception_Business` extends `_Exception_Validation`** so api2 answers
383
+ 4xx for a business condition while still recording it — with the ordering trap that every
384
+ `catch (_Exception_Validation …)` must now test business **first** (api2's controller and the
385
+ three blocks in `_Model_Prudential_ServiceRequest`), plus api2's `INTERNAL_SERVER_ERROR` message
386
+ guarded behind `!$isClientError`. **Fingerprinting reworked:** frames are FILE + FUNCTION (a
387
+ 10-line insertion in `V2.php` had split one failure into issues `1E`/`1I`) and the no-stack
388
+ fallback is class + entryPoint + digit-masked message (issues `1F`/`1G` were one OOM in
389
+ `_underscore/Model.php` one line apart) — a deliberate one-time re-fingerprinting of open issues.
390
+ Added the **ambient current client** (`_Error::setCurrentClientId()`, set inside
391
+ `_Database::registerClientDatabases()`), fixing issue `1I`'s NULL `clientId` on ordinary
392
+ authenticated api2 requests. Restructured `Event.context` to one object per top-level key with
393
+ scalars under `Reference` (identical in 1.0), added `$_POST` capture, and titled business issues
394
+ by `issueKey`. Recorded the 70-duplicate-Issue production incident (plural table literals in
395
+ hand-written SQL throwing 1146 after `save()` succeeded), the writer-read requirement, the 3109
396
+ reason `reference` is PHP-written, and the unrelated open `Logs.Api isAuthRequest` failure.
397
+ The 1.0 port (`App_Error_Capture`) landed, so this pipeline is no longer 2.0-only. (jcardinal)
292
398
  - 2026-08-03 — Added the **exception-class routing table** (`_Exception_Validation` → 400/no
293
399
  Issue; `_Exception_Business` → 500 + `issueKey`-fingerprinted Issue to business recipients;
294
400
  plain `Exception` → 500 + trace-fingerprinted Issue to a developer task) and recorded that
@@ -152,6 +152,24 @@ guard and swaps the real backend message for a generic fallback:
152
152
  Verification gap: `toga2-supply` had no `node_modules`, so `tsc` could not be run.
153
153
  **`npm run build` is the definitive check** — the type change will surface anything missed.
154
154
 
155
+ ## ⚠ Catch-order trap: a business exception IS a validation exception (2026-08-04)
156
+
157
+ `_Exception_Business` now **extends `_Exception_Validation`** (it previously extended `Exception`),
158
+ so a business condition answers **4xx** instead of 500 while still being recorded in the Issue
159
+ pipeline. The consequence:
160
+
161
+ > **`$e instanceof _Exception_Validation` is now TRUE for a business exception.** Every catch chain
162
+ > must test **business first**. Get the order wrong and business exceptions silently stop being
163
+ > captured — no error, no ticket, no email.
164
+
165
+ This applies to `api2/Controller/Index.php` and to any `catch (_Exception_Validation $e)` block
166
+ anywhere (the three in `_Model_Prudential_ServiceRequest` were audited for it). Also: the
167
+ controller's `INTERNAL_SERVER_ERROR` message is gated behind `!$isClientError`, so a business
168
+ exception no longer stacks a 500 message onto its 400 response.
169
+
170
+ Full mechanics:
171
+ [error-reporting-issue-event](../../_underscore/features/error-reporting-issue-event.md).
172
+
155
173
  ## Related recipes
156
174
 
157
175
  - **Add a writable field to a V2 record (3-file migration)** and **expose a scripted API**
@@ -164,6 +182,12 @@ Verification gap: `toga2-supply` had no `node_modules`, so `tsc` could not be ru
164
182
 
165
183
  ## Change history
166
184
 
185
+ - 2026-08-04 — Recorded the catch-order trap from `_Exception_Business` now extending
186
+ `_Exception_Validation` (business conditions answer 4xx and are still captured, but any
187
+ `catch (_Exception_Validation …)` must test business **first** or they go unrecorded), and the
188
+ `INTERNAL_SERVER_ERROR` message now gated behind `!$isClientError`. Also noted that api2 no
189
+ longer needs to derive the client in its catch block — capture reads the ambient client id set by
190
+ `_Database::registerClientDatabases()`. (jcardinal)
167
191
  - 2026-07-30 — **BREAKING:** envelope `error` changed from a bare string to
168
192
  `{ code, id }`, where `id` is the quotable Core Logs reference for a captured exception and
169
193
  `null` for a graceful rejection (always present, for a predictable shape); assembled from
@@ -3,7 +3,7 @@
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
5
  | [Worker (worker2) Architecture](architecture.md) | Worker (repo `worker2`) is an AWS Elastic Beanstalk **Worker Tier** application that processes background jobs. | worker2/Controller/Index.php, worker2/Worker/, worker2/LambdaFunctions/, _underscore/Worker.php, worker2/composer.json |
6
- | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
6
+ | [Deploy-Time Auto-Registration to the Shared ALB Target Group (non-production)](features/alb-target-group-auto-registration.md) | TOGA does **not** pay for EB-managed load-balancer registration, so an EB instance is normally **not** added to its environment's ALB target group — a fresh or | worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, worker2/ebs/register_instance_to_shared_application_load_balancer.php, worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh, worker2/.platform/hooks/prebuild/_shared/041-write-region.sh, worker2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh, api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh, worker2/.platform/hooks/postdeploy/015_install_composer.sh, api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php |
7
7
  | [All-Client Email Queue Monitor (Monitor/Operations/EmailQueue)](features/all-client-email-queue-monitor.md) | `_Worker_Monitor_Operations::EmailQueue()` is the cross-client health check for the 2.0 outbound email queue (`Logs_<Client>.Email` — see [2.0 Email Send Pipeli | worker2/Worker/Monitor/Operations.php, _underscore/Database.php, _underscore/Query.php, dbchanges2/Logs_Client/2026-05-21 - Email.sql |
8
8
  | [Automated PR Merger — Concurrent Force-Push Clobber Race](features/automated-pr-merger-force-push-race.md) | The automated PR merger `_Worker_Team_GitHub::Merge` (`worker2` `Worker/Team/Github.php`) merges approved PRs to `_production` by **force-pushing from a clone t | Worker/Team/Github.php |
9
9
  | [Callback Scheduling ("call me back") — worker2 AI-BDR](features/callback-scheduling.md) | The **AI-BDR callback path**: what happens between a prospect saying *"call me back later"* on a Vapi call and the dialer actually placing that second call. | worker2/Worker/Vapi.php, worker2/Worker/Ai/Bdr/Vapi.php, worker2/Controller/Index.php |
@@ -18,7 +18,7 @@
18
18
  | [Compass VIP Support Importer (worker2)](features/compass-vip-support-importer.md) | A worker2 action that ingests Compass's quarterly VIP spreadsheet and assigns each VIP user's support technician by setting `Users.c_supportedByUserId` in `Clie | worker2/Worker/Client/Compass/VipSupport.php |
19
19
  | [Creating Worker Actions](features/creating-worker-actions.md) | How to add a new callable Worker action — a PHP class whose `public static` methods are invoked as background jobs (via webhook, cron, or `_Worker::runTask()`). | worker2/Worker/, worker2/Controller/Index.php, _underscore/Worker.php |
20
20
  | [Elite Freshservice Sync (worker2)](features/elite-freshservice-sync.md) | `_Worker_Elite` processes Freshservice webhook events and syncs them into TOGA 2. | worker2/Worker/Elite.php, worker2/Config/dev-kmaramreddy-laptop.ini |
21
- | [Error Escalation Cron (Errors::Escalate → ClickUp / email)](features/error-escalation-cron.md) | `_Worker_Infrastructure_Errors::Escalate` (renamed from `SyncWithClickup`) is the sole owner of **escalation, de-escalation, ClickUp ticketing, reminders, busin | worker2/Worker/Infrastructure/Errors.php, worker2/Worker/Clickup/ErrorTask.php, worker2/Worker/Clickup.php, worker2/Config/production.ini, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql |
21
+ | [Error Escalation Cron (Errors::Escalate → ClickUp / email)](features/error-escalation-cron.md) | `_Worker_Infrastructure_Errors::Escalate` (renamed from `SyncWithClickup`) is the sole owner of **escalation, de-escalation, ClickUp ticketing, reminders, busin | worker2/Worker/Infrastructure/Errors.php, worker2/Worker/Clickup/ErrorTask.php, worker2/Worker/Clickup.php, worker2/Controller/Index.php, worker2/Config/production.ini, _underscore/Model/Core/Logs/Issue.php, dbchanges2/Core/2026-07-30a - Error escalation cron job.sql, dbchanges2/Logs/2026-08-03a - Issue clickupPriority.sql |
22
22
  | [Etilize Catalog Item Import & Refresh](features/etilize-catalog-item-import.md) | Client-generic catalog onboarding from an S3 CSV plus an Etilize re-pull. | worker2/Worker/Etilize/Items.php |
23
23
  | [Etilize Item Translation Import](features/etilize-item-translation-import.md) | The abstract worker class `_Worker_Etilize_ItemTranslations` imports **non-English** item text from Etilize into the client's `ItemTranslations` table. | worker2/Worker/Etilize/ItemTranslations.php |
24
24
  | [Monitoring Framework (Orchestrator + Child Monitors)](features/monitoring-framework.md) | A unified, DB-driven monitoring framework for business-critical data flows (Compass POs, Prudential asset imports, AIG closed claims, …). | worker2/Worker/Monitor.php, worker2/Worker/Monitors/, worker2/Worker/Monitors/RateEntitlement.php, worker2/Worker/Notification/Email.php, worker2/Worker/Rate.php, dbchanges2/Core/2026-05-21 - Monitors.sql, dbchanges2/Core/2026-06-29a - Rate Entitlement Contract Monitor.sql |
@@ -6,13 +6,15 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-07-28
9
+ updated: 2026-08-04
10
10
  owners: [jcardinal]
11
11
  files:
12
12
  - worker2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
13
13
  - worker2/ebs/register_instance_to_shared_application_load_balancer.php
14
14
  - worker2/.platform/hooks/prebuild/_shared/040-write-instance-id.sh
15
15
  - worker2/.platform/hooks/prebuild/_shared/041-write-region.sh
16
+ - worker2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh
17
+ - api2/.platform/hooks/prebuild/_shared/042-write-eb-environment.sh
16
18
  - worker2/.platform/hooks/postdeploy/015_install_composer.sh
17
19
  - api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh
18
20
  - api2/ebs/register_instance_to_shared_application_load_balancer.php
@@ -64,10 +66,27 @@ invocation**, not through SQS.
64
66
  |---|---|
65
67
  | `prebuild/_shared/040-write-instance-id.sh` | `storage/instance-id.txt` |
66
68
  | `prebuild/_shared/041-write-region.sh` | `storage/region.txt` |
69
+ | `prebuild/_shared/042-write-eb-environment.sh` | `storage/eb-environment.txt` |
67
70
  | `postdeploy/015_install_composer.sh` | `vendor/` (the AWS SDK) |
68
71
 
69
72
  Renumber it below `015` and the SDK autoloader does not exist yet.
70
73
 
74
+ ### `042-write-eb-environment.sh` — the EB environment name, cached to disk (2026-08-04)
75
+
76
+ Added to the same `_shared` hook family (plus a postdeploy hook) in **both `api2` and `worker2`**,
77
+ writing the EB environment name to disk. It resolves it from `get-config`, then
78
+ `$EB_ENVIRONMENT_NAME`, then the IMDS tag.
79
+
80
+ **Why cache it instead of shelling out when needed:** the first consumer is
81
+ [error capture](../../_underscore/features/error-reporting-issue-event.md), which also runs from the
82
+ **shutdown handler during a fatal — possibly an OOM**. Forking a process at that moment is the
83
+ least likely thing to succeed. Same reasoning as `040`/`041`: resolve at deploy time, read a file
84
+ at runtime.
85
+
86
+ 1.0 has no equivalent hook (its AL1 platform has no `get-config`), so `library` resolves the name
87
+ at runtime instead — see
88
+ [error-capture-1-0](../../../1.0/apps/library/features/error-capture-1-0.md).
89
+
71
90
  ### Porting prerequisites (all already satisfied in worker2)
72
91
 
73
92
  The port needed **no adaptation** because `worker2` already had: `aws/aws-sdk-php` in
@@ -153,6 +172,11 @@ which lists the other hooks but not `060` — pending an elevated-doc update.
153
172
 
154
173
  ## Change history
155
174
 
175
+ - 2026-08-04 — Added `_shared/042-write-eb-environment.sh` (+ a postdeploy hook) to **api2 and
176
+ worker2**, caching the EB environment name to disk from `get-config` → `$EB_ENVIRONMENT_NAME` →
177
+ the IMDS tag. Read from a file rather than shelled out to at use time because its first consumer
178
+ is error capture, which also runs from the shutdown handler during a fatal/OOM where a fork is
179
+ least likely to succeed. (jcardinal)
156
180
  - 2026-07-28 — Ported the hook pair from `api2` into `worker2` (new files `060_…​.sh` +
157
181
  `ebs/register_instance_to_shared_application_load_balancer.php`) so non-production worker2
158
182
  environments are reachable through the shared ALB for manual HTTP invocation. Hardened vs. the
@@ -6,17 +6,21 @@ project: Worker
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-01
9
+ updated: 2026-08-04
10
10
  owners: ["jcardinal"]
11
11
  files:
12
12
  - worker2/Worker/Infrastructure/Errors.php
13
13
  - worker2/Worker/Clickup/ErrorTask.php
14
14
  - worker2/Worker/Clickup.php
15
+ - worker2/Controller/Index.php
15
16
  - worker2/Config/production.ini
17
+ - _underscore/Model/Core/Logs/Issue.php
16
18
  - dbchanges2/Core/2026-07-30a - Error escalation cron job.sql
19
+ - dbchanges2/Logs/2026-08-03a - Issue clickupPriority.sql
17
20
  related:
18
21
  - ../../_underscore/features/error-reporting-issue-event.md
19
22
  - ../../../1.0/apps/tools/features/errors-curation-console.md
23
+ - ../../../1.0/apps/library/features/error-capture-1-0.md
20
24
  - ./creating-worker-actions.md
21
25
  - ./clickup-project-routing.md
22
26
  ---
@@ -34,9 +38,12 @@ through this one action, so there is exactly one place to mute or debug alerting
34
38
 
35
39
  ### Urgency = max of two independent axes, plus a floor
36
40
 
37
- - **Volume** — occurrences in a 10-minute window: **20 / 50 / 100**.
41
+ - **Volume** — occurrences in a **1-hour** window: **NORMAL 5 / HIGH 10 / URGENT 20** (retuned
42
+ 2026-08-04 from 20/50/100 over 10 minutes — a 10-minute window never fired for the
43
+ low-and-steady failures that actually matter).
38
44
  - **Neglect** — hours an *unacknowledged* ClickUp task has sat: **1 / 4 / 24**.
39
- - **Floor** — the Issue's declared `minimumUrgency` applies under *both* axes.
45
+ - **Sustained-24h** — an issue still occurring a day later escalates on duration alone.
46
+ - **Floor** — the Issue's declared `minimumUrgency` applies under *all* axes.
40
47
 
41
48
  The neglect axis exists because volume alone cannot see a rare-but-serious error, nor a serious
42
49
  error that everyone is ignoring.
@@ -44,9 +51,21 @@ error that everyone is ignoring.
44
51
  ### Fast up, slow down
45
52
 
46
53
  Escalation applies **the same run** a band is crossed. De-escalation requires the count to sit
47
- under a **lower release band (12 / 35 / 70)** for **3 consecutive windows**. Without that gap,
54
+ under a **lower release band** for **`REQUIRED_QUIET_RUNS`** consecutive runs. Without that gap,
48
55
  an issue hovering near a threshold flaps every minute and burns a ClickUp write each time.
49
56
 
57
+ > The constant was renamed `REQUIRED_QUIET_WINDOWS` → **`REQUIRED_QUIET_RUNS`** because it counts
58
+ > **cron runs**, not windows. The DB column `Issue.consecutiveQuietWindows` still carries the old
59
+ > name — that mismatch is known and intentional (renaming a live column was not worth it).
60
+
61
+ ### What was actually pushed to ClickUp is stored, not inferred
62
+
63
+ `Issue.clickupPriority` (migration `dbchanges2/Logs/2026-08-03a - Issue clickupPriority.sql`)
64
+ records the priority the cron **last pushed**. The old logic used "did our computed urgency
65
+ change?" as a proxy and rewrote the priority every minute — logging *"changed priority from high to
66
+ high"*. Storing what was pushed also makes it **self-healing** when someone edits the priority in
67
+ ClickUp by hand. Replaying a production run went from **8 PUTs to 2**.
68
+
50
69
  ### Task-creation gate
51
70
 
52
71
  A ClickUp task is created only when urgency ≥ NORMAL, **or** a floor is set, **or** the Issue is
@@ -83,6 +102,20 @@ ClickUp is the acknowledge/resolve system — developers already live there, and
83
102
  Tools would just be ignored. **Any** status change on an error task counts as acknowledgement,
84
103
  which is safe only because the automation never sets status itself.
85
104
 
105
+ > **60-second creation grace window (fix, 2026-08-04).** ClickUp fires a status webhook for a
106
+ > task's *initial* status, so the automation was acknowledging its **own** newly created tasks —
107
+ > stamping `dtAcknowledged` ~1s after `dtCreated` and permanently disabling the neglect axis for
108
+ > that issue. Status webhooks arriving within 60s of creation are ignored.
109
+
110
+ ### Notification and task presentation
111
+
112
+ - The technical escalation email to **devteam@togatech.com** now **coexists** with the ClickUp
113
+ update. It was an `elseif`, so only one of the two ever fired.
114
+ - ClickUp titles carry the **reference only** — `[1E]`, not `[1E-1]`. Including the occurrence
115
+ number made the title churn on every escalation.
116
+ - **Occurrences** and **Last Occurrence** custom fields are synced, and task bodies open with a
117
+ **"Debug in Tools"** deep link.
118
+
86
119
  ### Area-level owner learning (observation only)
87
120
 
88
121
  `IssueAreaOwner` learns an owner after **5 in a row** and ships **observation-only** — a hint in
@@ -115,8 +148,39 @@ recipients away** after a quiet month.
115
148
  - **`[clickup] sprint_folder_id`** — added to `worker2/Config/production.ini`, left **empty with
116
149
  instructions**. Note `[clickup]` exists in only **2 of the 8** worker2 configs.
117
150
 
151
+ ## Worker job failures are captured in the front controller, not by the handler
152
+
153
+ **worker2's front controller catches `Throwable` itself** (exactly like api2), writes the message
154
+ into `Core.WorkerJobs.output`, and returns 200 — so `_Error::exceptionHandler` **never fires** and
155
+ job failures went entirely uncaptured. Deploying `_underscore` changed nothing, because nothing
156
+ called capture.
157
+
158
+ `worker2/Controller/Index.php` now calls `_Error::captureException()` at **six** catch sites
159
+ (three per duplicated branch: DB-logs registration, `initialize()`, and the action itself). Each
160
+ call:
161
+
162
+ - happens **after** `_Database::transactionRollback()` — a capture write inside a doomed
163
+ transaction is lost;
164
+ - happens **before** the `WorkerJobs` UPDATE, so the reference is **prepended** to `output`. A
165
+ failed job row reads `[1Q-25] …`, letting you jump from `Core.WorkerJobs` straight to the Issue
166
+ in Tools;
167
+ - is **individually guarded** — capture must never be able to convert a handled job failure into
168
+ an unhandled one.
169
+
170
+ Diagnosed from `Core.WorkerJobs` id 583346 (`Team/Transcripts/Export`). **Any new catch site in
171
+ this controller needs the same treatment.**
172
+
118
173
  ## Gotchas / known issues
119
174
 
175
+ - **Persist state before, or independently of, any outbound call.** A regression chain took the
176
+ whole run down every minute: `syncCustomFields()` read `$issue->dtLastOccurred`, which
177
+ `loadWorkingSet()` never selected; `_Error::errorHandler()` promotes the undefined-property
178
+ warning to an **exception**, which killed the run **right after the ClickUp PUT and before
179
+ `persistIssueState()`** — so urgency never persisted and every run recomputed the same "change".
180
+ Three fixes: select the missing columns; wrap `processTechnicalIssue()` in try/catch so state
181
+ **always** persists even when an outbound call fails; and store `Issue.clickupPriority` instead
182
+ of inferring what was pushed. **A missing column in a working-set SELECT is a runtime exception
183
+ in 2.0, not a null.**
120
184
  - **The working-set query must be a UNION, not an OR.** It is a UNION of three separately
121
185
  indexable branches. MySQL will **not** `index_merge` across an `OR` when one branch is a
122
186
  correlated `EXISTS`, so the OR form **full-scans `Issue` every 60 seconds, forever**.
@@ -143,6 +207,24 @@ recipients away** after a quiet month.
143
207
 
144
208
  ## Change history
145
209
 
210
+ - 2026-08-04 — **Fixed: worker job failures were never captured at all** — worker2's front
211
+ controller catches `Throwable` and returns 200, so six catch sites in
212
+ `worker2/Controller/Index.php` now call `_Error::captureException()` after rollback and before the
213
+ `WorkerJobs` UPDATE, prepending the reference to `output`. **Fixed the every-minute priority
214
+ rewrite** ("changed priority from high to high"): `loadWorkingSet()` was missing
215
+ `dtLastOccurred`, and the promoted undefined-property warning killed the run after the ClickUp
216
+ PUT but before `persistIssueState()`; added the columns, wrapped `processTechnicalIssue()` so
217
+ state always persists, and replaced the urgency-change proxy with a stored
218
+ `Issue.clickupPriority` (new migration `dbchanges2/Logs/2026-08-03a`) — 8 PUTs down to 2 on a
219
+ replayed run. **Fixed the automation acknowledging its own new tasks** (ClickUp emits a status
220
+ webhook for the initial status, which froze the neglect axis) with a 60s creation grace window.
221
+ Retuned escalation: 1-hour window, thresholds 5/10/20 with release bands, a new sustained-24h
222
+ axis, `REQUIRED_QUIET_WINDOWS` → `REQUIRED_QUIET_RUNS` (the DB column keeps the old name),
223
+ ClickUp titles dropped the occurrence number (`[1E-1]` → `[1E]`), Occurrences/Last Occurrence
224
+ custom fields synced, a "Debug in Tools" deep link in task bodies, and the devteam escalation
225
+ email now coexists with the ClickUp update (was an `elseif`, so only one ever fired). Also fixed
226
+ plural table literals left in hand-written SQL here after the singular rename — part of the
227
+ 70-duplicate-Issue incident. (jcardinal)
146
228
  - 2026-08-01 — Table names updated for the Core Logs **singular** rename (`Issue`, `Event`,
147
229
  `IssueFingerprint`, `IssueClickupTask`, `IssueEmailAddress`, `IssueAreaOwner`). Naming-only;
148
230
  the cron's behavior is unchanged. See
@@ -4,8 +4,8 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
4
4
 
5
5
  ## 1.0 framework
6
6
 
7
- - **library** (Library) _(framework core)_ — 14 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
- - **worker** (Worker) — 18 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
7
+ - **library** (Library) _(framework core)_ — 15 doc(s) → [1.0/apps/library/INDEX.md](1.0/apps/library/INDEX.md)
8
+ - **worker** (Worker) — 19 doc(s) → [1.0/apps/worker/INDEX.md](1.0/apps/worker/INDEX.md)
9
9
  - **dbchanges** (Database Changes) _(framework core)_ — 1 doc(s) → [1.0/apps/dbchanges/INDEX.md](1.0/apps/dbchanges/INDEX.md)
10
10
  - **worker1.5** (Worker 1.5) — 0 doc(s) → [1.0/apps/worker1.5/INDEX.md](1.0/apps/worker1.5/INDEX.md)
11
11
  - **togadesk** (TOGa Desk) — 11 doc(s) → [1.0/apps/togadesk/INDEX.md](1.0/apps/togadesk/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.504",
3
+ "version": "1.0.505",
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",