Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/llms.txt
Full corpus: https://docs.registrystack.org/llms-full.txt

# Review an application and send an approval notice

> Run Base Registry Engine (BReg), Casework, Coordinator, and Messaging together, then prove that an unchanged approved application produces one local email.

import QuickstartMeta from '../../../components/QuickstartMeta.astro';

Run one Registry Coordinator **[workflow](../../reference/glossary/#workflow)**, `reviewed-notice`, from an application
in Base Registry Engine (BReg), through your decision as reviewer in Registry Casework, to an
approval email from Registry Messaging. Coordinator does the rest, even across a restart.

<QuickstartMeta
  outcome="One approved, unchanged application produces one finished run and one email in local Mailpit."
  time="About 20 minutes"
  level="Local evaluation only"
  prerequisites={['Docker 24 or later with host networking', 'OpenSSL', 'Python 3.11 or later and uv', 'curl', 'jq', 'Linux amd64 or macOS arm64', 'Verified native tools, a matching source archive, and the unified Python client']}
/>

## Prepare verified candidate or release assets

:::note[Coordinator distribution is pending]
The published v0.43.0 release does not include Coordinator. The verified local
evaluation uses v0.44.0 candidate native tools and the candidate unified wheel
with published Messaging v0.43.0 at
`sha256:695b6a6dc2f72ab2f2341510f171d683cc87a19c7d37c253b545198557bd8a2c`.
It ran on macOS arm64 with the Linux amd64 image under OrbStack. An actual
Linux amd64 host run, the macOS binary platform release work, and a fully
published Coordinator distribution are still pending. Use this path only
with a release or a reviewed candidate that supplies the Coordinator pair and
unified client. A candidate rehearsal is not proof that those assets have been
published. The [deployment runbook](https://github.com/registrystack/registry-stack/blob/main/products/coordinator/DEPLOYMENT.md)
retains the contributor build procedure.
:::

Verify the selected release's manifest and checksums using the
[release operations procedure](https://github.com/registrystack/registry-stack/blob/main/release/OPERATIONS.md).
For a local candidate, use the maintainer's reviewed source and checksum-bound
artifact handoff. The asset directory must contain its verified `SHA256SUMS`,
the host's unified wheel, and all six native assets named below. Do not use
v0.43.0 downloads as substitutes for missing Coordinator assets.

Get GitHub's source-code archive for that verified source tag or commit, or the
maintainer's checksum-bound `git archive` export of the same reviewed commit.
These are ordinary Git source archives, not a separate published support asset.
Extract it and run from its root. Rust is not required. Use a fresh working
directory; the maintained macOS extractor refuses an existing destination.

Set `REGISTRY_STACK_VERSION` to the candidate or release version and
`REGISTRY_STACK_ASSETS` to the absolute directory containing its verified local
artifacts. The preparation checks the selected file checksums, renames raw
Linux assets to executable tool names, and uses the existing strict macOS
archive extractor while retaining its adjacent FIPS libraries.

```sh test-skip="asset preparation is verified separately against the candidate handoff"
: "${REGISTRY_STACK_VERSION:?set the verified candidate or release version}"
: "${REGISTRY_STACK_ASSETS:?set the absolute verified artifact directory}"
mkdir -p tutorial-work
python3 - "$REGISTRY_STACK_VERSION" "$REGISTRY_STACK_ASSETS" >tutorial-work/prepared-tools.json <<'PY'
import hashlib, json, platform, shutil, sys
from pathlib import Path
sys.path.insert(0, "release/scripts")
from macos_fips_packaging import extract_macos_binary_archive

version, assets = sys.argv[1], Path(sys.argv[2])
if not assets.is_absolute():
    raise SystemExit("Use an absolute artifact directory.")
host = (platform.system(), platform.machine())
if host == ("Darwin", "arm64"):
    target, suffix = "macos-arm64", "_arm64.whl"
elif host in (("Linux", "x86_64"), ("Linux", "amd64")):
    target, suffix = "linux-amd64", "_x86_64.manylinux2014_x86_64.whl"
else:
    raise SystemExit("This tutorial supports macOS arm64 and Linux amd64.")
tools = ("breg", "bregctl", "casework", "caseworkctl", "coordinator", "coordinatorctl")
checksums = {}
for line in (assets / "SHA256SUMS").read_text().splitlines():
    if not line.strip():
        continue
    digest, name = line.split(maxsplit=1)
    checksums[name.removeprefix("*")] = digest
wheels = [path for path in assets.glob(f"registry_stack_client-{version}-*.whl") if path.name.endswith(suffix)]
if len(wheels) != 1:
    raise SystemExit("Supply exactly one verified host wheel.")
files = [assets / (f"{tool}-v{version}-{target}" + (".tar.gz" if target == "macos-arm64" else "")) for tool in tools]
for path in [*files, wheels[0]]:
    if checksums.get(path.name) != hashlib.sha256(path.read_bytes()).hexdigest():
        raise SystemExit(f"Checksum mismatch: {path.name}")
output = Path("tutorial-work/native-tools").resolve()
output.mkdir(mode=0o700)
for tool, path in zip(tools, files):
    stem = f"{tool}-v{version}-{target}"
    if target == "macos-arm64":
        extracted = Path("tutorial-work/unpacked") / tool
        extract_macos_binary_archive(path, extracted, stem)
        source = extracted / stem
        for library in extracted.glob("libaws_lc_fips_*.dylib"):
            destination = output / library.name
            if destination.exists() and destination.read_bytes() != library.read_bytes():
                raise SystemExit("Conflicting FIPS library bytes; stop and inspect the candidate.")
            if not destination.exists():
                shutil.copy2(library, destination)
    else:
        source = path
    shutil.copy2(source, output / tool)
    (output / tool).chmod(0o755)
print(json.dumps({"tools": str(output), "clientWheel": str(wheels[0])}))
PY
```

For the tested candidate evaluation, set `MESSAGING_IMAGE` to the exact public
Messaging v0.43.0 digest above, prefixed by
`ghcr.io/registrystack/messaging@`. Future published journeys must use the
combination recorded for their release, including the supported binary
platform roster. The helper accepts the public Messaging repository only; it
does not accept a private candidate-image reference.

```sh
: "${REGISTRY_STACK_VERSION:?set the verified release or candidate version}"
: "${COORDINATOR_TUTORIAL_BIN_DIR:=$(jq -r .tools tutorial-work/prepared-tools.json)}"
: "${REGISTRY_STACK_CLIENT:=$(jq -r .clientWheel tutorial-work/prepared-tools.json)}"
: "${MESSAGING_IMAGE:?set the published Messaging image digest}"
export COORDINATOR_TUTORIAL_BIN_DIR REGISTRY_STACK_CLIENT
for tool in breg bregctl casework caseworkctl coordinator coordinatorctl; do
  test -x "$COORDINATOR_TUTORIAL_BIN_DIR/$tool"
done
export PATH="$COORDINATOR_TUTORIAL_BIN_DIR:$PATH"
docker pull --platform linux/amd64 "$MESSAGING_IMAGE"
```

The session's `--released-clients` option reads BReg and admits the run through
the installed unified Python client. It uses the active workflow revision and
repeats one identical start key to confirm the same run identity. No Rust input
helper or Rust toolchain is needed. The preparation selects the exact local
wheel through `REGISTRY_STACK_CLIENT`. When that variable is unset, the start
command instead selects the named published version. Keep this terminal open.

## Read the workflow

A workflow is a finite list of named steps. Ask `coordinatorctl` to explain this one:

```sh
coordinatorctl explain --project products/coordinator/examples/reviewed-notice
```

The output ends with the steps from `start`, among them:

```text test-excerpt
  verify-review-subject: choose by review-subject-route(): changed -> changed-before-review, current -> submit-review
  changed-before-review: finish application-changed
  submit-review: call review review-submit-review-request (casework mutation, recovery same-command) with review-request(), then await-review
  await-review: wait for the result of submit-review, at most 1209600 seconds, then decide; on timeout withdraw-timeout; on cancel withdraw-operator
```

Each line is one step. A `call` reaches a product through a named connection, here `review` for
Casework; a `choose` picks the next step; a `finish` ends with an outcome.
`await-review` is a **[wait](../../reference/glossary/#wait)**: it holds up to fourteen days for the review result. Later steps read
the application again before calling Messaging.

## Start the products and the run

The session script starts BReg, Casework, and Coordinator from the selected native directory,
with Messaging, databases, and the Mailpit mail sink in Docker. It seeds one application for Amina Example and starts one
**[run](../../reference/glossary/#run)** of the workflow for it with `POST /v1/runs`: one admitted
execution that Coordinator keeps in its database. The registry project, review policy, and
notice template it uses are in `products/coordinator/scripts/real_service_fixtures.py`. Docker host networking lets Messaging reach the owned loopback services. This is
local evaluation configuration. Every service listens on loopback HTTP
(`tlsTermination: development-loopback`), and Coordinator applies
its schema as the database superuser and serves as a separate runtime role: a local setup only.

```sh
mkdir -p tutorial-work
uv run --quiet --project products/coordinator/examples/reviewed-notice --locked \
  --with "${REGISTRY_STACK_CLIENT:-registry-stack-client==$REGISTRY_STACK_VERSION}" \
  products/coordinator/examples/reviewed-notice/session.py start \
  --released-clients --messaging-image "$MESSAGING_IMAGE" \
  --bin-dir "$COORDINATOR_TUTORIAL_BIN_DIR" \
  --session-dir tutorial-work/reviewed-notice > tutorial-work/session.json
jq '{status, workflow}' tutorial-work/session.json
```

```json test-expect
{
  "status": "ready",
  "workflow": "reviewed-notice"
}
```

`session.json` holds service addresses and owner-only token file paths. Keep them in variables,
and copy the Coordinator token out of its header file for `coordinatorctl`:

```sh
session=tutorial-work/session.json
run_id="$(jq -r .runId "$session")"
coordinator_url="$(jq -r .coordinatorApi "$session")"
casework_url="$(jq -r .caseworkApi "$session")"
mailpit_url="$(jq -r .mailpit "$session")"
staff_headers="$(jq -r .staffHeaderFile "$session")"
(umask 077 && sed -n 's/^Authorization: Bearer //p' \
  "$(jq -r .coordinatorHeaderFile "$session")" > tutorial-work/coordinator.token)
```

{/* Evidence: products/coordinator/examples/reviewed-notice/session.py, start, serve;
    products/coordinator/scripts/test-real-services.py, Journey.start, coordinator_service,
    tls_database, mailpit; products/coordinator/scripts/real_service_fixtures.py, write_breg,
    write_review_casework. */}

## Inspect the waiting run

```sh
coordinatorctl inspect --run "$run_id" \
  --url "$coordinator_url" --token-file tutorial-work/coordinator.token
```

The run is at `await-review`, after Casework accepted the review request:

```yaml test-excerpt
  state: running
  step: await-review
```

```yaml test-excerpt
waits:
- acceptedStep: submit-review
  completion: result
```

Nothing has gone to Messaging. Inspection never shows the application data or the commands sent.

## Claim and approve the review task

As staff, find the open task in the `applications` queue and read the facts you decide on. The
applicant's email address stays in BReg.

```sh
caseworkctl api '/v1/review-tasks?queue=applications' \
  --url "$casework_url" \
  --header @"$staff_headers" \
  --header 'Registry-Casework-Profile: staff' \
  --output tutorial-work/tasks.json
task_id="$(jq -r '.items[] | select(.state == "open") | .taskId' tutorial-work/tasks.json)"
caseworkctl api "/v1/review-tasks/$task_id/context" \
  --url "$casework_url" \
  --header @"$staff_headers" \
  --header 'Registry-Casework-Profile: staff' | jq .context.snapshot
```

```json test-excerpt
{
  "applicantName": "Amina Example",
  "benefitProgram": "housing-support",
  "requestedAmount": 1200
}
```

Claim the task at the revision you read:

```sh
revision="$(jq -r '.items[] | select(.state == "open") | .revision' tutorial-work/tasks.json)"
caseworkctl api "/v1/review-tasks/$task_id/claim" \
  --url "$casework_url" \
  --header @"$staff_headers" \
  --header 'Registry-Casework-Profile: staff' \
  --method POST \
  --if-match "\"$revision\"" \
  --idempotency-key "$(openssl rand -hex 16)" \
  --output /dev/null
```

The command exits successfully when Casework returns the claimed task.

Before you decide, restart Coordinator. The run is durable state, not a running process:

```sh
python3 products/coordinator/examples/reviewed-notice/session.py restart-coordinator \
  --session-dir tutorial-work/reviewed-notice > "$session"
coordinator_url="$(jq -r .coordinatorApi "$session")"
jq .coordinatorRestarts "$session"
```

```text test-expect
1
```

Now approve at the task's current revision, with the policy's `approved` outcome:

```sh
caseworkctl api "/v1/review-tasks/$task_id" \
  --url "$casework_url" \
  --header @"$staff_headers" \
  --header 'Registry-Casework-Profile: staff' \
  --output tutorial-work/task.json
revision="$(jq -r .revision tutorial-work/task.json)"
cat > tutorial-work/decision.json <<'JSON'
{"decision":{"type":"approve","outcome":"approved"}}
JSON
caseworkctl api "/v1/review-tasks/$task_id/decisions" \
  --url "$casework_url" \
  --header @"$staff_headers" \
  --header 'Registry-Casework-Profile: staff' \
  --if-match "\"$revision\"" \
  --idempotency-key "$(openssl rand -hex 16)" \
  --input tutorial-work/decision.json \
  --output /dev/null
```

The command exits successfully when Casework records the decision and answers `204`.

If a request answers `401`, the local token expired. Refresh the staff token, and the Coordinator
token for `coordinatorctl`, then repeat:

```sh test-skip="only after a token expires"
owner="$(jq -r .ownerProject "$session")"
bregctl dev token staff "$owner"
bregctl dev token coordinator-producer "$owner"
(umask 077 && sed -n 's/^Authorization: Bearer //p' \
  "$(jq -r .coordinatorHeaderFile "$session")" > tutorial-work/coordinator.token)
```

## See the run finish

Casework tells the restarted Coordinator a result is ready. The workflow reads the application
again, checks it is unchanged and still allows a notice, and asks Messaging to send to the address
on that record. Check the run:

```sh test-retry="120"
coordinatorctl status --run "$run_id" \
  --url "$coordinator_url" --token-file tutorial-work/coordinator.token
```

```yaml test-excerpt
outcome: notice-accepted
output:
  messageId: <message-id>
  resultId: <result-id>
```

```yaml test-excerpt
state: finished
```

If it is still `running`, repeat it after a few seconds. Then look at the local mail server:

```sh test-retry="30"
curl -fsS "$mailpit_url/api/v1/messages" \
  | jq '{count: (.messages | length), to: .messages[0].To[0].Address, subject: .messages[0].Subject}'
```

```json test-expect
{
  "count": 1,
  "to": "amina@example.invalid",
  "subject": "Application approved"
}
```

One email reached Amina's address on record. A changed application, a withdrawn `noticeAllowed`,
or a rejection would each have finished without calling Messaging.

{/* Evidence: products/coordinator/examples/reviewed-notice/workflow.yaml, notice-accepted. */}

## Stop the session

The script removes only the containers and sessions it created:

```sh
python3 products/coordinator/examples/reviewed-notice/session.py stop \
  --session-dir tutorial-work/reviewed-notice
```

## Next

[How Registry Coordinator works](../../explanation/how-coordinator-works/) explains the run, its
wait, who owned each effect, and how an operator recovers a run.