toga-ai 1.0.565 → 1.0.567

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.
@@ -13,7 +13,7 @@
13
13
  ],
14
14
  "PreToolUse": [
15
15
  {
16
- "matcher": "Bash|Read|Grep|Glob|Edit|Write|MultiEdit|NotebookEdit|Task|WebFetch|WebSearch",
16
+ "matcher": "Bash|Read|Grep|Glob|Edit|Write|MultiEdit|NotebookEdit|Task|Agent|WebFetch|WebSearch",
17
17
  "hooks": [
18
18
  {
19
19
  "type": "command",
@@ -27,6 +27,7 @@
27
27
  | [Item-Fulfillment Stage Lifecycle (picked/packed/shipped) & Order Status](features/item-fulfillment-stage-lifecycle-and-order-status.md) | Every ItemFulfillment (IF) now carries an explicit **stage** — picked → packed → shipped — resolved through `ItemFulfillmentStages → ItemFulfillmentStatuses` (m | _underscore/Model/Client/SalesOrder.php, _underscore/Model/Quad/SalesOrder.php, _underscore/Model/Compass/SalesOrder.php, _underscore/Model/Compass/SalesOrderStatus.php, _underscore/Model/Client/SalesOrderItem.php, _underscore/Model/Client/Item.php, _underscore/Model/Client/PurchaseOrderItem.php, library/app/api/toga2.php, dbchanges2/Client/2026-06-30a - BackfillNullStageItemFulfillmentsToShipped.sql, dbchanges2/Client/2026-06-30b - SalesOrderStatusesPickedPacked.sql, dbchanges2/Client/2026-06-30c - ItemFulfillmentStageIdNotNull.sql, dbchanges2/Client_CompassCanada/2026-06-30a - ItemFulfillmentLifecycleAndShippedBackfill.sql |
28
28
  | [DB-free unit testing for _underscore model interceptors](features/model-interceptor-unit-testing.md) | `_underscore` shipped with **no** PHPUnit setup (no `composer.json`/`phpunit`; only vendored PhpOffice tests existed). | _underscore/Test/bootstrap.php, _underscore/Test/Prudential/ServiceRequestTest.php, test/@Bala/tests/netsuite_salesorder_payload_tests.php |
29
29
  | [_Model magic-field access (__get without __isset)](features/model-magic-field-access.md) | `_Model` exposes DB columns as "magic" properties via `__get()`, but it defines **no** `__isset()`. | _underscore/Model/Core/Model.php, _underscore/Model.php, _underscore/Model/Rate/Subscription.php |
30
+ | [_Model::save() parent FK cascade — stored-SQL-field recompute deadlocks](features/model-save-parent-cascade-stored-field-deadlock.md) | `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row that carries an FK to a parent causes `_Model` to **re-load an | _underscore/Model.php, _underscore/Model/Client/PurchaseOrder.php, _underscore/Model/Client/AdvanceShippingNotice.php |
30
31
  | [_Model::save() vs raw _Query — no atomic conditional update](features/model-save-vs-query-atomic-update.md) | `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic conditional update** (an optimistic-concurrency / row-claim guard such | _underscore/Model.php, _underscore/Query.php, _underscore/Model/Rate/Subscription.php |
31
32
  | [NetSuite REST Client (_Component_Api_Netsuite) — record writes & SuiteQL](features/netsuite-rest-client.md) | `_Component_Api_Netsuite` is the **2.0 `_underscore` NetSuite REST client** — the shared primitive every worker2/api2 NetSuite caller uses for record GETs, Suit | _underscore/Component/Api/Netsuite/Netsuite.php |
32
33
  | [NetSuite Sales Order sync — ship-to address, phone, and PO reference sourcing](features/netsuite-salesorder-address-phone-sync.md) | `_Trait_Netsuite_SalesOrder` is the **shared** sales-order importer composed into **22 client models** (every client on the dbchanges2 `netsuite` module). | _underscore/Trait/Netsuite/SalesOrder.php, _underscore/Model.php, dbchanges2/_modules/netsuite/2026-08-10a - AddressPhoneNumberApiRoleAcl.sql |
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: architecture
8
8
  status: active
9
- updated: 2026-07-27
9
+ updated: 2026-08-13
10
10
  owners: ["jcardinal", "rgirish", "mhammontree"]
11
11
  files:
12
12
  - _underscore/_underscore.php
@@ -38,6 +38,7 @@ business logic never moves into Surface config.
38
38
  Storage fields (`FIELD_STORAGE`) must be written by assigning then `save()` — historically a
39
39
  `__get` refetch on the write path silently dropped the assigned value (fixed 2026-07); still avoid
40
40
  *reading* a storage field between assign and save (it refetches and discards the unsaved value).
41
+ `_Model::save()` **cascades to FK parents** — saving a child re-saves its parent to refresh the parent's stored SQL fields (`FIELDOPT_SQL_STORED`). A stored field that aggregates the parent's own children (e.g. a correlated-subquery total) therefore re-emits a parent+children lock on **every** child insert and will **deadlock (MySQL 1213)** under concurrent same-parent inserts; opt a model out with `$_model_skipParentCascadeOnSave` when no touched parent's stored field can go stale.
41
42
 
42
43
  ## Entry point & boot sequence
43
44
 
@@ -401,4 +402,5 @@ multi-file UI components (`.php`/`.html`/`.css`/`.js`) invoked as `<_ComponentNa
401
402
  - 2026-07-02 — Fixed framework-wide `FIELD_STORAGE` write-drop and folder-read bugs in `Model.php`; noted the remaining unconditional-refetch dirty-read follow-up. (mhammontree)
402
403
  - 2026-07-07 — Documented `_Query` writes-only async mode (`isAsync` → Worker `Infrastructure/Database/Query`) in the database-architecture section, linked `features/async-query-execution.md`, and noted the SQS-first `_Worker::runTask()` enqueue departure (caller does no MySQL; worker tier creates the row on pickup). (jcardinal)
403
404
  - 2026-07-16 — Documented the misleading "Unclosed '{'" parse-error gotcha (autoloader reports the outermost `class {` line / caller trace, not the true dropped-brace location; merge conflicts a common cause). First hit production 500 EO-1 via a dropped `_Email::send()` brace. (jcardinal)
405
+ - 2026-08-13 — Documented that `_Model::save()` **cascades to FK parents** to refresh their `FIELDOPT_SQL_STORED` fields, so a parent stored field that aggregates its own children re-emits a parent+children lock on every child insert and **deadlocks (MySQL 1213)** under concurrent same-parent inserts; opt out with `$_model_skipParentCascadeOnSave`. (jcardinal)
404
406
  - 2026-07-23 — Fixed the Route.php swallow→mask gotcha: the controller-call catch now log+rethrows and catches `\Throwable` (not just `Exception`), so the true controller exception surfaces instead of the misleading line-525 "Failed to determine how to render view" fatal. Noted the deferred production `display_errors` info-leak follow-up. (jcardinal)
@@ -6,13 +6,14 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-12
10
- owners: [snaredla]
9
+ updated: 2026-08-13
10
+ owners: [snaredla, jcardinal]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  related:
14
14
  - ../architecture.md
15
15
  - ./model-magic-field-access.md
16
+ - ./model-save-parent-cascade-stored-field-deadlock.md
16
17
  ---
17
18
 
18
19
  ## Summary
@@ -61,9 +62,21 @@ recalculated per query; `true` persists to a real column on save).
61
62
  expression has not been defined…"*.
62
63
  - **`FIELDOPT_SQL_STORED => true` needs a refresh after bulk migrations** — the stored value is
63
64
  written on save, so rows changed by raw SQL keep a stale value.
65
+ - **A stored SQL field that aggregates the model's own children is a deadlock risk under the
66
+ `_Model::save()` parent cascade.** Because the cascade re-saves a parent (to refresh its stored
67
+ fields) on every child insert, a stored correlated-subquery field re-emits an UPDATE that X-locks
68
+ the parent and S-locks all its children on writes that change nothing it depends on — concurrent
69
+ same-parent child inserts then deadlock (MySQL 1213). This is exactly how `PurchaseOrder._total`
70
+ deadlocked the ASN path; see
71
+ [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md).
64
72
 
65
73
  ## Change history
66
74
 
75
+ - 2026-08-13 — Added a gotcha: a `FIELDOPT_SQL_STORED` field that aggregates the model's own
76
+ children deadlocks under the `_Model::save()` parent cascade (correlated-subquery recompute
77
+ X-locks the parent + S-locks its children on every child insert). Cross-linked the new
78
+ [save-cascade stored-field deadlock](./model-save-parent-cascade-stored-field-deadlock.md) doc,
79
+ where `PurchaseOrder._total` on the ASN path is the worked example. (jcardinal)
67
80
  - 2026-08-12 — Documented the enforced `FIELD_SQL` contract after `_serialNumbers` was queried as a
68
81
  camelCase violation: `_Model` **throws** both when the field lacks the `_` prefix
69
82
  (`_underscore/Model.php` ~L130) and when a static method of the exact same name is missing
@@ -6,7 +6,7 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-11
9
+ updated: 2026-08-12
10
10
  owners: ["dfranks", "jcardinal", "mhammontree", "ajean", "bala"]
11
11
  files:
12
12
  - _underscore/Error.php
@@ -438,20 +438,41 @@ app that has not been wired up yet.
438
438
  in `_underscore` and `library` in the **same** release, and the `/errors` console work needs
439
439
  **both** `library` and `tools` deployed before it is visible.
440
440
 
441
- ## ⚠ OPEN DEFECT (2026-08-06) — error capture is DEAD on sandbox-dev/beta: `Logs.Issue` is missing `clickupPriority`
441
+ ## ✅ RESOLVED (2026-08-12) — error capture was DEAD on sandbox-dev: `Logs.Issue` was missing SEVEN columns (originally filed as one)
442
442
 
443
- **Anyone debugging on that environment is flying blind.** The capture INSERT fails with **MySQL 1054
444
- (unknown column)** because `Logs.Issue` there has `clickupAssigneeId` and `createsClickupTask` but
445
- **not `clickupPriority`**. Consequences:
443
+ **Filed 2026-08-06 as a single missing column (`clickupPriority`); it was actually SEVEN.** On
444
+ **2026-08-12**, debugging a `/v2/surfaces/meta` 500 that returned `EO-1` with a null `error.id` and
445
+ wrote nothing to `Logs.Event`, a live `information_schema` diff of dev-sandbox `Logs.Issue` (**23
446
+ columns**) against `_Model_Core_Logs_Issue` (**30 columns**) found **seven** missing columns:
447
+ `clickupPriority` (migration `dbchanges2/Logs/2026-08-03a`) **plus the entire `2026-08-05a` lifecycle
448
+ migration** — `status`, `dtAutoResolved`, `baselineGapSeconds`, `baselineSampleCount`,
449
+ `dtBaselineAnchor`, `baselineAnchorOccurrences`. `_Model::save()` builds its INSERT from **all 30
450
+ declared fields**, so the first absent column throws **MySQL 1054 (unknown column)** and the whole
451
+ capture aborts. **Anyone debugging on that environment was flying blind.** While broken:
446
452
 
447
453
  - **No error is persisted at all** — no `Logs.Issue` row, no `Logs.Event` row, no ClickUp task.
448
- - The only trace is inline in the API response as **`identifiers.captureFailure`**. If you are
449
- looking in the database or ClickUp for an error you just triggered and finding nothing, look at the
450
- response body for `captureFailure` before concluding the error didn't happen.
451
-
452
- **Cause:** `_underscore` commit `4ebe12fe` *"Improve error fingerprinting, add ClickUp priority
453
- tracking"* shipped the code that writes the column **without its schema migration reaching that
454
- environment.** Fix is a one-column `dbchanges2` migration in the `Logs` folder.
454
+ - The only trace is inline in the API response as **`identifiers.captureFailure`** — and that is
455
+ **debug-mode-gated**, so on a deployed non-prod box with debug off the caller sees `identifiers: []`
456
+ and has *nothing*. (This blind spot is now closed — see the controller hardening below.)
457
+
458
+ **Cause:** both `dbchanges2/Logs/2026-08-03a` and `2026-08-05a` exist in the repo but **never ran on
459
+ dev-sandbox** — the runner tracks applied files per environment (see `Logs/2026-07-29a`), and these two
460
+ were skipped there. The originally-filed "one missing column, needs a one-column migration" was correct
461
+ about the mechanism but **undercounted the drift** — reading the 2026-08-06 note alone would have left
462
+ six columns still missing.
463
+
464
+ **Resolution (2026-08-12):** **re-applied the two existing migrations (`2026-08-03a`, then `2026-08-05a`)
465
+ to dev-sandbox** — no new migration was written; capture came back to life immediately (`Logs.Issue`/
466
+ `Logs.Event` rows written, `error.id` populated). **Still verify the same drift on beta and every other
467
+ non-prod env** before trusting their capture.
468
+
469
+ **Controller hardening (api2, 2026-08-12):** `api2/Controller/Index.php` now surfaces
470
+ `identifiers.captureFailure` on **every environment except production** (`_Environment::$name !==
471
+ 'production'`), even with debug mode off — in both the `execute()` catch and the DB-bootstrap catch. The
472
+ exception `message`/`trace` stay debug-only (they carry `_Database::register()` frame-arg credentials);
473
+ `captureFailure` names only *why the recorder failed* and cannot leak them. A dead capture layer now
474
+ announces itself instead of returning empty `identifiers`, so this class of `Logs` schema drift can never
475
+ again be silent on a non-prod box.
455
476
 
456
477
  **The general lesson (this is the pipeline's structural weak point):** error capture is the one
457
478
  subsystem whose own failure cannot be reported through itself. A schema drift here does not degrade
@@ -631,6 +652,17 @@ clientUserId). **Neither was built.** As built instead:
631
652
 
632
653
  ## Change history
633
654
 
655
+ - 2026-08-12 — **Corrected the 2026-08-06 open defect and marked it RESOLVED.** The sandbox-dev
656
+ `Logs.Issue` drift was **seven** missing columns, not one: `clickupPriority` (`2026-08-03a`) **plus the
657
+ whole `2026-08-05a` lifecycle migration** (`status`, `dtAutoResolved`, `baselineGapSeconds`,
658
+ `baselineSampleCount`, `dtBaselineAnchor`, `baselineAnchorOccurrences`) — found by an
659
+ `information_schema` diff (live 23 cols vs. model 30 cols) while debugging a `/v2/surfaces/meta` 500
660
+ that returned `EO-1` with a null `error.id` and wrote no `Logs.Event`. Both migrations existed but had
661
+ **never run on dev-sandbox** (the runner tracks applied files per environment); **re-applying the two
662
+ existing files** restored capture — no new migration. Also **hardened `api2/Controller/Index.php`** to
663
+ surface `identifiers.captureFailure` on every non-production environment even with debug off
664
+ (`message`/`trace` stay debug-only, as they carry DB creds), so a dead capture layer can no longer be
665
+ invisible on a non-prod box. (jcardinal)
634
666
  - 2026-08-11 — Sharpened the `error.id` lookup recipe from a production incident investigation
635
667
  (Compass, api2 500s): **`status = 'RESOLVED'` with `dtAutoResolved` set and `isManaged = 0` is a
636
668
  cron auto-close after a quiet window, not a fix** — it is indistinguishable from a real fix on
@@ -0,0 +1,121 @@
1
+ ---
2
+ title: _Model::save() parent FK cascade — stored-SQL-field recompute deadlocks
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-08-13
10
+ owners: [jcardinal]
11
+ files:
12
+ - _underscore/Model.php
13
+ - _underscore/Model/Client/PurchaseOrder.php
14
+ - _underscore/Model/Client/AdvanceShippingNotice.php
15
+ related:
16
+ - ./calculated-sql-fields.md
17
+ - ./model-save-vs-query-atomic-update.md
18
+ - ../../api2/features/v2-deadlock-retry.md
19
+ - ../../../../clients/compass-usa/features/asn-to-item-fulfillment.md
20
+ ---
21
+
22
+ ## Summary
23
+
24
+ `_Model::save()` runs a **generic parent foreign-key cascade**: inserting (or saving) a child row
25
+ that carries an FK to a parent causes `_Model` to **re-load and re-save that parent** so the
26
+ parent's stored SQL fields (`FIELDOPT_SQL_STORED => true`) are refreshed. When a parent's stored
27
+ field is a **correlated subquery** over the parent's own children, that re-save emits an UPDATE
28
+ that **X-locks the parent row and S-locks every child row** — even though the child insert never
29
+ changed any value the stored field depends on. Under concurrent inserts of children for the **same
30
+ parent**, this is a textbook **MySQL 1213 deadlock**. This is the definitive diagnosis for the api2
31
+ ASN-path deadlocks (see *Root case study*), and a reusable framework gotcha: **a stored SQL field
32
+ whose expression aggregates the model's children turns every child insert into a full parent-plus-
33
+ children lock.**
34
+
35
+ ## How it works
36
+
37
+ - The cascade lives in `_Model::save()` (`Model.php` ~L360-390). Saving a model with a populated FK
38
+ to a parent re-loads that parent and calls `save()` on it, whose purpose is to recompute and
39
+ persist the parent's `FIELDOPT_SQL_STORED` columns.
40
+ - `_Model_Client_PurchaseOrder` declares `_total` as a **stored** SQL field
41
+ (`Model/Client/PurchaseOrder.php:36`, `FIELDOPT_SQL_STORED => true`). Its expression is a
42
+ correlated subquery:
43
+
44
+ ```sql
45
+ UPDATE PurchaseOrders
46
+ SET dtUpdated = NOW(),
47
+ _total = (
48
+ SELECT SUM(PurchaseOrderItems.quantity * PurchaseOrderItems.cost)
49
+ FROM PurchaseOrderItems
50
+ WHERE purchaseOrderId = PurchaseOrders.id
51
+ )
52
+ WHERE id = <poId>
53
+ ```
54
+
55
+ - Inserting an `AdvanceShippingNotices` row (FK `purchaseOrderId`) therefore re-saves its
56
+ PurchaseOrder, re-emitting that statement. The subquery reads **all** `PurchaseOrderItems` for the
57
+ PO (S-locks) while the outer UPDATE X-locks the PO row. Two concurrent same-PO ASNs acquire these
58
+ locks in opposing orders → **1213**. The ASN insert never touches `PurchaseOrderItems.quantity`
59
+ or `.cost`, so the recompute is **pure collateral** — the deadlocking statement is not written by
60
+ any PO or ASN code, it is emitted by the framework cascade.
61
+
62
+ ## Root case study — Compass Office Depot cXML ASN feed
63
+
64
+ - 100% of the api2 1213 deadlocks were the identical `_total` UPDATE above, on
65
+ `POST /v2/advance-shipping-notices`, all from the Office Depot cXML feed
66
+ (`sourceIp 34.232.23.158`). Evidence: `Logs_Compass.Api` on the prod-logs cluster.
67
+ - Deadlocks cluster **per-PO** (one PO hit 5-7x within minutes) and are **bursty** (31 on
68
+ 2026-08-13 vs a 2-4/day baseline) — the fingerprint of Office Depot **re-transmitting the same
69
+ ASN** for days, producing concurrent same-PO ASN inserts.
70
+
71
+ ## Mitigation — `$_model_skipParentCascadeOnSave` opt-out (source fix, "B2")
72
+
73
+ A protected flag on `_Model`, `$_model_skipParentCascadeOnSave` (default **false** → no behavior
74
+ change for any existing model). When `true`, `_Model::save()` **skips the parent FK cascade**.
75
+ `_Model_Client_AdvanceShippingNotice` opts in from its constructor
76
+ (`$this->_model_skipParentCascadeOnSave = true`), so no ASN insert re-saves its PurchaseOrder and
77
+ the deadlocking statement is removed from the ASN path entirely.
78
+
79
+ **Why this is safe for ASN, for every client (reviewed):** among the ASN's four parents
80
+ (PurchaseOrder, Vendor, Address, ShippingMethod) only `PurchaseOrder._total` is a stored SQL field,
81
+ and it depends **solely** on `PurchaseOrderItems.quantity`/`cost` — values an ASN never touches. So
82
+ skipping the cascade cannot make any stored field stale. The opt-out is placed on the **base client
83
+ ASN model** (all clients) deliberately, because the premise is schema-level, not Compass-specific.
84
+ **Accepted caveat:** the PO's `dtUpdated` is no longer bumped on ASN receipt.
85
+
86
+ ## Gotchas / known issues
87
+
88
+ - **A stored SQL field that aggregates the model's own children makes every child insert a
89
+ parent+children lock.** Before marking a field `FIELDOPT_SQL_STORED => true` on a high-fan-in
90
+ parent, consider that every child write will re-emit its recompute under the parent cascade.
91
+ - **The deadlocking UPDATE is invisible in child/parent code.** Grepping PO or ASN code for the
92
+ `_total` UPDATE finds nothing — it is generated by the cascade in `Model.php`. Diagnose from the
93
+ DB error log statement, not the application code path.
94
+ - **Skipping the cascade is only correct when no touched parent has a stored field that could go
95
+ stale from this child's write.** `$_model_skipParentCascadeOnSave` is a per-model opt-out, not a
96
+ global switch — verify the parents' stored fields before opting a model in.
97
+
98
+ ## Decisions
99
+
100
+ - **Making `_total` non-stored (the cleaner fix) is BLOCKED cross-repo.** The `worker` repo's
101
+ toga2-supply transmit crons SELECT `PurchaseOrders._total` as a **raw physical column** (e.g.
102
+ `crons/toga2/compass/workflow/2_transmit_mits_purchase_orders_to_vendors.php`, plus the `quad` /
103
+ `compasscanada` equivalents and `DOA/open_purchase_order_reminder.php`). If the column were kept
104
+ but no longer maintained, those crons would read stale values. Making `_total` non-stored requires
105
+ first migrating every such consumer to compute the total inline. Until then, the opt-out flag is
106
+ the source fix.
107
+ - **Deploy blast radius.** 4 of 5 changed files live in `_underscore`, which is pulled into every
108
+ 2.0 app at deploy — so this is platform-wide. All changes are additive / default-safe; **no
109
+ SQL/schema change**. As of 2026-08-13 the code is **in the working tree only** — not committed or
110
+ deployed — pending sandbox validation and regression tests.
111
+
112
+ ## Change history
113
+ - 2026-08-13 — Diagnosed the api2 `_total` deadlocks (100% identical UPDATE on Office Depot cXML
114
+ ASN inserts) to the `_Model::save()` parent FK cascade re-saving `PurchaseOrder` to refresh its
115
+ **stored** `_total` correlated-subquery field, which X-locks the PO and S-locks all its items on a
116
+ write that changes neither. Added the `$_model_skipParentCascadeOnSave` opt-out (default false),
117
+ opted the base client ASN model in, and recorded that making `_total` non-stored is blocked by
118
+ `worker` crons reading it as a physical column. Code in working tree, pending sandbox validation.
119
+ (jcardinal)
120
+ </content>
121
+ </invoke>
@@ -6,14 +6,15 @@ project: _Underscore
6
6
  client: shared
7
7
  type: feature
8
8
  status: active
9
- updated: 2026-08-04
10
- owners: ["dfranks", "mhammontree"]
9
+ updated: 2026-08-13
10
+ owners: ["dfranks", "mhammontree", "jcardinal"]
11
11
  files:
12
12
  - _underscore/Model.php
13
13
  - _underscore/Query.php
14
14
  - _underscore/Model/Rate/Subscription.php
15
15
  related:
16
16
  - ./model-magic-field-access.md
17
+ - ./model-save-parent-cascade-stored-field-deadlock.md
17
18
  - ../../worker2/features/netsuite-opportunity-sync.md
18
19
  - ../../../../clients/rate/features/subscription-cancellation.md
19
20
  ---
@@ -108,9 +109,18 @@ retry loops, and compensating transactions all need the reload.
108
109
  affected-rows count. Use raw `_Query` + `getAffectedRows()`.
109
110
  - A no-diff `save()` emits **no** UPDATE, so "the write must have run because save() returned" is not a
110
111
  safe assumption.
112
+ - **`save()` also cascades to parents.** Saving a model with a populated FK re-saves its parent to
113
+ refresh the parent's stored SQL fields — a hidden extra UPDATE that can deadlock under concurrency.
114
+ See [save-cascade stored-field deadlocks](./model-save-parent-cascade-stored-field-deadlock.md)
115
+ and the `$_model_skipParentCascadeOnSave` opt-out.
111
116
 
112
117
  ## Change history
113
118
 
119
+ - 2026-08-13 — Noted that `save()` **cascades to parents** (re-saves an FK parent to refresh its
120
+ stored SQL fields) — a hidden extra UPDATE that can deadlock under concurrent same-parent child
121
+ inserts; cross-linked the new
122
+ [save-cascade stored-field deadlock](./model-save-parent-cascade-stored-field-deadlock.md) doc and
123
+ its `$_model_skipParentCascadeOnSave` opt-out. (jcardinal)
114
124
  - 2026-08-04 — TRUE-80282: documented that **`initial` is never resynced after a save**
115
125
  (`Model.php:244` diffs on `initial !== value`; `initial` set only at load/initialize,
116
126
  `Model.php:702`), so a **second `save()` on the same instance omits the column entirely** — every
@@ -20,6 +20,7 @@
20
20
  | [TableView field/column metadata (TableViewFields, hidden projected columns)](features/tableview-field-metadata.md) | The columns of a 2.0 table view are defined by DB metadata, not code. | _underscore/Model/Client/TableView.php, api2/Component/Api/V2/V2.php, dbchanges2/Client/2026-07-20 - ItemsUuidForPurchaseOrderItemsTableView.sql |
21
21
  | [Tickets API (/v2/tickets)](features/tickets-api.md) | The generic ticket endpoint of the 2.0 REST API. | Component/Api/V2/V2.php |
22
22
  | [V2 API error/message codes (EV/EZ troubleshooting map)](features/v2-api-error-codes.md) | The V2 JSON engine (`Component/Api/V2/V2.php`) returns short **message codes** in the response `error` field, grouped by family: `EN-*` authentication, `EZ-*` a | api2/Component/Api/V2/V2.php, api2/Component/Api/V2/Response/Response.php, api2/Component/Api/V2/Response/Oauth/Oauth.php, api2/Controller/Index.php, toga2-supply/src/globalTypes.ts, toga2-supply/src/pages/Orders/api/OrdersApi.ts, toga2-supply/src/api/toga.ts, _underscore/Model/Client/TrackingNumber.php |
23
+ | [V2 request deadlock-retry — route-scoped in-process replay](features/v2-deadlock-retry.md) | api2's front controller can **detect a MySQL deadlock (1213) / lock-wait timeout (1205) and replay the whole request in-process**, so a transient lock collision | api2/Controller/Index.php, _underscore/Database.php, _underscore/Query.php |
23
24
  | [V2 REST query contract (params, where grammar, encoding, ACL behavior)](features/v2-rest-query-contract.md) | What an **HTTP client** has to get right to query the Toga v2 REST API: which query params are recognized, the exact `where` grammar, how the query string is (n | api2/Component/Api/V2/V2.php |
24
25
  | [V2 reverse hasMany collections must be named in the fetch fields whitelist](features/v2-reverse-hasmany-fields-whitelist.md) | In the V2 JSON engine, a **reverse hasMany** relationship — the collection of child records that foreign-key back to a parent (e.g. | api2/Component/Api/V2/V2.php |
25
26
  | [AWS CodePipeline Deployment via CodeConnections (GitHub → Elastic Beanstalk)](workflows/codepipeline-codeconnections-deploy.md) | 2.0 apps (`api2`, `_underscore`) are deployed through **AWS CodePipeline**. | api2/.platform/hooks/postdeploy/060_register_instance_to_shared_application_load_balancer.sh, api2/ebs/register_instance_to_shared_application_load_balancer.php, api2/.platform/hooks/prebuild/git.sh, api2/.ebextensions/git.php, api2/.ebextensions/git.sandbox-dev.json |
@@ -176,6 +176,15 @@ migration instead of re-deriving it. All field/script ACL rows live in the **CLI
176
176
  a null payload, not an error — every consumer must null-guard, and "the page rendered empty"
177
177
  is a plausible symptom of a 500 you never saw.
178
178
 
179
+ 14. **A `4xx` validation (`EV-*`) response still leaks the raw failing SQL + DB name (pre-existing,
180
+ UNFIXED as of 2026-08-13).** Distinct from the 2026-07-30 fix that gated the 500 `message`/`trace`
181
+ behind `isDebugMode()`: on a DB error surfaced as a **validation** response, the `EV-10` body
182
+ returned to the **external** caller still contains the raw failing SQL statement **and the DB
183
+ name** (observed in Office Depot's 400 body on the ASN deadlock path). Worth scrubbing
184
+ independently. This is also **why deadlock detection was built on the MySQL errno** (1213/1205),
185
+ not on matching the response text — see
186
+ [V2 deadlock-retry](./v2-deadlock-retry.md).
187
+
179
188
  ## BREAKING (2026-07-30): `error` is now an object, not a bare string
180
189
 
181
190
  The V2 envelope's `error` changed from a bare string (`"EO-1"`) to an **object**:
@@ -261,6 +270,12 @@ Full mechanics:
261
270
 
262
271
  ## Change history
263
272
 
273
+ - 2026-08-13 — Added diagnosis note 14: a **validation (`EV-10`) response still leaks the raw failing
274
+ SQL statement + DB name** to the external caller (seen in Office Depot's 400 body on the ASN
275
+ deadlock path) — distinct from and NOT covered by the 2026-07-30 500 `message`/`trace` gating;
276
+ pre-existing and unfixed, worth scrubbing. Noted it is why the new
277
+ [V2 deadlock-retry](./v2-deadlock-retry.md) detection keys on the MySQL errno rather than response
278
+ text. (jcardinal)
264
279
  - 2026-08-11 — Added **`EO-2`** to the map as a **catch-all mislabel**: `Controller/Index.php`'s
265
280
  `catch (Throwable $dbBootstrapError)` (~L364) wraps the entire dispatch, so any escaped Throwable
266
281
  is reported as *"A required database could not be reached while initializing the request"* — the
@@ -0,0 +1,92 @@
1
+ ---
2
+ title: V2 request deadlock-retry — route-scoped in-process replay
3
+ framework: "2.0"
4
+ repo: api2
5
+ project: API
6
+ client: shared
7
+ type: feature
8
+ status: draft
9
+ updated: 2026-08-13
10
+ owners: [jcardinal]
11
+ files:
12
+ - api2/Controller/Index.php
13
+ - _underscore/Database.php
14
+ - _underscore/Query.php
15
+ related:
16
+ - ../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
17
+ - ../architecture.md
18
+ - ./v2-api-error-codes.md
19
+ ---
20
+
21
+ ## Summary
22
+
23
+ api2's front controller can **detect a MySQL deadlock (1213) / lock-wait timeout (1205) and replay
24
+ the whole request in-process**, so a transient lock collision self-recovers instead of returning an
25
+ HTTP 400 to the caller. The replay is **route-scoped by an allowlist**
26
+ (`DEADLOCK_RETRIABLE_ROUTES`) — currently only `/v2/advance-shipping-notices` — because whole-
27
+ request in-process replay is **only safe for routes with no non-rollbackable external side effect**.
28
+ This is the safety-net half of the ASN deadlock fix; the source-level fix (stop the collateral
29
+ parent re-save) is in
30
+ [_underscore's save-cascade doc](../../_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
31
+
32
+ > Status (2026-08-13): **in the working tree only** — not committed or deployed. Pending sandbox
33
+ > validation + regression tests.
34
+
35
+ ## How it works
36
+
37
+ **Detection primitive (`_underscore`, reusable).** `_Database` gains
38
+ `public static ?int $lastErrorNumber`, the consts `ERRNO_DEADLOCK` (1213) and
39
+ `ERRNO_LOCK_WAIT_TIMEOUT` (1205), plus `clearLastError()` and `wasDeadlock()`. `_Query` records the
40
+ driver errno **at its throw site**, and the recording is **sticky**: a later unrelated error does
41
+ **not** overwrite an already-recorded deadlock. That stickiness is what lets a deadlock caught
42
+ inside a model-save `try/catch` still be visible to the outer controller and trigger a retry.
43
+
44
+ **Retry loop (`api2/Controller/Index.php`).** The v2 dispatch is wrapped in a bounded loop
45
+ (`MAX_DEADLOCK_ATTEMPTS = 3`). On an unsuccessful response where `_Database::wasDeadlock()` is true
46
+ and the route is in `DEADLOCK_RETRIABLE_ROUTES`, the controller:
47
+
48
+ 1. rolls back all open transactions;
49
+ 2. wipes `_Database::$_transactionStarts` so every connection re-begins via the umbrella
50
+ lazy-transaction path on replay;
51
+ 3. resets the query + model caches;
52
+ 4. applies a **jittered backoff**;
53
+ 5. replays the **entire** request.
54
+
55
+ ## Key design constraint (durable) — replay-safety allowlist
56
+
57
+ Whole-request in-process replay is **only safe** for a route that has:
58
+
59
+ - **no non-rollbackable external side effect** before/around the deadlock — no NetSuite SOAP, no
60
+ email, no S3, no third-party HTTP; and
61
+ - **no destructive superglobal mutation** before the point of failure.
62
+
63
+ The V2 ASN request was verified side-effect-free end-to-end (all writes are local DB or in-process
64
+ `internalApiRequest`), which is why it is the only allowlisted route. Other routes are **not**
65
+ replay-safe and must stay off the list:
66
+
67
+ - the **OAuth** path destructively mutates `$_POST`;
68
+ - **`/purchase-orders`** fires email + SQS;
69
+ - **`/units`** hits NetSuite.
70
+
71
+ **Before adding any route to `DEADLOCK_RETRIABLE_ROUTES`, prove the whole request is replayable** —
72
+ idempotent or transaction-scoped, with no irreversible side effect and no consumed/mutated
73
+ superglobal. Default is to leave a route off the list.
74
+
75
+ ## Gotchas / known issues
76
+
77
+ - Detection is built on the **MySQL errno** (1213/1205), not on matching the response text —
78
+ deliberately, because the raw error text is not a reliable signal (and, separately, api2's
79
+ validation error body still leaks the failing SQL + DB name; see
80
+ [V2 API error codes](./v2-api-error-codes.md)).
81
+ - Retry is bounded (`MAX_DEADLOCK_ATTEMPTS = 3`) with jittered backoff — it is a collision damper,
82
+ not a substitute for removing the deadlock at its source.
83
+
84
+ ## Change history
85
+ - 2026-08-13 — Added deadlock/lock-wait detection to `_Database`/`_Query` (`$lastErrorNumber`,
86
+ `wasDeadlock()`, sticky errno recording) and a bounded, route-scoped in-process replay loop in
87
+ `Controller/Index.php`, gated to `/v2/advance-shipping-notices` only. Documented why whole-request
88
+ replay is allowlisted (no non-rollbackable external side effect; no destructive superglobal
89
+ mutation) — OAuth mutates `$_POST`, `/purchase-orders` fires email/SQS, `/units` hits NetSuite, so
90
+ they are excluded. Working tree only, pending sandbox validation. (jcardinal)
91
+ </content>
92
+ </invoke>
@@ -18,9 +18,9 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
18
18
 
19
19
  ## 2.0 framework
20
20
 
21
- - **_underscore** (_Underscore) _(framework core)_ — 55 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
+ - **_underscore** (_Underscore) _(framework core)_ — 56 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
22
22
  - **worker2** (Worker) — 48 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
23
- - **api2** (API) — 22 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
+ - **api2** (API) — 23 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
24
24
  - **dbchanges2** (Database Changes) _(framework core)_ — 8 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
25
25
  - **toga2-supply** (TOGa Supply) — 6 doc(s) → [2.0/apps/toga2-supply/INDEX.md](2.0/apps/toga2-supply/INDEX.md)
26
26
  - **saml** (SAML SSO Gateway) — 3 doc(s) → [2.0/apps/saml/INDEX.md](2.0/apps/saml/INDEX.md)
@@ -22,6 +22,8 @@ files:
22
22
  related:
23
23
  - ../../../2.0/apps/_underscore/features/recursive-item-fulfillments.md
24
24
  - ../../../2.0/apps/_underscore/features/item-fulfillment-stage-lifecycle-and-order-status.md
25
+ - ../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
26
+ - ../../../2.0/apps/api2/features/v2-deadlock-retry.md
25
27
  ---
26
28
 
27
29
  ## Summary
@@ -161,6 +163,17 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
161
163
  number) straight into queries. These are now passed through `_Database::escape()`.
162
164
  - Separate latent bug in the 1.0 worker: the Strategic Systems cron's no-serials branch
163
165
  builds `$itemLevelTrackingNumbers` but never attaches it to the ASN payload.
166
+ - **MySQL 1213 deadlocks on this path are a framework cascade, not ASN code (diagnosed
167
+ 2026-08-13).** Office Depot re-transmits the same ASN for days, so one PO receives many
168
+ concurrent same-PO ASN inserts. Each `AdvanceShippingNotices` insert (FK `purchaseOrderId`) trips
169
+ the generic `_Model::save()` parent cascade, which re-saves the PurchaseOrder to refresh its
170
+ **stored** `_total` correlated-subquery field — X-locking the PO row and S-locking all its
171
+ `PurchaseOrderItems` on a write that changes neither. Concurrent same-PO ASNs then deadlock. 100%
172
+ of the observed 1213s were this one `_total` UPDATE. Two mitigations (working tree, pending
173
+ validation): the base client ASN model opts out of the parent cascade
174
+ (`$_model_skipParentCascadeOnSave`), and api2 retries the ASN route on a detected deadlock. Full
175
+ mechanic:
176
+ [save-cascade stored-field deadlock](../../../2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md).
164
177
  - **Cross-line tracking contamination from bad SOI↔POI bridge rows (root cause, fixed
165
178
  forward 2026-06-11):** `postPost` resolves which IFI(s) a tracking number attaches to by
166
179
  joining `AdvanceShippingNoticeItems → SalesOrderItems_PurchaseOrderItems → SalesOrderItems`.
@@ -208,6 +221,11 @@ Compass Canada (`Model/Compass/Canada/`) is a separate sub-client. The Compass c
208
221
 
209
222
  ## Change history
210
223
  Dated one-liners, newest first.
224
+ - 2026-08-13 — Diagnosed the recurring MySQL 1213 deadlocks on `POST /v2/advance-shipping-notices`
225
+ (Office Depot cXML feed): 100% were the `_total` UPDATE emitted by the `_Model::save()` parent
226
+ cascade re-saving the PurchaseOrder on each ASN insert, deadlocking under concurrent same-PO ASN
227
+ re-transmissions. Recorded as a gotcha with the two working-tree mitigations (ASN model cascade
228
+ opt-out + api2 route-scoped deadlock retry). (jcardinal)
211
229
  - 2026-07-02 — Repaired SA133377 (SO 107609, line 1 MD7F4LL/A-S): the customer IF (IFI 276821)
212
230
  had no serial and the wrong tracking (`522944658493` — the first of two tracking-only vendor
213
231
  ASNs on PO 103641 that reconciled onto the direct-ASN IF 70635) instead of `873840444805`
@@ -0,0 +1,87 @@
1
+ ---
2
+ type: session
3
+ slug: api2-error-capture-schema-drift
4
+ title: Fix invisible api2 500s (sandbox-dev Logs.Issue schema drift killed error capture); harden the recorder and the kickoff KB-priming gate
5
+ author: jcardinal
6
+ repos: [api2, _underscore, dbchanges2]
7
+ framework: "2.0"
8
+ client: shared
9
+ status: active
10
+ created: 2026-08-12
11
+ updated: 2026-08-12
12
+ ---
13
+
14
+ # Session: api2-error-capture-schema-drift
15
+ **Date:** 2026-08-12
16
+ **Project/Repo:** api2, _underscore, dbchanges2 (2.0) + harness (`~/toga-tech` skills/hooks)
17
+ **Task:** A `/v2/surfaces/meta` 500 on dev.sandbox returned `EO-1` with a null `error.id` and wrote
18
+ nothing to `Logs.Event` — undebuggable. Find why nothing is recorded and make errors visible.
19
+
20
+ ---
21
+
22
+ ## What was wrong (root cause)
23
+
24
+ api2's `Controller/Index.php` catches `Throwable` from `V2.php::execute()` and calls
25
+ `_Error::captureException()` to record the error and mint an `error.id`. `captureException()` does
26
+ `$issue->save()`, and `_Model::save()` builds its INSERT from **all 30 fields declared on
27
+ `_Model_Core_Logs_Issue`**. Live **dev-sandbox `Logs.Issue` had only 23 columns** — the first missing
28
+ one throws **MySQL 1054** and the whole capture aborts (returns `null`): no `Logs.Issue` row, no
29
+ `Logs.Event` row, null `error.id`.
30
+
31
+ Seven columns were missing (an `information_schema` diff, not the migration files — the files lag):
32
+ - `clickupPriority` — `dbchanges2/Logs/2026-08-03a`
33
+ - `status`, `dtAutoResolved`, `baselineGapSeconds`, `baselineSampleCount`, `dtBaselineAnchor`,
34
+ `baselineAnchorOccurrences` — the whole `dbchanges2/Logs/2026-08-05a` lifecycle migration
35
+
36
+ Both migrations existed but had **never run on dev-sandbox** (the runner tracks applied files per
37
+ environment — see `Logs/2026-07-29a`). The KB had this filed (2026-08-06) as **one** missing column;
38
+ it was actually seven.
39
+
40
+ **Second, compounding blind spot:** capture's only failure signal is
41
+ `identifiers.captureFailure`, which the controller emitted **only in debug mode**. The deployed
42
+ sandbox box runs with debug off, so the caller got `identifiers: []` — no hint at all.
43
+
44
+ ## What was done
45
+
46
+ 1. **Schema (fix that unblocked it):** re-applied the two existing migrations (`2026-08-03a` then
47
+ `2026-08-05a`) to dev-sandbox. No new migration. Capture came back immediately — confirmed fixed by
48
+ the developer. **Still verify beta and other non-prod envs for the same drift.**
49
+ 2. **Observability hardening (`api2/Controller/Index.php`):** surface `identifiers.captureFailure` on
50
+ **every environment except production** (`_Environment::$name !== 'production'`, new constant
51
+ `PRODUCTION_ENVIRONMENT_NAME`), even with debug off — in both the `execute()` catch and the
52
+ DB-bootstrap catch. `message`/`trace` stay **debug-only** (they carry `_Database::register()`
53
+ frame-arg DB credentials); `captureFailure` names only *why the recorder failed*, so it cannot leak
54
+ them. A dead capture layer now announces itself instead of returning empty `identifiers`.
55
+ *(Working tree only — not committed; a project repo, so the developer deploys it.)*
56
+ 3. **KB doc corrected:** `_underscore/features/error-reporting-issue-event.md` — the 2026-08-06 open
57
+ defect updated from one column to seven and marked RESOLVED, with the controller-hardening note.
58
+
59
+ ## Harness change (kickoff KB-priming enforcement)
60
+
61
+ Prompted by a process failure this session: after `/kickoff`'s interview + preflight, the KB
62
+ `context-primer` was spawned **in the background** while repo code was read **in parallel** — the team
63
+ knowledge base was treated as a side-channel instead of the first source. Fixed so it cannot recur:
64
+
65
+ - **`scripts/hooks/kickoff-gate.js`** is now **two-stage**: preflight no longer releases the gate — it
66
+ advances `armed → primer`. In `primer`, all repo `Read/Grep/Glob/Edit/Write` and every non-primer
67
+ agent stay **blocked** until the **`context-primer` subagent is spawned in the foreground**
68
+ (`run_in_background: false`); that spawn releases the gate. Reading `repo-path-<repo>` memories and
69
+ `AskUserQuestion` stay allowed.
70
+ - **`.claude/settings.json`** — added `Agent` to the gate's `PreToolUse` matcher so it fires on
71
+ `Agent`-tool spawns.
72
+ - **`skills/kickoff/SKILL.md`** — Step 4 rewritten to mandate KB-first, foreground, wait-for-it
73
+ subagent priming *before any other research* (even files named in the `/kickoff` sentence); the
74
+ top gate prose now describes the two stages.
75
+
76
+ ## Gotchas worth remembering
77
+ - `_Model::save()` INSERTs **every declared field** — one missing `Logs` column silently kills ALL
78
+ capture on that environment (the recorder can't report its own failure). Any column added to a
79
+ `Logs` table must land its migration in **every** environment.
80
+ - Read the **live** `Logs` schema (`information_schema` / `toga_describe_table`), not the `dbchanges2`
81
+ migration files — the files lag the deployed schema.
82
+ - To debug an api2 500 you can't see: the `error.id` (`<Issue.reference>-<Event.eventNumber>`) →
83
+ `Logs.Issue.trace` by the reference (part before the dash) is the real stack; the response omits it.
84
+
85
+ ## Related
86
+ - [[error-reporting-issue-event]] (the capture pipeline this session repaired + documented)
87
+ - [[surface-meta-option]] (the endpoint whose 500 surfaced the blind spot)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.565",
3
+ "version": "1.0.567",
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",
@@ -2,32 +2,44 @@
2
2
  'use strict';
3
3
 
4
4
  /*
5
- * kickoff-gate.js — DETERMINISTIC enforcement of the /kickoff priming gate.
5
+ * kickoff-gate.js — DETERMINISTIC, TWO-STAGE enforcement of the /kickoff priming gate.
6
6
  *
7
- * Why this exists: the kickoff SKILL.md states a "hard gate" — when /kickoff is
8
- * invoked you MUST complete priming (Steps 0–6) BEFORE touching the task with any
9
- * Read/Grep/Glob/Edit/Write/Task call. Prose alone did not stop the model from
10
- * jumping straight to investigation when /kickoff was followed by a long, specific
11
- * task description. This hook makes the gate mechanical, not advisory.
7
+ * Why this exists: the kickoff SKILL.md states a "hard gate" — when /kickoff is invoked
8
+ * you MUST prime BEFORE touching the task. Prose alone did not stop the model from
9
+ * jumping to investigation when /kickoff was followed by a detailed task description.
10
+ * A first version of this hook released the gate the instant `kickoff-preflight` ran —
11
+ * but preflight only resolves the load-set; it does NOT read the team knowledge base.
12
+ * The model could (and did) run preflight, then read repo code BEFORE the team KB was
13
+ * primed, treating the knowledge base as a side-channel instead of the first source.
12
14
  *
13
- * It registers for TWO events from a single file:
15
+ * The team knowledge base is the whole point of kickoff — it is the distilled, shared
16
+ * gold the team maintains, and it must be primed FIRST, via the `context-primer`
17
+ * subagent, before ANY other research. This hook now enforces that in TWO stages:
14
18
  *
15
- * UserPromptSubmit → if the submitted prompt invokes /kickoff, ARM the gate:
16
- * write a session-scoped lock and inject a forceful reminder.
19
+ * Stage 1 ARMED (set by /kickoff) — allow ONLY the read-only priming steps
20
+ * (version check, team-repo probe, manifest, interview) and
21
+ * `kickoff-preflight`. Everything else is blocked. Running
22
+ * `kickoff-preflight` ADVANCES the gate to stage 2 — it no longer
23
+ * releases it.
17
24
  *
18
- * PreToolUse → while the gate is armed for THIS session, ALLOW only the
19
- * read-only priming steps (the priming Bash commands + the
20
- * interview via AskUserQuestion/Skill, which don't match the
21
- * PreToolUse matcher) and BLOCK every task tool
22
- * (Read/Grep/Glob/Edit/Write/MultiEdit/NotebookEdit/Task/
23
- * WebFetch/WebSearch and any non-priming Bash). The lock
24
- * AUTO-CLEARS the moment `knowledge.js kickoff-preflight`
25
- * runs — the definitive priming step that precedes Step 4's
26
- * first Read. So the model literally cannot investigate or
27
- * edit the task until it has actually primed.
25
+ * Stage 2 PRIMER (set by preflight) — the load-set is known but the team KB is not
26
+ * yet primed. Block all repo research (Read/Grep/Glob of code, Edit,
27
+ * Write, and every non-primer agent) until the `context-primer`
28
+ * subagent is spawned IN THE FOREGROUND (run_in_background: false),
29
+ * so the model actually waits for the briefing. Spawning it releases
30
+ * the gate. Reading repo-path MEMORY files and asking the developer
31
+ * remain allowed (they are part of priming).
28
32
  *
29
- * Fail-open: any internal/parse error allows the tool (a crashing gate must never
30
- * brick every session). The SKILL.md prose remains the backup.
33
+ * Net effect: after the interview, the model literally cannot read a single repo file,
34
+ * grep the code, or spawn any other agent until it has primed from the team KB via the
35
+ * context-primer subagent. KB-first, via subagent, without exception.
36
+ *
37
+ * Registered for UserPromptSubmit (arm) + PreToolUse (enforce). A defensive
38
+ * PostToolUse branch is a no-op — release is spawn-time in PreToolUse, so the gate can
39
+ * never get stuck waiting on an event that did not fire.
40
+ *
41
+ * Fail-open: any internal/parse error allows the tool (a crashing gate must never brick
42
+ * every session). The SKILL.md prose remains the backup.
31
43
  *
32
44
  * Escape hatch: set KICKOFF_GATE_DISABLED=1 to disable enforcement entirely.
33
45
  */
@@ -61,10 +73,11 @@ function readLock() {
61
73
  try { return JSON.parse(fs.readFileSync(lockPath(), 'utf8')); } catch (e) { return null; }
62
74
  }
63
75
 
64
- function writeLock(sessionId) {
76
+ // phase: 'armed' (priming not started) → 'primer' (preflight ran, team KB not yet primed).
77
+ function writeLock(sessionId, phase) {
65
78
  try {
66
79
  fs.mkdirSync(path.dirname(lockPath()), { recursive: true });
67
- fs.writeFileSync(lockPath(), JSON.stringify({ session: sessionId || null, ts: Date.now() }) + '\n');
80
+ fs.writeFileSync(lockPath(), JSON.stringify({ session: sessionId || null, ts: Date.now(), phase: phase || 'armed' }) + '\n');
68
81
  } catch (e) { /* non-fatal */ }
69
82
  }
70
83
 
@@ -84,7 +97,7 @@ function invokesKickoff(prompt) {
84
97
  }
85
98
 
86
99
  /* Bash commands that ARE the read-only priming steps (Steps 0–3). Everything else
87
- * is task work and stays blocked until preflight clears the gate. */
100
+ * is task work and stays blocked. */
88
101
  function isPrimingBash(command) {
89
102
  if (typeof command !== 'string') return false;
90
103
  return /knowledge\.js|kickoff-preflight|toga-ai\.version|npm\s+view\s+toga-ai|npx\s+toga-ai|registry\.json|rev-parse/i.test(command);
@@ -94,58 +107,107 @@ function isPreflightBash(command) {
94
107
  return typeof command === 'string' && /kickoff-preflight/i.test(command);
95
108
  }
96
109
 
97
- /* Tools that constitute touching the task — blocked while the gate is armed. */
110
+ /* A Read of a repo-path (or any) MEMORY file is part of priming (Step 3 resolves each
111
+ * repo's local path from a repo-path-<repo> memory). Allowed even in the primer stage;
112
+ * everything else under Read is repo research and stays blocked until the KB is primed. */
113
+ function isMemoryReadPath(p) {
114
+ if (typeof p !== 'string' || !p) return false;
115
+ const norm = p.replace(/\\/g, '/');
116
+ return /\.claude\/projects\/.+\/memory\//i.test(norm);
117
+ }
118
+
119
+ /* The subagent that primes the team knowledge base. It — and only it — may run in the
120
+ * primer stage. */
121
+ const PRIMER_SUBAGENT = 'context-primer';
122
+
123
+ /* Tools that constitute touching the task — blocked while the gate is armed. Both the
124
+ * classic `Task` tool and this harness's `Agent` tool are included. */
98
125
  const BLOCKED_TOOLS = new Set([
99
126
  'Read', 'Grep', 'Glob', 'Edit', 'Write', 'MultiEdit',
100
127
  'NotebookEdit', 'Task', 'Agent', 'WebFetch', 'WebSearch',
101
128
  ]);
102
129
 
103
- function blockMessage() {
130
+ function armedMessage() {
104
131
  return [
105
- '🛑 KICKOFF GATE — priming is not complete.',
132
+ '🛑 KICKOFF GATE (stage 1 of 2) — priming has not started.',
106
133
  '',
107
- '/kickoff was invoked this session. You MUST finish priming BEFORE touching the task',
108
- 'with any Read/Grep/Glob/Edit/Write/Task call — no matter how detailed the request after',
109
- '/kickoff looks. That trailing text is the Step 2 task description, NOT permission to start.',
134
+ '/kickoff was invoked this session. Finish the read-only priming steps BEFORE any',
135
+ 'Read/Grep/Glob/Edit/Write/Task — no matter how detailed the request after /kickoff',
136
+ 'looks. That trailing text is the Step 2 task description, NOT permission to start.',
110
137
  '',
111
- 'Do this now, in order (these are allowed while the gate is armed):',
138
+ 'Allowed now, in order:',
112
139
  ' • Step 0 version check (cat .claude/toga-ai.version ; npm view toga-ai version)',
113
140
  ' • Step 1 resolve the team repo (probe for knowledge/registry.json)',
114
141
  ' • Step 2 interview the developer (framework / layer / repo(s) / client / task)',
115
- ' • Step 3 node "<TEAM_REPO>/knowledge.js" kickoff-preflight ... ← this AUTO-CLEARS the gate',
116
- ' • Step 4+ THEN read the knowledge docs and start the task',
142
+ ' • Step 3 node "<TEAM_REPO>/knowledge.js" kickoff-preflight ...',
117
143
  '',
118
- 'The gate releases automatically the instant kickoff-preflight runs.',
144
+ 'Running kickoff-preflight ADVANCES the gate to stage 2 (team-KB priming). It no',
145
+ 'longer releases the gate — priming the knowledge base does.',
119
146
  '(Emergency override only: set KICKOFF_GATE_DISABLED=1.)',
120
147
  ].join('\n');
121
148
  }
122
149
 
150
+ function primerMessage() {
151
+ return [
152
+ '🛑 KICKOFF GATE (stage 2 of 2) — the TEAM KNOWLEDGE BASE has not been primed yet.',
153
+ '',
154
+ 'kickoff-preflight resolved the load-set, but the team KB is the FIRST source you must',
155
+ 'prime from — before ANY other research. Do NOT read repo code, grep/glob the source,',
156
+ 'edit, or spawn any other agent yet — not even files named in the /kickoff sentence.',
157
+ '',
158
+ 'Do this now (Step 4): spawn the context-primer subagent to read the resolved knowledge',
159
+ 'docs and return the briefing —',
160
+ ' Agent tool · subagent_type: "context-primer" · run_in_background: false',
161
+ ' pass it: TEAM_REPO, the full kickoff-preflight JSON, and the task description.',
162
+ 'Wait for its briefing. THEN read repo code and start the work.',
163
+ '',
164
+ 'Allowed meanwhile: reading repo-path-<repo> memory files, AskUserQuestion (to ask for',
165
+ 'a repo path), and knowledge.js priming commands.',
166
+ '(Emergency override only: set KICKOFF_GATE_DISABLED=1.)',
167
+ ].join('\n');
168
+ }
169
+
170
+ function foregroundMessage() {
171
+ return [
172
+ '🛑 KICKOFF GATE (stage 2 of 2) — spawn the context-primer in the FOREGROUND.',
173
+ '',
174
+ 'Pass run_in_background: false so team-KB priming actually COMPLETES before you',
175
+ 'continue. A backgrounded primer lets you race ahead and read repo code before the',
176
+ 'knowledge-base briefing lands — the exact failure this gate exists to prevent.',
177
+ ].join('\n');
178
+ }
179
+
123
180
  function main() {
124
181
  if (process.env.KICKOFF_GATE_DISABLED === '1') process.exit(0);
125
182
 
126
183
  const data = readPayload();
127
184
 
128
185
  // ---- UserPromptSubmit: arm the gate when /kickoff is invoked ----
129
- // Identify by the presence of a prompt and absence of a tool call.
130
186
  const isPromptEvent =
131
187
  data.hook_event_name === 'UserPromptSubmit' ||
132
188
  (typeof data.prompt === 'string' && !data.tool_name);
133
189
 
134
190
  if (isPromptEvent) {
135
191
  if (invokesKickoff(data.prompt)) {
136
- writeLock(data.session_id);
192
+ writeLock(data.session_id, 'armed');
137
193
  // stdout from UserPromptSubmit is injected into the model's context.
138
194
  console.log(
139
- '<system-reminder>KICKOFF GATE ARMED. Before ANY other tool call, complete priming ' +
140
- 'Steps 0–6 of the kickoff skill — version check, resolve team repo, interview, then ' +
141
- 'run `knowledge.js kickoff-preflight` (which releases the gate), THEN read docs and work. ' +
142
- 'The trailing task description is Step 2 input, not permission to start. ' +
143
- 'A hook will block Read/Grep/Edit/Write/Task until preflight runs.</system-reminder>'
195
+ '<system-reminder>KICKOFF GATE ARMED (two stages). Stage 1: complete read-only ' +
196
+ 'priming (version check, resolve team repo, interview) then run `knowledge.js ' +
197
+ 'kickoff-preflight`. Stage 2: preflight does NOT release the gate — you must FIRST ' +
198
+ 'prime the team knowledge base via the `context-primer` subagent (foreground, ' +
199
+ 'run_in_background: false) BEFORE any repo Read/Grep/Glob/Edit/Write or other agent. ' +
200
+ 'The trailing task description is Step 2 input, not permission to start.</system-reminder>'
144
201
  );
145
202
  }
146
203
  process.exit(0);
147
204
  }
148
205
 
206
+ // ---- PostToolUse: no-op. Release is spawn-time in PreToolUse, so the gate can never
207
+ // hang waiting on a completion event. Present defensively so a PostToolUse wiring
208
+ // never falls through to the PreToolUse enforcement below. ----
209
+ if (data.hook_event_name === 'PostToolUse') process.exit(0);
210
+
149
211
  // ---- PreToolUse: enforce while armed for THIS session ----
150
212
  const lock = readLock();
151
213
  if (!lock) process.exit(0);
@@ -158,22 +220,54 @@ function main() {
158
220
  if (data.session_id && lock.session && !sameSession) process.exit(0);
159
221
 
160
222
  const tool = data.tool_name || '';
161
- const command = (data.tool_input && data.tool_input.command) || '';
223
+ const toolInput = data.tool_input || {};
224
+ const command = toolInput.command || '';
225
+ const phase = lock.phase || 'armed';
162
226
 
163
- if (tool === 'Bash') {
164
- if (isPreflightBash(command)) { clearLock(); process.exit(0); } // priming reached — release
165
- if (isPrimingBash(command)) process.exit(0); // other read-only priming step
166
- console.log(blockMessage());
167
- process.exit(2);
227
+ // ================= STAGE 1: ARMED — only priming Steps 0–3 =================
228
+ if (phase === 'armed') {
229
+ if (tool === 'Bash') {
230
+ // preflight ADVANCES to stage 2 (does NOT release) — the team KB is still unprimed.
231
+ if (isPreflightBash(command)) { writeLock(lock.session, 'primer'); process.exit(0); }
232
+ if (isPrimingBash(command)) process.exit(0); // other read-only priming step
233
+ console.log(armedMessage());
234
+ process.exit(2);
235
+ }
236
+ if (BLOCKED_TOOLS.has(tool)) { console.log(armedMessage()); process.exit(2); }
237
+ process.exit(0); // AskUserQuestion, Skill, TodoWrite, … — part of the interview
168
238
  }
169
239
 
170
- if (BLOCKED_TOOLS.has(tool)) {
171
- console.log(blockMessage());
172
- process.exit(2);
240
+ // ============ STAGE 2: PRIMER — team KB must be primed via subagent first ============
241
+ if (phase === 'primer') {
242
+ if (tool === 'Agent' || tool === 'Task') {
243
+ if (toolInput.subagent_type === PRIMER_SUBAGENT) {
244
+ // Must be foreground so the model waits for the briefing before doing anything else.
245
+ // (The classic `Task` tool runs synchronously; only the async `Agent` tool needs the
246
+ // explicit run_in_background: false.)
247
+ if (tool === 'Agent' && toolInput.run_in_background !== false) {
248
+ console.log(foregroundMessage());
249
+ process.exit(2);
250
+ }
251
+ clearLock(); // team-KB priming is underway in the foreground — release the gate
252
+ process.exit(0);
253
+ }
254
+ console.log(primerMessage()); // any other agent must wait until the KB is primed
255
+ process.exit(2);
256
+ }
257
+ if (tool === 'Read') {
258
+ if (isMemoryReadPath(toolInput.file_path || toolInput.path)) process.exit(0); // repo-path lookup
259
+ console.log(primerMessage());
260
+ process.exit(2);
261
+ }
262
+ if (tool === 'Bash') {
263
+ if (isPrimingBash(command)) process.exit(0);
264
+ console.log(primerMessage());
265
+ process.exit(2);
266
+ }
267
+ if (BLOCKED_TOOLS.has(tool)) { console.log(primerMessage()); process.exit(2); } // Grep/Glob/Edit/Write/…
268
+ process.exit(0); // AskUserQuestion (ask for a repo path), Skill, … — allowed
173
269
  }
174
270
 
175
- // Anything else (AskUserQuestion, Skill, TodoWrite, ExitPlanMode, …) is part of
176
- // priming/interview — allow.
177
271
  process.exit(0);
178
272
  }
179
273
 
@@ -23,15 +23,24 @@ description: Start-of-session context loader for TOGA Technology projects. Run t
23
23
  > Only after Step 5's "primed and ready" summary (and Step 6's plan, for non-trivial work)
24
24
  > may you touch the task itself.
25
25
  >
26
- > **This gate is mechanically enforced — it is not just prose.** A hook
26
+ > **This gate is mechanically enforced in TWO stages — it is not just prose.** A hook
27
27
  > (`hooks/toga/kickoff-gate.js`, wired on `UserPromptSubmit` + `PreToolUse`) arms the
28
28
  > instant `/kickoff` is invoked and will **hard-block** `Read`, `Grep`, `Glob`, `Edit`,
29
- > `Write`, `Task`, and any non-priming `Bash` until you actually prime. The only calls it
30
- > permits while armed are the read-only priming steps (the version check, team-repo probe,
31
- > `knowledge.js manifest`, the developer interview) and `knowledge.js kickoff-preflight` —
32
- > **running preflight (Step 3) is what releases the gate.** So the correct response to a
33
- > block message is never to fight it: run Steps 0–3, and the moment preflight executes you
34
- > are free to read docs and work. (Emergency override only: `KICKOFF_GATE_DISABLED=1`.)
29
+ > `Write`, `Task`/`Agent`, and any non-priming `Bash` until you actually prime.
30
+ >
31
+ > - **Stage 1 (armed):** only the read-only priming steps (version check, team-repo probe,
32
+ > `knowledge.js manifest`, the developer interview) and `knowledge.js kickoff-preflight`
33
+ > are permitted. Running **preflight (Step 3) does NOT release the gate — it advances it to
34
+ > stage 2.**
35
+ > - **Stage 2 (primer):** the load-set is resolved but the **team knowledge base is not yet
36
+ > primed.** Everything stays blocked until you spawn the **`context-primer` subagent in the
37
+ > foreground** (Step 4) — priming the team KB via that subagent is **what releases the gate.**
38
+ > Reading `repo-path-<repo>` memories and asking the developer stay allowed.
39
+ >
40
+ > So the correct response to a block message is never to fight it: run Steps 0–3, then
41
+ > **prime the team KB via the foreground `context-primer` subagent (Step 4) BEFORE any other
42
+ > research** — before you open a single repo file, even one named in the `/kickoff` sentence.
43
+ > (Emergency override only: `KICKOFF_GATE_DISABLED=1`.)
35
44
 
36
45
  ## Session permission policy — auto-accept local file I/O, ALWAYS confirm dangerous execution
37
46
 
@@ -257,20 +266,30 @@ source — this is the one thing preflight can't know):
257
266
 
258
267
  Never ask for a repo not in `loadSet`.
259
268
 
260
- ## Step 4 — Load the knowledge via the `context-primer` subagent (do NOT read docs inline)
261
-
262
- **This is the token-discipline core of kickoff.** Reading every architecture + feature +
263
- standard + client doc *into this conversation* is exactly what bloats the thread and trips
264
- compaction later. So you do **not** read those docs here. Instead, **delegate the heavy
265
- reading to the `context-primer` subagent**, which reads them in *its own* context and
266
- returns a small distilled briefing. Preflight (Step 3) has already released the kickoff
267
- gate, so spawning a subagent is now permitted.
268
-
269
- Spawn it with the `Agent` tool (`subagent_type: context-primer`) and pass:
270
- - `TEAM_REPO` (the resolved path),
271
- - the **full `kickoff-preflight` JSON** from Step 3 (it contains `reads[]`, `clientScope`,
272
- `standards`, `client`, `estimate`),
273
- - the developer's one-line task description.
269
+ ## Step 4 — Prime the team knowledge base FIRST, via the `context-primer` subagent
270
+
271
+ > 🛑 **THIS IS THE FIRST RESEARCH YOU DO — NOT OPTIONAL, NOT PARALLELIZABLE.**
272
+ > After the interview, the team knowledge base is the **first source you prime from, before
273
+ > ANY other research.** You may **not** read repo code, `Grep`/`Glob` the source, spawn any
274
+ > other agent, or open a file **named in the `/kickoff` sentence** until the `context-primer`
275
+ > subagent has **returned its briefing.** Preflight (Step 3) did **not** release the gate — it
276
+ > advanced it to stage 2, and **spawning the foreground `context-primer` is what releases it.**
277
+ > The team KB is the team's distilled, shared gold: skipping it, or side-channeling it (running
278
+ > the primer in the background while you read code in parallel), is the exact failure this gate
279
+ > exists to prevent — do neither.
280
+
281
+ **This is also the token-discipline core of kickoff.** Reading every architecture + feature +
282
+ standard + client doc *into this conversation* bloats the thread and trips compaction later. So
283
+ you do **not** read those docs here — you **delegate the heavy reading to the `context-primer`
284
+ subagent**, which reads them in *its own* context and returns a small distilled briefing.
285
+
286
+ Spawn it with the `Agent` tool and **wait for it**:
287
+ - `subagent_type: context-primer`,
288
+ - **`run_in_background: false`** — foreground, so you MUST wait for the briefing before doing
289
+ anything else; a backgrounded primer running while you read code is precisely the violation,
290
+ - pass it: `TEAM_REPO` (the resolved path), the **full `kickoff-preflight` JSON** from Step 3
291
+ (`reads[]`, `clientScope`, `standards`, `client`, `estimate`), and the developer's one-line
292
+ task description.
274
293
 
275
294
  The primer returns a `## Primer` block (critical rules, task-relevant knowledge + gotchas,
276
295
  client variations, gaps) plus a `## Doc map`. **Keep only that briefing as your context for