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

# Create and query your first registry

> Start a local registry, create and update a record over HTTP, and see which requests the server refuses and why.

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

If you are a data publisher evaluating
[Base Registry Engine](../../reference/glossary/#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](../extend-a-registry-with-a-module/), continues in that directory.

<QuickstartMeta
  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 Silicon', 'Git and a Bash shell', 'Running Docker', 'OpenSSL', 'Python 3.11 or later and uv', 'curl 7.76 or later', 'An editor and two terminals']}
/>

{/* Evidence: crates/registry-bregctl/src/lib.rs, init_files();
    products/breg/quickstart/run.sh;
    crates/registry-mint/demo/support/key_material.py. */}

## Install Base Registry Engine

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:

```sh
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](../../security/openssf-evidence/), then rerun the installer with
`BREG_ASSET_DIR` pointing at the verified directory.

{/* Evidence: crates/registry-breg/install.sh; crates/registry-evidencectl/install.sh. */}

## Get the quickstart files

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

```sh
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.

## Start the registry

:::caution[Use synthetic data only]
The launcher creates a disposable database and local keys.
Stopping it removes the database container.
Starting it again replaces `products/breg/quickstart/.run/`, including its keys, tokens, and logs.
:::

In the first terminal, start the services:

```sh
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:

```text
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.

{/* Evidence: products/breg/quickstart/run.sh;
    products/breg/quickstart/support/quickstart.py, prepare() and enrich_local_package(). */}

## Read the first record

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

```sh
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](#renew-an-expired-token) and retry.

Read the records using the `operator` access profile:

```sh
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:

```json
{
  "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:

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

```text
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.

{/* Evidence: crates/registry-breg/src/auth.rs; crates/registry-breg/src/api/mod.rs;
    crates/registry-breg/tests/http_auth.rs; crates/registry-bregctl/src/lib.rs, init_files(). */}

## Create a record

Create the business record and save the response:

```sh
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"
```

```text
HTTP 201
```

Read the response:

```sh
python3 -m json.tool tutorial-work/created.json
```

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

```json
{
  "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](../../reference/breg-api/).

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.

{/* Evidence: crates/registry-breg/src/idempotency.rs; crates/registry-breg/src/mutation.rs, held_response();
    crates/registry-breg/src/record_profile.rs, record_member();
    crates/registry-breg/tests/postgres_mutation.rs. */}

## Choose which fields to read

Ask for labels only:

```sh
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`:

```sh
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"
```

```text
HTTP 400
```

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

```yaml
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:

```sh
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.

{/* Evidence: crates/registry-bregctl/src/lib.rs, init_files();
    crates/registry-breg/src/query.rs; crates/registry-breg/src/api/mod.rs;
    crates/registry-breg/tests/http_read_only.rs. */}

## Update the record

Read the created record and save its HTTP headers:

```sh
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:

```sh
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:

```sh
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:

```sh
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"
```

```text
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.

{/* Evidence: crates/registry-breg/src/api/mod.rs, patch_dispatch();
    crates/registry-breg/src/mutation.rs;
    crates/registry-breg/tests/postgres_mutation.rs. */}

## Try an invalid record

Omit the required `code` field:

```sh
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"
```

```text
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.

{/* Evidence: crates/registry-bregctl/src/lib.rs, init_files();
    crates/registry-breg/src/mutation.rs; crates/registry-breg/src/artifacts.rs. */}

## Stop the services

:::caution[Stopping deletes the demo database]
Pressing `Ctrl+C` in the first terminal stops BReg and Mint and removes the PostgreSQL container,
including the records you created.
:::

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.

{/* Evidence: products/breg/quickstart/run.sh, cleanup(). */}

## What you built

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.

## Troubleshooting

### Renew an expired token

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

```sh
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.

{/* Evidence: products/breg/quickstart/support/quickstart.py, prepare();
    crates/registry-mint/src/main.rs. */}

### Other problems

| Symptom | Next move |
| --- | --- |
| The launcher stops because `breg`, `bregctl`, or `mint` is not on `PATH` | Add the installers' directory, `~/.local/bin` unless you changed it, to `PATH` in that terminal. |
| A service does not become ready | Inspect `.run/logs/` under the quickstart directory. Do not share tokens, keys, or database URL files. |
| A create returns `409` with code `idempotency.conflict` | The 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.conflict` | The code is already taken; codes are unique in this project. Choose a new code and a new key. |
| You opened a fresh second terminal | Run the `registry_run` and `registry_url` assignments again. Keep the existing `tutorial-work` directory. |

## Next

- [Extend a registry with a module](../extend-a-registry-with-a-module/) to create a project of your own and change a field.
- [Review changes before updating a registry](../review-registry-changes/) to test a configurable approval workflow.
- [How a configured registry works](../../explanation/configuration-defined-registry/) for the compile model behind what you saw.
- [Query a registry from Python and Node](../query-breg-client/) to make the same requests from an application.
- [Evaluate Base Registry Engine](../../start/evaluate-breg/) for the operating requirements before you commit to it.