Archived docs.You are viewing v0.13.0. For current guidance, useLatest. Report archive issues onGitHub.
Run a protected registry API locally
Registry Relay turns a tabular source you already hold, such as a spreadsheet or a database table, into a protected, read-only API without copying the data out. Use this tutorial to start a protected registry API on your machine. You will create a local project from a tiny benefits workbook, compare operational and restricted identity reads, and inspect the contract Registry Stack generated.
This tutorial uses synthetic data and local demo credentials. Do not use the generated local keys in production.
Install registryctl
Section titled “Install registryctl”Install registryctl without cloning the repository:
The quick installer verifies downloaded release assets against the release’s
SHA256SUMS. The pinned v0.13.0 installer installs the binary and matching
release image lock beside each other. It
does not authenticate the installer script or release. Before running it,
review the available signature and provenance evidence in the
canonical release/VERIFY.md
guide. Evidence availability varies by release, and v0.8.0 is unsigned.
curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.13.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.13.0 bashregistryctl --versionIf your shell cannot find registryctl, add the install directory printed by the installer to your
PATH.
Prebuilt registryctl assets exist for Linux x86_64, Linux arm64, and macOS arm64.
On Intel macOS, the installer stops after printing the pinned cargo install command.
It does not run the source build automatically.
After installing a Rust toolchain, run the command printed by the installer. For
v0.13.0, also checksum-verify the matching release image lock and
place it beside the Cargo-installed binary, as described in the troubleshooting table.
Create the sample project
Section titled “Create the sample project”Create a project from the benefits sample:
registryctl init relay my-first-api --sample benefitscd my-first-apiThe initialization step prints this result before the shell changes directory:
Initialized Relay spreadsheet API "my-first-api". Directory: my-first-api Sample: benefits Bruno collection: my-first-api/bruno/registry-api
Next: cd my-first-api registryctl doctor --profile local registryctl startThe sample workbook has three sheets:
Households: household registration and district attributes.Persons: household members, dates of birth, registration status, and identity fields.Applications: benefit applications, decisions, verification flags, and review dates.
The workbook stores date_of_birth, not a second, time-dependent age_band value. Registry Notary
can derive an age or age band for a specific evaluation date when a relying party does not need the
birth date itself.
Start the local stack
Section titled “Start the local stack”Start the generated project:
registryctl startAfter the Docker Compose progress lines, registryctl prints the local URLs:
Relay API: http://127.0.0.1:4242API docs: http://127.0.0.1:4242/docsRun the smoke check
Section titled “Run the smoke check”Run the smoke checks:
registryctl smokeThe core checks pass:
PASS healthz is publicPASS ready is publicPASS anonymous dataset request is deniedPASS metadata key can list datasetsPASS metadata key cannot read rowsPASS row read without Data-Purpose returns 400PASS row reader can read filtered recordsPASS row reader cannot read restricted identity fieldsPASS identity reader with unpermitted Data-Purpose returns 403PASS identity reader can read one restricted identity recordPASS anonymous caller can fetch runtime OpenAPIregistryctl writes the detailed report to:
output/smoke-results.jsonLoad local demo keys
Section titled “Load local demo keys”Load the generated local keys into your shell:
set -a. secrets/local.envset +aThe same secrets/local.env file contains the raw demo keys and matching fingerprints.
Generated public config files contain only fingerprint environment variable names.
Make one denied request
Section titled “Make one denied request”Call the dataset route without a credential:
curl -i http://127.0.0.1:4242/v1/datasetsRelay refuses the request with 401 Unauthorized and a problem+json body whose stable code
is auth.missing_credential.
The registry is online, but the route is protected.
When you script against Relay, check the status and the code rather than the human-readable
message; the code is the part that will not change.
Make one allowed request
Section titled “Make one allowed request”Call the same route with the metadata key:
curl -i \ -H "Authorization: Bearer $METADATA_READER_RAW" \ http://127.0.0.1:4242/v1/datasetsRelay returns 200 OK and lists the dataset visible to that principal.
The API docs page printed by registryctl start documents the same operations these curl
commands call:
list datasets in the running API docs
opens this one.
Read one protected record
Section titled “Read one protected record”Relay exposes configured entities, not spreadsheet sheet names.
The workbook has a Persons sheet, but callers read the person entity.
Read records for one household:
curl -sS -G \ -H "Authorization: Bearer $ROW_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/tutorial" \ --data-urlencode "household_id=hh-1001" \ http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person/recordsThe response includes four synthetic people from household hh-1001.
Each record contains the canonical birth date and no stored age band. This abridged response shows
the first record; three more person records are omitted:
{ "data": [ { "date_of_birth": "1989-05-14", "household_id": "hh-1001", "id": "per-2001", "registration_status": "active", "relationship_to_head": "head" } ], "pagination": { "has_more": false }}This proves that the operational person projection returns the requested household records without
full names or national identifiers. The operational household projection also excludes the address.
Those values are available through separate restricted projections in the next section.
The person records operation in the running API docs
documents the household_id filter and the required Data-Purpose header.
Try the same route with the metadata-only key:
curl -i -G \ -H "Authorization: Bearer $METADATA_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/tutorial" \ --data-urlencode "household_id=hh-1001" \ http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person/recordsRelay refuses this with 403 Forbidden and the code auth.scope_denied.
The key is valid, but it does not have row-read scope.
Read restricted identity fields
Section titled “Read restricted identity fields”First, try to read the identity projection with the operational row key:
curl -i \ -H "Authorization: Bearer $ROW_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/identity-verification" \ 'http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person_identity/records/per-2001?expand=household_contact'Relay returns 403 Forbidden with auth.scope_denied. The row key can read the operational
projection, but it does not hold the separate benefits_casework:identity_release scope.
The response body looks like this; the generated request_id is omitted:
{ "type": "https://id.registrystack.org/problems/registry-relay/auth/scope_denied", "title": "Scope denied", "status": 403, "detail": "required scope: benefits_casework:identity_release", "code": "auth.scope_denied"}Now make the same single-subject request with the identity key:
curl -sS \ -H "Authorization: Bearer $IDENTITY_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/identity-verification" \ 'http://127.0.0.1:4242/v1/datasets/benefits_casework/entities/person_identity/records/per-2001?expand=household_contact'Relay returns the synthetic person’s given_name, family_name, and national_id, plus the
household’s address_line in the expanded household_contact object. The restricted projections
accept only their identity-verification purpose and cap each collection response at one record.
The response is:
{ "family_name": "Elm", "given_name": "Fae", "household_contact": { "address_line": "595 River Rd, Southvale", "id": "hh-1001" }, "household_id": "hh-1001", "id": "per-2001", "national_id": "FAKE-856648"}This proves that the additional identity scope and permitted purpose unlock access to the restricted projection, including its configured household relationship.
The response cap is not row-level authorization. This local identity key can read every record in the restricted projections, one page at a time. In a deployed registry, issue this scope only to a role allowed to read those identity fields, or add principal-bound row policy through your IAM design. The declared purpose is an additional policy gate, not a substitute for that authorization.
This route is appropriate when an authorized caseworker needs the source fields. When another
system needs only an age threshold or age band, use Notary to derive that result from
date_of_birth without returning the birth date or the rest of the row.
Inspect the generated contract
Section titled “Inspect the generated contract”Now that the API works, inspect what registryctl generated:
sed -n '1,520p' relay/config.yamlThe generated Relay contract defines the local service metadata, API-key scopes, source tables,
operational and restricted projections, purpose allowlists, filters, relationships, aggregates,
and audit sink.
Raw keys stay in secrets/local.env.
Open the runtime API reference:
registryctl openIn an environment that cannot launch a browser, the command prints:
http://127.0.0.1:4242/docsRun an aggregate
Section titled “Run an aggregate”Aggregate readers can run configured aggregate definitions without row-read scope. This request returns household counts by district and applies the configured disclosure rule:
curl -sS \ -H "Authorization: Bearer $AGGREGATE_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/tutorial" \ http://127.0.0.1:4242/v1/datasets/benefits_casework/aggregates/by_districtThe response is 200 OK, and the workbook has four districts, but only two appear.
This abridged response shows the important fields; other fields are omitted, and the order of
observations can vary:
{ "disclosure_control": { "min_cell_size": 2, "suppression": "omit" }, "observations": [ { "district": "north", "household_count": 2 }, { "district": "south", "household_count": 2 } ]}East and west hold one household each, under the aggregate’s disclosure floor of two,
so Relay omits those groups from the response.
The disclosure_control object in the response reports the active rule.
The generated Bruno collection includes more examples, including application counts grouped by
program and status.
The aggregate run operation in the running API docs
covers every configured aggregate through its aggregate_id parameter, including by_district.
Change the disclosure rule
Section titled “Change the disclosure rule”The floor that hid east and west is configuration you own.
Open relay/config.yaml, find the disclosure_control block under the by_district
aggregate, and raise min_group_size from 2 to 3:
disclosure_control: min_group_size: 3 suppression: omitThe containers read the config once at startup, so apply the edit by stopping and starting the stack:
registryctl stopregistryctl startRun the aggregate request from the previous section again:
curl -sS \ -H "Authorization: Bearer $AGGREGATE_READER_RAW" \ -H "Data-Purpose: https://example.local/purpose/tutorial" \ http://127.0.0.1:4242/v1/datasets/benefits_casework/aggregates/by_districtThe response is still 200 OK, but observations is now an empty array: no district
holds three households, so the rule suppresses every group.
An empty result is the only signal; suppressed groups are omitted, not marked.
{ "disclosure_control": { "min_cell_size": 3, "suppression": "omit" }, "observations": []}This proves that the configuration edit changed the API response without changing the workbook.
Now try to weaken the rule instead.
Set min_group_size to 1 and restart again:
registryctl stopregistryctl startThis time Relay refuses to start, and after about a minute registryctl start fails with
Error: Relay did not become healthy and ready before timeout.
Ask the logs why:
registryctl logsThe Relay container exited during config validation:
ERROR aggregate_only_execution on personal, confidential, or secret datasets requires disclosure_control.min_cell_size >= 2 code="config.validation_error" dataset_id=benefits_casework aggregate_id=by_districtThe dataset is marked sensitivity: personal in the same file, and Relay refuses to run
an aggregate-only definition over personal data with a disclosure floor under 2.
You can raise the rule; you cannot weaken it past the floor.
The error names min_cell_size, the API field that corresponds to the min_group_size
config key; the aggregate response uses the API name too.
Restore min_group_size to 2 and restart once more:
registryctl stopregistryctl startThe two-district response from the previous section is back.
Stop the stack
Section titled “Stop the stack”When you are done:
registryctl stopThis stops the local containers. It does not delete your workbook, generated config, local keys, or smoke results.
What you built
Section titled “What you built”You turned a sample Excel workbook into a protected API and watched the boundary hold: an
anonymous read was refused with 401 (auth.missing_credential), an authorized read returned
only the rows you asked for, and a key that lacked the row-read scope was refused with 403
(auth.scope_denied). Relay read the workbook in place and never wrote back to it: the data
stayed where it started, and only an authorized, scoped request got an answer out.
The workbook stored one canonical date of birth rather than a drifting age band. Operational reads
excluded names, national identifiers, and addresses, while a separate identity key and purpose
allowed a one-subject read of those fields.
You then raised the aggregate disclosure floor and watched every group disappear, and
Relay refused to start when the floor dropped under 2 on personal data.
- Evaluate a registry-backed claim locally: add Notary to this same project, match by name and date of birth, and evaluate registration acceptance without exposing the source row.
- Author an HTTP Registry Stack project: adapt a source through Relay and compile Notary claims against the exact consultation contract.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause | Resolution |
|---|---|---|
registryctl is not found | The install directory is not on PATH. | Add the directory printed by the installer, usually ~/.local/bin, to PATH. |
| The installer reports an unsupported platform | No binary is published for that OS or CPU. | Run the pinned cargo install command printed by the installer. For v0.9.0 or later, also checksum-verify the matching registryctl-vX.Y.Z-image-lock.json release asset and place it beside the installed binary. |
registryctl start cannot find Docker | Docker or another Compose provider is not installed or running. | Start Docker Desktop, OrbStack, Colima, Podman, or your supported provider, then run registryctl start again. |
Docker reports no linux/arm64 manifest for a Registry Stack image | The v0.13.0 Relay and Notary images are published for linux/amd64 only. | Start with DOCKER_DEFAULT_PLATFORM=linux/amd64 registryctl start. |
registryctl start fails and the container log shows failed to parse config YAML ... unknown field | The locally cached container image does not match the digest-pinned image in the generated compose.yaml. | Run docker compose pull in the project directory (on Apple silicon, prefix it with DOCKER_DEFAULT_PLATFORM=linux/amd64), then registryctl start again. |
registryctl init reports that its image lock is missing or invalid | The binary was moved or built without the strict image lock from the same release, or the file failed its release, source, platform, or digest checks. | Rerun the installer from the same pinned target tag so it checksum-verifies and installs both files. For an operator-managed or source-test location, set REGISTRYCTL_IMAGE_LOCK to the checksum-verified lock from that exact release. Do not substitute a lock from another version or a mutable image tag. |
registryctl start fails with Relay did not become healthy and ready before timeout after a config edit | The edited relay/config.yaml fails validation, so the container exits at startup. | Run registryctl logs, fix the field named in the ERROR line, then run registryctl stop and registryctl start again. |
A row read returns 403 Forbidden | The key is valid but lacks the row-read scope. | Use ROW_READER_RAW for row reads. |
A row read returns 400 auth.purpose_required | The entity requires a Data-Purpose header. | Send Data-Purpose: https://example.local/purpose/tutorial or another purpose URI. |