Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/dev/llms.txt
Full corpus: https://docs.registrystack.org/dev/llms-full.txt

# Author a registry project

> Model your own registry in the project bregctl init writes, from the registry file and its entities to references, validity time, location, modules, and event declarations, and compile it without a database.

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](../breg-access/) decides which operations, fields, and rows each caller may touch.
[Declare change requests and actions](../breg-change-control/) adds reviewed changes and writes that touch several records at once.
[Test with journeys](../breg-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](../../operate/breg/), and if you have never run a registry, [Create and query your first registry](../../tutorials/first-breg/) 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:

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

{/* Evidence: crates/registry-breg/install.sh; crates/registry-bregctl/src/lib.rs; crates/registry-breg/src/compiler.rs. */}

## Create a project

```sh
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:

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

```text
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](../breg-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.

{/* Evidence: crates/registry-bregctl/src/lib.rs, init() and write_files_with_before_publish(); products/breg/acceptance/asset-site-placement-change-requests/registry.yaml. */}

## 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](../../reference/glossary/#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`.

{/* Evidence: crates/registry-breg/src/contract.rs, RegistryProject; crates/registry-breg/src/compiler.rs, validate_id(), validate_project_header(), and validate_module_locks(); crates/registry-breg/src/package.rs, add_compiled_artifacts(). */}

## Entities and fields

```yaml
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](../../reference/breg-api/) lists the wire encoding of every type.

{/* Evidence: products/breg/generated/authoring/registry-module.schema.json; crates/registry-breg/src/compiler.rs, reserved_logical_name(). */}

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

```yaml
  - 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](../breg-access/) covers under `lookups` and `readPaths`.

{/* Evidence: products/breg/acceptance/publicschema-household/modules/publicschema-household-core/module.yaml; crates/registry-breg/src/api/mod.rs. */}

## Time and place

A temporal entity declares which fields bound a record's validity:

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

{/* Evidence: products/breg/acceptance/household-history/registry.yaml; products/breg/acceptance/spatial-service-sites/registry.yaml. */}

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

```yaml
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:

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

{/* Evidence: products/breg/generated/authoring/registry-module.schema.json; crates/registry-bregctl/src/lib.rs, project_lock(). */}

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

```yaml
  - 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:

```sh
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:

```text
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](../../operate/breg-webhooks/); [Send events to a webhook](../../tutorials/send-registry-events-to-a-webhook/) walks through a delivery end to end, and [Base Registry Engine API reference](../../reference/breg-api/#events-and-webhooks) documents the headers, signature, and retry contract.

{/* Evidence: products/breg/generated/authoring/registry-module.schema.json; crates/registry-breg/src/contract.rs, EventSource and EventConditionSource; crates/registry-breg/src/webhook.rs; crates/registry-bregctl/src/lib.rs, webhook_sample(). */}

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

## Next

- [Control access per profile](../breg-access/): profiles, grants, row boundaries, and the offline admission preview.
- [Declare change requests and actions](../breg-change-control/): reviewed changes and writes that touch several records at once.
- [Test with journeys](../breg-journeys/): `check`, `explain`, `generate`, findings, and journeys.
- [Modeling patterns for registries](../../explanation/registry-modeling-patterns/): how to shape entities, references, and history.
- [Base Registry Engine configuration reference](../../reference/breg-configuration/): every project and runtime key.