Skip to content
Registry StackDocsDevelopment (unreleased)

Create and query your first registry

For the data publisher

View as Markdown

If you are a data publisher evaluating Base Registry Engine (BReg), start with a small business directory. You will start a local registry, create a business record, change its label, and try requests the server refuses. The records use two required fields, code and label, plus an optional group and status; the business directory is the example you supply, not a built-in type. Everything you keep goes into one directory, tutorial-work, and the next tutorial, Extend a registry with a module, continues in that directory.

Outcome
A record created and updated over HTTP, with refused requests showing authentication, field permissions, version checks, and required fields at work.
Time
About 20 minutes, plus the image download
Level
Local evaluation only
Prerequisites
Linux amd64 or arm64, or macOS on Apple SiliconGit and a Bash shellRunning DockerOpenSSLPython 3.11 or later and uvcurl 7.76 or laterAn editor and two terminals

Install breg and bregctl, then the Evidence Gateway toolset, because Registry Mint ships in it and the launcher runs Mint to issue the local access tokens:

Terminal window
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bash
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/evidencectl-install.sh | bash
bregctl --version
mint --version

The URLs name latest, which this tutorial takes on purpose so an evaluation starts on the newest release; a deployment pins a version instead, so the binary that was tested is the binary that runs. Each installer checks its binaries against the release SHA256SUMS before anything reaches ~/.local/bin, and installs them together or not at all. Keep that directory on your PATH in both terminals.

The commands pipe a script from GitHub straight into bash; replace | bash with | less to read it first. Before these binaries reach a host that serves anyone else, verify the release as described in OpenSSF and release trust, then rerun the installer with BREG_ASSET_DIR pointing at the verified directory.

The launcher and its fixtures live in the repository. Clone it at the version you installed:

Terminal window
installed="$(bregctl --version | awk '{print $2}')"
git clone --depth 1 --branch "v$installed" https://github.com/registrystack/registry-stack.git breg-tutorial
cd breg-tutorial

The project is pre-1.0, so the default branch may carry changes the installed binaries do not implement; pinning the checkout keeps the launcher and the binaries on one contract. Run the commands from this checkout’s root in both terminals.

In the first terminal, start the services:

Terminal window
products/breg/quickstart/run.sh --installed

The flag tells the launcher to use the breg, bregctl, and mint on your PATH rather than compiling them from the checkout. Wait for this line; setup output is omitted:

Base Registry Engine generic quickstart is ready.

Leave this terminal running. The launcher has created one record with code QS-001, and BReg and Mint listen on loopback addresses it printed, with ports chosen for this run.

In the second terminal, save the address and prepare an authorization header file:

Terminal window
registry_run="$PWD/products/breg/quickstart/.run"
registry_url=$(cat "$registry_run/breg-origin")
umask 077
mkdir tutorial-work
sed 's/^/Authorization: Bearer /' "$registry_run/secrets/operator-token" \
> tutorial-work/authorization.header

The header file holds a token and is readable only by your user; keep it out of version control and support messages. If a request returns 401 after a pause, renew the token and retry.

Read the records using the operator access profile:

Terminal window
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header \
"$registry_url/v1/records/records?accessProfile=operator" | python3 -m json.tool

The items array contains one record, and its domainData object holds the configured fields:

{
"code": "QS-001",
"group": null,
"label": "Quickstart example record",
"status": null
}

group and status are optional fields of the same entity and read as null until a record supplies them. Beside domainData, recordIdentifier and revisionIdentifier identify the record and its version, and meta names the registry, dataset, and entity type.

What happened: you read a Registry Record envelope, the shape every response on this API shares, with the record’s fields and identifiers in the item and meta saying where the record lives. You chose an access profile, a named set of permissions a token may use, with the accessProfile query parameter.

Try the same request without the header:

Terminal window
curl --silent --show-error \
--output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \
"$registry_url/v1/records/records?accessProfile=operator"
HTTP 404

Open tutorial-work/problem.json: its code is resource.not_found.

What happened: BReg conceals a protected route from an unauthenticated caller by answering as if the route did not exist, and naming a profile in the query granted nothing.

Create the business record and save the response:

Terminal window
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: tutorial-create-1' \
--data '{"data":{"code":"DEMO-002","label":"North Quay Engineering"}}' \
--output tutorial-work/created.json --write-out 'HTTP %{http_code}\n' \
"$registry_url/v1/records/records?accessProfile=operator"
HTTP 201

Read the response:

Terminal window
python3 -m json.tool tutorial-work/created.json

The server generates the identifier, shown as <record-id>:

{
"data": {
"domainData": {
"code": "DEMO-002",
"group": null,
"label": "North Quay Engineering",
"status": null
},
"recordIdentifier": "<record-id>",
"revisionIdentifier": "1",
"snapshot": "<snapshot-token>"
},
"meta": {
"datasetIdentifier": "generic-registry",
"entityTypeIdentifier": "record",
"registryIdentifier": "generic-registry"
}
}

A single record sits under data; a list carries the same fields in each item. data.snapshot appears only on create, patch, and tombstone responses: an opaque token for a later :snapshot read, described in the Base Registry Engine API reference.

Run the create command again unchanged, then read the response again. You get 201 with the same identifier and revision, not a second record.

What happened: Idempotency-Key identifies a single write, so a client that lost the first response can retry without creating a duplicate; use a different key for a different write.

Ask for labels only:

Terminal window
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header --get \
--data-urlencode 'accessProfile=operator' \
--data-urlencode '$select=label' \
--data-urlencode '$top=10' \
"$registry_url/v1/records/records" | python3 -m json.tool

Each item’s domainData now contains only label. The identifiers stay in the envelope: $select names configured fields only and refuses recordIdentifier.

Reading a field does not permit filtering by it. Try a filter on label:

Terminal window
curl --silent --show-error \
--header @tutorial-work/authorization.header --get \
--data-urlencode 'accessProfile=operator' \
--data-urlencode "\$filter=label eq 'North Quay Engineering'" \
--output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \
"$registry_url/v1/records/records"
HTTP 400

The problem code is query.invalid. Open products/breg/quickstart/.run/project/registry.yaml and find the operator grant for record:

readableFields: [code, label, group, status]
writableFields: [code, label, group, status]
filterableFields: [code, status]

Repeat the request on code, which is filterable, and match the code you created, DEMO-002. The filter compares the field to a literal, so the value changes with the field:

Terminal window
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header --get \
--data-urlencode 'accessProfile=operator' \
--data-urlencode "\$filter=code eq 'DEMO-002'" \
"$registry_url/v1/records/records" | python3 -m json.tool

The request succeeds and items holds the one record you created, with recordIdentifier, revisionIdentifier, and domainData in each item. Filtering on code eq 'North Quay Engineering' would also succeed, with an empty items: no record carries that code, and a filter that matches nothing is a successful read, not a refusal.

What happened: a grant carries three separate permission lists, and label is readable and writable but not filterable, so $select may name it and $filter may not.

Read the created record and save its HTTP headers:

Terminal window
record_id=$(python3 -c 'import json; print(json.load(open("tutorial-work/created.json"))["data"]["recordIdentifier"])')
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header \
--dump-header tutorial-work/record.headers \
"$registry_url/v1/records/records/$record_id?accessProfile=operator" | python3 -m json.tool

The response still shows "revisionIdentifier": "1", and its ETag header identifies that version. Save the exact value, including its quotes:

Terminal window
record_etag=$(awk 'tolower($1) == "etag:" {print $2}' tutorial-work/record.headers | tr -d '\r')

Change the label with JSON Patch; If-Match refuses the update if the record changed since your read:

Terminal window
curl --silent --show-error --fail-with-body \
--header @tutorial-work/authorization.header \
--header 'Content-Type: application/json-patch+json' \
--header 'Idempotency-Key: tutorial-patch-1' \
--header "If-Match: $record_etag" \
--request PATCH \
--data '[{"op":"replace","path":"/data/label","value":"North Quay Engineering Ltd"}]' \
"$registry_url/v1/records/records/$record_id?accessProfile=operator" | python3 -m json.tool

The response has the same identifier, "revisionIdentifier": "2", and label North Quay Engineering Ltd. Patch paths address the object you sent at creation, so /data/label names the label; domainData in responses is not a patch target. Try a different update while still using the old ETag:

Terminal window
curl --silent --show-error \
--header @tutorial-work/authorization.header \
--header 'Content-Type: application/json-patch+json' \
--header 'Idempotency-Key: tutorial-patch-2' \
--header "If-Match: $record_etag" \
--request PATCH \
--data '[{"op":"replace","path":"/data/label","value":"North Quay Engineering Group Ltd"}]' \
--output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \
"$registry_url/v1/records/records/$record_id?accessProfile=operator"
HTTP 412

The problem code is precondition.failed; the label remains North Quay Engineering Ltd.

What happened: the ETag names the version you read and If-Match makes the server compare it before writing, so a stale client cannot overwrite a change it never saw, and a new idempotency key does not bypass that check.

Omit the required code field:

Terminal window
curl --silent --show-error \
--header @tutorial-work/authorization.header \
--header 'Content-Type: application/json' \
--header 'Idempotency-Key: tutorial-missing-code' \
--data '{"data":{"label":"South Harbour Logistics"}}' \
--output tutorial-work/problem.json --write-out 'HTTP %{http_code}\n' \
"$registry_url/v1/records/records?accessProfile=operator"
HTTP 400

The problem code is request.invalid; no record was created. In the project’s registry.yaml, find required: true on the code field.

What happened: the same required: true that validated this request also shapes the project’s generated request schema, so the model decides what a record needs, not application code.

Stop the launcher now. The .run/ files remain until the next launch and contain disposable secrets. Keep tutorial-work/: the next tutorial creates a project inside it, and the checkout’s .gitignore excludes the directory, so the header file stays out of commits.

You created and updated a record, retried a write, narrowed a read, and saw authentication, query permissions, version checks, and required fields affect real requests. All of that came from one project the launcher compiled; the next tutorial puts a copy in your hands.

The local token lasts five minutes. While Mint is still running, renew it and replace the header file:

Terminal window
umask 077
mint token \
--url "$(cat "$registry_run/mint-origin")/token" \
--client-id generic-quickstart \
--key "$registry_run/keys/operator/signing-p256-private-jwk" \
> "$registry_run/secrets/operator-token" &&
sed 's/^/Authorization: Bearer /' "$registry_run/secrets/operator-token" \
> tutorial-work/authorization.header

Success prints nothing; retry the request. If renewal fails, check that the first terminal is still running.

SymptomNext move
The launcher stops because breg, bregctl, or mint is not on PATHAdd the installers’ directory, ~/.local/bin unless you changed it, to PATH in that terminal.
A service does not become readyInspect .run/logs/ under the quickstart directory. Do not share tokens, keys, or database URL files.
A create returns 409 with code idempotency.conflictThe key was already used with a different body. Reuse the exact body, or choose a new key for a new write.
A create returns 409 with code mutation.conflictThe code is already taken; codes are unique in this project. Choose a new code and a new key.
You opened a fresh second terminalRun the registry_run and registry_url assignments again. Keep the existing tutorial-work directory.