Auth guard: callback-service identity (the static Keycloak kronos-callbacks client), enforced via actor.require_callback_service(). Admin tokens are also accepted for manual operator replays during incidents — same convention as every other role-specific gate (require_platform, require_benefit_provider, …).

Overview

Mandate execution is the runtime side of the mandate lifecycle — where a registered mandate (from the Mandate Module) is actually charged. Two routes:
  1. POST /mandate/{mandate_id}/execute — Kronos fires this once per autopay cycle. Each firing claims a mandate_executions row (idempotent on x-kronos-idempotency-key), calls Juspay’s /txns to debit, and stamps the txn status.
  2. POST /mandate/{mandate_id}/execution/{execution_id}/status_check — Kronos fires this +27h after a non-terminal debit to reconcile the txn status. If still non-terminal, it reschedules itself every 15min up to 6 attempts before giving up with PaymentStatus::Other("status_unknown").
State for the reconciliation retry counter lives in the Kronos firing’s payload — no DB columns track attempts. Each firing is self-describing, and Kronos’s idempotency key per (execution_id, attempt) pair prevents duplicate scheduling. 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.

Endpoints

POST /mandate/{mandate_id}/execute

Run a single autopay debit. Returns 201 on first claim, 200 on idempotent replay.

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

Reconcile a single execution row against Juspay. Body carries attempt.

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.

Lifecycle

If Juspay reports a still-non-terminal status on the +27h check, Aarokya schedules a +15min retry with attempt = 2, and so on up to status_check_max_attempts (default 6).

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.
  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 order_id (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.
StatusMeaningTerminal?
InitiatedFiring claimed; charge not yet confirmed (re-drivable on retry)No
PendingCharge dispatched to Juspay; reconciliation cron owns settling itNo
SuccessDebit settled (/orders returned Charged)Yes
FailedDebit declined, or the order never reached Juspay (/orders 404)Yes
The mapping from the provider’s ExternalOrderStatus to this enum is From<&ExternalOrderStatus>: Charged → Success, Failed → Failed, and any non-terminal Juspay state (PendingVbv, Other(_)) → Pending. The wire value is snake-case (initiated / pending / success / failed).

Trust subsidy

The amount Aarokya charges per firing is computed from the user’s single Issued insurance policy:
debit = policy.premium_amounts.daily × (10_000 - trust_contribution_bps) / 10_000
trust_contribution_bps is a basis-points config knob (0..=10_000). Default 5000 (50%). Integer division rounds down so the user always pays slightly less than the exact mathematical share when there’s a remainder. If the user has zero or multiple Issued policies, execute_mandate fails with a 400 — there’s no policy linkage on the mandate row today.

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.
Superposition keyDefaultMeaning
mandate_execution.autopay.endpoint_nameaarokya-execute-mandateKronos-registered endpoint for autopay firings
mandate_execution.status_check.endpoint_nameaarokya-mandate-execution-status-checkKronos endpoint for the reconciliation CRON
mandate_execution.autopay.initial_delay_secs3600Offset from now for the first autopay firing
mandate_execution.autopay.interval_minutes_override00 → daily-IST baseline; n > 0 → fire every n minutes (*/n * * * *)
mandate_execution.status_check.initial_delay_secs9720027h — first reconciliation poll
mandate_execution.status_check.retry_interval_secs90015min between reconciliation polls
mandate_execution.status_check.max_attempts6Caps total reconciliation firings
Kronos tenant identity (kronos.org_id, kronos.workspace_id) is also Superposition-served; the Kronos base_url and secret api_key stay in TOML.