Skip to content
Registry StackDocsDevelopment (unreleased)

Deploy Registry Casework

View as Markdown

An author has handed you a reviewed Registry Casework policy package and you want it serving. At the end of this page a casework process serves that package against PostgreSQL behind your own TLS proxy, answers GET /ready, and has a directory whose teams serve every queue the package declares.

Authoring, packaging, and the policy inside the package stay with the author; authoring a Casework policy covers that side. This page starts from the package directory and ends at a serving deployment.

  • One PostgreSQL database and two login credentials. The migration credential owns the Casework schema and applies migrations. The runtime credential serves requests and the background workers. Casework creates no extension and no role, and its migrations carry no version-gated SQL; the product’s own database suites run against PostgreSQL 17. The runtime requires TLS on both connections and refuses a plaintext one.
  • One OpenID Connect issuer. Casework verifies bearer access tokens and issues none. Configure the issuer to add the exact human-identity assertion, registry_actor_kind: human by default, only to interactive human sessions, and to issue the Requester scope to the services that call on a person’s behalf. A Staff, Supervisor, or Administrator token without that assertion is refused even when it carries valid scopes and names a directory member.
  • A TLS proxy or ingress in front of the listener. The process serves plain HTTP on a private or container-private address and reads no forwarded header, so TLS termination, HSTS, and client addresses stay with your proxy.
  • Secrets the process can read. Every credential in the runtime file is a reference: secret:file/<name> names an owner-only file under one file provider root, and secret:env/<NAME> names an environment variable.
  • Durable storage for the audit file. The runtime appends a keyed hash chain to one JSONL file and holds a process-lifetime lock on a sentinel file beside it, so exactly one process writes one chain.
  • A host for one process, or a place to run the container image.

A hosted deployment that declares no source needs no Base Registry Engine (BReg): the work items its callers create live in Casework’s own database, and the source bindings in the runtime file exist only for the sources a package declares. Work, claims, drafts, attempts, accountability records, and the idempotency ledger all live in PostgreSQL, so no message broker and no cache take part in a request. Browser traffic reaches your own application host, which calls Casework server to server, so the API enables no CORS.

Install the binaries on the host that will serve, and on the operator host that holds the migration credential:

Terminal window
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/casework-install.sh | bash
casework --version
caseworkctl --version

The installer verifies every downloaded binary against the release SHA256SUMS before anything reaches the install directory, and installs casework, caseworkctl, and mint together or not at all. Registry Mint comes along because a deployment with no identity provider uses it to issue access tokens; a deployment with its own OpenID Connect issuer can ignore it. The installer does not verify release authenticity. Replace | bash with | less to read it before running it on a host you operate. CASEWORK_INSTALL_DIR selects the install directory, and the default is ~/.local/bin; CASEWORK_VERSION pins one release; CASEWORK_ASSET_DIR installs from a directory you verified yourself. Binaries are published for linux-amd64, linux-arm64, and macos-arm64, and the release also publishes casework-install.sh as a movable alias of the pinned installer.

The container image is ghcr.io/registrystack/casework:v0.30.0, built on a distroless nonroot base for linux/amd64. Its entrypoint is /usr/local/bin/casework, its default arguments are --runtime-config /etc/registry-casework/runtime.yaml serve, and it exposes port 8100. It carries the runtime binary and its license and nothing else: no shell, no healthcheck command, and no caseworkctl, so migration and doctor run from a separate operator host. One directory is writable, /var/lib/registry-casework/audit, owned by UID and GID 65532 with mode 0700, for the audit chain; mount the runtime file, the policy package, and the secret root read-only. The release manifest published beside the binaries records the promoted digest of the image; pin that digest rather than the movable tag.

The runtime file binds one policy package to one database, one issuer, one listener, and one binding per source the package declares. It is a deployment artifact: keep it outside the authoring project, and put no credential in it.

apiVersion: registry.registrystack.org/casework-runtime/v1alpha1
kind: CaseworkRuntimeConfig
package:
# The selected package always contains the policy at casework.yaml.
root: /etc/registry-casework/package
listener:
# The proxy in front of this private listener terminates TLS.
bind: 10.42.0.7:8100
# operator-controlled-upstream for production; development-loopback only for loopback.
tlsTermination: operator-controlled-upstream
# container-private also accepts a wildcard bind on a private container network.
networkExposure: private-address
secretProviders:
file:
# Owner-only files, one per reference.
root: /etc/registry-casework/secrets
database:
runtimeUrlRef: secret:file/casework-runtime-database-url
migrationUrlRef: secret:file/casework-migration-database-url
# Optional: a private certificate authority for the PostgreSQL connection.
trustedRootCertificateRef: secret:file/casework-database-root.pem
authentication:
oidc:
issuer: https://identity.example.org/realms/registry
audience: urn:example:casework
scopeClaim: scope
humanIdentity:
claim: registry_actor_kind
value: human
jwksSource:
kind: discovery
audit:
path: /var/lib/registry-casework/audit/casework.ndjson
hashKeyRef: secret:file/casework-audit-key
sources:
# One entry per source the package declares, keyed by its exact source id.
professional-register:
baseUrl: https://registry.example.org
readerProfile: casework-reader
tokenEndpoint: https://identity.example.org/realms/registry/token
clientIdRef: secret:file/breg-reader-client-id
clientAssertionKeyRef: secret:file/breg-reader-key
webhookSecretRef: secret:file/breg-casework-webhook
eventSource: urn:registrystack:registry:professional-licences:instance:professional-licences-1
SectionWhat it binds
packageThe absolute root of the package containing casework.yaml, its manifest, and exact source descriptions.
listenerThe listener address and the transport boundary the deployment declares for it.
secretProvidersThe explicitly enabled file and environment secret providers.
databaseSecret references for the runtime and migration connection URLs, and an optional trusted root certificate for the PostgreSQL connection.
authenticationThe OpenID Connect issuer, audience, claim names, human-identity assertion, and the source of the issuer’s keys.
auditThe path of the external JSONL chain and the reference to the key that chains it.
sourcesOne BReg binding per source the package declares: base URL, reader profile, token endpoint, and references to the client identifier, client assertion key, and webhook secret. The set of keys must equal the set of declared source ids exactly, and a package with no source needs no block.

The package root, file secret root, and audit path must be absolute. An environment reference is valid only when secretProviders.environment: {} explicitly enables that provider.

The listener boundary is checked, not advisory. With operator-controlled-upstream and private-address, the address must be loopback or private (IPv4 private range or IPv6 unique local); container-private also accepts a wildcard bind for a container on a private network. development-loopback accepts only a loopback address with private-address, and it is the one mode that serves an authored project with no manifest beside it. Apply HSTS on the proxy’s TLS responses; the runtime adds its remaining security headers and Cache-Control: no-store to every response it returns.

scopeClaim defaults to registry_scopes for compatibility with earlier deployments. When the issuer is Registry Mint, set scopeClaim: scope explicitly so its OAuth scope values carry every required scope for the selected profile. humanIdentity defaults to the claim registry_actor_kind with the value human, must differ from scopeClaim, and no access profile in the package may use it as its principal claim. Each access profile names its own principalClaim; there is no runtime-wide principal claim. The runtime accepts RS256 and ES256 signatures and the token types at+jwt and JWT.

With the discovery source, the runtime reads the issuer’s discovery document at startup and fetches its keys from the jwks_uri that document names; an optional jwksUri in this file overrides that one value and leaves the rest of discovery in place. Prefer the static alternative when the Casework host cannot reach discovery, or when you pin the issuer’s keys deliberately:

jwksSource:
kind: static
documentRef: secret:file/casework-issuer-jwks

The document must be a JWKS with at least one RSA or elliptic-curve key, each carrying a distinct non-empty kid. It is read once, at startup, so a key rotation at the issuer is a replacement of that secret and a restart of every serving process.

Each source binding also accepts eventType, which defaults to casework-lifecycle-v1, trustedRootCertificatesRef for a private certificate authority in front of the source, requestTimeoutMilliseconds and connectTimeoutMilliseconds, which default to 30000 and 10000, accept at most 300000, and are refused when the connect timeout is the larger of the two, and reconciliationIntervalMilliseconds, how often the runtime re-reads the source for work the source’s own notifications did not deliver, which defaults to 60000 and accepts 1000 to 3600000. The interval changes scheduling only. Changing it does not invalidate displayed source bindings or durable attempts because it does not change source authority or saved source state. If one pass outlasts the interval, Casework skips missed ticks instead of replaying them back-to-back against the source.

Create each file as the user that runs casework, because the resolver refuses a file owned by anyone else:

Terminal window
install -d -m 0700 /etc/registry-casework/secrets
(umask 077; openssl rand -hex 32 | tr -d '\n' \
> /etc/registry-casework/secrets/casework-audit-key)
(umask 077; printf '%s' 'postgresql://casework_runtime:<runtime-password>@db.example.org:5432/casework' \
> /etc/registry-casework/secrets/casework-runtime-database-url)
(umask 077; printf '%s' 'postgresql://casework_migration:<migration-password>@db.example.org:5432/casework' \
> /etc/registry-casework/secrets/casework-migration-database-url)

Each file must be a regular file owned by the effective user, with mode 0400 or 0600, exactly one link and no symbolic link to it, at most 64 KiB, non-empty, and free of NUL bytes, so generate key material as text rather than raw bytes. The name starts with a lowercase letter and carries lowercase letters, digits, ., _, and -. The bytes are used exactly as written, neither trimmed nor decoded, so write them without a trailing newline. The audit key needs at least 32 bytes, which 64 hexadecimal characters satisfy. Percent-encode a password that carries reserved characters, and give the database user in each URL the role that owns the matching credential. A reference that cannot resolve is refused by name, together with the rule it broke, and the resolved bytes never appear in a message or a log.

Verify the package before it opens the listener

Section titled “Verify the package before it opens the listener”

The runtime verifies the package every time it loads the runtime file, before it opens the database, contacts the issuer, or binds the listener. It reads casework.package.json beside casework.yaml, recomputes the policy digest over the sorted file list, checks every file’s SHA-256 digest and byte count, and refuses a package directory holding any file the manifest does not declare or any symbolic link. A start that verifies the package records the policy digest in the runtime log before anything else happens.

With tlsTermination: operator-controlled-upstream, an absent manifest is a refusal rather than a fallback to the authored project. Each of these refusals is one run’s entire output on standard error, written before the listener binds, and each exits non-zero:

casework: operator-controlled production requires a verified Casework policy package
casework: the Casework policy package is invalid
casework: the Casework runtime configuration is not valid YAML
casework: the Casework runtime configuration is invalid
casework: the Casework secret-provider configuration is invalid; secretProviders.file.root must be an absolute path

The first line reports a production configuration with no manifest. The second reports a manifest whose digests, byte counts, or file set do not match the directory. The third and fourth report the runtime file itself: unparseable YAML, and a configuration the checks refuse, which covers an invalid listener boundary, an empty issuer or claim name, a human-identity claim equal to the scope claim, an access profile using the human-identity claim as its principal claim, and a sources map that does not match the declared source ids exactly. The fifth reports a secret provider root the resolver cannot use.

Apply the migrations with the migration credential, from the operator host:

Terminal window
casework --runtime-config /etc/registry-casework/runtime.yaml migrate

migrate loads the same runtime file, resolves migrationUrlRef, takes a PostgreSQL advisory lock, and applies every unapplied migration in order under its ledger, so a second migrate on the same database waits rather than racing it. Run it before the first serve and before rolling out a release that adds migrations.

Then run the service:

Terminal window
casework --runtime-config /etc/registry-casework/runtime.yaml serve

Run serve under your service manager and let it restart the process. serve refuses to start on an unverified production package, a listener the declared boundary does not allow, source bindings that do not match the declared sources, a database reference or connection it cannot use, an OIDC issuer it cannot initialize, and an audit secret or audit file it cannot open. Once it is serving, the listener stops when any supervised background worker stops, and the process exits reporting the stopped worker, because a deployment whose maintenance, reconciliation, or audit publication loop is gone keeps neither its deadlines nor its accountability records.

Probe GET /health for liveness: it answers 200 as long as the process runs. Probe GET /ready before routing traffic: it answers 503 while audit publication is unhealthy or PostgreSQL is unreachable, and 200 otherwise. Readiness checks the audit publisher first, then the store.

The audit publication worker runs once a second: it reads pending records, appends them to the keyed chain, and marks them published. A failed pass makes readiness fail, logs only the stage that failed (pending-read, record-identity, sink-append, or published-mark) rather than the record, and retries; a passing run restores readiness. A maintenance pass runs every two seconds and a source reconciliation pass per bound source at that binding’s reconciliationIntervalMilliseconds, every 60 seconds unless set. A source outage stays visible on the affected work-item operations rather than turning an inbox into an apparently complete empty page.

A serving process has no directory until an Administrator creates one, and a queue with no serving team holds work nobody can reach. Bootstrap the first team with an Administrator access token that carries the human-identity assertion. If-Match carries the directory revision the caller expects, which is "0" for the first call, and Idempotency-Key binds the request to one exact mutation:

POST /v1/directory/bootstrap HTTP/1.1
Authorization: Bearer <administrator-access-token>
Registry-Casework-Profile: administrator
If-Match: "0"
Idempotency-Key: <caller-selected-key>
Content-Type: application/json
{
"teamId": "licence-review",
"staff": [
{
"issuer": "https://identity.example.org/realms/registry",
"subject": "<staff-subject>"
}
],
"supervisors": [
{
"issuer": "https://identity.example.org/realms/registry",
"subject": "<supervisor-subject>"
}
],
"queueId": "corrections"
}

Afterwards, replace one team at a time with PUT /v1/directory/teams/{team_id}, carrying the same three headers with If-Match set to the revision the last directory response reported, and a body of staff, supervisors, and servedQueues. The replacement is whole: the members and queues it names become the team. A served queue belongs to one team, so a queue another team already serves is refused with precondition.failed; remove it from that team first, then assign it. A team holds at most 100 staff, 100 supervisors, and 100 served queues, and the whole directory document fits 2 MiB. Authority changes take effect immediately, newly ineligible held items are released through maintenance, and items with an unresolved source attempt stay held for a later retry. Administrator authority covers the directory and holiday maintenance only; it grants no item payload access and no source review or application authority.

caseworkctl doctor opens every live dependency the runtime opens, without binding a listener:

Terminal window
caseworkctl --format json doctor --runtime-config /etc/registry-casework/runtime.yaml

It checks the configuration, the exact imported source descriptions, each source connection and its reader grants, the database, the OIDC issuer, and directory readiness, and it stops at the first one that refuses and names it. Its secret preflight resolves the audit reference before it opens anything, and a reference that cannot resolve is reported by name with the rule it broke. Directory readiness requires a team serving every queue the package declares, and a gap is reported as an instruction to complete the queue assignments as an Administrator. A run that reaches the end prints a JSON report naming each check and each source it contacted.

The runtime verifies and loads the package once, at startup. Install the reviewed successor into a new directory beside the current one, point package.root at the new directory, and restart or roll out the process. Keep the previous package directory until the new one serves, so a rollback is a configuration change rather than a rebuild.

Activating a package does not rewrite running clock occurrences: each keeps the clock policy and calculation pinned when it started. Holiday changes are a separate, deliberate operation. An Administrator publishes an immutable holiday-set revision, then previews and applies the change in batches of at most 100 active occurrences, repeating both until every occurrence is pinned to the selected revision. A preview records the expected calculation generation for each occurrence and is bound to the actor and selected profile for 15 minutes; an expired preview returns clock.recompute-preview-expired, and an already-applied preview or a changed generation returns precondition.failed. Create and review a new preview after either response.

SymptomNext move
operator-controlled production requires a verified Casework policy package, or the Casework policy package is invalidThe first names a production configuration with no manifest, the second a package directory whose files disagree with the manifest. Point package.root at the installed package, and reinstall it from the reviewed artifact rather than editing it.
the Casework runtime configuration is invalidCheck the listener against tlsTermination and networkExposure, the non-empty issuer and claim names, the human-identity claim against scopeClaim and the package’s profiles, and sources against the declared source ids.
the Casework secret-provider configuration is invalidThe file provider root is relative or unusable. Write an absolute root.
A secret reference is refusedThe named file must be a regular file owned by the running user, mode 0400 or 0600, one link, no symbolic link, non-empty, and free of NUL bytes.
Startup refuses the database or doctor stops at itCheck the resolved URL’s user and database name, TLS on the server, and the trusted root reference when the server uses a private authority.
Startup refuses the OIDC issuerCheck discovery reachability from the Casework host, or the pinned JWKS document’s keys and their distinct kid values.
GET /ready returns 503 while GET /health returns 200Audit publication or PostgreSQL is failing. Read the runtime log for the failed audit stage, and check the audit file, its lock, and the database.
profile.not-human on a token you believe is humanThe issuer did not add the configured human-identity claim to that session. Check the claim name and value against the issuer’s mapping for interactive sessions.
Work items report a source outageThe bound source is unreachable or refusing the reader. Run caseworkctl doctor, which names the failing source, then check that binding’s base URL, token endpoint, and reader grants.