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.
- package/.claude/settings.json +1 -1
- package/knowledge/2.0/apps/_underscore/INDEX.md +1 -0
- package/knowledge/2.0/apps/_underscore/architecture.md +3 -1
- package/knowledge/2.0/apps/_underscore/features/calculated-sql-fields.md +15 -2
- package/knowledge/2.0/apps/_underscore/features/error-reporting-issue-event.md +44 -12
- package/knowledge/2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md +121 -0
- package/knowledge/2.0/apps/_underscore/features/model-save-vs-query-atomic-update.md +12 -2
- package/knowledge/2.0/apps/api2/INDEX.md +1 -0
- package/knowledge/2.0/apps/api2/features/v2-api-error-codes.md +15 -0
- package/knowledge/2.0/apps/api2/features/v2-deadlock-retry.md +92 -0
- package/knowledge/INDEX.md +2 -2
- package/knowledge/clients/compass-usa/features/asn-to-item-fulfillment.md +18 -0
- package/knowledge/sessions/2026-08-12-api2-error-capture-schema-drift-jcardinal.md +87 -0
- package/package.json +1 -1
- package/scripts/hooks/kickoff-gate.js +146 -52
- package/skills/kickoff/SKILL.md +40 -21
package/.claude/settings.json
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
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
|
-
##
|
|
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
|
-
**
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
**Cause:** `
|
|
453
|
-
|
|
454
|
-
|
|
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
|
package/knowledge/2.0/apps/_underscore/features/model-save-parent-cascade-stored-field-deadlock.md
ADDED
|
@@ -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-
|
|
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>
|
package/knowledge/INDEX.md
CHANGED
|
@@ -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)_ —
|
|
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) —
|
|
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
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
*
|
|
30
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
/*
|
|
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
|
|
130
|
+
function armedMessage() {
|
|
104
131
|
return [
|
|
105
|
-
'🛑 KICKOFF GATE — priming
|
|
132
|
+
'🛑 KICKOFF GATE (stage 1 of 2) — priming has not started.',
|
|
106
133
|
'',
|
|
107
|
-
'/kickoff was invoked this session.
|
|
108
|
-
'
|
|
109
|
-
'
|
|
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
|
-
'
|
|
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 ...
|
|
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
|
-
'
|
|
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
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
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
|
|
223
|
+
const toolInput = data.tool_input || {};
|
|
224
|
+
const command = toolInput.command || '';
|
|
225
|
+
const phase = lock.phase || 'armed';
|
|
162
226
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
|
package/skills/kickoff/SKILL.md
CHANGED
|
@@ -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.
|
|
30
|
-
>
|
|
31
|
-
>
|
|
32
|
-
>
|
|
33
|
-
>
|
|
34
|
-
>
|
|
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 —
|
|
261
|
-
|
|
262
|
-
**
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|