Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.26.1.
If you are evaluating Registry Casework as the
coordinated inbox for a team that decides professional licence renewals, start with one request and
one decision.
You will generate a Casework project, run it on your machine, submit a renewal as a Requester, claim
and decide it as Staff, read the outcome as the Requester, read the accountability record as a
Supervisor, and try requests Casework refuses.
Everything you keep goes into one directory, tutorial-work.
Install Registry Casework
Section titled “Install Registry Casework”Install casework, caseworkctl, and mint.
Registry Mint issues the local access tokens this
tutorial uses, standing in for the identity provider a deployment would have:
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bashcaseworkctl --versionmint --versionThe installer checks the three binaries against the release SHA256SUMS before anything reaches
~/.local/bin, and installs them together or not at all.
Keep that directory on your PATH.
The URL takes the latest release, and a deployment pins a version instead.
The command pipes a script from GitHub into bash; | less in place of | bash reads it first.
Verify the release as described in OpenSSF and release trust,
then rerun the installer with CASEWORK_ASSET_DIR pointing at the verified directory.
Create a project
Section titled “Create a project”Open a terminal in a directory of your choice and generate a project.
Keep this terminal in the same directory for the rest of the tutorial; every path starts with
tutorial-work/.
mkdir -p tutorial-workcaseworkctl --format json init tutorial-work/casework --template standalone-decision{ "command": "init", "created": [ "casework.yaml", "runtime.example.yaml", "dev-clients.yaml", "fixtures/standalone-decision.yaml", "sources/", ".casework/schemas/runtime.schema.json", ".vscode/settings.json" ], "next": [ "Run caseworkctl check and test, then caseworkctl dev to start a local Casework runtime, its database and its token issuer, with the directory in dev-clients.yaml already seeded." ], "ok": true, "project": "tutorial-work/casework", "template": "standalone-decision"}What init wrote
Section titled “What init wrote”The standalone-decision template declares a Casework deployment that owns the work it coordinates,
with no source system behind it:
casework.yaml: the access profiles, thedecisionsqueue, and one hosted kind nameddecisiondev-clients.yaml: the local callers, one per profile, and the team that serves the queueruntime.example.yaml: an operator’s runtime configuration, for a deployment rather than this tutorialfixtures/standalone-decision.yaml: the requestscaseworkctl testreplayssources/: where a source descriptor goes when Casework presents work a registry owns.casework/schemas/runtime.schema.jsonand.vscode/settings.json: editor validation for the runtime example
The decision kind carries a display schema of summary and reference, two outcomes, confirmed
and rejected, and the single profile allowed to decide it, staff.
rejected requires a reason and confirmed does not, because the template says so; the licence
renewal in this tutorial is the example you supply, not a built-in type.
The four profiles
Section titled “The four profiles”Casework separates authority by profile rather than by person.
A Requester submits work and reads its own outcomes, Staff claims and decides work in a served
queue, a Supervisor reads accountability records over decisions already made, and an Administrator
maintains the directory.
Each profile in casework.yaml names the token scopes it requires, and dev-clients.yaml gives
each one a local client.
Start Casework
Section titled “Start Casework”The runtime stores its records in a Docker volume on your machine, and the private keys it generates
live in tutorial-work/casework/.casework/dev/, an owner-only directory that stays out of version
control.
Nothing here is configured for anyone else’s data.
Start the project and keep its report:
caseworkctl --format json dev tutorial-work/casework | tee tutorial-work/dev-report.jsonThe first start downloads the pinned PostgreSQL image, so it takes longer than the later ones.
The command returns once Casework answers, with "status": "ready" in its report.
dev started PostgreSQL in a container, registered the four clients from dev-clients.yaml with
Mint under a fresh key each, wrote an operator file, migrated the database, started casework, and
seeded the directory from the same clients file, which the
report confirms as "directory": {"revision": 1, "teams": 1}.
The report also names the Casework address, the Mint token endpoint, the audience, the runtime journal, and the credential file paths for each client. The services keep running after the command returns, so this one terminal is enough.
Take the two addresses and the credential directory from the report:
casework_url=$(python3 -c 'import json; print(json.load(open("tutorial-work/dev-report.json"))["caseworkUrl"])')token_endpoint=$(python3 -c 'import json; print(json.load(open("tutorial-work/dev-report.json"))["tokenEndpoint"])')credentials=tutorial-work/casework/.casework/dev/credentialsReading the addresses from the report rather than typing them keeps the rest of the tutorial correct
on a machine where the default ports were taken and dev used others.
Submit a request as the Requester
Section titled “Submit a request as the Requester”Ask Mint for a token as the requester client and write it into a file curl can send as an
authorization header:
umask 077mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/requester/client-id")" \ --key "$credentials/requester/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.headerSuccess prints nothing.
mint token signs a request with the client’s private key and posts it to the token endpoint, the
way an application would; the token carries the casework:request scope and lasts five minutes.
tutorial-work/requester.header and the other header files written later each hold a bearer token
that acts as that client until it expires.
umask 077 makes them readable only by your user.
Keep them out of version control, screenshots, and support messages.
Submit the renewal:
curl --silent --show-error \ --header @tutorial-work/requester.header \ --header 'Registry-Casework-Profile: requester' \ --header 'Idempotency-Key: tutorial-renewal-1' \ --header 'Content-Type: application/json' \ --data '{"kind":"decision","requesterReference":"LIC-2026-0041","display":{"reference":"LIC-2026-0041","summary":"Renewal decision for licence LIC-2026-0041"}}' \ --output tutorial-work/requested.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/hosted-items"HTTP 201Every Casework request carries two things beyond the token: Registry-Casework-Profile names the
profile the caller acts under for this request, and a write carries Idempotency-Key so a retry
after a lost response returns the first result instead of creating a second item.
Read the response:
python3 -m json.tool tutorial-work/requested.json{ "itemId": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b", "requesterReference": "LIC-2026-0041", "kind": "decision", "version": "1", "display": { "reference": "LIC-2026-0041", "summary": "Renewal decision for licence LIC-2026-0041" }, "state": "open", "revision": 1, "kindPolicyDigest": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "createdAt": "2026-09-11T16:16:38.272286Z", "updatedAt": "2026-09-11T16:16:38.272286Z"}Casework generates itemId as a random identifier, so yours differs from this one.
requesterReference is the licence reference you chose, and it is how the Requester recognises its
own request later.
kindPolicyDigest pins the exact decision policy this
hosted item was created under, so a later edit to
casework.yaml cannot change the terms a decision was made on.
Open the inbox as Staff
Section titled “Open the inbox as Staff”Mint a second token, this time as the staff client:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/staff/client-id")" \ --key "$credentials/staff/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.headerList the work items the Staff client’s teams serve:
curl --silent --show-error \ --header @tutorial-work/staff.header \ --header 'Registry-Casework-Profile: staff' \ --output tutorial-work/inbox.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions&limit=25"HTTP 200Read the inbox:
python3 -m json.tool tutorial-work/inbox.json{ "items": [ { "itemId": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b", "subject": { "sourceId": "casework:hosted", "kind": "decision", "id": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b" }, "occurrenceKind": "hosted", "binding": { "sourceRevision": "1", "version": "1", "integrity": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "generation": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139" }, "bindingReference": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "state": "open", "queueId": "decisions", "revision": 1, "firstObservedAt": "2026-09-11T16:16:38.272286Z", "updatedAt": "2026-09-11T16:16:38.272286Z", "hosted": { "requesterReference": "LIC-2026-0041", "kind": "decision", "version": "1", "display": { "reference": "LIC-2026-0041", "summary": "Renewal decision for licence LIC-2026-0041" }, "kindPolicyDigest": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "outcomes": [ { "id": "confirmed", "label": "Confirm", "reasonRequired": false }, { "id": "rejected", "label": "Return for correction", "reasonRequired": true } ] }, "actions": [ { "operation": "claim", "href": "/v1/work-items/8567158b-6ea5-49c4-ba9f-74ab7f01d83b/claim", "ifMatch": "\"1\"" } ] } ], "status": "complete", "servedQueues": [ "decisions" ]}servedQueues is the answer to “what may this caller work on”: the directory entry seeded from
dev-clients.yaml puts the Staff client on the team that serves decisions, and view=my_teams
lists exactly that.
actions is the other half: Casework offers the operations this caller may take on this item right
now, each with the href to send it to and the ifMatch value that version of the item answers to.
An empty actions array means the item is visible and no operation is open to you.
Claim the item
Section titled “Claim the item”A claim makes one Staff member the holder, so two people do not decide the same item twice. Renew the Staff token before you act, so time spent reading the inbox cannot turn this request into an authentication refusal:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/staff/client-id")" \ --key "$credentials/staff/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.headerTake the identifier and the offered ifMatch value from the inbox rather than typing them:
item_id=$(python3 -c 'import json; print(json.load(open("tutorial-work/inbox.json"))["items"][0]["itemId"])')claim_match=$(python3 -c 'import json; actions = json.load(open("tutorial-work/inbox.json"))["items"][0]["actions"]; print(next(action["ifMatch"] for action in actions if action["operation"] == "claim"))')Claim it:
curl --silent --show-error \ --header @tutorial-work/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $claim_match" \ --header 'Idempotency-Key: tutorial-claim-1' \ --header 'Content-Type: application/json' \ --data '{}' \ --output tutorial-work/claimed.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items/$item_id/claim"HTTP 200Read the part of the response that changed:
python3 -c 'import json; item = json.load(open("tutorial-work/claimed.json"))["item"]; print(json.dumps({key: item[key] for key in ("state", "revision", "holder", "actions")}, indent=2))'{ "state": "claimed", "revision": 2, "holder": { "issuer": "http://127.0.0.1:8093", "subject": "urn:casework:dev:staff" }, "actions": [ { "operation": "release", "href": "/v1/work-items/8567158b-6ea5-49c4-ba9f-74ab7f01d83b/release", "ifMatch": "\"2\"" }, { "operation": "delegate", "href": "/v1/work-items/8567158b-6ea5-49c4-ba9f-74ab7f01d83b/delegate", "ifMatch": "\"2\"" }, { "operation": "confirmed", "href": "/v1/work-items/8567158b-6ea5-49c4-ba9f-74ab7f01d83b/hosted-decisions", "ifMatch": "\"2\"" }, { "operation": "rejected", "href": "/v1/work-items/8567158b-6ea5-49c4-ba9f-74ab7f01d83b/hosted-decisions", "ifMatch": "\"2\"" } ]}The holder is the token’s issuer and subject, the identity Mint put in the token, and the revision
moved to 2.
actions now offers the two declared outcomes beside release and delegate, each carrying the
new ifMatch: claiming an item is what opens deciding it.
Decide the item
Section titled “Decide the item”Renew the Staff token again before the two decision attempts:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/staff/client-id")" \ --key "$credentials/staff/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.headerTry to confirm the renewal with the ifMatch value the inbox offered before the claim:
curl --silent --show-error \ --header @tutorial-work/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $claim_match" \ --header 'Idempotency-Key: tutorial-decide-1' \ --header 'Content-Type: application/json' \ --data '{"outcome":"confirmed"}' \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items/$item_id/hosted-decisions"python3 -c 'import json; print(json.load(open("tutorial-work/problem.json"))["code"])'HTTP 412precondition.failedThe refusal body lands in tutorial-work/problem.json, an RFC 7807 problem whose code is the
stable name a client matches on, and whose detail reads
The item or directory changed since you loaded it. Reload and try again.
The claim moved the item to revision 2, so a decision sent against revision 1 was made on a view of
the item that no longer holds.
This is the check that stops a second caseworker deciding against a screen loaded before your claim.
Take the ifMatch value the claim offered for the confirmed outcome, and decide:
decide_match=$(python3 -c 'import json; actions = json.load(open("tutorial-work/claimed.json"))["item"]["actions"]; print(next(action["ifMatch"] for action in actions if action["operation"] == "confirmed"))')curl --silent --show-error \ --header @tutorial-work/staff.header \ --header 'Registry-Casework-Profile: staff' \ --header "If-Match: $decide_match" \ --header 'Idempotency-Key: tutorial-decide-1' \ --header 'Content-Type: application/json' \ --data '{"outcome":"confirmed"}' \ --output tutorial-work/decided.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items/$item_id/hosted-decisions"HTTP 200Read what was recorded:
python3 -m json.tool tutorial-work/decided.json{ "itemId": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b", "eventId": "60169bbc-3c8a-4e07-83e8-53ba439394cb", "requesterReference": "LIC-2026-0041", "state": "completed", "outcome": "confirmed", "actorRef": "actor_c5875a22cd6845dc98c08e00ec80836c", "kindPolicyDigest": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "terminalAt": "2026-09-11T16:17:02.277563Z"}The item is completed and the work is finished: a terminal state ends the claim and closes the
item to further decisions.
eventId identifies this decision, actorRef is an opaque handle for whoever made it, and
terminalAt starts the retention clock the decision kind declares.
Sending {"outcome":"rejected"} instead would have been refused without a reason, because the
template marks that outcome reasonRequired.
Read the outcome as the Requester
Section titled “Read the outcome as the Requester”The Requester never sees the inbox, the claim, or the caseworker. Renew the Requester token before reading its own terminal feed:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/requester/client-id")" \ --key "$credentials/requester/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.headerRead the feed:
curl --silent --show-error \ --header @tutorial-work/requester.header \ --header 'Registry-Casework-Profile: requester' \ --output tutorial-work/terminal.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/hosted-items/terminal"HTTP 200python3 -m json.tool tutorial-work/terminal.json{ "items": [ { "itemId": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b", "eventId": "60169bbc-3c8a-4e07-83e8-53ba439394cb", "requesterReference": "LIC-2026-0041", "state": "completed", "outcome": "confirmed", "actorRef": "actor_c5875a22cd6845dc98c08e00ec80836c", "kindPolicyDigest": "sha256:6f9fbf8b801dda6527aece6bbf34cdc7d0b8ff7899d9e012367302aff1aa3139", "terminalAt": "2026-09-11T16:17:02.277563Z" } ], "status": "complete"}The feed carries the outcome and requesterReference, which is what an application matching results
back to its own records needs, and the opaque actorRef in place of the caseworker’s identity.
"status": "complete" says this page is the end of the feed.
See who decided, as the Supervisor
Section titled “See who decided, as the Supervisor”A Supervisor resolves the opaque handle to a person, one decision at a time. Mint a Supervisor token:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/supervisor/client-id")" \ --key "$credentials/supervisor/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/supervisor.headerRead the accountability record for the decision event:
event_id=$(python3 -c 'import json; print(json.load(open("tutorial-work/decided.json"))["eventId"])')curl --silent --show-error \ --header @tutorial-work/supervisor.header \ --header 'Registry-Casework-Profile: supervisor' \ --output tutorial-work/accountability.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/hosted-accountability/$event_id"HTTP 200python3 -m json.tool tutorial-work/accountability.json{ "itemId": "8567158b-6ea5-49c4-ba9f-74ab7f01d83b", "eventId": "60169bbc-3c8a-4e07-83e8-53ba439394cb", "actorRef": "actor_c5875a22cd6845dc98c08e00ec80836c", "actor": { "issuer": "http://127.0.0.1:8093", "subject": "urn:casework:dev:staff" }, "profileId": "staff", "outcome": "confirmed", "recordedAt": "2026-09-11T16:17:02.277563Z", "retainedUntil": "2027-09-11T16:17:02.277563Z"}This is the whole accountability surface: the same actorRef the Requester saw, resolved to the
issuer and subject in the decider’s token, the profile they acted under, the outcome, and how long
the record is kept.
retainedUntil is one year after the decision because the decision kind declares
accountabilityDays: 365, and the Requester’s terminal entry declares a shorter 90 days, so
accountability outlives the feed.
Requests Casework refuses
Section titled “Requests Casework refuses”Three refusals show where the boundaries are. Renew the Requester token so the first refusal proves the profile boundary rather than token expiry:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/requester/client-id")" \ --key "$credentials/requester/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/requester.headerAsk for the Staff inbox with the Requester token:
curl --silent --show-error \ --header @tutorial-work/requester.header \ --header 'Registry-Casework-Profile: requester' \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions"python3 -c 'import json; print(json.load(open("tutorial-work/problem.json"))["code"])'HTTP 403operation.not-authorizedThe Requester profile carries no authority over the queue, whatever it asks for.
Renew the Staff token, then ask for the same inbox while selecting the supervisor profile:
mint token --url "$token_endpoint" \ --client-id "$(cat "$credentials/staff/client-id")" \ --key "$credentials/staff/assertion-key.jwk" \ | sed 's/^/Authorization: Bearer /' > tutorial-work/staff.headercurl --silent --show-error \ --header @tutorial-work/staff.header \ --header 'Registry-Casework-Profile: supervisor' \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions"python3 -c 'import json; print(json.load(open("tutorial-work/problem.json"))["code"])'HTTP 403profile.not-authorizedNaming a profile in a header grants nothing, because Casework checks the token’s scopes against the
scopes that profile requires in casework.yaml.
A caseworker who is also a supervisor holds a token carrying both scope sets and selects one profile
per request.
Last, send the Staff request with no profile header at all:
curl --silent --show-error \ --header @tutorial-work/staff.header \ --output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \ "$casework_url/v1/work-items?view=my_teams&queue=decisions"python3 -c 'import json; print(json.load(open("tutorial-work/problem.json"))["code"])'HTTP 400request.invalidCasework has no default profile: a request that does not say which authority it acts under is incomplete, not permissive.
Stop Casework
Section titled “Stop Casework”Stop the services:
caseworkctl dev stop tutorial-work/caseworkThe report’s status reads stopped.
The container and its volume stay, with the item, the decision, and the accountability record, and
the start command from Start Casework brings the same runtime back.
Stopping with --remove deletes the container and its volume.
The work item, its decision, its terminal entry, and its accountability record go with them, and
nothing here restores them.
caseworkctl dev stop tutorial-work/casework --removeEither way, keep tutorial-work/: the project inside it is what the configuration guide edits.
After a plain stop, the next start refuses a project whose declarations changed while records
exist, and names caseworkctl dev stop --remove as the way to discard them first.
After --remove, the next start builds the project as it stands, edits included.
What you built
Section titled “What you built”You generated a Casework project, ran it as a service, submitted one request as a Requester, claimed
and decided it as Staff, read the outcome from the Requester’s own feed, resolved the decider as a
Supervisor, and saw profile authority and version checks refuse three requests.
All of it came from the YAML in tutorial-work/casework.
Troubleshooting
Section titled “Troubleshooting”Renew an expired token
Section titled “Renew an expired token”A local token lasts five minutes.
Run the mint token command for that client again; it replaces the header file.
If it fails, the runtime is stopped: start it again with the command from
Start Casework, then renew.
Other problems
Section titled “Other problems”| Symptom | Next move |
|---|---|
caseworkctl, casework, or mint is not found | Add the installer’s directory, ~/.local/bin unless you changed it, to PATH in this terminal. |
caseworkctl dev refuses over a reported version | The casework or mint it resolved comes from another release than caseworkctl. The refusal names both versions. Install all three from the same release, or put the matching build first on PATH. |
caseworkctl dev refuses a port | Something else listens on 8092, 8093, or 55433. Set CASEWORKCTL_DEV_CASEWORK_PORT, CASEWORKCTL_DEV_MINT_PORT, or CASEWORKCTL_DEV_DATABASE_PORT, or pass --casework-port, --mint-port, or --database-port, on the first start; later starts keep the ports you chose, and the casework_url and token_endpoint assignments from Start Casework pick them up. |
caseworkctl dev fails before it reports ready | Read the refusal: it names the check that failed. Otherwise check that Docker is running. Run caseworkctl dev events tutorial-work/casework for the retained runtime journal; do not share the credential files beside it. |
A request returns 401 with code authentication.refused | The token expired, or the header file is stale. Mint it again for that client, as Renew an expired token describes. |
A request returns 400 with code request.invalid | A required header is missing. Every request carries Registry-Casework-Profile, and every write also carries Idempotency-Key. |
A claim or decision returns 412 | The item moved. Re-read it, take the ifMatch value from the actions entry for the operation you want, and send it again. |
A claim returns 409 | Another caller holds the item. Its actions array shows what remains open to you. |
| You opened a fresh terminal | Run the casework_url, token_endpoint, and credentials assignments from Start Casework again. The header files and tutorial-work directory are still there. |
- Author a Casework policy to change the queue, the hosted kind, and the profiles this tutorial used.
- How Casework works for the model behind claims, attempts, and accountability.
- Deploy Registry Casework before you plan a deployment.
- Retain, erase, and settle for what happens to the records after the clocks in this tutorial run out.
- Registry Casework API reference for every route and problem code.