Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.26.1.
You have a project from the first tutorials, or you are starting one, and you want to model your own registry.
This page covers the model: the registry file, the entities and fields that describe your records, references between records, validity time and location, the modules that carry reusable parts of the model, and the events an entity declares.
At the end, bregctl check compiles a project that describes your records instead of the placeholders bregctl init wrote.
This phase, Model your registry, is four pages read in order.
This page shapes the records.
Control access per profile decides which operations, fields, and rows each caller may touch.
Declare change requests and actions adds reviewed changes and writes that touch several records at once.
Test with journeys proves the model behaves, with check, explain, generate, and journeys that run against a database.
Everything on these four pages runs without a database. Database setup, packaging, and activation belong to Deploy a registry, and if you have never run a registry, Create and query your first registry comes first.
If bregctl is not installed yet, the release installer places breg and bregctl together in ~/.local/bin on Linux amd64, Linux arm64, and macOS on Apple Silicon:
curl -fsSL https://github.com/registrystack/registry-stack/releases/latest/download/breg-install.sh | bashbregctl --versionReplace | bash with | less to read the installer before you run it on a host you operate.
Every command accepts --format json for machine-readable output.
Every error and every finding names the document path that caused it, so a message at entities[id=record].fields[id=label] points at one member of one file, and you never have to guess where to look.
Create a project
Section titled “Create a project”bregctl init ./my-registryThe destination must be a new path.
init refuses an existing directory, even an empty one, with output.destination.invalid, so it can never overwrite a project you have edited; choose another path or remove the directory first.
The command writes a working example project rather than a blank one:
| File | What it holds |
|---|---|
registry.yaml | Registry and package identity, a Registry Manifest projection, one closed vocabulary, two entities, and two access profiles. |
modules/record-notes/module.yaml | One module adding an optional field to an entity the project owns, pinned by content digest in the project’s modules list. |
tests/journeys.yaml | A journey that creates a group and a record, reads it under both profiles, patches it, and lists it. |
runtime.example.yaml | An example of the runtime configuration an operator supplies. No command reads it; copy it out of the project and replace every value. |
README.md | What each file holds and which command to run next. |
Every block in those files carries a comment saying what it does and what an adopter changes. Replace the placeholder identifiers with your own; they are deliberately generic.
init then compiles the project, prints the compiled revision, and reports one finding:
init succeededrevision: sha256:<digest>finding access.profile.unrestricted_collection at entities[id=record].accessProfiles[id=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 accessartifacts: 5next: read ./my-registry/README.md, then run 'bregctl check ./my-registry'next: leave the finding above as it is; the example operator profile lists a whole collection on purpose, and ./my-registry/README.md says where to narrow itnext: replace canonicalBaseIri in ./my-registry/registry.yaml before you build a production package; the example value is a reserved .invalid name that never resolvesA finding is an advisory the compiler attaches to a document path; it does not fail check, and Test with journeys lists every finding the compiler can report and explains --deny-findings and --production.
This one says the operator profile can list every row, which is right for a registry-wide operations role and wrong once the registry holds more than one caller’s data.
The project’s record-reader profile shows the row boundary that closes it.
The next: lines name the command to run after the README, the finding the example keeps on purpose, and the placeholder canonicalBaseIri that has to be replaced before a production package. Only init prints them.
The registry file
Section titled “The registry file”Registry Relay compiles a file with the same name and an unrelated grammar. This page describes the Base Registry Engine (BReg) registry document only, and a snippet copied from a Relay page does not compile here.
registry.yaml is one document with these top-level members:
| Member | What it holds |
|---|---|
apiVersion, kind | registry.registrystack.org/v1alpha1 and RegistryProject. |
registry | id, version, defaultLanguage, and canonicalBaseIri, the base of every record IRI. |
package | Production identity: environment, instanceId, sequence, sourceRevision. Required for a production package. |
manifestProjection | The Registry Manifest catalog a deployment publishes: the accessProfile it describes, a classificationCeiling, catalog metadata, and the datasets[] and dataServices[] it lists. Omitting it raises the finding manifest_projection.missing, which is reported only in authoring mode: it does not fail bregctl check --production, and package builds a package without one. A project that still carries the retired singular dataset or dataService is refused; bregctl project migrate <project> --write rewrites it to the plural shape. |
modules | Included modules by id, version, and digest. A production compilation requires a digest on every lock, and a lock and a loaded source for every module. |
entities | Entities declared in this file. |
accessProfiles | Every profile a token can select. |
vocabularies | Closed code lists that vocabulary-code fields reference. |
Identifiers follow a closed grammar: a lowercase ASCII letter first, then lowercase letters, digits, hyphens, or underscores, at most 64 bytes.
check refuses anything else with identifier.invalid, so an event id such as record.status.changed fails where record-status-changed-v1 passes.
The project convention is kebab-case for entity ids, field ids, profile ids, and vocabulary ids.
The API exposes field ids in camelCase, so asset-code becomes assetCode, unless a field sets apiName.
Entities and fields
Section titled “Entities and fields”entities: - id: asset-item primaryDataset: asset-registry route: assets mutationMode: mutable batch: maximumItems: 100 maximumBytes: 262144 fields: - id: asset-code type: string required: true maxLength: 64 classification: internal - id: label type: string required: true maxLength: 200 classification: internal - id: asset-class type: vocabulary-code vocabulary: asset-classification required: true classification: internal constraints: - kind: unique fields: [asset-code]vocabularies: - id: asset-classification values: [equipment, vehicle, furniture]An entity needs an id, a primaryDataset that names the dataset its records belong to, a route that becomes the path segment under /v1/records/, and a mutationMode.
mutable allows patches; create_only refuses them, which suits event-like records such as inspections.
tombstone: true enables the tombstone operation.
batch sets the item and byte limits of one batch request for the entity.
Every field carries id, type, required, and classification, one of public, internal, or restricted.
Classification never grants access on its own: the manifest projection uses it as a ceiling, and profiles still list the fields they can read.
| Type | Field members | Notes |
|---|---|---|
boolean, int64, uuid | None | int64 holds whole numbers. |
string | maxLength, minLength | Single-line, bounded text. |
text | maxLength | Longer text; not filterable. |
decimal | precision, scale, minimum, maximum | Exact decimals, exposed as strings. |
date, timestamp | None | ISO 8601; timestamps are UTC. |
vocabulary-code | vocabulary or values | A code from a declared vocabulary or an inline list. |
reference | target, onDelete | The identifier of a record in another entity. onDelete defaults to restrict. |
crs84-point | precision, bbox | A GeoJSON Point in CRS84 longitude and latitude. |
structured | schema, maxBytes | A JSON value validated by an inline JSON Schema. |
Constraints are evaluated on every write:
kind | Members | Checks |
|---|---|---|
unique | fields, optional when | No two live records share the values. when narrows the rule to rows where a field equals a value, is null, is not null, or the record is in an active lifecycle. |
compare | left, operator, right | Two fields of one record compare as less_than, less_than_or_equal, greater_than, or greater_than_or_equal. |
int_range | field, minimum, maximum | An int64 field stays within bounds. |
vocabulary | field, values | A field takes one of the listed values. |
temporal-non-overlap | startField, endField, scopeFields | No two records with equal scope fields have overlapping validity. |
The compiler refuses field names that collide with Registry Record envelope members, so no field can be called recordIdentifier, revisionIdentifier, snapshot, request, or requestPresence.
Base Registry Engine API reference lists the wire encoding of every type.
Related records
Section titled “Related records”A reference field stores another record’s identifier, and the server checks that the target exists and that the caller may read it.
For a many-to-many relationship, declare a relationship entity with two references, then let readers walk it with a read path:
- id: household primaryDataset: household-registry route: households mutationMode: mutable fields: - id: household-code type: string required: true maxLength: 64 classification: internal - id: administrative-area type: string required: true maxLength: 64 classification: internal - id: local-household-number type: int64 required: true classification: internal selectorProfiles: - id: by-local-reference fields: [administrative-area, local-household-number] - id: by-household-code fields: [household-code] readPaths: - id: people through: group-membership to: person route: people - id: group-membership primaryDataset: household-registry route: group-memberships mutationMode: mutable fields: - id: household type: reference target: household required: true classification: internal - id: person type: reference target: person required: true classification: internalA selector profile names the fields a caller can present to find one record without knowing its identifier.
The lookup route accepts the selector values and returns the single matching record or a lookup.unresolved problem.
A read path exposes the records reachable through a relationship entity at /v1/records/households/{id}/people, paged and filtered like a list.
Both are inert until a profile grants them, which Control access per profile covers under lookups and readPaths.
Time and place
Section titled “Time and place”A temporal entity declares which fields bound a record’s validity:
- id: membership-record primaryDataset: household-registry route: memberships mutationMode: mutable fields: - id: subject type: reference target: person required: true classification: internal - id: group type: reference target: household required: true classification: internal - id: valid-from type: date required: true classification: internal - id: valid-to type: date required: false classification: internal temporal: startField: valid-from endField: valid-to constraints: - kind: temporal-non-overlap scopeFields: [subject] startField: valid-from endField: valid-toThe server then answers asOf reads by valid time, and callers can ask which membership was in force on a date.
A profile that needs to re-read records as they were recorded at an earlier mutation holds the snapshot operation.
A crs84-point field stores a location.
Add a bbox limit on the field and a spatialQueries.bbox grant on the profile, and the list route accepts a bounding box.
The GIS routes described in the API reference publish the same records as GeoJSON for desktop clients.
Modules
Section titled “Modules”A module is a reusable file under modules/<id>/module.yaml that contributes entities, vocabularies, events, and extensions to entities declared elsewhere, so a part of the model can be reviewed and versioned separately from the project that adopts it.
The module init wrote adds one optional field to the project’s record entity:
id: record-notesversion: 0.1.0extendEntities: - entity: record fields: - id: internal-note type: string maxLength: 500 classification: internalregistry.yaml includes it under modules with its id, version, and content digest.
An extendEntities entry may add fields, constraints, events, selectorProfiles, readPaths, accessRequirements, and change control to an entity the module does not own.
Adding a field to the model grants nobody access to it; a profile still has to list it before a caller can read or set it.
Recompute the digests after any module edit, because a stale digest is a compile error, which is how a reviewed project stays pinned to the module content it was reviewed with:
bregctl project lock ./my-registryproject lock ./my-registry --check reports each module’s digest and whether it changed, without writing the file, which suits a review gate.
Review a digest change together with the module diff that caused it.
Events
Section titled “Events”An event projects chosen fields of a committed change to a webhook destination the deployment binds by name. Events are declared on entities, in the registry file or in a module, and the project carries no receiver URL and no secret:
- id: record primaryDataset: generic-registry route: records mutationMode: mutable classification: internal fields: - id: code type: string required: true maxLength: 64 classification: internal - id: status type: vocabulary-code vocabulary: record-status classification: internal events: - id: record-status-changed-v1 trigger: patched projection: [code, status] when: kind: fields changed: [status] webhook: destinationId: record-receiver| Member | Values |
|---|---|
id | The event contract identifier, sent to receivers as the CloudEvents ce-type. Use a new id for a payload change receivers must notice. |
trigger | created, patched, tombstoned, or request_lifecycle for change-request state changes. |
when | Optional. kind: fields with changed, beforeEquals, and afterEquals; or kind: request_lifecycle with transitions, toStates, and stages. Every listed test must hold. |
projection | The field ids copied into the event payload’s values. Restricted fields cannot be projected. |
webhook.destinationId | The key the runtime configuration binds to a receiver URL and signing key. A production compilation requires every event to name one. |
Field ids in when and projection are the authored ids, and the payload keeps them.
changed is valid only with the patched trigger, beforeEquals with patched and tombstoned, and afterEquals with created and patched.
Render the exact HTTP request a receiver will get, with synthetic values, so you can build the receiver before the registry exists:
bregctl webhook sample ./my-registry --event record-status-changed-v1The sample prints the request line, the CloudEvents headers, and the body; only the placeholders for values the deployment supplies vary:
webhook sample succeededevent: record-status-changed-v1POST <configured-webhook-request-target> HTTP/1.1Content-Type: application/jsonX-Registry-Signature: v1=<computed-at-delivery>ce-specversion: 1.0ce-type: record-status-changed-v1
{"entity":"record","packageRevision":"sha256:<digest>","recordId":"<uuid>","revision":1,"trigger":"patched","values":{"code":"x","status":"draft"}}The remaining headers are omitted.
An event id the project does not deliver fails with webhook.sample.event_refused, and the message lists the ids the project delivers.
explain events prints every compiled delivery with its trigger, when tests, projection fields, destination, and the retry schedule the runtime will follow.
Binding record-receiver to a receiver belongs to Bind webhook receivers; Send events to a webhook walks through a delivery end to end, and Base Registry Engine API reference documents the headers, signature, and retry contract.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and next move |
|---|---|
init fails with output.destination.invalid. | The destination exists or contains a parent-directory component. Give a new path, or remove the directory if it holds nothing you want. |
An error or finding names a path such as entities[id=record].fields[id=label]. | The path is a document path into registry.yaml or the module file the message names. Fix the member it points at. |
check reports identifier.invalid. | An id breaks the closed grammar: start with a lowercase letter and use only lowercase letters, digits, hyphens, and underscores, at most 64 bytes. |
| A module digest does not match. | Run project lock after editing a module, then review the digest change together with the module diff. |
check refuses the manifest projection’s singular dataset or dataService. | The retired shape is no longer read. Run bregctl project migrate ./my-registry --write, then review the rewritten datasets[] and dataServices[]. |
- Control access per profile: profiles, grants, row boundaries, and the offline admission preview.
- Declare change requests and actions: reviewed changes and writes that touch several records at once.
- Test with journeys:
check,explain,generate, findings, and journeys. - Modeling patterns for registries: how to shape entities, references, and history.
- Base Registry Engine configuration reference: every project and runtime key.