Auth guard: the Kronos-fired routes (execute, status_check) require the callback-service actor (actor.require_callback_service()). The two dashboard read routes (executions list + get) use require_self_or_trusted_backend — the user themselves or a trusted backend / admin.

Overview

Mandate execution is the runtime side of the mandate lifecycle — where a registered mandate (from the Mandate Module) is actually charged. The mandate must be in the LIVE state. Two Kronos-fired routes drive it:
  1. POST /mandates/{mandate_id}/execute — Kronos fires this once per autopay cycle. Each firing claims a mandate_executions row (idempotent on the X-Mandate-Execution-Id header), creates a linked funding order, calls Juspay’s /txns to debit, and stamps the txn status.
  2. POST /mandates/{mandate_id}/execution/{execution_id}/status_check — Kronos fires this +27h after a non-terminal debit to reconcile the txn status, then reschedules every 15min up to status_check.max_attempts (default 6). If still non-terminal when the Kronos window (ends_at) expires, the row sits in its last non-terminal status for operator inspection — no forced terminal state.
There is no per-firing state in the request body. Each firing is identified by the X-Mandate-Execution-Id header (templated by Kronos via {{execution.execution_id}}), and the attempt cadence is implicit in Kronos’s cron schedule — no DB column tracks attempts. Two dashboard read routes (bearer auth, not Kronos) surface those rows: GET …/executions lists a mandate’s attempts (each joined to its order’s Juspay external_order_status), and GET …/executions/{execution_id} returns one by id (carrying the linked order_id). Both fail closed — the mandate must belong to user_id, and the execution to that mandate — so a guessed id returns 404, not another user’s row. A cross-user admin list also exists at GET /admin/mandate_executions.

Endpoints

POST /mandates/{mandate_id}/execute

Run a single autopay debit. Returns 201 on first claim, 200 on idempotent replay. Callback-service auth.

POST /mandates/{mandate_id}/execution/{execution_id}/status_check

Reconcile a single execution row against Juspay. Callback-service auth.

GET /users/{user_id}/mandates/{mandate_id}/executions

Dashboard list of a mandate’s execution attempts, each joined to its order’s Juspay external_order_status. Bearer auth.

GET /users/{user_id}/mandates/{mandate_id}/executions/{execution_id}

Single execution by id, carrying the linked order_id. Fails closed on cross-ownership. Bearer auth.

GET /admin/mandate_executions

Cross-user, paginated list for the admin dashboard. Admin / readonly-admin auth.

Lifecycle

If Juspay reports a still-non-terminal status on a reconciliation firing, the next Kronos firing (15min later) re-checks, up to status_check.max_attempts firings bounded by the cron window.

Two-layer idempotency

Kronos retries on transient failures, so duplicate firings are expected. Two layers prevent double-debits:
  1. DB claimINSERT ... ON CONFLICT (job_execution_id) DO NOTHING RETURNING *. Exactly one concurrent caller wins; the loser reads the winner’s row and returns it. The conflict key is job_execution_id (the value from the X-Mandate-Execution-Id header).
  2. Juspay-side dedup — Juspay deduplicates on order_id. We pass order_id = the linked orders-ledger row id (orders.id), stable across retries, so a firing-retry that somehow slips past the DB claim still doesn’t double-charge. A resumed Initiated firing reuses its existing linked order (no duplicate order).
The status_check route adds a third guard: a terminal-state fast-path. If the row’s status is already terminal (Success | Failed), the handler returns the row as-is without hitting Juspay or rescheduling.

Status vocabulary

The execution row’s status is the internal firing lifecycle (common_enums::MandateExecutionStatus) — repo-owned vocabulary, not the Juspay status. The Juspay-side status lives on the linked funding order (orders.external_status, reached via mandate_executions.order_id), so the execution row never stores provider strings. The mapping from the provider’s ExternalOrderStatus to this enum is From<&ExternalOrderStatus>: Charged → Success; Failed and the various failure states (AuthenticationFailed, AuthorizationFailed, JuspayDeclined, AutoRefunded) → Failed; and any non-terminal Juspay state (PendingVbv, Authorized, Authorizing, Other(_), …) → Pending. The wire value is SCREAMING_SNAKE_CASE (INITIATED / PENDING / SUCCESS / FAILED).

Debit amount

The amount Aarokya charges per firing is the user’s share of the daily premium on their single Issued insurance policy:
sponsor_contribution is resolved from Superposition (insurance.sponsor_contribution, keyed by plan_code / benefit_id / purchase date; default 0). The subtraction is exact MinorUnit (paise) math, floored at zero. If the computed user share is zero (sponsor covers the full premium), execute_mandate fails with MXE_1608 (ZeroDebitAmount). If the user has zero or multiple Issued policies, execute_mandate fails with MXE_1609 (NoIssuedInsurancePolicyForUser) or MXE_1610 (AmbiguousIssuedInsurancePolicies) respectively — there’s no policy linkage on the mandate row today.

Error responses


Configuration

The [mandate_execution] TOML block has been retired — every runtime knob is served by Superposition (static fallback in backend/config/fallback.superposition.toml), so values change from the admin UI without a redeploy.
The in-repo fallback.superposition.toml carries smaller placeholder values for the delay/interval keys; the production defaults are the Superposition code defaults listed above. Kronos tenant identity (kronos.org_id, kronos.workspace_id) is also Superposition-served; the Kronos base_url and secret api_key stay in TOML.