Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.26.1.
Lookup reference for the HTTP API a compiled Base Registry Engine (BReg) package serves and for the events it emits. Every registry serves the same route shapes; the entity routes, fields, profiles, and actions come from your project. The contract is not a frozen compatibility promise.
For the authoring grammar, use author a registry project.
For deployment and recovery, use deploy a registry.
For receivers, delivery, and replay, use
bind webhook receivers.
For every YAML key, use the configuration reference.
A running server publishes its own OpenAPI document at /openapi.json; the schemas there are
the exact ones its package compiled.
Authentication and profile selection
Section titled “Authentication and profile selection”Every request carries Authorization: Bearer <token> unless the selected profile sets
anonymous: true. The token is a JWT signed by the configured issuer with the configured
algorithm. It must carry
iss, aud, and exp, and its scopes come from the claim named by scopeClaim, split on
scopeSeparator; both are required settings. The principal and purpose claims are the ones named
under authorityClaims.
Each route has one default access profile. A caller selects another with the accessProfile
query option. A profile grants access when the token satisfies its requiredScopes,
requiredPurposes, and principalClaim, and when the profile grants the operation on that
entity. Row boundaries on the grant compare a record field with a token claim and hide every
record outside them, on reads and on writes.
A refusal is concealed as 404 resource.not_found whenever the token is missing, does not
satisfy the profile, or names a record outside the caller’s boundary. The only exception is a
token that was presented and rejected, which returns 401 authentication.refused. A caller
therefore cannot distinguish a record that does not exist from one it may not see.
Routes
Section titled “Routes”{route} is the entity’s route value. {readPath} is the route of one of the entity’s
readPaths. {stage} is a review stage identifier, and {action} under /v1/actions is an
immediate action identifier. An unknown path or a method the route does not accept returns the
same concealed 404 as an unauthorized request.
| Route | Operation | Available when | Response |
|---|---|---|---|
GET /v1/registry | metadata | The caller reaches at least one route | Entity inventory filtered to the caller: route, dataset identifier, the operations each entity exposes with the profile that grants each, the schema path, and for a request entity its changeRequest capability (planner kind, review mode, application policy). A caller with no visible route gets the concealed 404. |
GET /v1/schemas/{entity} | metadata | The caller reaches the entity | JSON Schema for the entity record, the target of the describedby link. |
GET /openapi.json | metadata | The caller reaches at least one route | The compiled OpenAPI document, filtered to the caller's routes. |
GET /health and GET /healthz | liveness | Always | {"status":"alive"}. |
GET /ready | readiness | Always | {"status":"ready"}, or 503 runtime.not_ready until the package and database are usable. |
POST /v1/records/{route} | create | Every entity | 201 with the record envelope, Location, and ETag. |
GET /v1/records/{route} | list | Every entity | Collection. |
GET /v1/records/{route}/{recordId} | get | Every entity | Record envelope with ETag. |
PATCH /v1/records/{route}/{recordId} | patch | mutationMode: mutable | Record envelope with data.snapshot. Requires If-Match. |
DELETE /v1/records/{route}/{recordId} | tombstone | tombstone: true | Envelope of the final revision with data.snapshot. Requires If-Match. |
POST /v1/records/{route}:lookup | lookup | Entity declares selectorProfiles | Record envelope, or 404 lookup.unresolved. |
POST /v1/records/{route}:batch | batch | Entity declares batch | {"snapshot", "results": [...]}. |
GET /v1/records/{route}/{recordId}/revisions | revisions | Every entity | Collection of the record's revisions, at most 100 per read. |
GET /v1/records/{route}/{recordId}/revisions/{revision} | revisions | Every entity | Record envelope for one revision. |
GET /v1/records/{route}:snapshot | snapshot | Every entity | Collection as recorded at a saved snapshot. |
GET /v1/records/{route}:current | list | Entity declares temporal | Collection of the records valid now. |
GET /v1/records/{route}:as-of | list | Entity declares temporal | Collection of the records valid at asOf. |
GET /v1/records/{route}/{recordId}/{readPath} | list through readPaths[] | Entity declares readPaths | Collection of related records. |
POST /v1/records/{route}/{recordId}/actions/{action} | submit_request, revise_request, cancel_request, apply_request | Entity declares changeRequest | Request action result. Requires If-Match and Idempotency-Key. |
POST /v1/records/{route}/{recordId}/actions/stages/{stage}/{decision} | approve_request, reject_request, request_revision | Entity declares changeRequest.review.stages | Request action result. Requires If-Match and Idempotency-Key. |
POST /v1/actions/{action}/target-conditions | invoke | Registry declares actions[] | {"preconditions": {...}} for the targets the input names. |
POST /v1/actions/{action} | invoke | Registry declares actions[] | {"action", "applicationId", "results"}. Requires Idempotency-Key. |
Record envelope
Section titled “Record envelope”Reads, creates, patches, tombstones, revisions, and snapshots return one shape, the Registry Record envelope:
{ "data": { "recordIdentifier": "2d7e5e0c-5b2c-4b8e-9f1e-0d3c1a2b3c4d", "revisionIdentifier": "3", "domainData": { "code": "SITE-001", "label": "North depot", "location": {"type": "Point", "coordinates": [30.5234, -1.9441]} } }, "meta": { "registryIdentifier": "asset-registry", "datasetIdentifier": "asset-sites", "entityTypeIdentifier": "asset-site" }}| Member | Type | Meaning |
|---|---|---|
data.recordIdentifier | string, UUID | Stable identity of the record. |
data.revisionIdentifier | string, ^[1-9][0-9]*$ | Revision counter, starting at 1. Compare as a string. |
data.domainData | object | The entity’s fields under their apiName, narrowed to the grant’s readableFields and to $select. |
data.snapshot | string | Present on mutation responses: an opaque token for :snapshot reads, at most 4096 bytes. |
data.request | object | Present on change request records: workflow state and the actions the caller may take. |
data.requestPresence | object | Present when the grant declares requestPresence: the pending change requests that target this record. |
meta.* | strings | Registry, dataset, and entity identifiers from the compiled package. |
A field name that would collide with an envelope member is refused at compile time, so
domainData never contains data, meta, items, pageInfo, nextCursor, @context,
@id, @type, or any of the identifier members named in the table.
Response headers:
| Header | Value |
|---|---|
Content-Type | application/json, or application/ld+json when the request sent Accept: application/ld+json. The JSON-LD form adds @context pointing at https://id.registrystack.org/contexts/registry-record/v1. |
ETag | "breg-hmac-sha256:<64 hex>", a strong validator of the current revision. Send it back in If-Match. |
Link | <https://id.registrystack.org/profiles/registry-record/v1>; rel="profile", then </v1/schemas/{entity}>; rel="describedby". |
Location | On 201 Created: /v1/records/{route}/{recordId}. |
Field values are encoded by type. The generated JSON Schema for each entity carries the same
rules, with an optional field spelled as anyOf of the type and null.
| Field type | JSON encoding | Notes |
|---|---|---|
boolean | true or false | |
string | string | minLength and maxLength from the field. |
text | string | maxLength from the field. |
int64 | integer | Full 64-bit range; clients that parse JSON numbers as doubles lose precision above 2^53. |
decimal | string | Decimal digits with the declared precision and scale, never a JSON number. |
date | string | RFC 3339 full date. |
timestamp | string | RFC 3339 date-time. |
uuid | string | Lower-case UUID. |
vocabulary-code | string | One of the vocabulary values; the schema carries them as an enum with x-registry-vocabulary. |
reference | string | The target record identifier as a UUID. |
crs84-point | object | GeoJSON Point, {"type": "Point", "coordinates": [longitude, latitude]}. |
structured | object | Validated against the declared schema, bounded by maxBytes, advertised as x-registry-maxBytes. |
Collections and paging
Section titled “Collections and paging”A collection read returns the matching records as items, a pageInfo object, and the same
meta as a single record. Only a single-record response wraps the record in data; a collection
member carries recordIdentifier, revisionIdentifier, and domainData directly, so the field
path is items[0].domainData, not items[0].data.domainData:
{ "items": [{"recordIdentifier": "…", "revisionIdentifier": "1", "domainData": {}}], "pageInfo": {"nextCursor": "eyJ…"}, "meta": {"registryIdentifier": "asset-registry", "datasetIdentifier": "asset-sites", "entityTypeIdentifier": "asset-site"}}nextCursor is null on the last page. To continue, send the cursor as $skiptoken with no
other query option except accessProfile. The cursor is signed with the secret named by the
runtime cursor.secretRef, binds the whole original query, and expires after
cursor.maxAgeSeconds; a cursor that fails
any of those checks returns 400 query.cursor_invalid. With $count, the response carries the
matching total beside the page.
Query options
Section titled “Query options”Options apply only where the grant permits them: $select draws from readableFields,
$filter from filterableFields, $orderby from sortableFields, and $count needs
allowCount. Anything else returns 400 query.invalid with the same status for an unknown
field and for a field the grant withholds.
| Option | Routes | Meaning | Bound |
|---|---|---|---|
accessProfile | Every record route | Evaluate the request under a profile other than the route default. The caller must satisfy that profile. | One profile identifier. |
$select | Collection reads | Comma-separated domain fields to return. Envelope members are never selectable. | 128 fields. |
$filter | Collection reads | Predicate over filterableFields: eq, ne, lt, le, gt, ge, in, startswith(), contains(), combined with and, or, not. | 32 predicates, depth 16, 128 nodes, 100 in values, 1024-byte literals. |
$orderby | Collection reads | One field from sortableFields, ascending. A desc direction is refused with query.invalid. | One field. |
$top | Collection reads | Page size. | 100. |
$count | Collection reads | Adds the matching count. Refused unless the grant sets allowCount. | Grant flag. |
$skiptoken | Collection reads | Continuation from pageInfo.nextCursor. It carries the original query, so repeating snapshot or validAt beside it is refused. | Expires after cursor.maxAgeSeconds. |
bbox | list on entities with a crs84-point field | west,south,east,north in CRS84 degrees, permitted by spatialQueries.bbox on the grant. | 256 bytes, plus the span limits on the grant. |
asOf | :as-of | The valid-time instant to evaluate, in the type of the entity's valid-time fields. | One value. |
snapshot | :snapshot | A data.snapshot value returned by an earlier mutation. | 4096 bytes. |
validAt | :snapshot | The valid-time instant to evaluate inside the snapshot, for temporal entities. | One value. |
| Whole query string | Every route | Decoded size of all options together. | 16 KiB. |
A filter is an expression over field names and literals:
$filter=status eq 'active' and (region in ('north','east') or startswith(label,'Depot'))$filter=not contains(label,'closed') and validFrom ge 2026-01-01Strings are single quoted, numbers and dates are bare, and null compares with eq and ne.
Literals must fit the field type. A bounding box is four CRS84 degrees, west, south, east, north:
bbox=30.0,-2.1,30.9,-1.8Mutations
Section titled “Mutations”Every write needs Content-Type: application/json, except a patch, which needs
application/json-patch+json. A body over the entity’s byte bound or with an unknown member is
refused with 400 request.invalid and a fieldPath.
Create
Section titled “Create”POST /v1/records/{route} with the fields the grant lists in writableFields:
{"data": {"code": "SITE-001", "label": "North depot", "location": {"type": "Point", "coordinates": [30.5234, -1.9441]}}}The response is 201 Created with the envelope, ETag, and Location. Required fields
missing from the body, values outside their type bounds, a reference to a missing target, and a
violated unique constraint are refused before anything is written.
PATCH /v1/records/{route}/{recordId} takes a JSON Patch document whose paths start at
/data/, followed by the field’s apiName:
[ {"op": "replace", "path": "/data/label", "value": "North depot (annex)"}, {"op": "add", "path": "/data/closedOn", "value": "2026-12-31"}]If-Match is required and must carry the ETag of the revision you read; a stale value returns
412 precondition.failed and a missing one 428 precondition.required. A field outside
writableFields, an envelope member, or a path that does not start with /data/ is refused.
The response is the new revision’s envelope with data.snapshot.
Tombstone
Section titled “Tombstone”DELETE /v1/records/{route}/{recordId} with If-Match writes a final revision that marks the
record tombstoned and returns that revision’s envelope. A tombstoned record stays readable through
its revisions and disappears from live collection reads. The route exists only when the entity
sets tombstone: true.
POST /v1/records/{route}:batch applies several items to one entity in one transaction, bounded
by the entity’s batch.maximumItems and batch.maximumBytes:
{ "items": [ {"operation": "create", "data": {"code": "SITE-002", "label": "East depot"}}, {"operation": "patch", "id": "2d7e5e0c-…", "ifMatch": "\"breg-hmac-sha256:…\"", "patch": [{"op": "replace", "path": "/data/label", "value": "West depot"}]} ], "changeContext": { "kind": "correction", "reasonCode": "effective-date-corrected", "reasonText": "Site start date was recorded one month late.", "sourceReferences": ["case-document:correction-001"] }}The response is {"snapshot": "…", "results": [...]} where each result carries operation,
id, revision, etag, and data. Note that the result items keep the older identifier
spelling rather than the Registry Record envelope.
changeContext is optional and shared by the whole batch; an item-level override is refused. A
correction requires a nonempty reasonCode. reasonCode holds at most 64 UTF-8 bytes,
reasonText at most 4 KiB, and sourceReferences at most 16 entries of 256 bytes each.
Temporal non-overlap constraints are checked on the final state of the batch, so two interval
edits can be sent in either order. One stale ifMatch or one violated constraint rolls back every
item.
Idempotency
Section titled “Idempotency”Send Idempotency-Key (at most 256 bytes) on any create, patch, tombstone, batch, request
action, or immediate action. A repeat with the same key and the same request returns the stored
response without writing again. The same key with a different request returns
409 idempotency.conflict. Request actions and immediate actions require the header.
History reads
Section titled “History reads”Every write appends a revision; nothing is updated in place. Three route families read that history, each under its own grant operation.
Revisions
Section titled “Revisions”GET /v1/records/{route}/{recordId}/revisions returns a collection of the record’s revisions,
and .../revisions/{revision} returns one. A read covers at most 100 revisions. Revisions show
stored field values only; derived fields and read paths are not evaluated over history.
Correction context from a batch is absent unless the revisions grant lists
provenanceFields, drawn from kind, reasonCode, reasonText, and sourceReferences, and
even then only when every revision that batch produced is visible to the caller.
Effective time
Section titled “Effective time”A temporal entity, one with temporal.startField and temporal.endField, gains two collection
routes. :current returns the records whose interval contains the current instant, and
:as-of returns the records whose interval contains asOf. Intervals include the start and
exclude the end, and a null end is open. asOf is a UTC RFC 3339 timestamp such as
2026-06-05T00:00:00Z for either field type.
Snapshots
Section titled “Snapshots”GET /v1/records/{route}:snapshot reproduces a collection as the registry recorded it. Without
snapshot, it captures the latest committed state once and returns the token with the page, so
a client can page consistently while writes continue. With snapshot, it reproduces that earlier
state. validAt adds an effective-time filter inside the snapshot and is refused on a
non-temporal entity. Its value follows the start field’s type: a calendar date such as
2026-06-05 for a date field, a UTC RFC 3339 timestamp for a timestamp field.
recordedAsOf is not an option.
A snapshot token is a bookmark, not a credential: the caller needs the snapshot operation on
the route, and today’s row boundaries apply. Historical queries use stored fields only.
History starts at an exact empty or verified baseline, and a saved token never falls back to
current data. A snapshot whose history is unavailable, whose field metadata no longer matches, or
whose database budget of two seconds is exhausted returns 503 source.unavailable. One query
resolves at most 64 originating schema descriptors. A retained-history erasure makes every
snapshot at or after the earliest erased commit unavailable, including later ones.
Change requests
Section titled “Change requests”A change request is a record of an entity that declares changeRequest. Creating it stores a
proposal; a proposal changes its targets only when it is applied. The record moves through seven
server states:
draft ──submit──▶ submitted ──approve (every stage)──▶ approved ──apply──▶ applied ▲ │ reject ──▶ rejected ──revise──▶ draft │ └ request-revision ──▶ needs_changes ──revise──▶ draft └── cancel from any state except applied and canceled ──▶ canceled (owner only)Three of those transitions belong to the principal that owns the request. submit and revise
move the owner’s own draft, and cancel is the owner’s withdrawal: a reviewer rejects a request
rather than cancelling it. Another principal does not see those actions in actions, and calling
one regardless is refused with precondition.failed.
Two declarations shorten the path. With review: {mode: none} there are no stages, and submit
moves the request straight to approved. When the proposal’s disposition is apply, from
application.mode: automatic or from a planner that chose apply, the submit or the final
approval also applies the request in the same transaction. That action is offered only to a
profile holding apply_request and applyTargets for every target; another profile does not see
it in actions, and calling it regardless is refused with precondition.failed.
An rhai planner runs at submit. A planner that produces no plan refuses the submission with
400 request.plan_refused, whose detail names the failure kind from the planner’s closed
vocabulary and nothing else; a planner that ran out of its time budget returns
503 service.unavailable instead. Nothing is stored either way: the request stays a draft, and
the refusal is journaled with the same failure kind.
A read of the request record adds data.request:
{ "bregState": "submitted", "proposalVersion": 2, "effectDigest": "sha256:9f2c…", "proposal": {"reviewMode": "staged", "applicationDisposition": "queue"}, "editable": false, "actions": [ {"operation": "approve_request", "stage": "review", "href": "/v1/records/placement-corrections/2d7e…/actions/stages/review/approve", "ifMatch": "\"breg-hmac-sha256:…\""} ]}actions lists only what the caller’s profile may do in the current state, with the exact
href and the ifMatch value to send. Follow those links rather than composing routes.
proposal appears once the request has been submitted and describes the frozen proposal’s policy:
reviewMode is staged or none, applicationDisposition is apply or queue, and a queued
plan adds queueReason with its code and label. editable is true only while the request
is a draft, the caller owns it, and the selected profile may patch it.
| Action | Route suffix | Body | Accepted from |
|---|---|---|---|
submit | /actions/submit | {} | draft; the request reaches approved when review.mode is none, and applied when the disposition is apply |
approve | /actions/stages/{stage}/approve | {"proposalVersion", "effectDigest"} | submitted; the request reaches approved once every stage is satisfied, and applied when the disposition is apply |
reject | /actions/stages/{stage}/reject | {"proposalVersion", "effectDigest"} | submitted |
request-revision | /actions/stages/{stage}/request-revision | {"proposalVersion", "effectDigest"} | submitted |
revise | /actions/revise | {"rebase": false} | needs_changes, rejected |
revise with rebase | /actions/revise | {"rebase": true} | submitted, approved, needs_changes, rejected |
cancel | /actions/cancel | {} | Any state except applied and canceled, and only for the principal that owns the request |
apply | /actions/apply | {"proposalVersion", "effectDigest"} | approved |
Every action requires If-Match and Idempotency-Key. A decision or an apply names the exact
proposal it acts on through proposalVersion and effectDigest; both come from the read that
showed the action. The server rejects a decision whose binding no longer matches, so a reviewer
never approves a proposal that changed under them. A stage with excludeSubmitter: true refuses
the principal that submitted the request.
The response of an action is not a Registry Record envelope:
{ "id": "2d7e5e0c-…", "revision": 4, "snapshot": "…", "request": { "bregState": "applied", "proposalVersion": 2, "effectDigest": "sha256:9f2c…", "application": {"applicationId": "7c1d…", "proposalVersion": 2, "effectDigest": "sha256:9f2c…", "appliedAt": "2026-09-02T10:15:00Z"} }}application is null until the request is applied. When the entity sets
retention.mode: operator_erase, an operator can erase the proposal payload of a terminal
request; a later read then shows request.detailErased: true and the provenance stub stays
while target revisions reference the request.
GET /v1/registry describes each request entity’s changeRequest capability: the planner kind
(declarative, or rhai with its ABI, limits, and possible write count), the review mode, and
the application policy with its allowed dispositions and queue reasons.
Immediate actions
Section titled “Immediate actions”An action declared under actions[] applies its effects in one request. Call
POST /v1/actions/{action}/target-conditions with the input to learn which records the effects
would touch and the ifMatch each one currently has:
{"input": {"assetCode": "AST-0042", "siteCode": "SITE-001"}}Then call POST /v1/actions/{action} with the same input and those preconditions:
{ "input": {"assetCode": "AST-0042", "siteCode": "SITE-001"}, "preconditions": {"asset": {"ifMatch": "\"breg-hmac-sha256:…\""}}}The response names the application and every record the effects wrote:
{ "action": "register-asset", "applicationId": "7c1d…", "results": {"asset": {"entity": "asset-item", "recordId": "2d7e…", "revision": 2}}}A precondition that no longer matches returns 412 precondition.failed and nothing is written.
The caller needs the invoke operation on the action, and its targets row boundaries apply to
every record an effect reaches.
Problem documents
Section titled “Problem documents”A refusal is an RFC 9457 problem document with Content-Type: application/problem+json:
{ "type": "https://id.registrystack.org/problems/registry-breg/precondition/failed", "title": "Precondition failed", "status": 412, "detail": "The If-Match header does not match the current revision.", "code": "precondition.failed", "traceId": "3f1a9c2e5b7d4e6f8a9b0c1d2e3f4a5b", "fieldPath": null}traceId is a 32-character hexadecimal value that also appears in the server’s request log and, for
every request the journal records, in its audit record, so an operator can find the refusal without
the client sending any record value. A request that presents no credential and is refused before
admission is counted on the metrics listener rather than journaled, so its traceId is in the
request log only; see Scrape metrics.
fieldPath names the offending body member when the refusal is about one.
type is the published identifier for the code, and code is the same fact in the short form a
program branches on: the type is the code with each dot read as a path segment, under
https://id.registrystack.org/problems/registry-breg/. Branch on code; resolve type when you
want the published description of a refusal you have not seen before.
| Code | Status | When |
|---|---|---|
authentication.refused | 401 | A bearer token was presented and rejected: bad signature, wrong issuer or audience, expired, denied key, or a claim outside policy. |
resource.not_found | 404 | No token, a token that does not satisfy the profile, a route the profile cannot reach, or a record outside the caller's row boundary. The server does not distinguish these cases. |
lookup.unresolved | 404 | A lookup matched zero records or more than one. |
request.invalid | 400 | The body, headers, or path did not match the compiled contract. fieldPath names the member when there is one. |
query.invalid | 400 | A query option was malformed, over a bound, or not permitted by the grant. |
query.cursor_invalid | 400 | A $skiptoken that is malformed, was issued for a different query, or has expired. |
request.plan_refused | 400 | A change request was submitted and its bounded planner produced no plan. The detail names the failure kind from a closed vocabulary and nothing else. A planner that ran out of time returns 503 service.unavailable instead. |
unsupported.media_type | 415 | A body without the required Content-Type. |
precondition.required | 428 | A route that requires If-Match was called without it. |
precondition.failed | 412 | If-Match did not match the current revision. |
mutation.conflict | 409 | A constraint or workflow rule refused the write: a unique constraint, a missing reference target, or a request that is not in a state the action accepts. |
idempotency.conflict | 409 | The Idempotency-Key was reused with a different request. |
request.timeout | 504 | The request exceeded operationalTimeouts.httpRequestMilliseconds. |
source.unavailable | 503 | The database refused or timed out while the request held it. |
service.unavailable | 503 | The runtime could not complete the mutation for a reason other than the request itself. |
runtime.not_ready | 503 | The readiness probe before the package and database are usable. |
Events and webhooks
Section titled “Events and webhooks”An event is declared on an entity or a module extension and delivered to a webhook destination
bound in the runtime configuration. Field paths in the next table are relative to one event
under entities[].events[]; types, defaults, and descriptions come from the generated module
schema, and compiler rules follow the table.
| Key path | Type | Required | Accepted values | Constraints |
|---|---|---|---|---|
idStable event contract identifier, sent as ce-type. Use a new identifier for a breaking payload change. | string | Yes | Not specified | None specified |
projectionDeclared field identifiers to include in values. System event metadata is included separately. | array | Yes | Not specified |
|
projection[]Array item | string | No | Not specified | None specified |
triggerCommitted record change that can produce this event. | string | Yes |
| None specified |
webhookLogical delivery destination. Production compilation requires a webhook. | null | object | No | Not specified | None specified |
webhook.destinationIdKey in runtime eventDestinations; the project carries no URL or secret. | string | Conditional | Not specified | None specified |
whenOptional field tests, combined with AND. Omit to emit on every matching trigger. | null | object | No | Not specified | None specified |
when.afterEqualsRequired values after the change. Valid with created and patched triggers. | object | Conditional | Not specifiedSchema default, where declared: {} | None specified |
when.afterEquals.*Map valueA comparison literal in the closed field-condition language.
Objects and arrays are refused during source parsing. The compiler then
validates each scalar against the declared Registry field type. | boolean | null | number | string | Conditional | Not specified | None specified |
when.beforeEqualsRequired values before the change. Valid with patched and tombstoned triggers. | object | Conditional | Not specifiedSchema default, where declared: {} | None specified |
when.beforeEquals.*Map valueA comparison literal in the closed field-condition language.
Objects and arrays are refused during source parsing. The compiler then
validates each scalar against the declared Registry field type. | boolean | null | number | string | Conditional | Not specified | None specified |
when.changedFields whose values must change. Only valid with the patched trigger. | array | Conditional | Not specifiedSchema default, where declared: [] |
|
when.changed[]Array item | string | Conditional | Not specified | None specified |
when.kind | string | Conditional |
| None specified |
when.stages | array | Conditional | Not specifiedSchema default, where declared: [] |
|
when.stages[]Array item | string | Conditional | Not specified | None specified |
when.toStates | array | Conditional | Not specifiedSchema default, where declared: [] |
|
when.toStates[]Array item | string | Conditional | Not specified | None specified |
when.transitions | array | Conditional | Not specifiedSchema default, where declared: [] |
|
when.transitions[]Array item | string | Conditional | Not specified | None specified |
Compiler rules:
- Event IDs are unique across the registry.
- A projection names at least one declared field.
- Production compilation requires a webhook destination.
- Conditions and projections use authored field IDs, not the record API’s
apiNamespellings. - The event’s classification is the highest classification of every projected or condition field, and a lifecycle event is at least as classified as its request entity.
Destination URLs, keys, network policy, and ceilings live in runtime eventDestinations, keyed by
webhook.destinationId. The runtime binds the exact compiled destination set, and each
destination’s classificationCeiling must cover the event classification; runtime configuration
cannot add projected fields.
Triggers and conditions
Section titled “Triggers and conditions”| Trigger | changed | beforeEquals | afterEquals | Projected snapshot |
|---|---|---|---|---|
created | Not allowed | Not allowed | Allowed | After |
patched | Allowed | Allowed | Allowed | After |
request_lifecycle | Not allowed | Not allowed | Not allowed | Request at transition |
tombstoned | Not allowed | Allowed | Not allowed | Before |
A create-only entity refuses patched and tombstoned, and tombstoned also requires tombstone
support. Only a change request entity permits request_lifecycle. Omitting when selects every
matching trigger. With kind: fields, at least one test is nonempty, every test combines with
AND, and changed compares before and after values rather than whether a field appeared in the
patch. With kind: request_lifecycle, at least one of transitions, toStates, or stages
is set and each nonempty list must match; the projection then contains request fields, not target
values.
Capture and mutation commit in one transaction. Delivery uses the captured projection; later record or package changes do not recompute it.
HTTP request
Section titled “HTTP request”Method POST to the configured fixed path, without query parameters, encoded as
CloudEvents 1.0 HTTP binary mode
with canonical JSON in the body. Every listed header is present on every delivery.
| Header | Format or value | Meaning |
|---|---|---|
Accept | application/json | Accepted response media type. |
Content-Type | application/json | Canonical JSON event data. |
ce-specversion | 1.0 | CloudEvents version. |
ce-id | UUID | Captured event identity; unchanged by retry or replay. |
ce-source | urn:registrystack:registry:<registry-id>:instance:<instance-id> | Registry instance identity. |
ce-type | Authored event ID | External event contract, such as record-labelled-v1. |
ce-time | RFC 3339 timestamp | Mutation capture time; unchanged by retry or replay. |
ce-dataschema | urn:breg:event-schema:<registry-id>:<entity-id>:<event-id>:sha256:<digest> | Generated event payload schema identity and fingerprint. |
Idempotency-Key | sha256:<digest> | Opaque key for deduplicating automatic retries. |
X-Registry-Event-Generation | Positive integer, initially 1 | Increments on operator replay. |
X-Registry-Delivery-Attempt | Positive integer, initially 1 | Attempt number within the generation. |
X-Registry-Delivery-Time | RFC 3339 timestamp | Current attempt time; used for receiver freshness checks. |
X-Registry-Signature | v1=<base64url-without-padding> | HMAC-SHA-256 signature over the versioned signing input. |
The package carries each event schema at
generated/event-schemas/<entity-id>.<event-id>.schema.json. Record identifiers appear in the
body, never in CloudEvents headers.
The six common payload members are required, and a lifecycle event also requires request.
Other top-level members and unprojected values are absent.
| Field | Type | Meaning |
|---|---|---|
entity | String | Authored entity ID. |
recordId | UUID string | Record identity. |
revision | Positive integer | Record revision after the mutation. |
trigger | created, patched, request_lifecycle, or tombstoned | Captured mutation or request-transition trigger. |
packageRevision | sha256:<digest> string | Package active at capture time. |
values | Object | Exactly the declared projection, using authored field IDs; unset optional fields are null. |
request | Object; required only for request_lifecycle | Proposal and workflow identity, transition, states, optional stage and effect digest, and stable deduplication key. |
The lifecycle request object requires proposalVersion and workflowRevision as positive
integers, transition, fromState, toState, and deduplicationKey as strings, and stage
and effectDigest as strings or null. The deduplication key is stable across retries and
operator replay. Receiving a lifecycle event grants no authority to approve or apply a request.
{ "entity": "record", "recordId": "<record-uuid>", "revision": 2, "trigger": "patched", "packageRevision": "sha256:<package-digest>", "values": {"code": "EX-003", "label": "Updated label"}}Signature
Section titled “Signature”X-Registry-Signature is an HMAC-SHA-256 over the request, keyed with the exact bytes resolved
by hmacSha256KeyRef, at least 32 bytes, neither trimmed nor hex-decoded.
This is a Base Registry Engine format, not a CloudEvents signing standard.
The signing input starts with the ASCII bytes breg-webhook-signature-v1. Each later
value is prefixed with its byte length as an unsigned 64-bit big-endian integer, in this order:
ce-specversionce-idce-sourcece-typece-timece-dataschemaX-Registry-Event-GenerationX-Registry-Delivery-AttemptX-Registry-Delivery-Time- The method bytes,
POST - The configured request path bytes, without the origin
- The content type bytes,
application/json Idempotency-Key- The exact body bytes
Header values keep their original bytes; header names are not included. The header value is:
v1=BASE64URL_WITHOUT_PADDING(HMAC_SHA256(key, signing_input))A receiver compares the signature in constant time, validates the expected source, type, schema,
and payload, bounds the skew of X-Registry-Delivery-Time (not ce-time), and deduplicates
before it acts. The accepted skew is receiver policy, not a BReg limit.
Delivery, retry, and replay
Section titled “Delivery, retry, and replay”| Property | Contract |
|---|---|
| Delivery mode | Asynchronous, after commit, at least once. |
| Acknowledgement | Any 2xx response. |
| Failed attempt | Redirect, other non-2xx response, timeout, or transport failure. Redirects are not followed. |
| Total attempts | Compiled ceiling: 5 per generation, including the first attempt. Runtime may lower it. |
| Attempt timeout | Compiled ceiling: 5,000 ms. Runtime may lower it to a minimum of 100 ms. |
| Retry delays | 1, 2, 4, then 8 seconds, stopping at the deployed attempt limit. |
| Payload size | Event-specific compiled bound, at most 1 MiB. |
| Payload retention | eventDelivery.payloadRetentionDays: default 7, range 1 to 30 days, measured from capture. Replay does not extend expiry. |
| Successful payload | Erased when the successful terminal state commits. |
| Ordering | No global or per-record ordering guarantee. |
Runtime ceilings may tighten compiled limits, never widen them. A durable attempt audit precedes every send, and the terminal audit, state transition, and payload erasure commit together. Activation refuses a destination change that would strand retained nonterminal work.
| Property | Automatic retry | Operator replay |
|---|---|---|
| Event ID, source, type, time, and schema | Unchanged | Unchanged |
| Body and captured package revision | Unchanged | Unchanged |
| Generation | Unchanged | Incremented |
| Idempotency key | Unchanged | New key |
| Attempt number | Incremented | First send is 1 |
| Delivery time and signature | New for the attempt | New for the attempt |
| Payload expiry | Unchanged | Unchanged |
An idempotency key identifies one delivery generation, not an event across generations. A replay
opens a new generation with a new key, so a receiver whose effect must happen once deduplicates on
event identity, ce-source plus ce-id, or on a business key.
| State | Meaning | Shown by webhook list | Replay eligible |
|---|---|---|---|
pending | Waiting for an attempt or retry. | Yes | No |
leased | An attempt is in progress. | No | No |
delivered | Acknowledged successfully; payload erased. | No | No |
dead_lettered | Attempt limit exhausted. | Yes | Only with unexpired retained payload and matching generation and destination binding. |
expired | Payload retention ended. | Yes | No |
An empty webhook list result is neither a delivery history nor proof of receiver acceptance,
and an expired payload cannot be replayed.
Operator commands
Section titled “Operator commands”Command prefix bregctl; add --format json for a machine-readable report.
| Command | Required arguments | Result |
|---|---|---|
explain events | <project> | Compiled event inventory; no database access. |
webhook sample | <project> --event <event-id> | Synthetic request with a placeholder signature; no HTTP send. |
webhook list | --runtime-config <absolute-file> | Delivery metadata without record values; optional --limit defaults to 50 and accepts 1 to 100. |
webhook replay | --runtime-config <absolute-file> --event-id <uuid> --delivery-id <id> --expected-generation <number> | Queues an eligible dead letter in a new generation; does not confirm receiver acceptance. |
list and replay connect directly to PostgreSQL through the runtime configuration, and
bind webhook receivers is the operator procedure that runs them
against a bound destination. The public
record API has no outbox, payload, delivery, or replay endpoint. A replay needs a retained,
unexpired dead letter and the matching generation and activated destination binding. The JSON
result of list is a deliveries array whose entries carry these fields and no record values:
| Field | Type | Meaning |
|---|---|---|
eventId | UUID string | Captured event ID; matches ce-id. |
deliveryId | String | Compiled delivery identifier used for replay. |
generation | Positive integer | Current generation used as --expected-generation. |
state | pending, dead_lettered, or expired | Reported delivery state. |
attempt | Nonnegative integer | Attempt count; 0 before the first send of a generation. |
payloadAvailable | Boolean | Payload is retained and unexpired. |
payloadExpiresAt | RFC 3339 timestamp | Capture-based payload expiry. |
replayEligible | Boolean | Dead letter with an available payload; replay also validates generation and destination binding. |
Spatial adapter
Section titled “Spatial adapter”An entity with a crs84-point field and a geojson.geometryField is also served under
/v1/gis for desktop GIS clients. The adapter exposes /v1/gis, /v1/gis/api,
/v1/gis/conformance, /v1/gis/collections, /v1/gis/collections/{collection}, and
/v1/gis/collections/{collection}/items with bbox, limit (at most 10000), and cursor.
It answers with GeoJSON feature collections under the same profile, grant, and row-boundary rules
as the record routes, and it declares the profile
breg-gis-bounded-crs84-point-bbox-v1. It does not declare OGC API Features
conformance, compute collection extents, or add a query engine of its own.
Sources
Section titled “Sources”The implementation and generated schemas in the same source checkout are the source of truth. Configuration fields are schema-generated; route, query, problem, and protocol tables are maintained data checked against that implementation.
- Deploy a registry for runtime configuration and erasure.
- Bind webhook receivers for destinations, the signing key, and replay.
- Author a registry project for entities, profiles, change requests, and actions.
- Base Registry Engine configuration reference for every project, module, and runtime key.
- Create and query your first registry to see the envelope and headers in a running server.