toga-ai 1.0.371 → 1.0.372

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  | Doc | Summary | Files |
4
4
  |-----|---------|-------|
5
+ | [Proposed — ExternalIdentifiers map table for integration id ↔ external-system linkage](architecture/external-identifiers-map-table.md) | A **proposal / counter-proposal (status: proposed — nothing here is built)** that cross-system identifier linkage (e.g. | _underscore/Model/Forecast/Opportunity.php, worker2/Worker/Netsuite/Opportunity.php |
5
6
  | [Proposed — git-sourced base+overlay JSON authoring for the Surface layer](architecture/surface-authoring-proposal.md) | A **proposal / handoff recommendation** (not implemented) that the Surface layer's *authoring* model move off hand-authored SQL against the `SurfaceOverrides` E | _underscore/Model/Core/Surface.php, _underscore/Model/Client/SurfaceOverride.php |
6
7
  | [_underscore Framework Architecture](architecture.md) | `_underscore` is the shared PHP backend framework for **all 2.0 applications**. | _underscore/_underscore.php, _underscore/Loader.php, _underscore/Framework.php, _underscore/Model.php, _underscore/Database.php, _underscore/Query.php, _underscore/Route.php, _underscore/Component.php |
7
8
  | [ACL Permission Chain (Record & Field Authorization)](features/acl-permission-chain.md) | Authorization in the 2.0 API is **metadata-driven**: whether a role may Create/Read/Update/Delete a record is decided by rows across **four linked tables**, not | api2/Component/Api/V2/V2.php, _underscore/Model/Core/Page.php, dbchanges2/Client/2026-06-03- BLANK_CLIENT_DATABASE.sql, dbchanges2/Client/2026-06-23b - ItemTranslationsAcl.sql |
@@ -18,6 +19,7 @@
18
19
  | [Forecast.Sales NetSuite import engine (real-time webhook)](features/forecast-sale-import.md) | Real-time importer that takes a NetSuite **sale** record and writes its lines into `Forecast.Sales` (the Forecast2 revenue table). | worker2/Component/Forecast/SaleImport/SaleImport.php, worker2/Component/Forecast/Db/Db.php, _underscore/Component/Api/Netsuite/Netsuite.php, worker2/Worker/Netsuite/Invoice.php, worker2/Worker/Netsuite/CashSale.php, worker2/Worker/Netsuite/CreditMemo.php, worker2/Worker/Netsuite/CashRefund.php, worker2/Worker/Netsuite/JournalEntry.php, worker2/Worker/Netsuite/Opportunity.php, worker2/Worker/Netsuite/SalesOrder.php, dbchanges2/Forecast/2026-06-26a - Add journalEntry to Sales transaction type enum.sql, test/@dave/test_invoice_lifecycle.php, test/@dave/test_je_lifecycle.php, test/@dave/test_creditmemo_lifecycle.php, test/@dave/test_cashsale_lifecycle.php, test/@dave/test_cashrefund_lifecycle.php, test/@dave/test_fetchrecord_routes.php, test/@dave/verify_je_classification.php, test/@dave/probe_je_accounts.php, test/@dave/probe_je_shape.php, test/@dave/fixer.php, test/@dave/Junk Drawer/NetSuite/api-message-queue/ue_api_msg_queue_enqueue.js, test/@dave/Junk Drawer/NetSuite/api-message-queue/dev_ue_api_msg_queue_enqueue.js |
19
20
  | [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 |
20
21
  | [_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 |
22
+ | [_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 |
21
23
  | [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 |
22
24
  | [Per-Client Database Connections & the Local Logs Trap](features/per-client-database-connections.md) | When `_underscore` serves a request for a client it opens **three distinct per-client database connections**, not one. | _underscore/Database.php, _underscore/ApiRequest.php, _underscore/Model/Client/Logs/Api.php |
23
25
  | [Recursive Item Fulfillments (upstream mirroring)](features/recursive-item-fulfillments.md) | In a multi-tier supply chain a sales order (SO) spawns a purchase order (PO) that becomes another SO downstream, and so on. | _underscore/Model/Client/ItemFulfillment.php, _underscore/Model/Client/ItemFulfillmentItem.php, _underscore/Model/Client/ItemFulfillmentItemUnit.php, _underscore/Model/Client/ItemFulfillmentPackage.php, _underscore/Model/Compass/AdvanceShippingNotice.php, dbchanges2/Core/2026-02-13 - 75601 - RecursiveItemFulfillmentCreation.sql, dbchanges2/Core/2026-06-04 - RecursiveItemFulfillmentPut.sql, dbchanges2/Client_Compass/2026-07-02a - FixSA133377TrackingSerialAndDuplicateIF.sql |
@@ -0,0 +1,77 @@
1
+ ---
2
+ title: Proposed — ExternalIdentifiers map table for integration id ↔ external-system linkage
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: architecture
8
+ status: proposed
9
+ updated: 2026-07-17
10
+ owners: ["dfranks"]
11
+ files:
12
+ - _underscore/Model/Forecast/Opportunity.php
13
+ - worker2/Worker/Netsuite/Opportunity.php
14
+ related:
15
+ - ../features/model-save-vs-query-atomic-update.md
16
+ - ../../worker2/features/netsuite-opportunity-sync.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ A **proposal / counter-proposal (status: proposed — nothing here is built)** that cross-system
22
+ identifier linkage (e.g. a NetSuite opportunity ↔ its ClickUp task id) live in a dedicated
23
+ **`ExternalIdentifiers` map table** rather than as ad-hoc columns bolted onto whichever business
24
+ table happens to be nearby. The immediate driver is the TRUE-80044 atomic ClickUp-task claim, whose
25
+ currently-mandated approach adds a `clickupTaskId` column to `Forecast.Opportunities`. This doc is
26
+ `dfranks`'s architectural counter-proposal to that mandate, raised for open discussion — **not** a
27
+ silent supersede of the reviewer decision. See the rebuttal section below.
28
+
29
+ **Critical framing:** This is a **proposal under open discussion**, not the current system and not a
30
+ settled reversal. The mandated approach on record (a `clickupTaskId` column on
31
+ `Forecast.Opportunities`) remains the standing decision until the disagreement is resolved with the
32
+ reviewer.
33
+
34
+ ## Design
35
+
36
+ A single shared table keyed by (local record identity, external system, external id kind):
37
+
38
+ - **Columns (sketch):** `id`, `entityType` (e.g. `opportunity`), `entityId` (the local business
39
+ key, e.g. `netsuiteOpportunityInternalId`), `externalSystem` (e.g. `clickup`), `externalIdKind`
40
+ (e.g. `task`), `externalId` (the value/sentinel), plus `dtCreated`/`dtUpdated`.
41
+ - **Uniqueness:** a UNIQUE index on `(entityType, entityId, externalSystem, externalIdKind)` — this
42
+ is the single-row lock that makes the atomic claim (`UPDATE … SET externalId='<uuid sentinel>'
43
+ WHERE … AND externalId IS NULL`, commit, `affectedRows===1` wins) work exactly as the mandated
44
+ column approach would, but without touching the Forecast schema.
45
+ - **Atomic claim mechanics are unchanged** from the mandated design — same raw `_Query` guarded
46
+ UPDATE (ORM `save()` cannot express it — see
47
+ [model-save-vs-query-atomic-update](../features/model-save-vs-query-atomic-update.md)), same
48
+ commit-before-external-side-effect, same sentinel-guarded compensation-delete on failure.
49
+
50
+ ## Rebuttal to the clickupTaskId-column mandate
51
+
52
+ `2.0/apps/worker2/features/netsuite-opportunity-sync.md` records reviewer **Rohan Girish** mandating
53
+ (2026-07-15) that the ClickUp-task dedup claim be implemented as a **`clickupTaskId` column on
54
+ `Forecast.Opportunities`**, and rejecting the earlier plan's dedicated table. This doc is `dfranks`'s
55
+ **counter-proposal**, offered as an open architectural disagreement to be resolved *with* the
56
+ reviewer — the mandate stands until then. The three rebuttal arguments:
57
+
58
+ 1. **Separation of concerns.** A ClickUp task id is *integration-linkage* state, not a business
59
+ attribute of a Forecast opportunity. Storing it on `Forecast.Opportunities` conflates the
60
+ integration/mapping layer with the forecasting domain model.
61
+ 2. **Forecast-schema dumping-ground.** Every future external linkage (a second external system, a
62
+ different id kind) under the column approach means another one-off nullable column on a business
63
+ table, accreting integration cruft on the Forecast schema. A map table absorbs all of them with
64
+ no schema change per linkage.
65
+ 3. **Forecast-sync lifecycle decoupling.** The integration mapping must be able to **outlive a
66
+ possibly-discontinued Forecast sync.** Binding the ClickUp linkage to the Forecast opportunity row
67
+ ties its lifetime to the Forecast importer; if that sync is ever retired or reworked, the mapping
68
+ goes with it. A standalone map table keeps the NS↔ClickUp linkage durable independently of
69
+ Forecast.
70
+
71
+ This is framed as a disagreement to resolve, not a reversal already taken. If the reviewer position
72
+ holds, the mandated column approach is what ships.
73
+
74
+ ## Change history
75
+
76
+ - 2026-07-17 — Proposed as a rebuttal to the reviewer-mandated clickupTaskId-column approach
77
+ (TRUE-80044). (dfranks)
@@ -0,0 +1,75 @@
1
+ ---
2
+ title: _Model::save() vs raw _Query — no atomic conditional update
3
+ framework: "2.0"
4
+ repo: _underscore
5
+ project: _Underscore
6
+ client: shared
7
+ type: feature
8
+ status: active
9
+ updated: 2026-07-17
10
+ owners: ["dfranks"]
11
+ files:
12
+ - _underscore/Model.php
13
+ - _underscore/Query.php
14
+ related:
15
+ - ./model-magic-field-access.md
16
+ - ../../worker2/features/netsuite-opportunity-sync.md
17
+ ---
18
+
19
+ ## Summary
20
+
21
+ `_Model::save()` is a plain load-then-write ORM primitive and **cannot express an atomic
22
+ conditional update** (an optimistic-concurrency / row-claim guard such as
23
+ `... WHERE col IS NULL`). For any DB-level claim / optimistic-concurrency pattern you must drop
24
+ to raw `_Query` — `_Query::getAffectedRows()` is the only surface that exposes an affected-rows
25
+ count, which is what a claim needs to know whether *this* caller won. `save()` remains the correct
26
+ idiom for ordinary by-primary-key writes.
27
+
28
+ ## How it works
29
+
30
+ `_Model::save()`'s update branch always emits:
31
+
32
+ ```sql
33
+ UPDATE <table> SET <changed cols> WHERE <primaryKey> = <id>
34
+ ```
35
+
36
+ (`Model.php` ~line 285). Consequences that make it unusable for a conditional claim:
37
+
38
+ - **No conditional guard.** The `WHERE` is hard-coded to the primary key; there is no way to attach
39
+ an extra predicate like `AND clickupTaskId IS NULL`. So two concurrent callers both pass any
40
+ in-PHP check and both write — the update is not a claim.
41
+ - **Skips the write when nothing changed.** If no field differs from the loaded row, `save()` emits
42
+ no UPDATE at all. A claim that relies on "did my write land?" gets no signal.
43
+ - **Returns no affected-rows count.** `save()` does not surface how many rows the UPDATE touched, so
44
+ even a guarded statement (if it could emit one) couldn't be adjudicated through the model.
45
+
46
+ **For an atomic claim / conditional update, use `_Query` directly.** `_Query::getAffectedRows()`
47
+ (`Query.php` line 106) is the only API that exposes the affected-rows count. The idiom is:
48
+
49
+ ```php
50
+ // UPDATE ... SET claim='<sentinel>' WHERE <businessKey>=? AND claim IS NULL (raw _Query)
51
+ // then commit; affectedRows === 1 → this caller won the claim; === 0 → another already claimed it.
52
+ ```
53
+
54
+ Back the guarded UPDATE with a UNIQUE index on the business key so it locks a single row, and commit
55
+ before any external side effect so the row lock is released (and to dodge the `_Database` lazy-transaction
56
+ write-drop). All **non-claim** writes stay on the ORM — `save()` is fine for plain
57
+ load → set → save → `transactionCommit` by primary key (the idiom
58
+ `_Worker_Netsuite_Opportunity::delete()` already uses).
59
+
60
+ ## Gotchas / known issues
61
+
62
+ - Do **not** reach for `save()` to implement a row-claim / optimistic-concurrency guard — it silently
63
+ degrades to a non-atomic check-then-act because its `WHERE` is primary-key-only and it exposes no
64
+ affected-rows count. Use raw `_Query` + `getAffectedRows()`.
65
+ - A no-diff `save()` emits **no** UPDATE, so "the write must have run because save() returned" is not a
66
+ safe assumption.
67
+
68
+ ## Change history
69
+
70
+ - 2026-07-17 — Documented that `_Model::save()` cannot perform an atomic conditional update
71
+ (primary-key-only `WHERE`, skips no-diff writes, no affected-rows count) and that raw `_Query` +
72
+ `_Query::getAffectedRows()` is the required surface for any DB row-claim / optimistic-concurrency
73
+ pattern. Surfaced while designing the TRUE-80044 atomic ClickUp-task claim. (dfranks)
74
+ </content>
75
+ </invoke>
@@ -31,6 +31,7 @@ files:
31
31
  related:
32
32
  - ./netsuite-salesorder-open-orders-sync.md
33
33
  - ../architecture.md
34
+ - ../../_underscore/architecture/external-identifiers-map-table.md
34
35
  ---
35
36
 
36
37
  ## Summary
@@ -177,6 +178,9 @@ tasks. The fix is a **race-safe DB claim** on the **existing `Forecast.Opportuni
177
178
  `NetsuiteOpportunityClickupTask` table in `Client_True`; reviewer Rohan Girish rejected that and mandated
178
179
  reusing `Forecast.Opportunities`.)
179
180
 
181
+ A counter-proposal (ExternalIdentifiers map table) has been raised — see
182
+ `_underscore/architecture/external-identifiers-map-table.md`; unresolved.
183
+
180
184
  How the claim is race-safe:
181
185
  - **Conditional UPDATE = the atomic claim.** A raw `_Query`:
182
186
  `UPDATE Opportunities SET clickupTaskId='<uuid sentinel>' WHERE netsuiteOpportunityInternalId=X AND
@@ -17,7 +17,7 @@ _Auto-generated by `knowledge.js index`. Do not hand-edit._
17
17
 
18
18
  ## 2.0 framework
19
19
 
20
- - **_underscore** (_Underscore) _(framework core)_ — 33 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
20
+ - **_underscore** (_Underscore) _(framework core)_ — 35 doc(s) → [2.0/apps/_underscore/INDEX.md](2.0/apps/_underscore/INDEX.md)
21
21
  - **worker2** (Worker) — 30 doc(s) → [2.0/apps/worker2/INDEX.md](2.0/apps/worker2/INDEX.md)
22
22
  - **api2** (API) — 12 doc(s) → [2.0/apps/api2/INDEX.md](2.0/apps/api2/INDEX.md)
23
23
  - **dbchanges2** (Database Changes) _(framework core)_ — 3 doc(s) → [2.0/apps/dbchanges2/INDEX.md](2.0/apps/dbchanges2/INDEX.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toga-ai",
3
- "version": "1.0.371",
3
+ "version": "1.0.372",
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",