Skip to main content

Runs & the Verdict Matrix

Trigger idempotent app runs with dry-run support, then read the projection, per-subject verdict matrix, camelCase summary, journal, and sealed decision record.

POST /v1/apps/:id/runs triggers a run at the run tier. For rules and assisted apps the engine executes synchronously inside the call — assemble the frozen input package from the version's bindings, evaluate the cards, enforce thresholds, execute any actions through the outbox — and the 200 response already carries the terminal status (completed or failed), the decision, and decided_by. For external apps the response returns immediately with status: "awaiting_decision": the run parks until the external agent submits a decision or the decision SLA elapses and the app's fallback policy (rules, hold, or none) takes over.

Runs are idempotent per trigger: pass an idempotency key in the body (idempotency_key, max 64 chars) or the Idempotency-Key header, and a duplicate trigger returns the existing run instead of starting a new one — same shape, same run_id. Give every machine-initiated trigger a key derived from the upstream event. dry_run (body boolean or ?dry_run=true) executes the full engine with a complete trace but zero side effects: no actions, no webhooks from actions, no charges against live thresholds — and dry runs are allowed even while the app is disabled, which makes them the safe test vehicle.

A disabled app answers 409 to live runs but accepts dry runs. Terminal controller-path runs fire app.run.completed / app.run.failed webhooks; a run held for review or parked awaiting_decision fires neither until it later reaches a terminal state.
POST/v1/apps/:id/runs

Body & query

idempotency_keystringMax 64 chars. Duplicate (app, key) triggers return the existing run. The Idempotency-Key header is an equivalent alternative; the body field wins when both are present.
dry_runbooleanFull engine, zero side effects. Also accepted as ?dry_run=true.
inputobjectReserved by the manifest input_schema; run inputs are assembled from the version's input bindings.

curl

curl -s -X POST https://api.talonic.com/v1/apps/$APP_ID/runs \
  -H "Authorization: Bearer tlnc_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: erp-event-83621" \
  -d '{}'

Response (200) — rules app, synchronous decision

{
  "run_id": "66c905a7-d67a-45be-a161-de11695ff564",
  "app": "load-auto-billing@v1",
  "status": "completed",
  "mode": "rules",
  "dry_run": false,
  "trigger": {
    "type": "api",
    "actor": { "id": "22e9f8d8-ae2b-47f9-a1fc-d29cf8440271", "type": "api_key", "label": "API client" }
  },
  "decision": {
    "outcome": {
      "decision": "hold",
      "card_results": [
        { "cardId": "quantity-tolerance", "status": "indeterminate",
          "reason": "no records for subject 'loads' in the input package",
          "evidence": [], "revision": 1 }
      ]
    },
    "evidence": [],
    "rationale": "Held pending review: card(s) quantity-tolerance could not be decided deterministically.",
    "confidence": 1
  },
  "decided_by": { "id": "load-auto-billing@v1", "type": "rules" },
  "thresholds_applied": [],
  "reviews": [],
  "created_at": "2026-08-29T11:31:59.140Z",
  "completed_at": "2026-08-29T11:31:59.155Z"
}

// external app: the run parks instead
// { "run_id": "021cbd72-...", "status": "awaiting_decision", "mode": "external",
//   "decision": null, "decided_by": null, "completed_at": null, ... }

GET /v1/apps/:id/runs?limit=N lists recent runs newest-first (default 50, cap 200) in the same shape. GET /v1/runs/:runId returns the full projection for one run — everything the trigger response shows plus the frozen input_package (bindings with pinned snapshots and materialized records, each value carrying its provenance locator), precedents_applied, error, and credit_cost. The projection is the queryable state; the audit artifact is the sealed record below.

The verdict matrix

Apps whose decision_policy is batch_verdicts decide every subject group separately — one verdict row per load, invoice, or case — and GET /v1/runs/:runId/verdicts is the results matrix: a SQL-aggregated summary plus a filterable, cursor-paginated page of per-subject rows. Each row carries subject_key, a readable subject_label with the anchoring subject_record_id (resolved fail-soft; null when unavailable), the rolled_outcome (worst across the subject's cards: failed > indeterminate > passed > not_applicable), the auto flag, substantive_passes, and the per-card rule_outcomes map.

GET/v1/runs/:runId/verdicts

Query parameters

rolledstringFilter by rolled outcome: passed, failed, indeterminate, or not_applicable (422 otherwise).
autobooleantrue → only auto-executable subjects; false → only subjects needing attention.
rulestringCard id to filter by, paired with status. Providing one of rule/status without the other is a 422.
statusstringThe outcome the named rule must have for the subject (same vocabulary as rolled).
limitintegerRows per page, 1-200. Default: 50
cursorstringCursor from the previous page's next field.

Response — verdict matrix page

{
  "summary": {
    "total": 214,
    "auto": 187,
    "by_outcome": { "passed": 187, "failed": 19, "indeterminate": 8, "not_applicable": 0 },
    "by_rule": {
      "rate-card-match": { "passed": 195, "failed": 19, "indeterminate": 0, "not_applicable": 0 },
      "quantity-tolerance": { "passed": 206, "failed": 0, "indeterminate": 8, "not_applicable": 0 }
    },
    "top_blockers": [ { "cardId": "rate-card-match", "failed": 19 } ]
  },
  "verdicts": [
    {
      "subject_key": "L-2026-0851",
      "subject_label": "Load L-2026-0851 — ACME Freight",
      "subject_record_id": "pd_9c2f41ab",
      "rolled_outcome": "failed",
      "auto": false,
      "substantive_passes": 1,
      "rule_outcomes": {
        "rate-card-match": { "status": "failed", "reason": "billed 1249.90 vs rate card 1180.00 (5.9% > 2%)",
          "evidence": ["dp:dp_loads_v2@v3:row_8851:total_amount", "ref:ref_rate_cards:1182:rate"] },
        "quantity-tolerance": { "status": "passed" }
      }
    }
  ],
  "next": "eyJzdWJqZWN0IjoiTC0yMDI2LTA4NTEifQ"
}

GET /v1/runs/:runId/verdicts/summary is the value surface — the few headline numbers a person reads, aggregated in SQL, working for every decision policy (a single-decision run reports its one verdict and zero cells). Note its response is camelCase (runId, verdictCounts, actionCounts, notApplicableCells, inconclusiveCells, adjudication, metrics) — it is one contract with the app surface that renders it, unlike the snake_case bodies elsewhere. metrics folds the manifest's display.metrics over the run's frozen package, so the headline numbers speak the app's own vocabulary.

Response — GET /v1/runs/:runId/verdicts/summary

{
  "runId": "66c905a7-d67a-45be-a161-de11695ff564",
  "versionId": "af2f1dc5-74c4-4b49-830b-5fe8b92c69d3",
  "subjectLabel": null,
  "sealed": true,
  "verdictCounts": { "approve": 0, "hold": 1 },
  "actionCounts": {},
  "notApplicableCells": 0,
  "inconclusiveCells": 0,
  "adjudication": { "used": 0, "budget": 200 },
  "metrics": []
}

Journal and sealed record

GET /v1/runs/:runId/events returns the append-only journal: one typed event per step (triggered, assembled, decision_submitted, review_raised, sealed, …), each carrying its actor, timestamp, and details. GET /v1/runs/:runId/record returns the sealed decision record — minted exactly once at terminal state, immutable thereafter, shape-versioned via record_shape_version: trigger, decided_by, decision with card results, evidence locators, rationale, thresholds applied, reviews, precedents applied, and timing. Exporting the record writes an app.record_exported audit event. Both survive app deletion by design.

Frequently asked questions

Why did GET /v1/runs/:runId/verdicts return 404 for a completed run?+
The run produced no verdict rows. Single-decision runs (decision_policy "single", the default) never have any — read the decision from the run projection or /verdicts/summary instead. A batch_verdicts run also 404s here until it has actually decided.
How does idempotency behave across retries?+
Uniqueness is enforced on (workspace, app, idempotency_key): a retried trigger with the same key returns the already-existing run, whatever its state, with the same run_id. Charges are keyed on the run id, so retries never double-charge. Use a key derived from the upstream business event, not a random UUID per attempt.
What exactly does dry_run skip?+
Side effects only. The engine assembles the real frozen input package, evaluates every card, applies thresholds, and writes the full journal and sealed record — but nothing is enqueued at the outbox: no actions execute and no delivery webhooks fire. Dry runs also work on disabled apps.
What is the difference between rolled_outcome and auto?+
rolled_outcome is the worst outcome across the subject's card results. auto additionally requires that enough substantive rules genuinely passed (min_substantive_passes): a subject whose only passes are vacuous — nothing on either side of a comparison — rolls up passed but stays auto: false, so emptiness never auto-executes.
Why is /verdicts/summary camelCase when everything else is snake_case?+
It is one shared contract with the run surface that renders it (the operating strip and verdict matrix), kept camelCase end to end — the documented exception on this surface, alongside the verdict-diff request/response. Treat both as their own contracts rather than assuming the global convention.