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.
/v1/apps/:id/runsBody & query
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.
/v1/runs/:runId/verdictsQuery parameters
50Response — 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.