@drawbridge/drawbridge-agents 0.1.37 → 0.1.39

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.
@@ -9,6 +9,11 @@ skill) so it stops living only in tribal memory.
9
9
 
10
10
  - `OAUTH_TOKEN_HMAC_KEY` must be identical in **drawbridge-api** and **drawbridge-sync** — both
11
11
  `assertEnv` it at boot. A mismatch silently breaks realtime token verification.
12
+ - `HMAC_UNSUBSCRIBE_TOKEN_KEY` must be identical in **drawbridge-api** and **drawbridge-sync** —
13
+ both `assertEnv` it at boot. Sync signs the unsubscribe tokens it builds into notification
14
+ emails (`lib/suppression.js buildToken`); api verifies them on the public `/unsubscribe/:token`
15
+ routes (`lib/unsubscribe.js`). A mismatch makes every unsubscribe link 404 as "invalid or
16
+ expired" — one-click unsubscribes silently stop landing, which mail providers punish.
12
17
  - reCAPTCHA on auth is a key pair: `GOOGLE_RECAPTCHA_SECRET` (**drawbridge-api**) + `NEXT_PUBLIC_GOOGLE_RECAPTCHA_KEY`
13
18
  (**drawbridge-app-web**), both `assertEnv`'d at boot. The api verifies the v3 token app-web's
14
19
  `/auth` forms attach and enforces it **unconditionally** on `/oauth/{signup,signin}/request`
@@ -48,6 +53,19 @@ skill) so it stops living only in tribal memory.
48
53
  `ads-republishing.<connectionId>`). Never unique-index `coalesce` (superseding requires the
49
54
  second insert to land), and writes that set `key` need the writer-side E11000 handler or a
50
55
  replayed event logs a false error.
56
+ - **The opt-out floor: `suppression` is the single consent gate** (api ↔ sync ↔ webhooks).
57
+ Writers: api's `/unsubscribe` routes and draw preflight, sync's twilio buffer drain
58
+ (`lib/buffer.js 'twilio.message.inbound'`). The ONLY reader on any send path is sync
59
+ `lib/suppression.js canSend()` — a new lead-facing send path must call it, or opted-out
60
+ recipients get contacted (TCPA/CAN-SPAM exposure). Lead-facing = the notification carries
61
+ `lead`/`workflow`/`connection`; those sends also REQUIRE an org connection (no system-sender
62
+ fallback), while org-less system mail (OTC codes, member invites, security notices) keeps the
63
+ system sender and is never suppressed. Inbound STOP flows webhooks
64
+ (`POST /connection/:id/twilio`, signature-verified against the URL built from
65
+ `APP_CLIENT_WEBHOOKS_URI`) → `buffer` doc → sync drain → platform-global suppression row;
66
+ api registers the number's SmsUrl at twilio connect (`lib/twilio.js`) and `scripts.js
67
+ registerTwilioWebhooks` backfills — a deployed webhooks route with unregistered numbers hears
68
+ nothing, silently.
51
69
  - **Adding an email/SMS vendor is a two-repo change** (api ↔ sync). A connection is deliverable
52
70
  only if sync implements a sender for its slug — `lib/email.js` (`{ mailchimp, sendgrid }`) and
53
71
  `lib/sms.js` (`{ twilio }`), which `queue/notification.js` reads **directly** as its guard and
@@ -217,6 +235,33 @@ skill) so it stops living only in tribal memory.
217
235
  drawbridge-utils (15 min). The prize-draw branch is a genuinely different 5 min
218
236
  (api `route/otc.js` CODE_TTL_MINUTES) — the two are not interchangeable.
219
237
 
238
+ ## Submission answers are immutable (api ↔ sync ↔ app-web ↔ share)
239
+
240
+ - **Nothing rewrites `submission.fields[]` after the write.** Renaming a field or one of its
241
+ options, and deleting a field, all used to reach back into every stored submission (`$set` on
242
+ `fields.$[element].label` / `.value`, `$pull` by `reference`). An answer is the record of what a
243
+ person was asked and said; a definition change must not touch it. The link forward is
244
+ `fields[].reference` (the field id) and, for choice answers, `fields[].option` (the option id
245
+ captured at submission time) — text is display, ids are identity.
246
+ - **Deleting a field is a tombstone**, `deleted : true`, never a `delete`. Every live read must
247
+ filter `deleted : { $ne : true }` — api's builder field list, create position count, delete
248
+ position decrement, and the share `campaign-fields` cache aggregate; sync's leads exporter and
249
+ the nightly `totals.fields` recount. The **lead-detail endpoint deliberately does not filter**:
250
+ app-web pivots `fieldsById[reference]`, so returning tombstoned definitions is the only thing
251
+ keeping a removed field's historical answers displayable (it labels them `(removed)`).
252
+ - Because the doc survives, **`stream/field.js`'s `delete` handler never fires for a removal** —
253
+ its `update` handler watches `updateDescription.updatedFields.deleted === true` for the
254
+ `campaign.totals.fields` decrement and cache-chain invalidation. A new consumer of field
255
+ deletion must hook the transition, not the delete.
256
+ - **Field validators live in `@drawbridge/drawbridge-utils/fields-validate`**, consumed by BOTH
257
+ drawbridge-share's form and drawbridge-api's submission POST — `yup` is injected so each keeps
258
+ its own copy. A rule added on one side only is client-side theater: the submission endpoint is
259
+ publicly reachable. `select` is checked in-list against the field's own options; `required()`
260
+ honours `attributes.required === false`.
261
+ - **`scripts/backfill-submission-option-ids.js` must run before any further option rename lands.**
262
+ It matches legacy answers to options by text one last time; once a definition and its stored
263
+ text diverge, nothing can match them again.
264
+
220
265
  ## Growth attribution tags (growth ↔ api)
221
266
 
222
267
  - drawbridge-growth `lib/tags.js` MIRRORS drawbridge-api `lib/utm.js` — allow-listed keys, trim,
@@ -298,6 +343,34 @@ skill) so it stops living only in tribal memory.
298
343
  - `sizes.original` must be set when a multipart upload completes, not at create — the resize
299
344
  worker reads it as its source, and a file without it is shaped unlike every other file.
300
345
 
346
+ ## Connection manifests are the single declaration (utils ↔ api ↔ sync ↔ webhooks)
347
+
348
+ A connection is **one file** in `drawbridge-utils/lib/connections/`. Everything else
349
+ derives from it: the builder catalog and `enums.step.type` in api, the queue routing table
350
+ and hook registry in sync, the inbound receiver in webhooks, the connections page in
351
+ app-web. Adding a vendor is that file plus its import in `index.js` — a test reads the
352
+ directory and fails if the two disagree, because a manifest nothing imports is a vendor
353
+ that exists on disk and in nobody's catalog.
354
+
355
+ **`STEPS` and `RETIRED` in `contract.js` must stay in step with `enums.step.type` in the
356
+ api.** That enum is the `$jsonSchema` validator on the workflow collection: a type absent
357
+ from it is rejected by the DATABASE with "Document failed validation" naming no field. The
358
+ enum DERIVES from `STEPS` and UNIONS its own legacy map — union rather than replace, or
359
+ stored documents carrying a retired type become unwritable, including by the migration that
360
+ would retire them.
361
+
362
+ **A hook lives in drawbridge-sync only if it needs Drawbridge's own database, sockets or
363
+ queues.** Everything else belongs on the manifest. `lib/hooks/` in sync mirrors
364
+ `lib/connections/` in utils, one file per connection. A hook declared `{}` means "supported,
365
+ implemented in the repo holding the dependencies"; if that registration is missing the
366
+ runner resolves nothing and the step is SKIPPED with a `succeeded` record — silent, so
367
+ `step-handler-coverage.test.js` asserts both directions.
368
+
369
+ **Job identity is production state.** Step types, queue names and jobIds are live in Redis;
370
+ renaming one orphans what is already enqueued and starts a parallel stream nobody consumes,
371
+ with no error. `bullmq-identity.test.js` in sync spells out the routing table so moving a
372
+ step between queues requires editing it deliberately.
373
+
301
374
  ## Import surfaces
302
375
 
303
376
  - Import names against a package's **actual exports** — a missing export resolves to `undefined`
@@ -6,3 +6,9 @@
6
6
  3. Don't touch unrelated code. If a file or function is not directly part of the current task, do not modify it, even if you think it could be improved.
7
7
 
8
8
  4. Flag uncertainty explicitly. If you are not confident about an approach or technical detail, say so before proceeding. Confidence without certainty causes more damage than admitting a gap.
9
+
10
+ 5. Read the code before you have an opinion about it. Never describe what a component, handler or helper does — what props it takes, what it cannot do, whether two things are the same — on the strength of a grep or a partial read. Open the whole file first. A grep tells you a string is present; it cannot tell you what surrounds it, what the other variant does, or which prop already solves the problem you are about to declare unsolvable.
11
+
12
+ This is not a style preference, it is the difference between advice and noise. Asserting a limitation that isn't real sends the reader to check your work, and asserting two implementations are identical when one carries a field the other drops ships a regression. Both have happened.
13
+
14
+ In practice: before claiming a component can't do something, read the component. Before calling two implementations equivalent, read both ends to end. Before proposing a new endpoint or field to work around a gap, confirm the gap exists in the code rather than in your sample of it.
@@ -0,0 +1,70 @@
1
+ # Workflow step naming
2
+
3
+ Step `type` strings follow **`step.<domain>[.<resource>].<verb>`** — dots, never dashes.
4
+
5
+ The resource segment appears when the domain acts on more than one thing, and is omitted
6
+ when it acts on one. Both forms are correct:
7
+
8
+ step.shopify.order.record four — shopify manages orders, customers,
9
+ step.shopify.customer.insert discounts, products, tokens
10
+ step.shopify.token.refresh
11
+
12
+ step.email.send three — email sends one thing
13
+ step.email.notify
14
+ step.sms.send
15
+ step.segment.sync
16
+
17
+ State it as a flat four segments and the three-segment ones read as drift, and someone
18
+ tries to "correct" them. They are not drift.
19
+
20
+ **The last segment is a verb.** `step.webhook.outgoing` breaks that — `outgoing` is a
21
+ direction — and should have been `step.webhook.send`. **Do not rename it**: webhooks are
22
+ shipped and step slugs are stored in workflow documents. Known exception, not a pattern.
23
+
24
+ ## Renaming
25
+
26
+ **Stored step slugs cannot be renamed once merchants hold them.** A rename is only free
27
+ while a slug has no workflows anywhere, which in practice means before it reaches `main`.
28
+ Check both environments before assuming — dev counts prove nothing about production.
29
+
30
+ ## Adding a step
31
+
32
+ **This used to be a six-place ritual.** The type string had to appear identically in the
33
+ api's `enums.step.type`, its workflow catalog, its `stepSettingsShapes`, sync's
34
+ `stepTypeToQueueName`, sync's handler registry, and app-web's builder — and a mismatch in
35
+ any one failed silently, saving the step and then dropping the work at run time.
36
+
37
+ It is now **two places**, and both are declarations rather than copies:
38
+
39
+ 1. `drawbridge-utils/lib/connections/contract.js` — the type and its label in `STEPS`
40
+ 2. `drawbridge-utils/lib/connections/<vendor>.js` — the step, and the hook it points at
41
+
42
+ Everything else DERIVES: the builder catalog, `enums.step.type`, `stepSettingsShapes`,
43
+ sync's routing table. `build()` refuses the manifest at import if the declaration and the
44
+ hook disagree, and `step-handler-coverage.test.js` in drawbridge-sync fails if the body is
45
+ missing.
46
+
47
+ **A step that does real work must declare a `hook`.** The runner resolves hooks by
48
+ declaration, so a step declaring none is *skipped* — the step doc reads `succeeded` with a
49
+ null message and the work silently stops. That shipped once, for `segment.sync`.
50
+
51
+ ## Labels
52
+
53
+ **One label, in `STEPS`.** The enum label and the builder's `key` used to be two strings
54
+ for one step and they drifted — `step.sms.send` was "Send SMS" in the enum and "Send Twilio
55
+ SMS" in the builder. The enum now derives from `STEPS`, so there is nothing to keep in
56
+ step.
57
+
58
+ The label lives on the vocabulary rather than on a vendor because a step type belongs to
59
+ the CAPABILITY: Klaviyo and Mailchimp both do `contacts.sync`, and a label taken from
60
+ either would show a merchant two identically-named entries. A manifest's own `key` is the
61
+ INSTANCE label — "Sync contact to Acme Co" — which is a different sentence for a different
62
+ place.
63
+
64
+ ## Retiring a type
65
+
66
+ `RETIRED` in `contract.js` maps each retired type to what it became. Stored documents carry
67
+ the old names until a backfill runs, and the runner resolves a manifest **by type** — so
68
+ without that map a legacy step finds no manifest and is silently skipped. `enums.step.type`
69
+ keeps every retired key so those documents stay writable, including by the migration that
70
+ retires them. A type leaves the map a release AFTER its backfill, never with it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@drawbridge/drawbridge-agents",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "description": "Shared agent-instruction content (rules, code style, conventions) for the drawbridge-* monorepo.",
5
5
  "license": "UNLICENSED",
6
6
  "publishConfig": {