Skip to content
Registry StackDocsDevelopment (unreleased)

Review changes before updating a registry

For the data publisher

View as Markdown

If you are evaluating Base Registry Engine for data that needs approval, this tutorial takes you through an asset-location correction: propose a different site, collect two approvals, and apply the change. A change request is a record that proposes a write to another record and carries it out only after the review its configuration requires, and a review stage is one named round of that review with its own approval count and its own deciding profiles, both declared in declare change requests and actions. You will run the supplied workflow against PostgreSQL, adapt its configuration, and check a refusal.

Outcome
A tested approval workflow with a review stage you configured
Time
About 25 minutes, plus the PostgreSQL image download
Level
Local evaluation only
Prerequisites
The checkout and binaries from Create and query your first registryRunning Docker, and two terminalsPostgreSQL client tools (psql) and OpenSSLPython 3.11 or later, uv, and an editor

Create and query your first registry installs breg, bregctl, and mint and clones the repository at the tag that matches them. Open a terminal at the root of that breg-tutorial checkout and confirm the binaries answer:

Terminal window
bregctl --version

This tutorial adds two requirements to that setup. The example runner calls psql directly, so confirm the PostgreSQL client tools are installed:

Terminal window
psql --version

It also needs a disposable PostgreSQL database, which you borrow from the quickstart launcher in step 3 rather than from any database you already run.

Run commands from the root of that clone unless a step says otherwise. The workflow runs through bregctl test, which sends requests through the real HTTP router with separate caller credentials and a disposable database. It does not leave the change-request example running as an HTTP service.

Copy the supplied example into a working directory so your edits leave the original unchanged, then check it:

Terminal window
mkdir -p tutorial-work
cp -R products/breg/acceptance/asset-site-placement-change-requests tutorial-work/asset-corrections
bregctl check tutorial-work/asset-corrections

The check succeeds, prints the compiled configuration’s digest, and lists eighteen findings: seven access.profile.no_required_scope and eleven access.profile.unrestricted_collection. The block shows the first of each kind and omits the rest:

check succeeded
revision: sha256:<configuration-digest>
finding access.profile.no_required_scope at entities[id=asset-item].accessProfiles[id=asset-operator].requiredScopes: no scope restricts who may select this profile; any authenticated principal satisfying its purpose and row claims qualifies. Add a required scope unless this is intended
finding access.profile.unrestricted_collection at entities[id=asset-item].accessProfiles[id=asset-operator].rowBoundaries: this profile can list all rows, subject only to query bounds; caller filters are not authorization. Add a claim-bound row restriction or review this registry-wide access

Findings are advisories, not failures. The example’s asset-operator and site-planner profiles admit any authenticated caller with the right purpose and can list every row, and the correction submitter and reviewer can list every row they reach. The four correction profiles require scopes. Leave the findings as they are for this tutorial; --deny-findings turns them into failures when you want a project held to that bar.

You have a valid request type without writing server code. No database has changed yet. Ask the CLI to describe what it compiled:

Terminal window
bregctl --format json explain change-requests tutorial-work/asset-corrections \
> tutorial-work/change-requests.json
python3 - tutorial-work/change-requests.json <<'PY'
import json
import sys
explanation = json.load(open(sys.argv[1]))["explanation"]
request = explanation["requests"][0]
print(request["requestEntity"], request["requestRoute"])
for stage in request["stages"]:
print(stage["id"], stage["approvals"])
for action in request["actions"]:
print(action["operation"], action["stage"] or "-", ",".join(action["preconditions"]))
PY
placement-correction-request placement-correction-requests
review 1
final-approval 1
submit_request - Idempotency-Key,If-Match
revise_request - Idempotency-Key,If-Match
cancel_request - Idempotency-Key,If-Match
apply_request - Idempotency-Key,If-Match,proposalVersion,effectDigest
approve_request review Idempotency-Key,If-Match,proposalVersion,effectDigest
reject_request review Idempotency-Key,If-Match,proposalVersion,effectDigest
request_revision review Idempotency-Key,If-Match,proposalVersion,effectDigest
approve_request final-approval Idempotency-Key,If-Match,proposalVersion,effectDigest
reject_request final-approval Idempotency-Key,If-Match,proposalVersion,effectDigest
request_revision final-approval Idempotency-Key,If-Match,proposalVersion,effectDigest

The example requires one approval at each of two stages. Every action needs an Idempotency-Key and an If-Match; a decision or an apply also binds to the proposalVersion and effectDigest the caller read. The JSON file also lists the compiled effects, the planner and application policy, the grants of each role, and the request bounds. A planner is the script that computes a request’s effects when they depend on the request’s own content, declared in place of a fixed effects list; this example declares effects, so the JSON reports its planner kind as declarative. See plan effects with a Rhai script for the alternative.

Open registry.yaml in tutorial-work/asset-corrections. The placement-correction-request entity has its own fields: a placement reference, a proposed site, and a reason. The relevant configuration, expanded for readability, is:

changeRequest:
retention:
mode: operator_erase
effects:
- target:
fromField: placement
operation: patch
set:
site:
fromField: proposed-site
review:
stages:
- id: review
approvals: 1
excludeSubmitter: true
- id: final-approval
approvals: 1
excludeSubmitter: true

The request stores a proposal separately from the placement record. Its configured effect says which placement field changes when the request is applied. Submitting and approving the request do not apply that effect. retention.mode defaults to retain, which keeps every proposal version and refuses operator erasure. The example selects operator_erase so an operator can later erase the detail of a finished request with an explicit command; nothing is deleted on a schedule.

The access profiles separate the work. Each grant lists the operations its role may perform on the request entity:

ProfileOperationsResponsibility
correction-submittercreate, get, patch, submit_request, revise_request, cancel_requestDraft, edit, submit, and if asked, revise the request
correction-reviewerget, list, approve_request, reject_request, request_revisionDecide the review stage
correction-supervisorget, approve_request, reject_request, request_revisionDecide the final-approval stage
correction-applierget, apply_requestApply the approved proposal to the placement

A reviewer’s grant names the stages it may decide under reviewStages, with the placement fields it may see while deciding. The applier’s grant names the placement under applyTargets. Each stage excludes the submitting principal from approving. That rule does not require a different person for every stage: access profiles and stage grants determine which other principals can approve. The example supplies different identities for the two reviewers.

On asset-placement, changeControl.requiredFor: [patch] makes patching a controlled operation. The ordinary placement grants omit patch, and the generated API has no PATCH route for placements at all: the only way to change a placement’s site is an applied request. Those grants carry requestPresence instead, so an operator reading a placement sees that a correction is pending.

In a second terminal, at the root of the same clone, start the local quickstart. You are borrowing its disposable PostgreSQL cluster, which the runner reaches over TLS with the quickstart’s certificate authority. The --installed flag makes the launcher use the breg, bregctl, and mint you installed rather than building them from the checkout.

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

The launcher first prints == Using installed breg, bregctl, and mint from PATH with the three paths it resolved. Wait for this line, then leave the terminal running:

Base Registry Engine generic quickstart is ready.

Return to your first terminal. Create a Python environment for the example runner’s YAML support:

Terminal window
uv venv tutorial-work/venv
uv pip install --python tutorial-work/venv/bin/python 'PyYAML==6.0.2'
export PATH="$PWD/tutorial-work/venv/bin:$PATH"

Success installs PyYAML into your working directory rather than changing your system Python.

Create a private connection file from the quickstart’s generated credentials, so the database password never enters your shell history:

Terminal window
python3 - "$PWD/products/breg/quickstart/.run" tutorial-work/test.env <<'PY'
from pathlib import Path
from urllib.parse import urlsplit
import shlex
import sys
run = Path(sys.argv[1])
connection = urlsplit((run / "secrets/runtime-database-url").read_text().strip())
password = (run / "secrets/database-password").read_text().strip()
url = f"postgresql://postgres:{password}@localhost:{connection.port}/postgres"
values = {
"BREG_TEST_DATABASE_URL": url,
"BREG_TEST_TLS_CA_PEM_PATH": str(run / "tls/ca.pem"),
}
target = Path(sys.argv[2])
target.touch(mode=0o600)
target.write_text("".join(
f"export {name}={shlex.quote(value)}\n" for name, value in values.items()
))
PY
products/breg/scripts/test-change-request-examples.sh --installed \
--env tutorial-work/test.env --asset-project "$PWD/tutorial-work/asset-corrections"

The --installed flag makes the runner use the same installed breg and bregctl. Look for these lines in its output:

running change-request fixture: asset-site-placement-change-requests
change-request fixture passed: asset-site-placement-change-requests
running change-request fixture: publicschema-household-change-requests
change-request fixture passed: publicschema-household-change-requests
running change-request fixture: person-name-change-rhai
change-request fixture passed: person-name-change-rhai

The runner checks and tests your copy, the unchanged household example, and a third example whose effects a Rhai planner computes. Each run creates its own test databases and roles and removes them on exit. The quickstart’s generic registry stays separate from those test databases.

Open tests/journeys.yaml in your copy. A journey is a sequence of API calls with the status and fields expected from each response. This one creates an asset, two sites, and a placement, drafts the correction, submits it, collects both approvals, and applies it. On a running server, the same journey is this sequence of HTTP calls:

Journey stepsCallerHTTP call
Create the asset, both sites, and the placementasset-operatorPOST /v1/records/assets, /v1/records/sites, and /v1/records/placements
Create and edit the draft requestcorrection-submitterPOST /v1/records/placement-correction-requests, then PATCH /v1/records/placement-correction-requests/{id} with the record’s ETag
Submitcorrection-submitterPOST /v1/records/placement-correction-requests/{id}/actions/submit
Approve the review stagecorrection-reviewerPOST /v1/records/placement-correction-requests/{id}/actions/stages/review/approve
Approve the final-approval stagecorrection-supervisorPOST /v1/records/placement-correction-requests/{id}/actions/stages/final-approval/approve
Applycorrection-applierPOST /v1/records/placement-correction-requests/{id}/actions/apply

Before each action, the journey gets the request as the next caller. The response’s data.request.actions[] lists what that caller may do next, each with its href, its own ifMatch, and, for decisions and apply, the proposalVersion and effectDigest it binds to. The journey’s etagRef, proposalVersionRef, and effectDigestRef take those values from the captured response, and a client making HTTP calls itself reads the same members. The record’s ordinary ETag header edits the draft; it is not an action’s ifMatch.

Rename final-approval to operations-approval in your copied registry.yaml, in two places: the stage’s id and the correction-supervisor grant’s reviewStages entry. Then change the stage of the approve-final-stage step in tests/journeys.yaml to the same name. The step and capture identifiers can keep their original names.

Your stage, grant, and journey step now agree on who approves what:

# Under changeRequest.review.stages:
- {id: operations-approval, approvals: 1, excludeSubmitter: true}
# The correction-supervisor grant:
- entity: placement-correction-request
operations: [get, approve_request, reject_request, request_revision]
readableFields: [placement, proposed-site, reason]
reviewStages:
- stage: operations-approval
targets:
- {entity: asset-placement, readableFields: [site], rowBoundaries: []}
# The approve-final-stage step in tests/journeys.yaml:
request:
operation: approve_request
stage: operations-approval

Run the structural check and the database journey again:

Terminal window
bregctl check tutorial-work/asset-corrections
products/breg/scripts/test-change-request-examples.sh --installed \
--env tutorial-work/test.env --asset-project "$PWD/tutorial-work/asset-corrections"

The check reports a different configuration digest with the same findings, and all three fixtures pass again. You changed the stage name, its authority grant, and the caller’s action without changing server code. For a new stage rather than a rename, you would also add its GET and approval steps to the journey.

5. Check that a direct-write bypass is refused

Section titled “5. Check that a direct-write bypass is refused”

In your copied registry.yaml, find the asset-operator grant for asset-placement. Temporarily add patch to its operations:

operations: [create, get, list, patch]
Terminal window
bregctl check tutorial-work/asset-corrections

The check fails with change_control.direct_write_grant and the message a controlled mutation operation cannot remain directly granted. This is an expected refusal: a grant cannot bypass the placement’s changeControl rule. Remove patch from that grant and rerun the check; it succeeds again.

The example runner already removed its temporary databases, roles, and credentials. In the second terminal, press Ctrl+C to stop the quickstart services and remove its container.

Your copied project remains in tutorial-work/asset-corrections. To keep that project but remove the tutorial connection file:

Terminal window
rm -f tutorial-work/test.env
Terminal window
rm -rf products/breg/quickstart/.run

You tested a proposal with its own schema, two approval stages, and a controlled effect on an existing record. You changed one stage and verified that an ordinary write grant cannot override change control. These are local authoring and database tests; packaging and activating the configuration for your deployment are separate steps.

SymptomNext move
check prints findingsFindings are advisories; the first line is the verdict. Pass --deny-findings when you want them to fail the check.
The quickstart cannot start Docker or a serviceStart Docker and check the named prerequisite. Inspect .run/logs/ without sharing secrets.
The database connection is refused or the certificate failsKeep the quickstart running and recreate test.env after restarting it. Each run has new ports and certificates.
Python cannot import yamlRepeat the virtual environment installation and PATH assignment in your first terminal.
Renaming the stage makes check failMatch the stage id to the supervisor’s reviewStages entry.
The runner reports test.step.failedIts path journeys[0].steps[<n>] counts the steps of tests/journeys.yaml from zero. Check that step’s stage and expected status.
change-requests is not a recognized explain subjectYour bregctl predates the checkout. Confirm that bregctl --version matches the checkout’s tag, then reinstall or clone again so they agree.
command not found for bregctl or mintThe installers in Create and query your first registry place all three binaries in ~/.local/bin. Add that directory to PATH in this terminal, or rerun the installer you skipped.
  • Declare change requests and actions for every member of changeRequest and changeControl.
  • Base Registry Engine API reference for the seven request states, the action routes, and the request.actions[] shape.
  • Deploy a registry to package and activate your project, to review a later revision with bregctl diff, and to erase proposal detail under operator_erase.
  • Open products/breg/CHANGE_REQUEST_EXAMPLES.md in your clone for the household example and stale-proposal recovery.
  • Configure Registry Mint when connecting callers to your own deployment.