Why it attaches to a dataset
Row actions hang off the dataset, not off the pipeline that fills it, and that distinction matters more than it first appears. A real development runs many lead-capture pipelines — one per landing page, per language, per variant. One customer has 25 of them, all writing a single leads dataset. Anything attached to a pipeline covers that pipeline alone, so the day someone launches landing page 26, its rows stop being acted on and nothing looks broken. Attaching to the dataset covers every writer, including the ones that don’t exist yet.Declaring one
PUT /v1/dataset-row-actions takes the dataset, a name for this declaration, and
the steps. The declaration replaces whatever was there under the same name.
last_error and records the original
action and observed HTTP status in bounded diagnostic notes, when available.
These notes omit provider response bodies and lead values. A successful fallback
clears its recovered failure. An HTTP status, including 404, does not prove that
a paid lookup was uncharged and never authorizes an automatic retry.
An unresolved paid attempt remains manual reconciliation work for that lead.
It does not stop independently verified new leads from being processed: a new
purchase must belong to a live-captured canonical lead created after both the
receipt cutover and the earliest unresolved ordinary chunk, across every
declaration generation. Historical or adopted identities still require explicit
recovery. Exact completed receipts are reused without another provider call.
The original unresolved receipts, chunk cursors, and manual counts remain
retained; continuing new arrivals does not mark those earlier attempts successful.
A missing or malformed quarantine cursor stops traversal visibly. Carried
quarantine state is limited to 2,048 blocked keys and 1 MiB of serialized cursor
state. Reaching either limit requires explicit repair before traversal continues;
no retained receipt or historical chunk is pruned to fit the limit.
GET /v1/dataset-row-actions?dataset=<slug> lists everything declared on a
dataset; add &name=<name> to read one. That state is read from the automations
themselves, so there is no second copy of the declaration to drift out of sync
with reality.
Passing "enabled": false stops a declaration. For a declaration that has used
outcome_contract: "lookup-v1", this disables its automation and triggers and
withdraws standing grants while preserving lookup history, provider receipts,
and resolution tools. It remains readable with enabled: false, and unresolved
outcomes can still receive an audited disposition. Removing the public outcome
contract later does not remove this retention. Other declarations are removed.
Re-enabling a retained lookup reuses the same automation and starts from the
current arrival and update cursors. Rows and updates that arrived while it was
off are not automatically purchased; use the counted recovery or explicit
backfill flow when historical work is intended. A run already executing when
disabled may finish. Deleting the automation directly remains a destructive
operation that removes its lookup history; use enabled: false to retain it.
Validate before you activate
Add"validate_only": true to prepare the exact declaration without changing
anything. Erdo resolves the dataset and edit access, checks the real timestamp
and identity columns, validates after, normalizes the backfill range, builds
and compiles the managed script, and confirms that any first-party email step
has a safely bounded standing-grant shape. It then returns before any job,
trigger, grant, or run is created or changed. The approval service persists the
grant only on the subsequent save, so an infrastructure failure there can still
make that write fail visibly.
The response has "validated_only": true and an impact object. Read all of
these fields before sending the same declaration without validate_only:
operationiscreate,update,disable,remove, orno_op.backfill_requestedsays this request explicitly asks to replay history.backfill_in_progresssays an earlier replay still has historical rows left.pending_runsays an already-queued run or dataset debounce exists.would_run_nowsays the write would start a run immediately.would_contact_historical_rowsis the direct safety check for an outreach declaration: it is true for a requested or unfinished backfill.
summary states the same impact in plain English. An ordinary edit with no
backfill does not replay rows the declaration has already settled. It can still
affect an unfinished backfill or a run already waiting for newly arrived rows,
which is why those are reported separately.
For erdo/send_email, preflight also checks the first-party action schema and
the recipient grant. A lead autoreply must address literal email addresses or
use exactly {{row.email}} (with email replaced by the dataset’s recognized
identity column) on every email step. A recipient derived from another field or
from a mixed dynamic shape is rejected because Erdo cannot safely authorize an
unattended send to it.
For a lead autoreply, set key_column to the verified email column even when
the capture pipeline itself upserts on lid. This is the one identity choice
specific to autoreplies: it bounds the standing approval and deduplication to
the recipient address. The lead reference in the follow-up-page URL is separate
and only personalizes and attributes the link: write {{?lead_ref}}, which is
the lead’s permanent reference on a dataset enrolled in
lead identity and blank on one that is not.
Script steps receive a read-only row and a read-only run.rows batch. The whole
batch is captured before any step runs, so a script cannot rewrite another row’s
address or replace, remove or reorder the rows later steps will process. Reading
the batch to compose a digest still works; return computed values from the script
for later steps to use through result. Test-send rehearsals use the same rule.
A script that builds a link itself reads the row’s lead reference as
run.lead_ref, since text a script returns is not searched for placeholders.
A row whose recipient resolves to nothing — a lead captured on a phone call, for
instance, who left a number and no address — is recorded as skipped for the
send step, with the reason no recipient address on this row, instead of being
invoked and failing. The rest of that row’s steps run as usual: nothing was
sent, so nothing answered, and a fallback step behind the send still gets its
turn.
Test-send the message to yourself
validate_only proves the declaration is well formed. It cannot tell you
whether the message reads properly, because that is a question about words —
whether the greeting your script composes gets the first name right, whether the
subject line reads well beside a real one in an inbox, whether a placeholder you
half-remembered resolves at all.
POST /v1/dataset-row-actions/test-send answers that. Give it the dataset, the
steps you are about to declare, one sample row, and deliver_to — the address
the rendered message should be delivered to. Erdo runs your script steps against
that row and renders the email through the same template engine the live
automation uses, so what lands in your inbox is the message a real row would
receive.
{column, value} entries rather than an object,
because a map of arbitrary values cannot cross the API schema — the same reason
a step’s input is raw JSON. Every value is text, which is what a template
resolves anyway.
Two things bound what a test send can do, and both are worth knowing before you
rely on it:
deliver_tois the only address it can ever reach. Whatever the declaration’s owntorenders to — the sample row’s address, a literal, a placeholder you mistyped — it is replaced withdeliver_tobefore the message is sent, and any recipient name the row rendered is dropped. Point a sample row at a real lead’s address and that lead still hears nothing.- The steps may only be scripts and the one email. A test send accepts zero
or more
scriptsteps followed by exactly oneerdo/send_emailstep. Any other action — an enrichment, a web search, a provider write — is refused, so asking to preview a message can never invoke something paid or something an approval gate exists to hold. Declare the full list withPUT /v1/dataset-row-actionsand test-send the message part on its own.
{{?column}} for a value to use when it is there and do without
when it is not. A placeholder mixed with surrounding text keeps what it found
rather than emptying the field, so About {{missing}} still renders and sends
with the gap left in — which is also what a live run does, and a good reason to
read the preview rather than trust the shape.
A script step that throws is reported the same way: nothing is sent and
skipped names the step and the error. A live run would carry on to the email
with whatever the step before it produced and report the error on the run, so
this is the one place a test send deliberately stops short — emailing a
half-rendered draft while saying nothing about the exception would be the worst
of both.
The send counts toward your organization’s daily distinct-recipient limit and
leaves from the same address a real send would, so what you receive is a fair
preview of the whole thing. It is recorded in the sent-email log with
test_send set on its context, alongside whatever context the declaration
itself carries — so POST /v1/emails-query with
{"context": {"test_send": "true"}} lists every rehearsal, and a message about
a real lead is never confused with one.
Ask an agent to set up a lead autoreply
You can hand Erdo approved single-language or multilingual copy and ask it to email each new lead immediately. The agent uses the same row-action declaration shown above; it does not create a polling job or send the copy itself while setting it up. The setup checks the live system before it writes anything: every active lead-capture pipeline must feed one leads dataset, the dataset must have a real email column, and any scheduling button must point to a published follow-up page. It also reads the sending identity. For a message that does not invite a reply, an unverified domain makes the agent stop and ask you to accept mail leaving fromhello@mail.erdo.ai instead of quietly using the fallback.
That fallback is only suitable when the message does not ask for a reply. Its
Reply-To is hello@erdo.ai, not your nominated mailbox. If the approved copy
invites replies, or the workflow requires replies to reach your team, the agent
requires an active sending domain and a working route reported by Erdo: either
receiving with forwarding configured, or a sending-only identity whose Reply-To
forwards to your mailbox. It refuses activation while DNS or that reply route is
unfinished, even if you would otherwise accept the fallback sender.
Copy you provide is treated as approved content, not a writing prompt. Erdo
keeps each language version unchanged and only normalizes the retired
{{FOLLOWUP_PAGE}} marker to [[FOLLOWUP_PAGE]]. Put that marker on its own
line where the scheduling button belongs; use
[[FOLLOWUP_PAGE|Choose your time]] to supply the button label.
When copy includes that scheduling marker, the agent reads the page’s current
visibility and visitor URL from the page list. Public pages carry their exact
environment or custom-domain public_url; private pages do not. The agent uses
that returned URL verbatim and never publishes a draft merely to inspect it.
The declaration is always named lead-autoreply and is read before it is
replaced. The agent first submits the complete declaration with
validate_only: true and checks the returned impact summary; that preflight
does not save the declaration, contact anyone, or raise an approval card. The
subsequent mutating call does pause for Erdo’s systemic human approval card —
whether it activates, replaces, removes, or backfills the declaration — so the
exact live change remains a human decision. Activation proceeds only when
the preflight confirms that no backfill was requested or already running, no run
is pending or would start immediately, and no historical rows would be
contacted. A normal write happens only when you explicitly asked to activate it,
and the result is read back after saving. It carries no backfill and leaves
on_update at its default skip, so enabling it neither contacts historical
leads nor emails the same address again when their row is completed or
updated. Setup is verified structurally — Erdo never tests it by sending to a
real lead. To read the message itself before it goes live, use
a test send, which delivers the rendered
email to an address you name and to nobody else.
By default a row action never works through a backlog. Where the dataset stands is
recorded the moment you declare it, so only rows arriving afterwards are acted on
— including the very first one. Generic declarations are deleted when stopped.
Lookup declarations preserve their owner and evidence, then refresh their arrival
and update cursors when re-enabled. Both paths avoid automatically invoking a paid
action for everything that accumulated while the declaration was off. Ask for a
backfill when you want the history done.
Skipping rows before any action runs
Useskip_when when some new rows must not reach this declaration at all:
skip_when; an uncertain check
never silently suppresses work. When a row has an email address, every
definitive sign from DNS that its domain cannot receive email — the domain does
not exist, it declares that it accepts no mail, its only mail servers are
unreachable by design, or it has no mail server and no address at all — is
given to the judge as checked evidence, not used as a rule by itself. Anything
short of definitive, including a DNS error, simply contributes no evidence.
Form/contact values are kept separate from request, advertising, browser, page
and location context. The latter can prove a visit happened, but—unless your rule
asks about that context—it cannot prove that the name, email or phone typed into
the form is genuine. Every row value is treated as untrusted data, never as an
instruction to the judge.
skip_when belongs to this declaration only. Row actions on the same dataset
run independently, so every sibling declaration that must suppress the same row
needs the policy declared on it as well.
A leads dataset now holds leads from more than one surface, and every lead row
names its own in a source column: landing_page_form (a landing page’s
lead form), landing_followup_page (the scheduler on a follow-up page),
widget_booking_form (a website widget’s “Book a call” form),
widget_conversation (details a visitor gave the widget’s assistant in
conversation), phone_incoming_call (someone who rang your agent’s number), sms_incoming
(someone who texted it). An
action written for one surface can say so — “leads that did not come from a
landing page form” is a skip_when the judge can decide from that column
alone. Rows captured before the column existed carry a blank source, so a rule
that must not act on them should say that rather than assume a value.
Applying one to the rows already there
Not acting on a dataset’s history is the right default — declaring an action must not quietly spend a paid lookup on every row already in the table. But it makes a declaration that was wrong permanent: every row it decided about is remembered as decided, so fixing the steps helps the next row and none of the ones already handled.backfill is how you say “and do the ones you got wrong”.
"all" takes every row in the dataset. An RFC 3339 timestamp — "2026-07-01T00:00:00Z"
— takes every row that arrived at or after that moment, including one stamped
exactly at it. Omit the field and nothing historical is touched.
A backfill re-invokes the steps for each row in range, including rows this
declaration has already acted on, because reconsidering them is the entire
request. Every action they invoke is paid for again.
It does not all happen in one run. A run acts on a bounded number of rows and
makes a bounded number of invocations, which is what stops a backfill becoming
hundreds of external calls at once — so a large one is worked through over
consecutive runs, each picking up where the last got to, until the declaration is
level with the dataset and goes back to acting on what arrives. You do not have to
do anything to keep it moving. The run summaries say how many rows are left.
Editing a declaration is not a backfill. Changing a step rewrites the automation
and leaves its position alone, so an ordinary edit never replays the dataset.
Several per dataset
The name is what lets one dataset carry more than one declaration. A leads dataset can alert the sales desk and enrich each record, as two independent declarations that neither replace nor interfere with each other. The name is also the handle you use to change or remove one later, and it is how each appears in your automation list.Steps run in order
Each new row is taken through the steps in order, and each step sees what the ones before it produced. That is what lets a declaration look a lead up and then email the desk about them, with the email saying what the lookup found. A step is either an action to invoke or a script to run — never both.Alternatives, with fallback
Mark a step "fallback": true and it runs only when nothing has answered yet.
That is how “look this person up in the contact database, and web-search them
when it has never heard of them” is expressed: the search costs nothing when the
lookup already answered, because it never runs.
Without the flag, steps are a sequence rather than a set of alternatives. Use the
flag for the second way of getting the same answer; leave it off for the next
thing to do with the answer you have.
found_when — did it answer?
An action that finds nothing rarely returns nothing. It returns an envelope
saying so, and Erdo appends ok: true to every native action’s result. A contact
lookup that has never heard of an address replies:
found_when is a dotted path into the result
that decides the question: "found_when": "found" reads the miss for what it is.
This is deliberately separate from result_path, which decides what
{{result}} records. They were once the same knob, and restoring the
fall-through then meant pointing it at a scalar and throwing away the employer,
title and profile URL you called the action for.
result_path
Many actions wrap their answer in an envelope that echoes the request.
result_path selects the part worth recording — and becomes what {{result}}
refers to, which usually makes the destination templates shorter too. When no
found_when is given, it is also what decides whether the step answered.
for_each — one action, several times
Give a step a for_each list — a JSON array, or one {{placeholder}} resolving
to one — and the action is invoked once per element, with {{item}} and
{{item_index}} available in its input. Each invocation counts against the run’s
budget.
Steps that are scripts
Some decisions cannot be written as a template. Give a step ascript instead of
an app and key, and it runs as JavaScript against the row:
Templates
Any string in a step’sinput, and every value in destination.columns, may
carry {{placeholder}} references.
answered_by is how a destination records provenance in a multi-step
waterfall: a column mapped to {{answered_by.action}} says whether a row came
from the contact database or the web search behind it, and stays blank on a
lookup that found nothing. answered_by.app, answered_by.key and
answered_by.step (the step’s index) are also available separately. It names
the action step whose answer {{result}} currently carries, so a script
discarding a finding clears it, and a fallback that then answers becomes the
provenance instead.
trail records the rest of that story: which steps ran before the one that
answered, what each was asked, and how each came out. It is one JSON string, so
a destination column mapped to {{trail}} stores it whole. It holds one entry
per declared step, in order, including the steps that never ran:
When a lookup names the wrong person, the trail says which step and which
vendor endpoint produced the match, without paying to run the lookup again.
The engine is deliberately small: dotted paths, and nothing else. Anything it
cannot resolve becomes blank rather than an error, because a declaration
written against one row shape will meet rows that lack a column — and one missing
column must not stop the run for every other row.
A value that is exactly one placeholder keeps the resolved type, so
"num_results": "{{result.count}}" reaches the action as a number rather than
the string "3". Anything else is text substitution. Values that are not strings
— "num_results": 3 — are settings you wrote, and pass through untouched.
Required and optional placeholders
A step whose templated input resolves to nothing cannot be built for that row, so it is skipped and the next step is tried. That is how a declaration handles a row with no email at all, and it is usually what you want. Write{{?path}} when the value is decoration rather than a requirement:
Ordered steps, and what one row costs
Actions cost money and scripts do not, which is the whole reason the two are treated differently. A fallback exists so you do not pay twice for the same answer; a script runs every time because it costs nothing to ask it.The destination
Give adestination to record what came back. Omit it entirely for an action
whose effect is the action — sending an email, filing a ticket.
key_column makes the write an upsert on that column, so acting on the same
subject twice replaces what was known rather than appending a rival copy of it. A
row whose key renders empty is not written: there would be nothing to identify it
by, and every such row would collide with every other one.
The destination must be a different dataset from the source. Writing back into
the source would trigger the automation again, and a keyed write would match each
source row and overwrite it with a partial one — erasing the captured columns your
result does not carry forward.
A run that found nothing still writes its row. “We looked and found nothing” is a
different and useful fact from “we never looked” — the first says this subject has
nothing worth surfacing, the second says nothing has reached them yet — and a
surface can only tell them apart if the empty result is recorded.
A row written twice
Datasets that receive leads are upserted, not appended: a landing page that captures an address first and the rest of the form a minute later writes the same row twice. Arrival time alone cannot tell that from a new row — the second write moves it forward — so a declaration would invoke its actions a second time for a subject it has already acted on, and pay for them a second time. The write settles it. Every row records the moment the dataset created it, in a column namederdo_created_at, and a second write to the same row leaves that
moment alone however far the arrival column moves. A declaration keeps its
position in that column as well as in the arrival column, so a row created no
later than the point it has already dealt with is one it has already decided
about.
On top of that it remembers what it has acted on: the value in the row’s
identifying column, and a fingerprint of what the row said. That is what covers
rows written before erdo_created_at existed, and it is what act below is
decided from. A row recognised either way is left alone and counted under
repeats. Erdo detects the identifying column the same way it detects the
arrival column — by probing what the newest row actually carries, since a column
can exist and be empty on nearly every row — and key_column overrides it.
Rows identified by email or phone
A dataset can contain people who supplied only a phone number. Declare the existing email identity and its fallback explicitly:dataset, name, and steps when saving it. The first nonblank source column
wins. Values keep their original spelling, so email identities stay the same
and a phone-only row can be recorded even when its lookup found nothing.
A row with every identity column blank is counted as unkeyed and cannot be
written to a keyed destination. Normalize contact spellings in the source
writer if different spellings must represent one identity.
A notification configured with after inherits the lookup’s source identity
and can therefore wait for phone-only results too. Another row action using
after must use the same primary column and exactly the same ordered fallback
columns. Its destination can join through {{row_key}} without
pretending a phone number is an email address. {{row_key}} never authorizes an
email recipient; an autoreply still addresses {{row.email}}. Notification
readback reports its resolved key_column, key_fallback_columns, and
after_key_column, so clients can detect a dependency whose destination key
changed and save the notification to bind it again.
For an existing destination, add and populate lead_key from its existing
identity values before changing its upsert key. Updating a declaration does
not migrate historical rows or request paid lookups; use an explicit backfill
only for rows you intend to look up again.
on_update decides what a second write means:
Even under
act, a rewrite that says exactly what the last one said is ignored:
that is not new information, and a writer replaying a batch must not become a
second bill. Choose act where a later write is what makes the actions able to
answer at all — a form’s first write may carry no name, which is exactly when a
contact lookup has nothing to check its match against.
A row with nothing identifying it is always acted on. Acting twice is bad;
never acting is worse.
What one run does
A declaration may list up to 5 steps. Each run acts on at most 10 new rows and invokes at most 10 actions, whichever binds first. That keeps an import from becoming hundreds of paid calls in one pass. The watermark still advances past everything that arrived, so a burst larger than the cap has its excess left rather than retried forever, and the run result reports the count undernot_processed rather than claiming a clean
success.
A failed row does not abandon the other rows in the same run. The run stores
failed identity keys separately from its advancing arrival cursor. Each later scheduled or write-triggered execution selects one safely retryable queued identity and leaves row capacity for new arrivals. Unknown provider outcomes are manual work and do not enter that purchase queue.
Retry and fresh work alternate priority so a retry that consumes the whole
action budget cannot monopolize later triggers. Thus a
permanent provider error cannot block new leads. Queued keys rotate fairly and
bypass the creation watermark. When the retry budget leaves fresh arrivals
beyond the cap, the arrival cursor drains them using timestamp and identity
together, including peers with identical timestamps. Anonymous peers at such
a boundary require explicit repair because they cannot be resumed safely.
After a run result is persisted, successful peers are not replayed to retry a
failure, including successful rows without identity keys. Managed action steps save an encrypted provider-result receipt before returning
the result to the script. If a destination write or execution checkpoint then
fails, the next run reuses the saved output after checking current access and
approval. It does not buy that action again for the same generation, row,
step and input. A meaningful changed input can run under the declaration’s
existing on_update: "act" policy.
During a receipt-system rollout, a temporary setup failure preserves arrival
progress and the existing retry queue, then resumes once activation completes. It does not charge
the provider or turn the setup failure into a manual reconciliation requirement.
If a trusted authorization or identity check stops an action before the provider
is invoked, the refusal remains retryable. Restoring authorization or correcting
the declaration lets a later run check it again. This is different from a provider
HTTP error or timeout, which cannot prove that no lookup happened. An explicitly
approved historical recovery also restores its unused call allowance only for a
proven pre-provider refusal.
An enabled row-action declaration establishes its durable receipt boundary
before its first scheduled execution. A lead captured after the declaration is
active is not mistaken for history merely because the job has not run yet.
Saving or enabling the declaration preserves any existing boundary and does not
purchase lookups. An older declaration that names its source by slug can acquire
that same source’s verified UUID during a save. This requires a matching existing
source-event trigger and unchanged identity keys; the receipt boundary and saved
provider results stay intact. A different source or identity selector still
requires explicit reconciliation. Fresh-lead admission requires trusted live-capture creation
records for the complete canonical identity family; assigning identities to
historical rows does not make those rows new or authorize a lookup.
A started provider attempt with no saved result has an unknown outcome. That
includes a timeout or a failure while saving the output. It requires explicit
reconciliation and appears as manual_retry_required, not an automatic retry
queue. Anonymous action rows are refused before a provider call because they
cannot have a stable receipt. Historical failed rows without receipts are
also held for reconciliation; installing this protection never replays them. Immediate continuations drain untouched arrivals or an authorized backfill without retrying failed identities. Provider failures alone never request an immediate continuation loop. A run in which every row fails
still persists its failure queue. Its job transport completes to save that checkpoint, while last_execution.status
reports incomplete; it never reports a clean processing outcome.
A failed digest retains its complete timestamp window and retries that window on
a later scheduled execution, preserving its total and bounded preview. It does
not turn into a one-lead alert. Later arrivals remain outside the retried window
and are processed afterward. The window is a timestamp range, not a frozen
snapshot: rows newly visible at the same timestamp are included when that range
is re-read. Immediate continuations never retry the failed shared window.
Older failed digests without complete window bounds, and a mixed window
containing a successful paid effect without a stable row identity, require explicit repair; the system does not guess the missing window.
The queue holds at most 500 identities. Failed rows without an identity and
failures beyond that bound require an explicit repair: the result retains
manual_retry_required and unresolved_failed_rows, including on quiet runs.
No failed identity is silently discarded as successful. Explicitly requesting
a backfill starts a new generation and may purchase historical actions again.
A new generation does not clear an unresolved provider attempt or historical
unknown outcome; those identities still require explicit reconciliation.
GET /v1/dataset-row-actions includes a small last_execution view when a run
exists: status, started_at, completed_at, failed_rows, pending_rows,
pending_retry_rows, unresolved_failed_rows, degraded_rows and manual_retry_required. Its status is incomplete
when a completed job reports failures, pending work, or degraded rows; an enabled
declaration and a completed job alone do not establish successful processing.
The view contains no row identities, provider responses or raw error messages.
Use the automation history for the run’s diagnostic summary.
degraded_rows counts processed rows whose final answer is empty after an action
step failed. A fallback that returns no match, or a result subsequently set aside
by a script, does not erase that failure. A reliable recovered answer is not
degraded. These rows are complete for scheduling: they are recorded as processed,
not queued for retry, and the diagnostic never purchases another lookup. Original
failure details remain in the operator execution notes. This count describes that
execution only; a later quiet execution does not retain a historical backlog.
Which column marks arrival
Erdo detects the column recording when a row arrived (submitted_at,
created_at, received_at, and similar) by probing the dataset’s real columns
when you declare the row action. If your dataset names it something
unconventional, pass timestamp_column explicitly.
Detection happens at declaration time on purpose: a dataset with no usable column
is an immediate error you can fix, rather than an automation that looks perfectly
healthy and quietly never acts on anything.
Whichever column you name, it has to hold timestamps. The declaration starts from
the newest value that column already carries and compares later rows against it as
text, so a column of free-text answers — a “when are you looking to buy?” question
answered “Immediately” — would leave every row that arrives afterwards sorting
below it. Erdo checks what the column actually holds and refuses the declaration
when it is not a timestamp, naming the value it found.
An existing declaration is checked the same way as it runs, on both the point it
is starting from and the one it is about to record. Re-declare it against a column
that records arrival and it recovers by itself on its next run: the unusable
starting point is dropped and the declaration resumes from where the dataset now
stands, without replaying history. Until then its runs fail, naming the column and
the value — a run that fails invokes nothing and leaves the starting point where
it was, so the rows it could not order are still waiting once the column is
corrected.
Cost
Withoutskip_when, nothing per run. The automation is a deterministic script
fired by an event trigger on the dataset, with no polling loop burning tokens to
ask whether anything changed. It runs when a row lands and is otherwise idle;
the only metered work is the action itself.
With skip_when, each eligible row gets one small LLM judgement before its
steps. Rows the rule skips do not invoke those steps, which is particularly
useful when the actions are paid.
A backfill is the exception worth
knowing about, and only because of what the steps do: it invokes them once per row
in range, so a metered lookup over a thousand historical rows is a thousand
lookups. The runs themselves stay negligible.
Actions that need approval
Some actions require a standing approval before an unattended automation may invoke them. Grant one by running the action once in a thread and choosing always allow; without it, the invocation fails and — since that failure affects every row — the run goes red rather than passing quietly.Related
Arrival notifications
The purpose-built version of “email these people when rows land”.
Automations
The run history of the automation behind a row action.
Approvals
Standing policies that let an automation invoke an action unattended.
Datasets
Where the rows land, and where results are written.
Recover selected historical leads
A provider can complete a lookup even when its response or the destination write fails. An uncertain attempt therefore requires an explicit decision before another purchase. Saving settings and normal retries never authorize that purchase. Use the same declaration endpoint to preview between 1 and 100 currently eligible canonical lead identities for an existing saved declaration:recovery response contains preview_token, selected_rows,
maximum_provider_calls, and action_call_ceilings for each provider action.
The call ceiling includes possible fallback steps and up to 100 calls per dynamic
fanout step per selected row. Selected rows and possible paid calls are different
counts; a historical missing result does not prove its previous lookup was unpaid.
The preview performs no provider calls. These are conservative provider-action invocation bounds, not prices, predicted charges, or limits on the vendor HTTP requests made inside an action. One action may perform multiple vendor lookups. Recovery reuses a previously completed step when its exact inputs, declaration generation and current lead identity family match; reused output consumes no provider-call allowance. Dependent inputs may only become known during execution, so the preview does not subtract assumed reuse. Unusable completed output requires reconciliation rather than an automatic repeat purchase.
After reviewing these counts, explicitly authorize possible repeat purchases:
reconciliation_required and cannot be
purchased again by retrying that token. A fresh preview and explicit confirmation
are required for any further purchase decision. Previous uncertain outcomes remain in the
audit record. Normal processing keeps its original cursor and seen rows.
Existing skip policy still applies to selected rows. Rows intentionally skipped
are not automatically included in a recovery selection. No prices are inferred
from the call ceiling; each connected provider’s billing terms still apply.
Recovery execution results include managed_recovery_outcome; a completed script
transport can still require reconciliation. Historical recovery has its own
outcome and does not mark the ordinary automation healthy, settle its repair,
change its normal failure streak, or replace its normal progress checkpoint.
Cancelling a historical recovery requests that its executor stop admitting more
work. The execution remains active until that executor acknowledges termination;
confirming again during that interval joins it instead of starting another run.
Any provider response already purchased is retained for a later writeback retry.
If termination cannot be established after a process failure, recovery reports
that reconciliation is required rather than purchasing the same result again.
Canonical lead updates
Canonical lead capture keeps first arrival separate from later writes. Its optional internalerdo_updated_at field is platform-owned and declared with the lead contract; caller timestamps cannot replace it. Existing incompatible definitions are refused without rewriting the contract. An ordinary dataset’s business column with this name keeps its ordinary meaning.
An update-aware action resolves canonical enrollment from the dataset owner when saved and refreshed. It advances a bounded timestamp-and-identity cursor independently of arrival progress, alternating the two streams when both have work. A large update backlog therefore cannot hold new leads behind it. Failed identities remain queued, and a failed shared window retains its original stream and bounds; unknown provider outcomes still require explicit reconciliation.
Changing on_update from skip to act starts future update processing at the current write cutoff. Editing an already update-aware action preserves its existing progress. Neither operation requests historical paid execution.
Canonical lead notifications use the same ownership check to keep the internal update cursor out of email answers and duplicate fingerprints. An ordinary business column with the same name remains visible. Re-ensuring an enrolled older lead contract adds the optional cursor under the contract transaction; a conflicting producer declaration leaves the older contract unchanged. Failed arrival drains retain their arrival ordering and cannot advance the independent update cursor, including when a previous execution exhausted its budget before reaching a fresh row.
Existing active declarations without an immutable receipt owner are initialized automatically in bounded, paginated startup work. Initialization does not run actions, clear failed keys, reset execution results, or advance an existing receipt boundary. A declaration with a still-running legacy execution remains uninitialized and visible in startup warnings; a subsequent startup retries it after that execution drains.
Recovery may retain a useful partial destination row while a later action remains incomplete. The result reports recovery_incomplete_rows and safe recovery_incomplete_steps (app, action, step and a fixed reason), separately from unknown-provider-outcome counters. An unrecovered action refusal or a partially exhausted invocation budget requires reconciliation; a partial destination write does not certify completion. Completion requires an explicit zero incomplete-row count. Successful declared fallbacks and the recipe’s legitimate no-input, no-match, policy and conditional skips retain their existing meaning.
Safe recovery step diagnostics may include an observed numeric http_status for the first provider failure. Later reconciliation refusals retain that status on the original recipe step; a successful declared fallback clears the recovered diagnostic. Recovery does not return raw provider errors or normal execution notes.
Both recovery-intent and ordinary completed caches must decrypt to a usable JSON object. A null, malformed or unreadable saved output refuses recovery without falling through to another receipt or purchasing a replacement.
A successful fallback clears incomplete steps only within its current action chain. A later independent lookup or its fallback cannot complete an earlier refused effect, even when the later lookup has no input or no connected app.
Durable lookup outcomes
Lookup declarations can opt into"outcome_contract": "lookup-v1". Map exactly
one destination column to "{{outcome}}"; the value is a JSON scalar containing:
completion is complete, incomplete, or not_run. answer is matched,
not_found, discarded, or none. These describe the final validated result and
its actual lookup trail. A successful fallback can recover an earlier provider
failure; an error in the winning step, including partial fan-out, remains
incomplete. An empty result after a failed or disconnected source is incomplete.
A searched no-match is complete only when the trail explicitly establishes it.
A discarded candidate is complete only when the searches completed without
errors. No search is not_run with answer none.
The outcome is staged as unresolved before the destination write. It resolves a
previous unresolved outcome only after the destination write succeeds and that
execution is durably completed. A failed write or cancellation preserves staged
lookup evidence without certifying that an accepted answer was saved.
A blank destination key also stages an incomplete outcome, even when no row can
be written. Completed paid work remains remembered, so this failure does not
automatically repeat a provider call. In counted historical recovery, a blank
destination key counts as one incomplete row and holds that chunk’s cursor.
Earlier completed chunks remain completed; recovery cannot report success or
continue past the unsaved row.
Quiet runs leave unresolved outcomes intact. Older declarations and historical
rows without this contract do not acquire invented completion evidence.
GET /v1/dataset-row-actions returns unresolved_lookup_rows independently of
last_execution. Add include_unresolved=true to return up to 100 safe summaries
in unresolved_lookups, with outcome_id, authorized lead_ref, completion,
answer, and observed_at. Provider payloads and raw errors are excluded. Existing
resource permissions apply. A summary can describe a completed lookup while its
destination write or terminal confirmation remains unresolved; completion alone
does not certify persistence. The durable unresolved count is the health signal.
An operator can record an audited disposition through the existing write endpoint:
{ "dataset": "my.leads", "name": "enrich", "action": "inventory_lookup" }.
This requires the current lookup-v1 declaration and a keyed destination. It reads
up to 200 distinct destination-key groups per call using an encrypted durable
cursor. Duplicate keys are unverified and are never imported. The response’s
lookup_inventory gives scanned, imported, unverified, and complete;
repeat until complete is true. Only corroborated source rows with an explicit
failed provider trail and provably empty mapped answer are imported as unresolved.
Missing trails, ambiguous mappings and historical success are unverified. Inventory
never marks a legacy row healthy or replaces an existing outcome, and invokes no
providers. An interrupted page resumes from the last committed cursor.
The existing maintenance pass also records an incomplete outcome when a verified provider receipt remains uncertain after its execution ends, including a crash during the provider call. It never repeats the provider call. Receipts from an older declaration or source binding cannot reopen current outcomes; a later accepted result or audited disposition keeps older uncertainty resolved.
