Skip to content
Registry StackDocsv0.13.0

Run a protected registry API locally

View as Markdown

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.

Outcome
A protected local API over a benefits workbook, with operational and restricted identity reads separated, one canonical birth date retained, and an edited disclosure rule enforced.
Time
About 15 minutes after Docker is installed
Level
Local single-node
Prerequisites
registryctl 0.13.0A Docker Compose providercurl

This tutorial uses synthetic data and local demo credentials. Do not use the generated local keys in production.

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.

Terminal window
curl -fsSL https://raw.githubusercontent.com/registrystack/registry-stack/refs/tags/v0.13.0/crates/registryctl/install.sh | REGISTRYCTL_VERSION=v0.13.0 bash
registryctl --version

If 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 a project from the benefits sample:

Terminal window
registryctl init relay my-first-api --sample benefits
cd my-first-api

The 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 start

The 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 generated project:

Terminal window
registryctl start

After the Docker Compose progress lines, registryctl prints the local URLs:

Relay API: http://127.0.0.1:4242
API docs: http://127.0.0.1:4242/docs

Run the smoke checks:

Terminal window
registryctl smoke

The core checks pass:

PASS healthz is public
PASS ready is public
PASS anonymous dataset request is denied
PASS metadata key can list datasets
PASS metadata key cannot read rows
PASS row read without Data-Purpose returns 400
PASS row reader can read filtered records
PASS row reader cannot read restricted identity fields
PASS identity reader with unpermitted Data-Purpose returns 403
PASS identity reader can read one restricted identity record
PASS anonymous caller can fetch runtime OpenAPI

registryctl writes the detailed report to:

output/smoke-results.json

Load the generated local keys into your shell:

Terminal window
set -a
. secrets/local.env
set +a

The same secrets/local.env file contains the raw demo keys and matching fingerprints. Generated public config files contain only fingerprint environment variable names.

Call the dataset route without a credential:

Terminal window
curl -i http://127.0.0.1:4242/v1/datasets

Relay 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.

Call the same route with the metadata key:

Terminal window
curl -i \
-H "Authorization: Bearer $METADATA_READER_RAW" \
http://127.0.0.1:4242/v1/datasets

Relay 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.

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:

Terminal window
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/records

The 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:

Terminal window
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/records

Relay refuses this with 403 Forbidden and the code auth.scope_denied. The key is valid, but it does not have row-read scope.

First, try to read the identity projection with the operational row key:

Terminal window
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:

Terminal window
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.

Now that the API works, inspect what registryctl generated:

Terminal window
sed -n '1,520p' relay/config.yaml

The 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:

Terminal window
registryctl open

In an environment that cannot launch a browser, the command prints:

http://127.0.0.1:4242/docs

Aggregate readers can run configured aggregate definitions without row-read scope. This request returns household counts by district and applies the configured disclosure rule:

Terminal window
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_district

The 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.

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: omit

The containers read the config once at startup, so apply the edit by stopping and starting the stack:

Terminal window
registryctl stop
registryctl start

Run the aggregate request from the previous section again:

Terminal window
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_district

The 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:

Terminal window
registryctl stop
registryctl start

This 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:

Terminal window
registryctl logs

The 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_district

The 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:

Terminal window
registryctl stop
registryctl start

The two-district response from the previous section is back.

When you are done:

Terminal window
registryctl stop

This stops the local containers. It does not delete your workbook, generated config, local keys, or smoke results.

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.

SymptomCauseResolution
registryctl is not foundThe install directory is not on PATH.Add the directory printed by the installer, usually ~/.local/bin, to PATH.
The installer reports an unsupported platformNo 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 DockerDocker 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 imageThe 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 fieldThe 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 invalidThe 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 editThe 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 ForbiddenThe key is valid but lacks the row-read scope.Use ROW_READER_RAW for row reads.
A row read returns 400 auth.purpose_requiredThe entity requires a Data-Purpose header.Send Data-Purpose: https://example.local/purpose/tutorial or another purpose URI.