Skip to content
Registry StackDocsv0.34.0

Author a registry project

View as Markdown

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:

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

Replace | 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.

Terminal window
bregctl init ./my-registry

The 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:

FileWhat it holds
registry.yamlRegistry and package identity, a Registry Manifest projection, one closed vocabulary, two entities, and three access profiles.
modules/record-notes/module.yamlOne module adding an optional field to an entity the project owns, pinned by content digest in the project’s modules list.
tests/journeys.yamlA journey that creates a group and a record, reads it under both profiles, patches it, and lists it.
dev-clients.yamlTwo local clients bound to the operator and record-reader profiles, for bregctl dev to issue tokens to. It holds no secret.
runtime.example.yamlAn example of the runtime configuration an operator supplies. No command reads it; copy it out of the project and replace every value.
README.mdWhat 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
resolves

A 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.

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:

MemberWhat it holds
apiVersion, kindregistry.registrystack.org/v1alpha1 and RegistryProject.
registryid, version, defaultLanguage, and canonicalBaseIri, the base of every record IRI.
packageProduction identity: environment, instanceId, sequence, sourceRevision. Required for a production package.
manifestProjectionThe 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.
modulesIncluded 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.
entitiesEntities declared in this file.
accessProfilesEvery profile a token can select.
vocabulariesClosed 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:
- 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.

TypeField membersNotes
boolean, int64, uuidNoneint64 holds whole numbers.
stringmaxLength, minLengthSingle-line, bounded text.
textmaxLengthLonger text; not filterable.
decimalprecision, scale, minimum, maximumExact decimals, exposed as strings.
date, timestampNoneISO 8601; timestamps are UTC.
vocabulary-codevocabulary or valuesA code from a declared vocabulary or an inline list.
referencetarget, onDeleteThe identifier of a record in another entity. onDelete defaults to restrict.
crs84-pointprecision, bboxA GeoJSON Point in CRS84 longitude and latitude.
structuredschema, maxBytesA JSON value validated by an inline JSON Schema.

Constraints are evaluated on every write:

kindMembersChecks
uniqueOptional id, fields, optional whenNo 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.
compareOptional id, left, operator, rightTwo fields of one record compare as less_than, less_than_or_equal, greater_than, or greater_than_or_equal.
int_rangeOptional id, field, minimum, maximumAn int64 field stays within bounds.
vocabularyOptional id, field, valuesA field takes one of the listed values.
temporal-non-overlapOptional id, startField, endField, scopeFieldsNo 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.

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: true

Phase 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.

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: internal

A 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.

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-to

The 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.

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-notes
version: 0.1.0
extendEntities:
- entity: record
fields:
- id: internal-note
type: string
maxLength: 500
classification: internal

registry.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:

Terminal window
bregctl project lock ./my-registry

project 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
MemberValues
idThe event contract identifier, sent to receivers as the CloudEvents ce-type. Use a new id for a payload change receivers must notice.
phaseafter. An entity hook runs after the triggering transaction commits; the engine refuses any other phase.
triggercreated, patched, tombstoned, or request_lifecycle for change-request state changes.
whenOptional. kind: fields with changed, beforeEquals, and afterEquals; or kind: request_lifecycle with transitions, toStates, and stages. Every listed test must hold.
projectionThe field ids copied into the event payload’s values. Restricted fields cannot be projected.
handler.kindurl, 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.destinationIdThe 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:

Terminal window
bregctl webhook sample ./my-registry --event record-status-changed-v1

The 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.1
Content-Type: application/json
X-Registry-Signature: v1=<computed-at-delivery>
ce-specversion: 1.0
ce-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.

SymptomCause 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[].