Skip to content

Tutorial: a first decision ​

Fifteen minutes with curl against the seeded tenant. You will create a subject, start the kyb-onboarding workflow, fill in the collection flow as the applicant, watch the case appear in the admin console, decide it, read the decision and the audit trail, then change one rule in the console and run again to see the outcome change from a manual review to an automatic reject.

Every command here is one the end-to-end smoke (scripts/smoke.sh) runs in CI, or an exact equivalent; the HTML comments in this page name the smoke step each section mirrors, and the smoke carries matching # tutorial: comments, so the two stay in sync (a docs test checks it). Ids and timestamps below come from a real run and will differ on your machine.

Before you start: the stack from Get started is running (pnpm dev), and you have curl and jq.

Two tokens, two roles ​

pnpm auth:seed wrote two personal access tokens under docker/zitadel/bootstrap/. The integration PAT is what your own backend would use: it can create subjects, start runs and read their status, but it cannot see cases, decisions or definitions. The admin PAT stands in for the people who work in the admin console. Load both:

bash
export API=http://localhost:4000
export INTEGRATION_PAT=$(tr -d '[:space:]' < docker/zitadel/bootstrap/smoke-integration.pat)
export ADMIN_PAT=$(tr -d '[:space:]' < docker/zitadel/bootstrap/smoke.pat)

1. Check who you are ​

bash
curl -s $API/me -H "authorization: Bearer $INTEGRATION_PAT" | jq .
json
{
  "principal": {
    "kind": "machine",
    "subject": "393158186116317189",
    "name": "Aletheia smoke test (integration role)"
  },
  "tenantId": "00000000-0000-0000-0000-000000000001",
  "orgId": "393156396775964677",
  "permissions": [
    "subjects:read",
    "subjects:write",
    "runs:read",
    "runs:start",
    "submissions:read",
    "submissions:write",
    "events:read",
    "events:write",
    "documents:write"
  ]
}

No cases:*, no definitions:*: the API will say 403 forbidden with the missing permission whenever this token oversteps, and you will see that happen twice below. Readiness, while you are here:

bash
curl -s $API/health/ready
# {"status":"ok","checks":{"db":"ok","temporal":"ok"}}

2. Create a subject ​

A subject is the merchant the decision is about. The seeded rules read subject.country, so give it one; the applicant will supply the rest.

bash
SUBJECT=$(curl -s -X POST $API/subjects \
  -H "authorization: Bearer $INTEGRATION_PAT" -H "content-type: application/json" \
  --data '{"kind":"merchant","data":{"country":"US"}}' | jq -r .id)
echo $SUBJECT

The response is the stored subject: kind: merchant, status: active, data: { country: "US" }, riskScore: null and a fresh id such as 31be0396-020b-4572-a3c8-247c47d45511.

3. Start kyb-onboarding ​

bash
RUN=$(curl -s -X POST $API/workflow-runs \
  -H "authorization: Bearer $INTEGRATION_PAT" -H "content-type: application/json" \
  --data "{\"definitionKey\":\"kyb-onboarding\",\"subjectId\":\"$SUBJECT\"}" | jq -r .id)
sleep 3
curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $INTEGRATION_PAT" | jq '{status, currentStepId, context}'
json
{
  "status": "waiting_collection",
  "currentStepId": "collect",
  "context": {
    "subject": { "country": "[redacted]" },
    "subjectId": "[redacted]",
    "submissionId": "[redacted]",
    "submissionUrl": "[redacted]"
  }
}

The worker picked the run up, ran its first step (collect, a wait_for_collection step on the kyb-basic flow), created a submission and is now waiting for the applicant. The context values are redacted for every role without runs:context (only admin has it): the context holds subject data, and whoever holds the link can act as the applicant. In practice your backend would hand the link to the applicant through its own channel; for the tutorial, read it with the admin token:

bash
LINK=$(curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $ADMIN_PAT" | jq -r .context.submissionUrl)
echo $LINK
# http://localhost:5177/flow/9dd10c0f-9dd2-566a-bdd3-2213a0761a19#token=eyJhbGciOiJIUzI1NiIs...

You can also watch the run in the Temporal UI (http://localhost:8233): the workflow is named wf:<tenantId>:<runId> and shows the signal it is waiting for.

4. Complete the collection flow ​

Open $LINK in a browser. The collection terminal opens on a welcome screen for the kyb-basic flow ("KYB basic"); Start, then fill in its three steps:

  1. Business details: Legal name Evil Corp, Country United States, Entity type Company, Website empty. Continue.
  2. Ultimate beneficial owner: Owner name Eve, Ownership % 100, Owner country United States. Continue.
  3. Check your answers: the last step, Review and confirm, under a summary of your answers. Tick "I confirm the details are accurate", leave the identity document empty, Send. A "Thank you" screen follows while the run continues, then "We're reviewing your application" once it waits for a reviewer.

Evil Corp matters: the seeded install of the mock-sanctions app is configured with blocklist: ["Evil Corp"], so this name produces a hit. Any other name would be approved without a case.

Without a browser, the same calls the terminal makes, authenticated with the applicant token that the link carries (PATCH saves the answers, POST .../submit sends them):

bash
SUBMISSION=$(curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $ADMIN_PAT" | jq -r .context.submissionId)
APPLICANT=$(node -e 'console.log(new URLSearchParams(new URL(process.argv[1]).hash.slice(1)).get("token"))' "$LINK")
curl -s -X PATCH $API/collection-submissions/$SUBMISSION \
  -H "authorization: Bearer $APPLICANT" -H "content-type: application/json" \
  --data '{"data":{"legalName":"Evil Corp","country":"US","entityType":"company","uboName":"Eve","uboOwnershipPct":100,"uboCountry":"US","confirm":true},"currentStepId":"review"}' > /dev/null
curl -s -X POST $API/collection-submissions/$SUBMISSION/submit -H "authorization: Bearer $APPLICANT" | jq '{status, submittedAt}'
# { "status": "submitted", "submittedAt": "2026-10-03T03:55:07.582Z" }

The applicant token is scoped to this one submission: with it, GET /subjects answers 403 and another submission's id answers 403 too.

5. Watch the case appear ​

Submitting sent the collectionSubmitted signal. The worker ran the remaining steps: screen (the mock-sanctions app), has_document (no document, so skip verification), rules (the kyb-onboarding rule set) and route, which sent the run to review because the rules said manual_review.

bash
sleep 3
curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $ADMIN_PAT" \
  | jq '{status, currentStepId, rules: (.context.rules | {outcome, riskScore, band, results: (.results | map({ruleKey, outcome, weight}))}), sanctions: .context.sanctions}'
json
{
  "status": "waiting_manual",
  "currentStepId": "review",
  "rules": {
    "outcome": "manual_review",
    "riskScore": 50,
    "band": { "max": 90, "min": 40, "outcome": "manual_review" },
    "results": [
      { "ruleKey": "country_allowed", "outcome": "pass", "weight": 100 },
      { "ruleKey": "submitted_country_allowed", "outcome": "pass", "weight": 50 },
      { "ruleKey": "country_blocklisted", "outcome": "pass", "weight": 100 },
      { "ruleKey": "sanctions_hit", "outcome": "fail", "weight": 50 },
      { "ruleKey": "high_ownership_foreign_ubo", "outcome": "pass", "weight": 30 },
      { "ruleKey": "identity_not_verified", "outcome": "pass", "weight": 40 }
    ]
  },
  "sanctions": { "hit": true, "matches": [{ "name": "Evil Corp", "score": 1 }] }
}

This is the run context from Concepts: the app's output under sanctions, the applicant's data under submission, the evaluation under rules. One rule failed; its weight of 50 is the risk score, and 50 falls in the set's manual_review band (40 to 90), so create_case opened a case.

Open the admin console (http://localhost:5176, admin@aletheia-dev.localhost / Password1!) and go to Operate › Cases. The case is in the Unassigned and High priority queues (kyb-onboarding opens its cases with priority: high); click it to see why it is there (the failed sanctions_hit rule), the subject and the run context with the applicant's answers. The same through the API, where the integration role is refused:

bash
curl -s "$API/cases?status=open" -H "authorization: Bearer $INTEGRATION_PAT"
# {"error":{"code":"forbidden","message":"forbidden","details":{"permission":"cases:read"},"requestId":"…"}}
CASE=$(curl -s "$API/cases?status=open&limit=500" -H "authorization: Bearer $ADMIN_PAT" \
  | jq -r ".items[] | select(.workflowRunId==\"$RUN\") | .id")
curl -s $API/cases/$CASE -H "authorization: Bearer $ADMIN_PAT" | jq '{case: (.case | {type, status, priority, slaState}), decisions, runStatus: .run.status}'
# { "case": { "type": "kyb_review", "status": "open", "priority": "high", "slaState": "none" }, "decisions": [], "runStatus": "waiting_manual" }

slaState is none because neither the step nor the seeded tenant sets an SLA; see Admin console: SLAs.

6. Decide the case ​

In the admin console, the case page offers Claim to decide while the case is unassigned; claim it, then Decide →: choose Reject, type a note and confirm with Reject (Request info would instead send the applicant the form again and wait for their answers). Through the API, the integration role cannot decide (cases:decide), the admin can:

bash
curl -s -X POST $API/cases/$CASE/decide -H "authorization: Bearer $INTEGRATION_PAT" \
  -H "content-type: application/json" --data '{"outcome":"reject"}'
# {"error":{"code":"forbidden","message":"forbidden","details":{"permission":"cases:decide"},"requestId":"…"}}
curl -s -X POST $API/cases/$CASE/decide -H "authorization: Bearer $ADMIN_PAT" \
  -H "content-type: application/json" \
  --data '{"outcome":"reject","reasons":[{"code":"manual","message":"tutorial"}]}' \
  | jq '{case: (.case | {status, decidedAt}), decision: (.decision | {outcome, source, reasons, decidedBy})}'
json
{
  "case": { "status": "decided", "decidedAt": "2026-10-03T03:55:57.494Z" },
  "decision": {
    "outcome": "reject",
    "source": "manual",
    "reasons": [{ "code": "manual", "message": "tutorial" }],
    "decidedBy": "393157404683927557"
  }
}

The decision is recorded with source: manual and the deciding user's id, and the run receives the manualDecision signal. Its last step, emit_decision, takes the manual decision over the rules, and the case closes with run_completed:

bash
sleep 3
curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $INTEGRATION_PAT" | jq '{status, currentStepId, decisionId, finishedAt}'
# { "status": "completed", "currentStepId": "decide", "decisionId": "06f866d4-...", "finishedAt": "2026-10-03T03:55:59.319Z" }

7. Read the decision and the audit trail ​

Decisions need cases:read, so again the admin token:

bash
curl -s "$API/decisions?subjectId=$SUBJECT" -H "authorization: Bearer $ADMIN_PAT" \
  | jq '.items | map({outcome, source, riskScore, ruleSetKey, reasons})'
# [ { "outcome": "reject", "source": "manual", "riskScore": null, "ruleSetKey": null, "reasons": [ { "code": "manual", "message": "tutorial" } ] } ]
curl -s "$API/app-invocations?workflowRunId=$RUN" -H "authorization: Bearer $ADMIN_PAT" \
  | jq -c '.items | map({appName, action, status, attempt})'
# [{"appName":"mock-sanctions","action":"screen","status":"success","attempt":1}, {...same...}]

Two invocations: the screen step called the app, and the sanctions_hit rule (an app rule) called it again during evaluation. Both carry an idempotency key and the attempt number. Then the trail itself, per resource:

bash
curl -s "$API/audit-events?resourceType=workflow_run&resourceId=$RUN" -H "authorization: Bearer $ADMIN_PAT" | jq '.items | map({occurredAt, actorType, action})'
curl -s "$API/audit-events?resourceType=case&resourceId=$CASE" -H "authorization: Bearer $ADMIN_PAT" | jq '.items | map({occurredAt, actorType, actorId, action})'
json
[
  { "occurredAt": "2026-10-03T03:55:08.306Z", "actorType": "workflow", "action": "workflow.rules.evaluated" },
  { "occurredAt": "2026-10-03T03:55:04.077Z", "actorType": "service", "action": "workflow.run.started" }
]
[
  { "occurredAt": "2026-10-03T03:55:59.089Z", "actorType": "workflow", "actorId": "950a0d94-...", "action": "case.closed" },
  { "occurredAt": "2026-10-03T03:55:57.543Z", "actorType": "service", "actorId": "393157404683927557", "action": "case.decided" },
  { "occurredAt": "2026-10-03T03:55:08.691Z", "actorType": "workflow", "actorId": "950a0d94-...", "action": "case.created" }
]

service is your PAT (a machine principal), workflow is the run itself. The admin console shows the same events under Audit › Trail and in the case page's History, and the subject's page merges everything into one timeline:

bash
curl -s "$API/subjects/$SUBJECT/timeline" -H "authorization: Bearer $ADMIN_PAT" | jq '.items | map(.title)'
json
[
  "Run completed",
  "Closed: run_completed",
  "Picked up",
  "Decided",
  "Decision: reject (manual)",
  "Case created",
  "workflow.rules.evaluated",
  "Submission submitted: kyb-basic",
  "Submission started: kyb-basic",
  "Run started: kyb-onboarding",
  "subject.created"
]

That is one decision, end to end: what the applicant said, what the vendor said, which rules fired with which weights, who decided and when.

8. Change one rule and run again ​

The policy routed Evil Corp to a person because sanctions_hit is a warn rule with weight 50. Make it decisive: severity block, weight 100, so a hit alone reaches the reject band (90 to 100) and no case is opened.

In the admin console: Define › Rules, open sanctions_hit, set Severity to block and Weight to 100, Save draft, then Publish →. The rule's version goes up by one and its status returns to published; History shows the diff. The same through the API: a PUT creates the draft version, publish makes it live, and the versions and diff routes show what changed.

bash
curl -s -X PUT $API/rule-definitions/sanctions_hit -H "authorization: Bearer $ADMIN_PAT" -H "content-type: application/json" \
  --data '{"name":"Sanctions hit","description":"Flags subjects that appear on the mock sanctions list.","type":"app","severity":"block","weight":100,"config":{"app":"mock-sanctions","action":"screen","inputMapping":{"name":"submission.legalName","country":"submission.country"},"resultPath":"hit","failWhen":true}}' \
  | jq -c '{version, status, severity, weight}'
# {"version":10,"status":"draft","severity":"block","weight":100}
curl -s "$API/rule-definitions/sanctions_hit/diff?from=9&to=10" -H "authorization: Bearer $ADMIN_PAT" | jq '.changes | map(.summary)'
# [ "rule \"sanctions_hit\" severity warn -> block", "rule \"sanctions_hit\" weight 50 -> 100", "rule \"sanctions_hit\" config.input added" ]
curl -s -X POST $API/rule-definitions/sanctions_hit/publish -H "authorization: Bearer $ADMIN_PAT" | jq -c '{version, status}'
# {"version":10,"status":"published"}

Your version numbers depend on how often the rule was saved before; use the version from the PUT response and the one before it in the diff query. Had the tenant required approvals, the publish would have answered 409 approval_required and the console would show Request approval → instead of Publish → (Admin console: approval settings).

Now repeat sections 2 to 4 with a new subject (new SUBJECT, RUN, LINK, SUBMISSION, APPLICANT) and the same Evil Corp answers, then look at the run:

bash
sleep 3
curl -s $API/workflow-runs/$RUN -H "authorization: Bearer $INTEGRATION_PAT" | jq '{status, currentStepId, decisionId}'
# { "status": "completed", "currentStepId": "decide", "decisionId": "c8827961-..." }
curl -s "$API/decisions?subjectId=$SUBJECT" -H "authorization: Bearer $ADMIN_PAT" | jq '.items | map({outcome, source, riskScore, ruleSetKey, ruleSetVersion})'
# [ { "outcome": "reject", "source": "automated", "riskScore": 100, "ruleSetKey": "kyb-onboarding", "ruleSetVersion": 10 } ]
curl -s "$API/cases?status=open&limit=500" -H "authorization: Bearer $ADMIN_PAT" | jq "[.items[] | select(.workflowRunId==\"$RUN\")] | length"
# 0

No pause, no case: the run went collect, screen, rules, route, decide and completed on its own with source: automated, a risk score of 100 and the rule set version that produced it. The decision records the rule versions too, so a reviewer later can tell which policy applied.

9. Put the rule back ​

The smoke test and the other seeded workflows expect the seeded weights. Publish one more version with severity: warn and weight: 50 (the same PUT and publish as above with the original values), or run pnpm db:seed, which notices the published version differs from the seed and publishes the seeded one again.

What you have seen, and where next ​

  • A run is a Temporal workflow that pauses on signals; its context is the single source of truth for rules, branches and app inputs (Concepts).
  • Permissions follow roles: the integration token could start the work but not look at cases or decisions, the applicant token could touch one submission only.
  • A policy change is a new published version with a diff and an audit event, and it takes effect on the next run.

Next: write your own rules and workflows with the rule reference and the admin console; embed the collection flow in your own page (embedding); or wire a real vendor as an app. To take the platform beyond your laptop, follow Run a pilot.

Released under the Apache-2.0 License.