Released docs. You are viewing the documentation published with v0.34.0. Development docs are available at Latest.
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 three 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. |
dev-clients.yaml | Two local clients bound to the operator and record-reader profiles, for bregctl dev to issue tokens to. It holds no secret. |
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.
Derive a registry from PublicSchema starts a project from named concepts instead of placeholders.
init then compiles the project, prints the compiled revision, and reports two findings:
Initialized a registry project. 6 artifacts written. revision sha256:<digest>
README.md dev-clients.yaml modules/record-notes/module.yaml registry.yaml runtime.example.yaml tests/journeys.yaml
finding access.profile.unrestricted_collection 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 access finding access.profile.unrestricted_rows entities[id=record].accessProfiles[id=evidence-source].rowBoundaries this profile has no claim-bound row restriction for its granted operations; requestVisibility owner limits request reads only, and other lifecycle rules still apply. Review this registry-wide access
0 errors, 2 findings.
Next: 1. read ./my-registry/README.md, then run 'bregctl check ./my-registry' 2. leave the findings above as they are; the example operator profile lists a whole collection and the example evidence-source profile looks up any record, both on purpose, and ./my-registry/README.md says where to narrow them 3. 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.
The first 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 second says the evidence-source profile can look up any record by its code, which is right for a source that vouches for the whole registry.
The project’s record-reader profile shows the row boundary that closes either one.
The steps under Next: name the command to run after the README, the findings 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. |
Configure the OpenID Connect (OIDC) issuer to include registry_actor_kind on every BReg access
token. The value must be exactly human, agent, or service; a missing or malformed value causes
authentication to fail before BReg selects an access profile. An actorKind on an access profile
further restricts that profile to the matching token kind and must be paired with
requesterClients. Omit actorKind only when the profile deliberately accepts all three declared
actor kinds.
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 id: asset-code-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 | Optional id, 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 | Optional id, 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 | Optional id, field, minimum, maximum | An int64 field stays within bounds. |
vocabulary | Optional id, field, values | A field takes one of the listed values. |
temporal-non-overlap | Optional id, startField, endField, scopeFields | No two records with equal scope fields have overlapping validity. |
Give each constraint an id that is unique within its entity. The ID stays stable in
explain model, migration statements, and operator diagnostics when the constraint’s fields or
bounds change. If you omit id, the compiler derives a content-based fallback that can repeat on
another entity and changes when the constraint changes.
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.
Encrypt restricted fields
Section titled “Encrypt restricted fields”A field whose classification is restricted and whose type is one of string, text, date,
decimal, or structured can declare encrypted: true. Base Registry Engine then stores every
value of the field as an AES-256-GCM envelope in a dedicated bytea column and decrypts it only at
the response edge, so a database reader of dumps, replicas, and backups sees no plaintext. What the
key custody behind that promise requires, and what the blind index still reveals, is the model in
Field encryption for restricted fields; the deployment
binding that carries the key is configured as Deploy a registry describes.
fields: - id: national-identifier type: string required: true maxLength: 32 classification: restricted encrypted: true lookup: normalization: [trim, remove-separators] unique: truePhase 1 refuses pattern on an encrypted field with field.encrypted.pattern_refused, because
native patterns are PostgreSQL checks and encrypted plaintext must not reach PostgreSQL. Remove
pattern, or keep the field in plaintext storage when the native pattern is required.
The lookup block declares the blind index that equality lookups resolve through.
normalization lists the transformations applied to the value in declared order, from a closed
vocabulary: trim, uppercase, lowercase, collapse-whitespace, and remove-separators.
unique: true adds a uniqueness rule over the normalized value. For a national identifier, prefer
the narrow trim and remove-separators pair over Unicode case conversion, because case folding
decides which values collide and a registry identifier rarely needs it.
An encrypted field gives up the query shapes a sealed column cannot answer, and the compiler
refuses them at authoring time with a message naming the field: filtering and sorting on the field,
any comparison other than equality, a change request that targets it, derived SQL over it, and
event projection of it. Declare a lookup block when the registry must find records by the field’s
exact value.
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 id: subject-validity-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.
A hook projects chosen fields of a committed change to a destination the deployment binds by name. Hooks 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 hooks: - id: record-status-changed-v1 phase: after trigger: patched projection: [code, status] when: kind: fields changed: [status] handler: kind: url 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. |
phase | after. An entity hook runs after the triggering transaction commits; the engine refuses any other phase. |
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. |
handler.kind | url, rhai, or wasm. A url hook delivers to a bound destination; a rhai or wasm hook runs a reviewed local program in the post-commit worker. |
handler.destinationId | The key the runtime configuration binds to a receiver URL and signing key. Required for a url hook; a local handler names its reviewed script or module path instead. |
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:
Built the sample delivery. The canonical request follows. event record-status-changed-v1
POST <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
{"causation":{"hop":0,"root":"<event-uuid>"},"data":{"entity":"record","packageRevision":"sha256:<digest>","recordId":"<uuid>","revision":1,"trigger":"patched","values":{"code":"x","status":"draft"}},"dataschema":"urn:breg:event-schema:generic-registry:record:record-status-changed-v1:sha256:<digest>","id":"<event-uuid>","source":"urn:registrystack:registry:generic-registry:instance:<configured-instance>","subject":{"recordReference":"hmac-sha256:<64-hex>","recordRevision":1},"time":"2026-01-01T00:00:00Z","type":"record-status-changed-v1"}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 a filter, sort, comparison, change request, or projection that uses an encrypted field. | An encrypted field answers equality lookups only. Declare a lookup block for exact-value lookups, or move the comparison to a field that is not sealed; Encrypt restricted fields lists what is refused. |
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, permissions, 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.