Automations and webhooks for the AT Protocol airglow.run

Server-side guard against self-triggering automations #49

Open there-hugo.dev
Open opened by there-hugo.dev

An automation can trigger itself in a loop. This happens when one of its record-producing actions writes to the collection it triggers on. For example, a trigger on app.bsky.feed.post with a "Post to Bluesky" action, or a record action whose target collection matches the trigger lexicon. Each write shows up as a new Jetstream event, the event matches the same automation, and it runs again.

Since #28, the automation form shows a warning for this case (draftSelfTriggerTarget in app/islands/action-editors/registry.ts). The warning appears when the action's target collection equals the trigger lexicon and the trigger listens for that kind of write: create, or update for patch-record. It is advisory only. Nothing on the server prevents the loop, so the only backstop is the rate limiter auto-disabling the automation once it hits its per-second, per-minute or per-hour limit.

Expected

A record written by an automation's own action should not fire that same automation again. Other automations watching that collection should still see the write.

Possible approaches

  • Remember the URIs or rkeys an automation wrote recently, and skip matching events for those records in that automation only (in lib/jetstream/consumer.ts).
  • Tag each write with the automation that made it, and drop events that automation produced.
  • Leave it to the rate limiter and the form warning, and document that it's by design.

Open questions

  • Should a condition that already breaks the loop make the guard unnecessary? Or should the guard always apply?
  • How long should "recently written" be remembered? The answer has to hold up across restarts and Jetstream replay.

Done when

  • A self-write can't re-fire the same automation.
  • Other automations still receive the write.
  • Tests cover both cases.

Queued: the loop will work on this version of the issue.

loop:queued cid=bafyreiac443sqyoxchwvluhln6h436zdodr6ag4mxnbu2fbpgxrni4rctu

Working on this, attempt 1.

loop:claim run=contribute-20261108-212231-49 branch=loop/49 attempt=1 issue=49

feat(jetstream): stop an automation from re-triggering on its own writes

An automation whose action writes to the collection it triggers on no longer runs again on the Jetstream event that write produces. Each successful record-producing action already returned the record's AT URI and CID; the delivery log now keeps the CID beside the URI (new delivery_logs.record_cid column), and handleMatchedEvent drops an event whose URI and CID match a write logged by that same automation. Other automations watching the collection are not affected, since the lookup is keyed on the automation.

How it works

  • lib/jetstream/self-writes.ts: isSelfWrite(match) returns false straight away for deletes, for commits with no CID and for events from another repo (actions write only to the owner's repo, with the owner's session). Next it checks an in-memory map, then runs one indexed query on delivery_logs (automation_uri, record_uri) WHERE record_uri IS NOT NULL matching record_cid.
  • logDelivery adds each write to the in-memory map before it inserts the row. This covers the short gap in which the echo could arrive before the row exists. Entries expire after 10 minutes, with a cap of 10,000.
  • handleMatchedEvent runs the guard after the catch-up throttle and before the rate-limit gate, so an echo can never be the event that disables the automation. It covers both the steps engine and the legacy engine.
  • The migration (0005_supreme_mastermind.sql, written by pnpm db:generate) adds a nullable column and a partial index:
    ALTER TABLE "delivery_logs" ADD COLUMN "record_cid" text;
    CREATE INDEX "delivery_logs_automation_uri_record_uri_idx" ON "delivery_logs" USING btree ("automation_uri","record_uri") WHERE "delivery_logs"."record_uri" IS NOT NULL;
    
  • CONCEPTS.md gets a short "Self-write guard" entry next to "Source dedupe".

Answers to the open questions (decisions the owner can reverse)

  • Should a condition that breaks the loop make the guard unnecessary? No: the guard always applies. An automation's own write is never a run it should start. Checking whether the conditions already break the loop would mean evaluating them, and the result would be the same.
  • How long is "recently written" remembered? For as long as the delivery log keeps the row: 30 days (lib/db/cleanup.ts). That is far longer than the Jetstream replay lookback, so the guard holds across restarts and replays. The in-memory map only covers the moment before the row is written.
  • The key is URI plus CID, not URI alone. After patch-record updates a record, a later edit of that record by the owner has a new CID and still triggers. Only the exact write the automation made is dropped.
  • A dropped echo writes no delivery row and is not counted as "filtered out". The write itself already shows in the log as the delivery that made it. Showing the skip would change the logs UI, which the issue does not ask for.

Limits

  • Rows written before this deploy have no record_cid, so a replay right after the deploy of writes made before it is not guarded. That gap lasts for the Jetstream lookback window, and the rate limiter is still the backstop there.
  • If Jetstream delivered the echo before the PDS answered the write's HTTP request, the guard would miss it. Jetstream relays through the relay after the commit, so this is unlikely, and the rate limiter would still catch it.

Done when

Verified

  • pnpm run check passes.
  • pnpm run test passes: 187 files, all tests.

UI

No UI changes. The form still shows its advisory self-trigger warning, which is now inaccurate. The follow-up proposes removing it.

Follow-up, filed as a new issue on merge if the review agrees:

fix(form): drop the self-trigger warning now that the server stops the loop

The automation form warns, below a record-producing action whose target collection is the trigger lexicon, that "This action writes to <nsid>, the collection that triggers this automation, so it may trigger itself in a loop. Add a condition or watched repo that its own records do not match." Since the self-write guard (#49, lib/jetstream/self-writes.ts, called first thing after the catch-up throttle in handleMatchedEvent), an automation never runs on the event of a record its own action wrote, so the warning's claim is no longer true and its advice (add a condition or a watched repo) is no longer needed.

How I know: lib/jetstream/self-writes.test.ts runs handleMatchedEvent against a PGlite database with a record action that posts to the trigger collection; the echo of its own post does not run it again (the PDS write is called once), while a second automation on the same collection still runs on it. The warning is still rendered by app/islands/AutomationForm.tsx (selfTriggerTarget, around lines 2945 and 3059).

Where to start: remove the selfTriggerTarget block in app/islands/AutomationForm.tsx, draftSelfTriggerTarget in app/islands/action-editors/registry.ts with its tests in registry.test.ts, and the "AutomationForm self-trigger warning" suite in app/islands/AutomationForm.test.tsx (the "record action NSID input" test there only mentions toggling the warning in a comment, which goes too). Alternatively, if the owner wants to keep a hint, reword it to what still holds: other automations watching that collection will see the write.

Review: https://tangled.org/exosphere.site/airglow/compare/main...loop/49

loop:pushed run=contribute-20261108-212231-49 sha=79d02d63ec9451441a64fdca2f544c0a6a3f4692

The migration on 79d02d63ec94 is what pnpm db:generate writes from the schema, but it does more than add:

  • a comment the check cannot read past: --> statement-breakpoint CREATE INDEX "delivery_logs_automation_uri_record_uri_idx" ON "delivery_logs" USING btree ("aut...

Only loop merge 49 --destructive merges it, once the gate passes.

0005_supreme_mastermind.sql:

ALTER TABLE "delivery_logs" ADD COLUMN "record_cid" text;--> statement-breakpoint
CREATE INDEX "delivery_logs_automation_uri_record_uri_idx" ON "delivery_logs" USING btree ("automation_uri","record_uri") WHERE "delivery_logs"."record_uri" IS NOT NULL;

loop:migration run=verify-20261108-213141-49 sha=79d02d63ec9451441a64fdca2f544c0a6a3f4692 result=destructive

Verdict: merge. The branch adds a server-side guard (lib/jetstream/self-writes.ts, called in handleMatchedEvent after the catch-up throttle and before the rate-limit gate). It drops a Jetstream event when the same automation's own action wrote that exact record: the URI and CID match one it logged. Other automations still get the event. pnpm run check and pnpm run test pass (187 files, 2797 tests), and the three "Done when" items hold in the code and the tests.

What I checked and how:

  • Every write path. Every record-producing action I found returns uri and cid: record, bsky-post, patch-record, follow, bsky-list-add, semble-save, kipclip-annotation, margin-bookmark and calendar-rsvp. All of them log through wrapWithDelivery, on the first attempt, on in-memory retries and on scheduled replays, and that path now saves record_cid and fills the in-memory map before the insert. Before the branch, resultRecordUri had one reader, delivery.ts, and both of its call sites now use resultRecord. The steps engine and the legacy engine both reach the guard. Dry run writes nothing, so nothing is logged for it.
  • The key. The automation URI keys the lookup, so other automations are not affected. Deletes, commits with no CID and events from other repos never count as self-writes. A later edit with a new CID still triggers. Delivery logs are kept for 30 days and only lib/db/cleanup.ts deletes them, which is far longer than the Jetstream lookback, so the guard holds across restarts and replays.
  • Real input. I wrote a throwaway PGlite test and removed it afterwards. A legacy-engine automation on app.bsky.feed.post with a bsky-post action ran on a plain post. Its echo, sent after clearing the in-memory map as if after a restart, did not run it again. A steps-engine patch-record automation on create and update dropped the echo of its own patch, then ran on a later owner edit with a new CID. Both cases passed.
  • Limits. In two rare cases the guard misses: a write whose HTTP response is lost, and an echo that arrives before the response. In both, the automation runs once more at most. That run's own write is logged, so its echo is dropped and the loop still breaks. Rows written before the deploy have no CID, so a replay of them is not guarded. The rate limiter is still the backstop for all three.
  • Docs. The CONCEPTS.md entry matches the code. The branch changes no UI, and the issue has no out-of-scope list.
  • Migration (0005_supreme_mastermind.sql). It adds a nullable record_cid text column to delivery_logs, which leaves every existing row's record_cid as NULL and changes no data. It also builds a partial btree index on (automation_uri, record_uri) WHERE record_uri IS NOT NULL. The plain CREATE INDEX, not CONCURRENTLY, scans the whole table and blocks writes to delivery_logs while it builds. Only the owner merges it.

Follow-up: file. The form warning in app/islands/AutomationForm.tsx (lines 2945 and 3059, from draftSelfTriggerTarget in app/islands/action-editors/registry.ts) still says the action "may trigger itself in a loop" and tells the user to add a condition or watched repo. With this guard that is no longer true. The issue only describes the warning and does not ask for any change to it, so it is outside this branch. Removing the warning or rewording it is a UI decision for the owner. The file and test references in followup.md are correct.

loop:verdict run=review-20261108-214204-49 sha=79d02d63ec9451441a64fdca2f544c0a6a3f4692 verdict=merge followup=1

Merged main into the branch: dba8bf07b9f90a116cf6c1e368d79d9338bace94.

loop:carry from=79d02d63ec9451441a64fdca2f544c0a6a3f4692 sha=dba8bf07b9f90a116cf6c1e368d79d9338bace94

The migration on dba8bf07b9f9 is what pnpm db:generate writes from the schema, but it does more than add:

  • a comment the check cannot read past: --> statement-breakpoint CREATE INDEX "delivery_logs_automation_uri_record_uri_idx" ON "delivery_logs" USING btree ("aut...

Only loop merge 49 --destructive merges it, once the gate passes.

0005_supreme_mastermind.sql:

ALTER TABLE "delivery_logs" ADD COLUMN "record_cid" text;--> statement-breakpoint
CREATE INDEX "delivery_logs_automation_uri_record_uri_idx" ON "delivery_logs" USING btree ("automation_uri","record_uri") WHERE "delivery_logs"."record_uri" IS NOT NULL;

loop:migration run=verify-20261109-105546-49 sha=dba8bf07b9f90a116cf6c1e368d79d9338bace94 result=destructive

Sign up or Sign in to add to the discussion.