9. Connectors and exactly-once posting
9.1 Connector kinds
kind | Configuration | Behaviour |
|---|---|---|
accounting_stub | — | Development stand-in. Actions post_bill (returns external_id: BILL-<key> and echoes the payload) and get_vendor. |
webhook | options.url, options.timeout_sec, credentials.hmac_secret | POSTs { tenant, action, payload } with Idempotency-Key: <key> and X-Signature: sha256=<hex HMAC-SHA256 of the body>. Retries HTTP 429, 5xx and network errors up to 5 times. Only actions declared in the file can be called. |
A receiver verifies the signature by computing HMAC-SHA256 over the raw request body with the
shared secret. bo_integrations::verify is a reference implementation.
9.2 How exactly-once works
- Before calling out,
connector.invokereserves the idempotency key inidempotency_keyswith a single atomic insert. Only one caller can win the reservation. - Whoever wins calls the connector, passing the key along so the receiver can deduplicate too.
- On success, the response is stored with the key. Any later attempt (a replayed decision, a second driver, a retry) gets the stored response and does not call out.
- On failure, the reservation is released so a later attempt may try again.
- A reservation that is still unfinished after the step's
task_timeout_secmust belong to a call that was interrupted, for example by a crash between calling out and recording the response. It is never retried automatically. The run becomesneeds_reconciliationand the audit trail recordsrun.needs_reconciliation.
Two drivers racing on one run is safe: one posts, the other gets a conflict, and the run is left alone.
9.3 Reconciling an interrupted post
Find the parked runs:
SELECT session_id, workflow_id, updated_at FROM workflow_runs WHERE status = 'needs_reconciliation';
Then check the accounting system for that run's key (<thread>:post_bill, also sent to the
receiver as Idempotency-Key):
-
It was posted. Record the response, so the run treats the post as done:
UPDATE idempotency_keys SET response = '{"external_id": "<id from the accounting system>"}'WHERE key = '<tenant id>:<thread>:post_bill'; -
It was not posted. Release the key so the post can be made:
DELETE FROM idempotency_keys WHERE key = '<tenant id>:<thread>:post_bill' AND response IS NULL;
Then continue the run by sending an AG-UI request for its thread with no resume
(§11.2). The decision is already recorded, so the run continues from the post step.