> ## Documentation Index
> Fetch the complete documentation index at: https://developers.myhero.so/llms.txt
> Use this file to discover all available pages before exploring further.

# How automations fire

> Every trigger, event, condition and token a HERO automation can use — what starts a run, what it is told when it does, and every way it can silently not fire.

How a HERO automation gets started, what it is told when it does, and every way it can
silently not fire.

An automation is three parts:

```
trigger(s)   →   condition (optional)   →   actions (in order)
what starts it   whether it proceeds        what it does
```

Any trigger can start it. The condition is checked once, before the first action. The
actions then run in order, each able to read the ones before it — except where a
`control.paths` step branches, and a `control.wait` step suspends the run and resumes it
later with the earlier steps' outputs restored.

***

## 1. Triggers

**Four types are offered.** An automation can carry several, of mixed types.

| Type        | Starts when                                   | Supplies                                                                               |
| ----------- | --------------------------------------------- | -------------------------------------------------------------------------------------- |
| `event`     | Something changes in the database             | event, row, document, clause, actor, previous, version, member, table, column, project |
| `schedule`  | A cron expression or a one-off time           | —                                                                                      |
| `manual`    | Someone presses Run                           | actor                                                                                  |
| `connector` | New mail arrives in a connected Gmail/Outlook | payload                                                                                |

"Supplies" is the set of **slots** that trigger can fill — see §6. A token addressing a slot
its trigger cannot fill is refused at save rather than resolving to nothing at run time
(`automationReference.service.ts`, `SLOTS_BY_TRIGGER`).

**Shapes**

```jsonc theme={null}
{ "type": "event", "event": "row.created", "filter": { "tableId": "…" } }
{ "type": "event", "event": "clause.added", "projectId": "…", "filter": { "documentIds": ["…"] } }
{ "type": "schedule", "mode": "recurring", "cron": "0 9 * * 1", "timezone": "UTC" }
{ "type": "schedule", "mode": "once", "runAt": 1754524800000 }
{ "type": "manual" }
{ "type": "connector", "provider": "gmail", "event": "mail.received", "query": "from:legal@" }
```

### Three trigger types are superseded

`button`, `webhook` and `api` remain in the type so a saved automation still round-trips,
but **none of them is offered or read any more**. None is a thing that happens to your data —
each is a way of *asking* for a run, which is what made them read like one more "when" while
actually being consent, or in the webhook's case nothing at all.

The two entry points they stood for still exist, as properties of the automation rather than
as triggers:

* `POST /api/automation/hook/{publicId}` — authenticated by the automation's own webhook
  secret, because the caller is outside HERO and has no session. Enabled by generating a
  secret (`automation.webhook`), not by adding a trigger.
* The API entry point — authenticated by the caller's personal token, so the run records who
  asked while still executing as the automation's owner.

Both accept an `Idempotency-Key`; a repeat of a key that already ran returns the original run
with `replayed: true` rather than running the workflow twice.

**Limits**

* Schedules run **no more often than every 5 minutes**, and two schedules on the same
  automation must be ≥5 minutes apart (`MIN_INTERVAL_MS`).
* Connectors are **polled**, not pushed, at the same 5-minute floor, **10 messages per poll**
  (`MIN_POLL_INTERVAL_MS`, `MAX_MESSAGES_PER_POLL`).
* A webhook body must be JSON — an **object or an array** (many services batch) — and is
  capped at **1 MB** (`MAX_PAYLOAD_BYTES`); an oversized body is refused before the queue. The
  secret is sent as `X-Hero-Webhook-Secret` or `Authorization: Bearer …`, compared in constant
  time, and only its hash is stored — the plaintext is shown once, at creation or rotation.

***

## 2. Events

Thirty-two exist in the type. **Thirty are offered**; `row.enteredView` and `row.leftView` are
in the schema but the bridge never emits them, so they are refused at save with a reason
rather than accepted and left dead.

The list lives once, in `packages/script-shared/src/types/Automation.ts` as
`AUTOMATION_EVENTS`. The save schema, the trigger picker and the AI composer's prompt are all
derived from it.

Alongside it, `EVENT_SOURCES` declares **how each event reaches the bridge** — `stream`,
`emitted`, or `none`. Declaring it is the point: the recurring failure in this feature is an
event that exists in the type, is offered in the picker, saves without complaint and is
emitted by nothing at all. `none` is confined to the two unimplemented events.

### Rows and cells

| Event          | Fires when                                                            |
| -------------- | --------------------------------------------------------------------- |
| `row.created`  | A row document is inserted                                            |
| `row.updated`  | A row is updated and `isDeleted` was **not** among the changed fields |
| `row.deleted`  | A row's `isDeleted` changes (the soft delete)                         |
| `cell.changed` | Any cell update                                                       |

### Clauses and terms

These ride the block projection the editor already writes on every save — no separate scan.
Only blocks of type `CLAUSE_BLOCK` and `TERM_DEFINITION` are considered; everything else is
ignored.

| Event            | Fires when                                       |
| ---------------- | ------------------------------------------------ |
| `clause.added`   | A clause block is inserted                       |
| `clause.edited`  | A clause block is updated, `isDeleted` untouched |
| `clause.deleted` | A clause block's `isDeleted` changes             |
| `term.defined`   | A term block is inserted **or** edited           |
| `term.removed`   | A term block's `isDeleted` changes               |

`term.defined` covering both insert and edit is deliberate: a definition being written and
being rewritten are the same event to anyone watching for it.

### Signatures

Driven by the `DocumentSignatures` projection, which the collaboration server rewrites on
**every** persist of a document containing a signature block. So "the projection changed"
only means someone typed — each branch has to name a real state change.

| Event                       | Fires when                                          |
| --------------------------- | --------------------------------------------------- |
| `signature.signed`          | A signature line gains a `signedAt` it did not have |
| `document.fullySigned`      | `completedAt` goes from unset to set                |
| `document.signaturesVoided` | `completedAt` goes from set to unset                |

`completedAt` is derived, not stored intent: it is the latest `signedAt` when there is at
least one signature line and **every** line is signed, and unset otherwise (`completionOf`).

That makes `document.signaturesVoided` **"the document is no longer fully signed"**, which is
what the trigger picker calls it. Voiding is the usual cause and the only one reachable while
the document is locked, but deleting the last signature blocks from an unlocked document
reaches it too. The event key is unchanged for saved automations; only the label is honest.

`signature.signed` compares the before and after projections, so a block being added or
renamed is not mistaken for a signing. That comparison needs change-stream **pre-images**,
which are enabled on `DocumentSignatures` only (§8).

### Documents

| Event                 | Fires when                                                           |
| --------------------- | -------------------------------------------------------------------- |
| `document.created`    | A document is inserted with no `originId`                            |
| `document.duplicated` | A document is inserted **with** an `originId`                        |
| `document.deleted`    | `isDeleted` changes                                                  |
| `document.moved`      | `parentId` changes                                                   |
| `document.published`  | `isPublished` changes — **either direction**, including unpublishing |
| `document.renamed`    | `name` changes                                                       |
| `document.updated`    | Any other update                                                     |

Checked in that order, first match wins. `document.updated` is the fallback, so a move, a
publish or a rename does **not** also fire it. `document.renamed` sits before the fallback for
the same reason `document.moved` does: renaming is a deliberate act, and folding it into
"updated" makes it untriggerable.

### Workspace and access

| Event                          | Fires when                                      |
| ------------------------------ | ----------------------------------------------- |
| `workspace.memberJoined`       | Someone accepted an invitation to the workspace |
| `workspace.memberLeft`         | Someone lost their place in it                  |
| `document.collaboratorAdded`   | Someone was given access to one document        |
| `document.collaboratorRemoved` | That access was taken away                      |

All four are **emitted**, not derived: they are reported by the invitation, workspace and
document services, which know who acted. They fill the `member` slot.

### Structure

The shape of the data changing, rather than the data in it.

| Event             | Fires when                     |
| ----------------- | ------------------------------ |
| `table.created`   | A table document is inserted   |
| `table.deleted`   | A table's `isDeleted` changes  |
| `column.added`    | A column is inserted           |
| `column.deleted`  | A column's `isDeleted` changes |
| `project.created` | A project is inserted          |

Creating a table creates its columns, so a new table reports itself and then each column —
the same way it reports each row. Truthful rather than tidy.

`project.created` is **creation only**: a project has no `isDeleted`, so there is no soft
delete to read, and a hard removal cannot be told apart from the cron tidying up after one.

### Versions

| Event                     | Fires when                           |
| ------------------------- | ------------------------------------ |
| `document.newVersion`     | A saved version is written           |
| `document.restoreVersion` | An earlier version is rolled back to |

Both carry **who did it** (`actorId`) and **which version** (`versionId`, `versionName`) —
the only events besides the void that name an actor.

`document.newVersion` covers every route that produces a version a person can see in the
history: the editor's *Save version*, the AI's `create_document_snapshot`, and the
`Signed — <date>` version a completed signing preserves. It does **not** fire for the AI's
own pre-edit undo checkpoints (`isAiManaged`), which are hidden from the history — firing on
those would announce a version for every AI edit, and an automation that edits a document
could then trigger itself through its own checkpoint.

These two are the only events **not** derived from a change stream:

* Versions live in `EditorYjsSnapshots`, which nothing watches — and each row holds a whole
  Y.js state blob, so putting it on the realtime stream to reach the bridge would push
  megabytes at every subscriber on every save.
* A restore writes nothing on the server at all. The editor applies the old content through
  the collaboration session, which reaches the server as an ordinary document change,
  indistinguishable from a large paste.

So both are emitted directly by the code that performs them, and **neither is debounced** —
each is one deliberate act, not an edit burst. Causation still applies, which matters because
the AI can save a version itself.

> **A restore of a signed version fires no signature events.**
> Restoring brings the version's signatures back with it, so the projection gains a
> `signedAt` on every line and a `completedAt` — which reads exactly like people signing.
> Nothing was signed; a rollback happened, and `document.restoreVersion` says so. So
> `signature.signed`, `document.fullySigned` and `document.signaturesVoided` are suppressed
> for that document for **30 seconds** from the moment the restore is reported, and anything
> already queued for it is dropped.
>
> Dropping the queued run is the part that matters: the client applies the restore to the
> shared document and only *then* tells the server, so the projection write normally reaches
> the change stream first. It survives that because dispatch waits out the 5-second debounce,
> and the report lands inside it.
>
> The restore also re-locks the document if the version it brought back was fully signed, and
> hands the live audit slots back to the records that version refers to.

> **Limit — a restore is reported by the client, not derived.**
> `POST /api/snapshot/{snapshotId}/restore` is called by the editor after the content lands.
> It is authenticated and checks edit access on the document, but a client that dies mid-restore
> reports nothing, and the event is only as trustworthy as the session that sent it. Everything
> else in this document is observed server-side.

### Deletes are soft

Deleting anything sets `isDeleted: true`; the real removal happens \~48 hours later when a cron
sweeps. The bridge therefore fires on the **soft delete** and ignores Mongo `delete`
operations entirely. A hard delete is garbage collection, not something a person did, and
firing on it would repeat the event two days after anyone cared.

***

## 3. Filters

An event trigger may narrow itself:

```jsonc theme={null}
{ "type": "event", "event": "cell.changed", "filter": { "tableId": "…", "columnId": "…" } }
{ "type": "event", "event": "clause.added", "filter": { "documentIds": ["…", "…"] } }
```

Honoured keys: `tableId`, `columnId`, and **`documentIds`** — a list, read through
`triggerDocumentIds`, which folds in the superseded single `documentId` so an automation saved
before the picker existed keeps narrowing exactly what it did. Each is compared with `===`
against the same field on the fired event's context. An absent key means "don't care", and an
empty `documentIds` means every document — the filter is opt-in.

A trigger may also carry **`projectId`**, outside the filter, which narrows it to one project.
It was offered by the type from the beginning and read by nothing, so an automation scoped to
one project fired for every project in the workspace; it is honoured now.

Which events offer which picker is declared, not inferred:

* **`DOCUMENT_SCOPED_EVENTS`** — the clause, term, signature and document events that fire
  about a document which already exists. `document.created` and `document.duplicated` are
  absent on purpose: the document they fire for does not exist yet, so there is nothing to
  pick from.
* **`TABLE_SCOPED_EVENTS`** — the row, cell and column events, plus `table.deleted`.

**There is no `folderId` filter.** Matching one means walking a document's parent chain, and a
filter the bridge silently ignores is worse than one that does not exist: it narrows nothing
while looking like it narrows something.

> **Trap — a filter key the event never populates matches nothing.**
> `filter.tableId` on `document.updated` means the trigger never fires: document events carry
> no `tableId`, so the comparison fails every time. Filter on a key the event actually has.

> **Trap — filters take bare ids, never `hero:` URIs.**
> `"tableId": "hero:table/0fcb…"` is compared literally against a raw database id and matches
> nothing. Saving normalises the known structural keys (`tableId`, `columnId`, `documentId`,
> `rowId`, `viewId`, `blockId`) back to bare ids, so this is handled — but only for those keys.
> `hero:` tokens belong in prose.

Which ids each event carries:

| Event group                                                          | `documentId` | `tableId` | `rowId` | `columnId` | `blockId` |
| -------------------------------------------------------------------- | ------------ | --------- | ------- | ---------- | --------- |
| `row.*`                                                              | —            | ✓         | ✓       | —          | —         |
| `cell.changed`                                                       | —            | ✓         | ✓       | ✓          | —         |
| `clause.*` / `term.*`                                                | ✓            | —         | —       | —          | ✓         |
| `signature.*` / `document.fullySigned` / `document.signaturesVoided` | ✓            | —         | —       | —          | —         |
| `document.*`, including the version and collaborator events          | ✓            | —         | —       | —          | —         |
| `table.*`                                                            | —            | ✓         | —       | —          | —         |
| `column.*`                                                           | —            | ✓         | —       | ✓          | —         |
| `project.created`                                                    | —            | —         | —       | —          | —         |
| `workspace.*`                                                        | —            | —         | —       | —          | —         |

Every event carries a `workspaceId`, and a run is **refused** without one — matching everyone
is how one customer's row edit once ran another customer's automation, and a loud log beats
quietly restoring that.

***

## 4. Conditions

**One** condition per automation, top-level — never inside a trigger, where it would be
silently dropped. It is evaluated once, before the first action; false means the run does not
proceed. Individual steps and `control.paths` branches carry their own conditions, of the same
three kinds.

Three kinds, and they are not interchangeable.

### `fields` — for document triggers

Looks up the document that fired and tests its own fields.

```jsonc theme={null}
{ "kind": "fields", "conjunction": "and",
  "predicates": [{ "field": "name", "op": "contains", "value": "Contract" }] }
```

* Fields: `name`, `parentId`, `type`, `isPublished`, `projectId`, `userId`
* Operators: `is`, `is_not`, `contains`, `starts_with`, `matches_regex`, `is_empty`, `is_not_empty`
* `conjunction`: `and` | `or`
* No predicates is vacuously **true**
* `matches_regex` is **anchored** — `Contract` does not match `Employment Contract`; use
  `.*Contract.*`. A malformed pattern is no-match, not a crash.
* `is` / `is_not` compare booleans as booleans, everything else as text. A `false` is not empty.
* **No `documentId` on the trigger context → false.** Used on a row trigger, it matches nothing.

### `rowFilter` — for row and cell triggers

The same engine that powers saved table views, so a condition and a view can never disagree.

```jsonc theme={null}
{ "kind": "rowFilter", "group": { "conjunction": "and", "filters": [
  { "id": "c1", "columnId": "<bare column id>", "op": "is_not_empty", "value": "" }
]}}
```

Operators depend on the column's effective type (a computed column uses its formula's result
type):

| Column type                                                              | Operators                                                                                    |
| ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------- |
| Text-like (string, email, url, phone, identifier, image, link, location) | `contains`, `not_contains`, `is`, `is_not`, `is_empty`, `is_not_empty`                       |
| Number, money                                                            | `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `is_empty`, `is_not_empty`                            |
| Date, time                                                               | `is`, `is_not`, `before`, `after`, `on_or_before`, `on_or_after`, `is_empty`, `is_not_empty` |
| Select, multi-select                                                     | `is`, `is_not`, `is_empty`, `is_not_empty` (case-insensitive, any-of)                        |
| Checkbox                                                                 | `is_checked`, `is_unchecked`                                                                 |
| Button                                                                   | none — always passes; the cell holds run state, not a value                                  |

* **No `tableId` + `rowId` on the trigger context → false.** Used on a document trigger, it
  matches nothing.
* Text comparisons are case-insensitive.

> **Trap — a relation column never matches inside an automation.**
> Relation cells store linked cell ids, not text, so the engine needs a label resolver to
> compare them. Both view layers pass one; **neither automation path does**, and unresolvable
> is treated as excluded (deliberately — the alternative made every negated operator match
> every row). So a condition on a relation column evaluates false and the automation never
> runs, and a `table.updateRows` filtered on one matches no rows and silently edits nothing.
> Filter on a scalar column instead.

### `value` — compares any two resolved values

```jsonc theme={null}
{ "kind": "value", "conjunction": "and", "predicates": [
  { "left": "@[Summary](hero:step/a1.summary)", "op": "contains", "right": "overdue" }
]}
```

Both sides are prose and both go through the same token resolution as an email body. That is
what it is for: it is the only condition kind that can test **something a step produced during
the run**, because the other two start by loading the row or document that fired and so can
only ever see the trigger.

Operators: `is`, `is_not`, `contains`, `not_contains`, `starts_with`, `ends_with`,
`matches_regex`, `is_empty`, `is_not_empty`, `gt`, `gte`, `lt`, `lte`.

### The half-filled row

There is no "wait" trigger. `row.created` fires the moment a blank row appears, so
"once it has a name" is a `rowFilter` condition on the column that must be filled — not a
delay.

***

## 5. What a run is told

Every run carries a trigger context. AI steps are handed it as prose before their
instructions, under **"What fired this run"**, so a step never has to guess which of several
triggers went off.

```
## What fired this run
  source: event
  event: document.signaturesVoided
  documentId: 4f0e…
  actorId: <uid of whoever broke the lock>
  changedFields: completedAt, signatures, updatedAt
  had signed before this change:
    - Alexander Pelosi — signed 2026-08-07T10:14:02.000Z
```

| Field                                                                     | Meaning                                                                                                   |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `source`                                                                  | `event` \| `manual` \| `schedule` \| `api` \| `webhook` \| `button_block` \| `button_cell` \| `connector` |
| `event`                                                                   | Which event fired, when `source` is `event`                                                               |
| `documentId` / `tableId` / `rowId` / `columnId` / `blockId` / `projectId` | Ids of what fired                                                                                         |
| `workspaceId`                                                             | Whose data it is — the tenant boundary; a run is refused without it                                       |
| `memberId`                                                                | Who joined, left, or gained or lost access — access events only                                           |
| `changedFields`                                                           | Which fields the write touched                                                                            |
| `signatures`                                                              | Signature lines as they stand now — signature events only                                                 |
| `versionId` / `versionName`                                               | The saved version — version events only                                                                   |
| `previousValues`                                                          | The whole document before the change — pre-image collections only                                         |
| `actorId`                                                                 | Who did it — **see the limits below**                                                                     |
| `payload`                                                                 | The webhook/API body, or the connector message                                                            |

**Signature events additionally get**, rendered from the above:

* every signature line with its signatory name and whether/when it was signed;
* on a void, **who voided it** and **everyone who had already signed** before it was voided —
  the current state is empty by then, so the affected signers only exist in the pre-image.

> **Limit — only three events supply `actorId`.**
> `document.signaturesVoided`, `document.newVersion` and `document.restoreVersion`. The first
> has to work for it: the write that clears a document's signatures comes from the
> collaboration server, which does not know who asked, so the signing flow stamps `voidedBy`
> on the projection *before* clearing — the clearing is the change the bridge sees. The other
> two are emitted directly by code that already knows the user. Nothing equivalent exists for
> row, cell, clause or the other document events, so `hero:trigger/actor` on those resolves to
> an empty string even though the save layer permits it.

> **Limit — `actorId` is a Firebase uid, not a name.**
> There is no tool that maps a uid to a person, so an AI step cannot turn it into "Alex". The
> verified account email lives on the `SignatureAudits` record and would have to be surfaced
> separately.

***

## 6. Addressing the trigger from a step

In **prose** — an AI step's instructions, a title, an email body, a webhook body — these
resolve at run time:

| Token                                       | Resolves to                                                             |
| ------------------------------------------- | ----------------------------------------------------------------------- |
| `hero:trigger/event`                        | Which event started the run, by name — `document.fullySigned`           |
| `hero:trigger/row`                          | The fired row's identifier value (its readable label)                   |
| `hero:trigger/document`                     | The fired document's id                                                 |
| `hero:trigger/clause`                       | The fired block's id                                                    |
| `hero:trigger/actor`                        | Who caused it — see the limit above                                     |
| `hero:trigger/member`                       | Who joined, left, or gained or lost access                              |
| `hero:trigger/table`, `/column`, `/project` | What a structure event was about                                        |
| `hero:trigger/payload.<pointer>`            | A field of the webhook/API/connector body                               |
| `hero:trigger/previous.<field>`             | That field's value **before** the change                                |
| `hero:trigger/version.name`                 | What the saved version is called; bare `hero:trigger/version` is its id |
| `hero:step/<actionId>.summary`              | An earlier AI step's summary                                            |
| `@[Name](hero:column/<tableId>.<columnId>)` | That column's value on the fired row                                    |
| `hero:now`, `hero:today`                    | Timestamp, date — note these carry no `<kind>/` segment                 |

`hero:trigger/event` is the one slot a rule needs to tell two triggers apart, and the only way
to: every other slot describes what the event was *about*, and two events about the same
document look identical through them.

Two rules that bite:

* **Structural id fields take bare ids; prose takes tokens.** `"tableId"`, `"columnId"`,
  `"documentId"`, `"rowId"`, `"viewId"` and `"blockId"` are matched literally against database
  ids. `rowRef` and `documentRef` are the deliberate exceptions — they hold a `hero:` URI.
* **`hero:trigger/previous.*` is empty unless pre-images are on** for that collection. It is
  never approximated from the current value.

There is no `{{…}}` template syntax anywhere in HERO. A `{{trigger.email}}` in a recipient
field is posted to the mail provider as a literal address.

***

## 7. Ordering, repetition and loops

**Debounce — 5 seconds.** Changes are collapsed per *(automation, event, entity)*. An edit
burst produces one run, not one per keystroke; the change stream coalesces anyway, so one run
per burst is the honest reading. The event is part of the key because without it a rename and
an update to the same document shared a slot, and an automation triggering on both had one
cancel the other. The emitted events skip the debounce entirely — each is one deliberate act.

**Causation.** A change an automation caused is not ignored — chaining is the point — but it
carries where it came from. Each run records which entity it wrote and under which run id;
a change to that entity within **60 seconds** inherits the causation chain.

A run is refused before it is created when:

* the chain is deeper than **3**, or
* the automation **already appears** in the chain (a cycle).

Refused at enqueue rather than at run time, so a self-retriggering write does not mint a failed
run for every write it makes.

**Queue limits.** A run is deduplicated by *(automation, row)* — or *(automation, document)*
for an event trigger, or the idempotency key — rather than by automation alone, or row 2's
button click would be rejected while row 1 was still running. Beyond that: **3** attempts per
run, **3** concurrent runs overall, **2** per automation, and **20** chained runs per
60-second window.

**Budgets** (per run, overridable per automation):

|                                 | Default    |
| ------------------------------- | ---------- |
| Wall clock                      | 10 minutes |
| AI tool calls                   | 50         |
| Rows touched                    | 200        |
| External writes (webhook/email) | 10         |
| Document writes                 | allowed    |

***

## 8. Pre-images

Some questions need the state *before* a write. MongoDB only answers them when
`changeStreamPreAndPostImages` is enabled on the collection, and that costs a stored copy of
every version.

Enabled on **`DocumentSignatures` only**. That buys:

* `signature.signed` distinguishing a real signing from a block being added or renamed;
* the prior-signer list on a void, which exists nowhere else once the signatures are cleared.

Everything else is derived from `updatedFields`, which names exactly the fields whose values
actually changed — verified: a persist that rewrites `completedAt` to the same value reports
only `updatedAt`, so a keystroke cannot fire a signature event.

Not enabled on Rows, Cells, Documents or Blocks: deletes are soft, so the "before" state is
still readable in the database for 48 hours, and the events that would need a pre-image are
already answered by `updatedFields`.

The command is not part of MongoDB's Stable API v1, and the app connects with `strict: true`
on purpose, so it runs on its own short-lived connection. If it fails the streams still open
and the affected comparisons degrade rather than being silently approximated — the warning at
boot names the collection.

***

## 9. Checklist for "it never fired"

1. **Is the automation enabled?** **5** consecutive failed runs disable it and notify the
   owner — a silently dead automation is worse than a noisy one.
2. **Does the filter key exist on that event?** `tableId` on a document event matches nothing (§3).
3. **Is there a `hero:` URI in a filter?** It is compared literally (§3).
4. **Is the trigger scoped to the wrong project?** `projectId` is honoured now (§3).
5. **Does the condition kind match the trigger kind?** `fields` on a row trigger, or `rowFilter`
   on a document trigger, matches nothing (§4).
6. **Does the condition filter a relation column?** It always evaluates false (§4).
7. **Is it `row.enteredView` / `row.leftView`?** Not implemented; refused at save.
8. **Is the trigger a `button`, `webhook` or `api` type?** Superseded and no longer read; the
   webhook and API entry points are properties of the automation now (§1).
9. **Did an automation cause the change?** Depth >3 or a repeat in the chain is refused (§7).
10. **Was it within 5 seconds of another change to the same entity and event?** Debounced into
    one run (§7).
11. **Is it a signature event within 30 seconds of a version restore?** Suppressed — a restore
    is not a signing (§2).
12. **Is it `document.updated` after a move, publish or rename?** Those fire the specific event
    only (§2).
13. **Is it `document.newVersion` on an AI edit?** AI undo checkpoints are not versions (§2).

***

## Where this lives

| Concern                                      | File                                                                                                                        |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| The event list                               | `packages/script-shared/src/types/Automation.ts`                                                                            |
| Change → event, filters, debounce, causation | `apps/script-server/src/services/automation/automationEventBridge.service.ts`                                               |
| Cycle limits                                 | `…/automation/automationCausation.service.ts`                                                                               |
| Conditions                                   | `packages/script-shared/src/utils/automationCondition.ts`, `tableFilterEngine.ts`                                           |
| Slots per trigger                            | `…/automation/automationReference.service.ts`                                                                               |
| Token resolution                             | `…/automation/actions/interpolate.ts`                                                                                       |
| Trigger context as prose                     | `…/automation/actions/ai.actions.ts`                                                                                        |
| Signature projection                         | `apps/hero-hocuspocus/src/services/editor.service.ts`, `packages/script-shared/src/services/signatureProjection.service.ts` |
| Version events, restore suppression          | `apps/script-server/src/services/snapshot.service.ts`, `…/controllers/snapshot.controller.ts`                               |
| Workspace / collaborator events              | `…/services/workspace.service.ts`, `…/services/invitation.service.ts`, `…/services/document.service.ts`                     |
| Webhook and API entry points                 | `…/controllers/automationHook.controller.ts`, `…/automation/automationWebhook.service.ts`                                   |
| Save validation                              | `…/automation/automation.service.ts`, `apps/script-server/src/schema/automation.schema.ts`                                  |
| Stream wiring                                | `apps/script-server/src/services/mainStreamDB.service.ts`                                                                   |
| Pre-images                                   | `apps/script-server/src/services/server.service.ts`                                                                         |
