···11+# Follow actions
22+33+A **follow action** writes a follow record on one of the AT Protocol social
44+graphs Airglow knows about (Bluesky, Tangled, Sifa). Every supported target
55+shares the same record shape — `{ subject, createdAt }` — so a single executor
66+covers all three; the only thing that differs is the collection NSID written
77+to and the profile NSID checked before the write.
88+99+```json
1010+{ "type": "follow", "target": "bluesky", "subject": "{{event.did}}" }
1111+```
1212+1313+The most common use case is a cross-graph follow mirror ("when I follow someone
1414+on Bluesky, also follow them on Tangled"). To make that one-liner safe out of
1515+the box, two pre-flight checks are baked into the executor — users do not
1616+configure them, but they are always on. See "Built-in safety checks" below.
1717+1818+## Anatomy
1919+2020+[lib/actions/follow.ts](../lib/actions/follow.ts):
2121+2222+1. **Render the subject template** — `{{event.did}}`,
2323+ `{{event.commit.record.subject}}`, etc. The rendered value is trimmed and
2424+ validated against `DID_RE`. Failures return 400 with no retry.
2525+2. **Profile pre-check** — does the subject have a profile record on the
2626+ target graph? If not, return 204 ("skip"). Implemented as a single
2727+ `fetchRecord` call against the target's profile NSID
2828+ (see `FOLLOW_TARGET_PROFILE` in [lib/db/schema.ts](../lib/db/schema.ts)).
2929+3. **Already-follows pre-check** — does the automation owner already follow
3030+ the subject on the target graph? If so, return 204. Implemented by
3131+ constructing a synthetic `FetchStepSearch` and handing it to
3232+ [`executeSearch`](../lib/actions/searcher.ts), which transparently uses the
3333+ Bluesky appview's `getRelationships` for `target: "bluesky"` and falls back
3434+ to a paginated `listRecords` scan for Tangled/Sifa.
3535+4. **Write the follow record** — `createArbitraryRecord(automation.did,
3636+targetCollection, { subject, createdAt })`. Errors are parsed for an HTTP
3737+ status (regex `/\((\d{3})\)/` against the error message); 5xx and network
3838+ failures schedule a retry chain via `RETRY_DELAYS`.
3939+4040+The executor never throws to the handler. Every error path returns an
4141+`ActionResult` with a status code so the delivery log records something useful.
4242+4343+## The 204 "skip" status
4444+4545+The pre-flight checks return `{ statusCode: SKIP_STATUS, message: "..." }`
4646+where `SKIP_STATUS = 204` ([lib/actions/delivery.ts](../lib/actions/delivery.ts)).
4747+204 is chosen so it slots into the existing semantics with no special-casing:
4848+4949+| Predicate | Source | 204 behaviour |
5050+| ----------------------------------------------- | ----------------------------------------------- | ------------------------------------------------------------ |
5151+| `isSuccess` (`code >= 200 && code < 300`) | [delivery.ts](../lib/actions/delivery.ts) | true — counts as success for the action chain. |
5252+| `isActionSuccess` (`code >= 200 && code < 400`) | [handler.ts](../lib/jetstream/handler.ts) | true — the action chain continues to the next action. |
5353+| `isRetryable` (`code >= 500 \|\| code === 0`) | [delivery.ts](../lib/actions/delivery.ts) | false — no retry. |
5454+| Rate-limit count | [rate-limit.ts](../lib/jetstream/rate-limit.ts) | excluded — `ne(statusCode, SKIP_STATUS)` in the count query. |
5555+| `actionN` context injection | [handler.ts](../lib/jetstream/handler.ts) | not injected — `result.uri` is undefined on a skip. |
5656+5757+The net effect: a skipped follow is a no-op in the timeline. It writes a
5858+delivery-log row with the message ("Skipped: no Bluesky profile for did:plc:…"
5959+or "Skipped: already following did:plc:… on Bluesky") so the user can see what
6060+happened, but it does not retry, does not stop a downstream action, does not
6161+burn the rate-limit budget, and does not pollute `{{actionN.*}}` chaining.
6262+6363+## Built-in safety checks
6464+6565+Without the pre-flight checks, a "follow on Tangled when I follow on Bluesky"
6666+automation would happily write thousands of dead Tangled follow records — one
6767+for every Bluesky follow whose subject doesn't actually use Tangled. And every
6868+re-trigger of the source event would create a duplicate. The two checks turn
6969+the failure modes into 204s.
7070+7171+### Profile check
7272+7373+```
7474+at://{subject}/{FOLLOW_TARGET_PROFILE[target].collection}/{FOLLOW_TARGET_PROFILE[target].rkey}
7575+```
7676+7777+A `fetchRecord` call. `found: false` → skip with `"Skipped: no <AppName>
7878+profile for <did>"`. Transient lookup failures (DID unresolvable, PDS down)
7979+fall through to the write — _fail-open_. Rationale: a 502 from the subject's
8080+PDS is not the same signal as a 404; if the follow genuinely can't succeed,
8181+the subsequent `createRecord` will surface the failure with the right status
8282+code and the retry machinery will pick it up.
8383+8484+### Already-follows check
8585+8686+```ts
8787+const synthetic: FetchStepSearch = {
8888+ kind: "search",
8989+ name: "__follow_preflight_already_follows",
9090+ repo: match.automation.did,
9191+ collection: FOLLOW_TARGET_COLLECTION[action.target],
9292+ where: [{ field: "subject", operator: "eq", value: subject }],
9393+ limit: 1,
9494+};
9595+await executeSearch(synthetic, match.event, match.automation.did, {});
9696+```
9797+9898+For `target: "bluesky"`, `executeSearch` recognises the
9999+`app.bsky.graph.follow` + `subject eq <DID>` shape and answers from the
100100+appview's `app.bsky.graph.getRelationships` in O(1). For Tangled and Sifa the
101101+fallback is a paginated `listRecords` scan over the owner's repo, capped at
102102+100 pages × 100 records = 10,000 follows. Owners with more than ~10k follows
103103+on those graphs may see false negatives (and a duplicate row); see the comment
104104+in `checkNotAlreadyFollowing` for context. Same fail-open policy as the
105105+profile check.
106106+107107+The check is **not atomic** with the subsequent write. Two events racing for
108108+the same subject can both observe `found: false` and both create a record.
109109+Bluesky's appview collapses duplicate follows by subject in the public graph,
110110+so this is cosmetic (extra storage, same observable state) until cleaned up.
111111+112112+### Why inline, not synthetic fetches
113113+114114+We considered injecting the two checks as synthesized entries into
115115+`fetchContext` so they'd flow through the existing fetch + condition
116116+machinery. We didn't because:
117117+118118+- They are action-specific semantics, not user-visible data. Synthesizing
119119+ them would pollute `{{actionN.*}}` chaining and complicate dry-run output.
120120+- All the primitives we need (`fetchRecord`, `executeSearch`) already exist
121121+ and can be called directly. No new abstraction earns its keep here.
122122+123123+## Dry-run mode
124124+125125+Pre-flight checks are **not run** in dry-run. The dry-run log instead
126126+advertises that they will run when the automation is enabled:
127127+128128+```
129129+Would follow did:plc:abcd… on bluesky (will skip if no Bluesky profile exists or already following)
130130+```
131131+132132+Rationale: dry-run is supposed to be cheap. Running the two pre-flights on
133133+every preview burns PDS calls for what is essentially documentation.
134134+135135+## Retry semantics
136136+137137+Same chain as every other action. `RETRY_DELAYS = [5_000, 30_000]`. Retries
138138+fire only when the result is **not successful** _and_ retryable
139139+(`code >= 500 || code === 0`). 204 is success, so skips don't retry; 4xx is
140140+non-retryable, so template / DID-format errors don't either; 5xx and network
141141+failures do. Each retry writes its own delivery-log row with `attempt`
142142+incrementing from 1.
143143+144144+## Validation
145145+146146+[lib/actions/validation.ts](../lib/actions/validation.ts):
147147+148148+- `target` must be in `VALID_FOLLOW_TARGETS` (the runtime allow-list).
149149+- `subject` must be present, non-empty, ≤512 chars, and a valid text template
150150+ (`validateTextTemplate` — placeholders must reference defined fetches /
151151+ earlier actions).
152152+- The runtime `DID_RE` check happens at execution time against the rendered
153153+ value, not at validation time. A subject of `{{event.commit.record.subject}}`
154154+ validates fine even if some events deliver a non-DID subject; that case
155155+ becomes a 400 in the delivery log at run time.
156156+157157+## Rate limiting
158158+159159+`checkRateLimit` ([lib/jetstream/rate-limit.ts](../lib/jetstream/rate-limit.ts))
160160+sums non-dry-run delivery-log rows per window. The query explicitly excludes
161161+`statusCode = SKIP_STATUS`:
162162+163163+```ts
164164+or(ne(deliveryLogs.statusCode, SKIP_STATUS), isNull(deliveryLogs.statusCode));
165165+```
166166+167167+So flooding a follow automation with events that all skip (subject has no
168168+profile / already followed) does **not** trip the rate limit. Only fires that
169169+actually wrote (or genuinely failed to write — 4xx/5xx/0) count.
170170+171171+The `IS NULL` half is forward-compat: dry-run rows have a null status code and
172172+are filtered out earlier by `eq(dryRun, false)`, but the disjunction stays
173173+robust if any future non-dry-run path lands a null status.
174174+175175+---
176176+177177+# Adding a new follow target
178178+179179+Walk-through for adding a hypothetical new social graph "Foo" living at
180180+`org.foo` with profile collection `org.foo.actor.profile/self` and follow
181181+collection `org.foo.graph.follow`. Three of the changes are caught by the
182182+parity test in [lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts);
183183+the rest are user-visible enough that a missed touchpoint will surface in
184184+manual testing or a related test.
185185+186186+## 1. Lexicon
187187+188188+[lexicons/run/airglow/automation.json](../lexicons/run/airglow/automation.json) — add
189189+`"foo"` to `followAction.properties.target.knownValues`. After editing, run:
190190+191191+```sh
192192+goat lex lint lexicons/
193193+```
194194+195195+## 2. Backend types and maps
196196+197197+[lib/db/schema.ts](../lib/db/schema.ts) — three things:
198198+199199+```ts
200200+export type FollowTarget = "bluesky" | "tangled" | "sifa" | "foo";
201201+202202+export const FOLLOW_TARGET_COLLECTION: Record<FollowTarget, string> = {
203203+ // ...
204204+ foo: "org.foo.graph.follow",
205205+};
206206+207207+export const FOLLOW_TARGET_PROFILE: Record<FollowTarget, { collection: string; rkey: string }> = {
208208+ // ...
209209+ foo: { collection: "org.foo.actor.profile", rkey: "self" },
210210+};
211211+```
212212+213213+[lib/actions/validation.ts](../lib/actions/validation.ts):
214214+215215+```ts
216216+export const VALID_FOLLOW_TARGETS = new Set(["bluesky", "tangled", "sifa", "foo"]);
217217+```
218218+219219+…and update the `Invalid follow target` error string nearby to mention the
220220+new target.
221221+222222+[lib/automations/pds.ts](../lib/automations/pds.ts) and
223223+[lib/automations/sanitize.ts](../lib/automations/sanitize.ts) — extend the
224224+inline `target: "bluesky" | "tangled" | "sifa"` literal type to include
225225+`"foo"`. (These can't reference `FollowTarget` because they're at the PDS
226226+serialization boundary, but a future refactor could collapse them.)
227227+228228+## 3. UI metadata
229229+230230+[lib/automations/follow-targets.ts](../lib/automations/follow-targets.ts) —
231231+add an entry to `FOLLOW_TARGET_META`:
232232+233233+```ts
234234+foo: {
235235+ catId: "apps", // or "bluesky" if it belongs in the Bluesky tile group
236236+ colorKey: "foo", // requires a matching theme color, see step 4
237237+ appName: "Foo",
238238+ label: "Follow on Foo",
239239+ faviconDomain: "foo.example",
240240+ description: "Follow someone on Foo",
241241+},
242242+```
243243+244244+Add `"foo"` to the `ColorKey` union in the same file.
245245+246246+[lib/automations/labels.ts](../lib/automations/labels.ts) — add
247247+`foo: "Foo"` to `followTargetLabels` (used by the dashboard log view).
248248+249249+## 4. Catalogue tile
250250+251251+[lib/automations/action-catalogue.ts](../lib/automations/action-catalogue.ts) —
252252+add `"follow-foo"` to the `AddableActionId` union and an entry under the
253253+appropriate category:
254254+255255+```ts
256256+{
257257+ id: "follow-foo",
258258+ label: FOLLOW_TARGET_META.foo.label,
259259+ description: FOLLOW_TARGET_META.foo.description,
260260+ icon: UserPlus,
261261+ available: true,
262262+ colorKey: FOLLOW_TARGET_META.foo.colorKey,
263263+ faviconDomain: FOLLOW_TARGET_META.foo.faviconDomain,
264264+},
265265+```
266266+267267+Update the corresponding tile-order test in
268268+[action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts) so
269269+the new ordering is intentional and reviewable.
270270+271271+## 5. Theme color
272272+273273+[app/styles/tokens/colors.ts](../app/styles/tokens/colors.ts) — add `foo`
274274+and `fooSubtle` entries in both the dark and light palettes.
275275+276276+[app/styles/theme.css.ts](../app/styles/theme.css.ts) — register the new
277277+tokens on `vars.color`.
278278+279279+[app/styles/action-header.css.ts](../app/styles/action-header.css.ts) — add
280280+the matching `&[data-cat="foo"]` selector. (A typo silently loses the accent.)
281281+282282+## 6. Favicon
283283+284284+[app/components/Favicon/index.tsx](../app/components/Favicon/index.tsx) —
285285+register the local SVG so the tile renders the right brandmark instead of a
286286+generic icon. Drop the SVG into `public/static/favicons/foo.example.<hash>.svg`
287287+and add the entry to the favicon map.
288288+289289+## 7. Tests
290290+291291+The parity test
292292+([lib/automations/action-catalogue.test.ts](../lib/automations/action-catalogue.test.ts))
293293+already pins `FOLLOW_TARGET_COLLECTION` ↔ `VALID_FOLLOW_TARGETS` ↔
294294+`FOLLOW_TARGET_META`, so it will fail until those three are in sync. Beyond
295295+that, add or update:
296296+297297+- [lib/actions/follow.test.ts](../lib/actions/follow.test.ts) — extend
298298+ `"maps tangled and sifa targets to their collections"` to cover `foo`,
299299+ and `"uses the target-specific profile NSID for the profile pre-check"`
300300+ to assert the new profile URI shape.
301301+302302+> **Drift caveat.** The parity test does **not** include
303303+> `FOLLOW_TARGET_PROFILE`. If you add a new target and forget the profile
304304+> entry, the executor will throw `Cannot read property 'collection' of
305305+undefined` at the first fire. Worth adding to the parity test next time
306306+> someone touches that file.
307307+308308+## 8. Manual verification
309309+310310+Run `vp check && vp test`, then in `vp dev`:
311311+312312+1. Create a follow automation with the new target. Trigger it with a subject
313313+ that has a profile on the new graph and isn't already followed → record
314314+ created, delivery log shows 200.
315315+2. Trigger with a subject that has no profile → 204 with the right "no Foo
316316+ profile" message.
317317+3. Trigger again with the same already-followed subject → 204 with the right
318318+ "already following" message.
319319+4. Confirm the catalogue tile renders with the right color, favicon, and
320320+ label, and that the automation detail page shows the right collection NSID.
+49
lib/pds/fetch-with-retry.ts
···11+/** Short in-process backoff schedule for transient PDS failures.
22+ * Not a persistent queue: if the process dies between attempts the event
33+ * is lost. Sufficient for brief PDS blips, not for extended outages. */
44+export const FETCH_RETRY_DELAYS_MS: readonly number[] = [1_000, 3_000];
55+66+/** `fetch` with retries on 5xx responses and thrown network/timeout errors.
77+ *
88+ * After retries are exhausted, returns the final response (5xx case) or
99+ * rethrows the error (network case) so callers can handle normally. 4xx
1010+ * responses (including 404 and XRPC `RecordNotFound`) are stable answers
1111+ * and never retried.
1212+ *
1313+ * A per-attempt timeout is applied via `AbortSignal.timeout`; callers
1414+ * should not pass their own `signal`.
1515+ */
1616+export async function fetchWithRetry(
1717+ input: URL | string,
1818+ init: Omit<RequestInit, "signal"> & { timeoutMs?: number } = {},
1919+): Promise<Response> {
2020+ const { timeoutMs = 10_000, ...rest } = init;
2121+ const delays = FETCH_RETRY_DELAYS_MS;
2222+2323+ let attempt = 0;
2424+ while (true) {
2525+ try {
2626+ const res = await fetch(input, {
2727+ ...rest,
2828+ signal: AbortSignal.timeout(timeoutMs),
2929+ });
3030+ if (res.status >= 500 && attempt < delays.length) {
3131+ await sleep(delays[attempt]!);
3232+ attempt++;
3333+ continue;
3434+ }
3535+ return res;
3636+ } catch (err) {
3737+ if (attempt < delays.length) {
3838+ await sleep(delays[attempt]!);
3939+ attempt++;
4040+ continue;
4141+ }
4242+ throw err;
4343+ }
4444+ }
4545+}
4646+4747+function sleep(ms: number): Promise<void> {
4848+ return new Promise((resolve) => setTimeout(resolve, ms));
4949+}