@drawbridge/drawbridge-agents 0.1.31 → 0.1.33

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.
@@ -0,0 +1 @@
1
+ @./node_modules/@drawbridge/drawbridge-agents/claude/CLAUDE.md
@@ -16,10 +16,18 @@ const rmrf = (p) => {
16
16
 
17
17
  const copied = []
18
18
 
19
- // Root-template files are stored dotless and the leading dot is restored on mirror. This is
20
- // required for `npmrc` (npm strips a literal `.npmrc` from published tarballs) and applied to
21
- // the rest for one consistent rule: template files never start with a dot.
19
+ // The complete manifest of what .root-template installs, as stored name -> installed name.
20
+ // EVERY file in .root-template needs an entry, including one that installs under its own name:
21
+ // test/package.test.js asserts the map covers the directory, and that only works as a tripwire
22
+ // if the map is exhaustive. Adding a file without an entry is the mistake being guarded against
23
+ // — a dotfile stored dotless would land in consumers without its dot and silently do nothing.
24
+ //
25
+ // Dotfiles are stored dotless because npm strips a literal `.npmrc` from published tarballs;
26
+ // the rest follow for one consistent rule: nothing in a template directory starts with a dot.
27
+ // CLAUDE.md is not a dotfile and installs unchanged — its entry is an identity mapping, which
28
+ // is a deliberate declaration rather than an oversight.
22
29
  const RENAME = {
30
+ 'CLAUDE.md': 'CLAUDE.md',
23
31
  editorconfig: '.editorconfig',
24
32
  'mcp.json': '.mcp.json',
25
33
  nvmrc: '.nvmrc',
@@ -116,6 +116,12 @@ skill) so it stops living only in tribal memory.
116
116
  `schema/action.js`) is a shared dependency: sync's `computeCycleActions` (billing, by `createdAt`
117
117
  window) AND api's date-ranged reporting (`route/organization-usage.js`) both match on it. Drop it
118
118
  → COLLSCAN on every invoice + usage query.
119
+ - An `action` row's **`units` are BILLABLE units, never a display metric**. A `submission` row
120
+ carries accepted entries on a giveaway (drawbridge-sync `stream/submission.js`) but 1 per
121
+ submission on a default campaign, which collects no entries at all (drawbridge-api
122
+ `route/subdomain-submission.js`) — so both writers stamp the real count at `meta.entries`, and
123
+ reporting (`route/organization-usage.js`) reads that. Reading `units` as Entries made the Usage
124
+ page's Entries card mirror Submissions for every default campaign (Asana 1217406893229252).
119
125
  - The `action` ledger is **permanent — no TTL** (drawbridge-api `schema/action.js`): the
120
126
  proof-of-billing record AND the source for date-ranged usage reporting, both read as raw rows by
121
127
  `createdAt`. Re-adding a TTL shorter than any billing interval silently under-bills long cycles
@@ -164,6 +170,37 @@ skill) so it stops living only in tribal memory.
164
170
  land on the wrong day). The param rides each endpoint's existing query shape (top-level vs
165
171
  `filters`) — do NOT normalise the shapes.
166
172
 
173
+ ## OTC dispatch: silence is never success (api ↔ app-web ↔ sync)
174
+
175
+ - The auth request endpoints (`/oauth/signin/request`, `/oauth/signup/request`) answer `200
176
+ { result : { otcId : null } }` for a malformed address and for an internal error — deliberately
177
+ shaped like success so neither can be probed. **A missing `otcId` means NOTHING was dispatched;
178
+ a client must never advance to a "we sent you a code" screen on one.** app-web enforces this in
179
+ one place, `lib/otc.js` `requestOtc()`. Advancing anyway is invisible to every alarm we have:
180
+ the user waits on mail that does not exist and nothing anywhere records a failure.
181
+ - The **reCAPTCHA verdict is NOT an enumeration signal** — it is computed before the email is
182
+ parsed — so an auth block answers explicitly (403 refresh / 503 outage), unlike the email-shape
183
+ branches. Don't "harmonise" it back to the silent 200.
184
+ - `google.recaptcha.verify()` **throws** on both a refused token and a Google outage (the outage
185
+ carries `status: 503`). Express 4 does not route a rejected promise to the error handler, so any
186
+ async middleware calling it MUST try/catch and `next( error )` — unguarded it dangles the request
187
+ with no response at all (`route/organization-issue.js` did exactly this).
188
+ - Auth code emails are dispatched by **sync** (`stream/otc.js`, on the change-stream update that
189
+ writes `code.text`) and must state the window **api** enforces: `lifetimes.code` from
190
+ drawbridge-utils (15 min). The prize-draw branch is a genuinely different 5 min
191
+ (api `route/otc.js` CODE_TTL_MINUTES) — the two are not interchangeable.
192
+
193
+ ## Growth attribution tags (growth ↔ api)
194
+
195
+ - drawbridge-growth `lib/tags.js` MIRRORS drawbridge-api `lib/utm.js` — allow-listed keys, trim,
196
+ 255 cap then lowercase `source`/`medium`, in that order. The API side is what actually stores a
197
+ tag when a click lands; a drifted mirror shows salespeople a tuple that never existed. Growth's
198
+ `test/tags.test.js` asserts parity against the API's source file — changing `lib/utm.js` fails
199
+ growth's suite, which is the intended tripwire, not an accident.
200
+ - `campaign.destination.utm.id` is the ONLY key a signup joins back on and is unique-indexed
201
+ (partial, `$type: 'string'`) in growth's schema. Two campaigns sharing a utm_id don't split
202
+ signups — each claims all of them and both read as working, invisibly.
203
+
167
204
  ## `@drawbridge/*` package coordination
168
205
 
169
206
  - Pins are version-exact across the whole family; bumping one package means auditing every
@@ -220,9 +257,28 @@ skill) so it stops living only in tribal memory.
220
257
  message `'Shopify product not found'`** when the Storefront API can't see a product
221
258
  (unpublished from the Drawbridge channel / deleted); transport and GraphQL failures throw
222
259
  `'Shopify storefront error: …'`. drawbridge-sync's product worker (`queue/product.js`)
223
- string-matches the not-found message to send publish-this-product ResourceFeedback instead of
224
- retrying. Reword the message → unpublished products retry-loop forever and merchants get no
225
- feedback; drawbridge-api's shopifyProduct route relies on the not-found throw staying a throw.
260
+ string-matches the not-found message to deactivate the product and send ResourceFeedback
261
+ instead of retrying. Reword the message → unpublished products retry-loop forever and
262
+ merchants get no feedback; drawbridge-api's shopifyProduct route relies on the not-found
263
+ throw staying a throw.
264
+ - **`sendProductResourceFeedback` returns `null` for states Shopify won't carry — that is not a
265
+ failure.** Shopify accepts product feedback only for a product published to the calling app's
266
+ channel, and refuses anything else with `'… not available to the channel'`. That is exactly
267
+ the state REQUIRES_ACTION describes (unpublished, or drafted — drafting also removes it), so
268
+ the refusal is matched and returned as `null` rather than thrown. Treating `null` as an error
269
+ re-creates the 2026-08-12 QA finding: a per-sync failure report for an expected condition,
270
+ which is what buried the real defect. The embedded app's publishing section is the surface for
271
+ these products (App Store 5.7.8/5.7.11), not the product page.
272
+ - **Feedback must forward the product's own `updatedAt`, never a fresh timestamp.** Shopify
273
+ versions feedback on `productUpdatedAt` and refuses a payload older than the version it holds,
274
+ so stamping `new Date()` records a version ahead of the product and every later send comes back
275
+ `'Feedback for a later version of this resource was already accepted'`. `@drawbridge/shopify`
276
+ reads it (costing one extra `read_products` query per send) so callers cannot get this wrong.
277
+ - **ResourceFeedback messages are format-validated by Shopify, not by us**: one message, ≤ 100
278
+ characters, leading capital, trailing period, not all caps. A breach fails the whole send.
279
+ - Feedback failures in sync go through `logger.error`, never `logger.warn` — a warn reaches
280
+ Sentry **Logs** but not **Issues**, which is how a feedback path failing on every send survived
281
+ two rounds of QA that were explicitly pointed at Issues.
226
282
  - The `write_resource_feedbacks` and `read_publications` scopes live in drawbridge-shopify-app's
227
283
  `shopify.app.*.toml` but power other repos: sync's product ResourceFeedback sends and the
228
284
  embedded app's publishing section. Removing either → silent Shopify userErrors, no crash.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {
@@ -1,88 +0,0 @@
1
- // What communication channels are actually flowing, HubSpot's count against ours.
2
- //
3
- // Exists because "the integration is connected" and "the data is arriving" are
4
- // different claims, and the second is the only one that matters here. HubSpot's
5
- // communication object holds SMS, WhatsApp and LinkedIn messages, told apart by
6
- // channel — so whatever writes LinkedIn messages into that object, this pipeline
7
- // reads. The question is never whether we can read them; it is whether anything is
8
- // writing them.
9
- //
10
- // Same shape as the `missing` figure on the contact ingest, which is what caught 50
11
- // contacts that had been silently absent for the life of the project.
12
-
13
- const { growth } = require( '../lib/mongodb' );
14
- const { enabled, request } = require( '../lib/hubspot' );
15
- const { run } = require( '../lib/cycle' );
16
-
17
- // The channels HubSpot files under the communication object. PHYSICAL_MAIL is
18
- // hidden on this portal but counted anyway, because a hidden property still holds
19
- // records.
20
- const CHANNELS = [
21
- 'LINKEDIN_MESSAGE',
22
- 'SMS',
23
- 'WHATS_APP',
24
- 'PHYSICAL_MAIL',
25
- 'CUSTOM_CHANNEL_CONVERSATION'
26
- ];
27
-
28
- run( 'channels', async () => {
29
-
30
- if( ! enabled() ) return { skipped : 'HUBSPOT_ACCESS_TOKEN not set' };
31
-
32
- const { db, database } = await growth();
33
-
34
- const report = {};
35
-
36
- for( const channel of CHANNELS ){
37
-
38
- const body = await request({
39
- body : {
40
- filterGroups : [
41
- {
42
- filters : [
43
- {
44
- operator : 'EQ',
45
- propertyName : 'hs_communication_channel_type',
46
- value : channel
47
- }
48
- ]
49
- }
50
- ],
51
- limit : 1
52
- },
53
- method : 'POST',
54
- path : '/crm/v3/objects/communications/search'
55
- });
56
-
57
- const inHubspot = Number( body?.total || 0 );
58
-
59
- // Ours is counted on the touch, which is one row per contact on the message,
60
- // so it can legitimately exceed HubSpot's count of messages.
61
- const held = await database.collection( 'touch' ).countDocuments({
62
- channel,
63
- type : 'communication'
64
- });
65
-
66
- report[ channel ] = {
67
- held,
68
- inHubspot,
69
- // The number that answers "is anything writing this channel".
70
- missing : inHubspot === 0 ? 0 : Math.max( 0, inHubspot - held )
71
- };
72
-
73
- }
74
-
75
- const flowing = Object.entries( report )
76
- .filter( ( [ , value ] ) => value.inHubspot > 0 )
77
- .map( ( [ key ] ) => key );
78
-
79
- return {
80
- byChannel : report,
81
- // Empty means nothing outside email, calls, meetings, notes and tasks is
82
- // reaching HubSpot at all - which is a setup answer, not a pipeline answer.
83
- flowing : flowing.length ? flowing : 'nothing beyond email/calls/meetings/notes/tasks',
84
- note : 'LINKEDIN_MESSAGE at 0 means no tool is writing LinkedIn messages into'
85
- + ' HubSpot. Whatever writes them, this pipeline already reads them.'
86
- };
87
-
88
- });