Skip to content
Registry StackDocsDevelopment (unreleased)

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

init then compiles the project, prints the compiled revision, and reports one finding:

init succeeded
revision: 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 access
artifacts: 5
next: 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 it
next: 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. 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.

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.

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
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
uniquefields, 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.
compareleft, operator, rightTwo fields of one record compare as less_than, less_than_or_equal, greater_than, or greater_than_or_equal.
int_rangefield, minimum, maximumAn int64 field stays within bounds.
vocabularyfield, valuesA field takes one of the listed values.
temporal-non-overlapstartField, endField, scopeFieldsNo 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.

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

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
MemberValues
idThe event contract identifier, sent to receivers as the CloudEvents ce-type. Use a new id for a payload change receivers must notice.
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.
webhook.destinationIdThe 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:

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:

webhook sample succeeded
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
{"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.

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