Skip to main content
Version: 1.0.0

External effects and bounded idempotency

Deduplicating an incoming event does not prove that an external write happened once. A service may apply a write before the handler loses its response. Touba distinguishes event_id, run_id, a per-attempt call_id, and the stable effect_id of the logical business write.

Declare the external service contract​

A touba-manifest-v1 binding declares effect_contract. Every binding in that manifest must specify either kind: none or kind: external_write with an explicit retry mode. Missing contracts and retired formats are refused. The generator leaves a non-executable null until the developer reviews the actual handler behavior.

{
"kind": "external_write",
"retry_mode": "idempotency_key"
}

This is the effect_contract field, not a complete manifest. Existing handler, grant, timeout and payload-limit fields remain required as applicable.

Retry modeUnknown external outcome
idempotency_keyRetry the same request with the same effect identity. The handler must actually pass the key to an idempotent service.
reconcileStop with EffectReconciliationRequired until explicit reconciliation.
noneStop with EffectOutcomeUnknown; retry is not presented as safe.

An external-write binding uses touba-tool-handler-v1. The SDK exposes context.effect.effect_id and context.createEffectReceipt(...); the receipt binds the outcome to the request identity and digest. Outcomes are applied, not_applied and unknown. These helpers do not make the external service idempotent.

Use a durable host​

The CLI, SDK, project server and trigger service connect execution to a durable effect journal. Direct CLI runs require --effect-journal <path.effect-journal.sqlite>; ToubaEngine.run accepts the same path through effectJournal. A fresh run gets a distinct scope; a human-checkpoint resume retains its run identity. Rerunning a command is not an idempotent continuation. The CLI holds an exclusive <journal>.lock; a crashed lease requires host inspection before removal. Do not share this journal with another host. The journal alone is not a continuation checkpoint. For the trigger service:

touba trigger serve program.air.json \
--manifest touba.manifest.json \
--state touba.triggers.sqlite \
--queue touba.trigger-queue.sqlite \
--effect-journal touba.effect-journal.sqlite

The journal records prepared, dispatched and the observed outcome. After an interruption, a surviving dispatched state becomes unknown. An applied effect with a restorable result is read without redispatch; without that result, Touba refuses with EffectResultUnavailable. Reusing an identity with different content produces EffectRequestConflict before the handler.

For webhooks, provide a stable x-touba-event-id. Identical pending deliveries reuse the queue entry; a changed payload under that identity is refused with TriggerEventPayloadConflict. A consumed event is refused with TriggerReplayRejected. Without an explicit event identity, each delivery is a new event.

Operating boundary​

The provided journal is single-host, single-process storage. Do not share its file across processes or machines. It is not a distributed transaction or a universal exactly-once guarantee. An external service remains responsible for its own persistence and idempotency. Handlers remain host code to audit.

See Durable resume and Explicit compensation.

Handler error and effect outcome​

A business error retains its public code even when its receipt is missing. The separate receipt_error_code diagnostic describes the missing or invalid receipt. The effect outcome stays unknown until a valid receipt or reconciliation establishes it.

After a confirmed rollback, a Python handler can report:

from touba_sdk import ToubaHandlerError, create_effect_receipt

raise ToubaHandlerError(
"ExternalWriteRefused",
"The database refused the transaction.",
{"effect_receipt": create_effect_receipt(
context["effect"], {"outcome": "not_applied"}
)},
)

JavaScript equivalent, after a confirmed rollback:

throw new ToubaHandlerError("ExternalWriteRefused", "The database refused the transaction.", {
effect_receipt: context.createEffectReceipt({ outcome: "not_applied" }),
});

Use unknown if the commit might have succeeded. Keep connection details, SQL queries and private data out of the public message.