Skip to content
Registry StackDocsDevelopment (unreleased)

Base Registry Engine API reference

View as Markdown

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.

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.

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

RouteOperationAvailable whenResponse
GET /v1/registrymetadataThe caller reaches at least one routeEntity 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}metadataThe caller reaches the entityJSON Schema for the entity record, the target of the describedby link.
GET /openapi.jsonmetadataThe caller reaches at least one routeThe compiled OpenAPI document, filtered to the caller's routes.
GET /health and GET /healthzlivenessAlways{"status":"alive"}.
GET /readyreadinessAlways{"status":"ready"}, or 503 runtime.not_ready until the package and database are usable.
POST /v1/records/{route}createEvery entity201 with the record envelope, Location, and ETag.
GET /v1/records/{route}listEvery entityCollection.
GET /v1/records/{route}/{recordId}getEvery entityRecord envelope with ETag.
PATCH /v1/records/{route}/{recordId}patchmutationMode: mutableRecord envelope with data.snapshot. Requires If-Match.
DELETE /v1/records/{route}/{recordId}tombstonetombstone: trueEnvelope of the final revision with data.snapshot. Requires If-Match.
POST /v1/records/{route}:lookuplookupEntity declares selectorProfilesRecord envelope, or 404 lookup.unresolved.
POST /v1/records/{route}:batchbatchEntity declares batch{"snapshot", "results": [...]}.
GET /v1/records/{route}/{recordId}/revisionsrevisionsEvery entityCollection of the record's revisions, at most 100 per read.
GET /v1/records/{route}/{recordId}/revisions/{revision}revisionsEvery entityRecord envelope for one revision.
GET /v1/records/{route}:snapshotsnapshotEvery entityCollection as recorded at a saved snapshot.
GET /v1/records/{route}:currentlistEntity declares temporalCollection of the records valid now.
GET /v1/records/{route}:as-oflistEntity declares temporalCollection of the records valid at asOf.
GET /v1/records/{route}/{recordId}/{readPath}list through readPaths[]Entity declares readPathsCollection of related records.
POST /v1/records/{route}/{recordId}/actions/{action}submit_request, revise_request, cancel_request, apply_requestEntity declares changeRequestRequest action result. Requires If-Match and Idempotency-Key.
POST /v1/records/{route}/{recordId}/actions/stages/{stage}/{decision}approve_request, reject_request, request_revisionEntity declares changeRequest.review.stagesRequest action result. Requires If-Match and Idempotency-Key.
POST /v1/actions/{action}/target-conditionsinvokeRegistry declares actions[]{"preconditions": {...}} for the targets the input names.
POST /v1/actions/{action}invokeRegistry declares actions[]{"action", "applicationId", "results"}. Requires Idempotency-Key.

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"
}
}
MemberTypeMeaning
data.recordIdentifierstring, UUIDStable identity of the record.
data.revisionIdentifierstring, ^[1-9][0-9]*$Revision counter, starting at 1. Compare as a string.
data.domainDataobjectThe entity’s fields under their apiName, narrowed to the grant’s readableFields and to $select.
data.snapshotstringPresent on mutation responses: an opaque token for :snapshot reads, at most 4096 bytes.
data.requestobjectPresent on change request records: workflow state and the actions the caller may take.
data.requestPresenceobjectPresent when the grant declares requestPresence: the pending change requests that target this record.
meta.*stringsRegistry, 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:

HeaderValue
Content-Typeapplication/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".
LocationOn 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 typeJSON encodingNotes
booleantrue or false
stringstringminLength and maxLength from the field.
textstringmaxLength from the field.
int64integerFull 64-bit range; clients that parse JSON numbers as doubles lose precision above 2^53.
decimalstringDecimal digits with the declared precision and scale, never a JSON number.
datestringRFC 3339 full date.
timestampstringRFC 3339 date-time.
uuidstringLower-case UUID.
vocabulary-codestringOne of the vocabulary values; the schema carries them as an enum with x-registry-vocabulary.
referencestringThe target record identifier as a UUID.
crs84-pointobjectGeoJSON Point, {"type": "Point", "coordinates": [longitude, latitude]}.
structuredobjectValidated against the declared schema, bounded by maxBytes, advertised as x-registry-maxBytes.

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.

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.

OptionRoutesMeaningBound
accessProfileEvery record routeEvaluate the request under a profile other than the route default. The caller must satisfy that profile.One profile identifier.
$selectCollection readsComma-separated domain fields to return. Envelope members are never selectable.128 fields.
$filterCollection readsPredicate 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.
$orderbyCollection readsOne field from sortableFields, ascending. A desc direction is refused with query.invalid.One field.
$topCollection readsPage size.100.
$countCollection readsAdds the matching count. Refused unless the grant sets allowCount.Grant flag.
$skiptokenCollection readsContinuation from pageInfo.nextCursor. It carries the original query, so repeating snapshot or validAt beside it is refused.Expires after cursor.maxAgeSeconds.
bboxlist on entities with a crs84-point fieldwest,south,east,north in CRS84 degrees, permitted by spatialQueries.bbox on the grant.256 bytes, plus the span limits on the grant.
asOf:as-ofThe valid-time instant to evaluate, in the type of the entity's valid-time fields.One value.
snapshot:snapshotA data.snapshot value returned by an earlier mutation.4096 bytes.
validAt:snapshotThe valid-time instant to evaluate inside the snapshot, for temporal entities.One value.
Whole query stringEvery routeDecoded 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-01

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

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.

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.

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.

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.

Every write appends a revision; nothing is updated in place. Three route families read that history, each under its own grant operation.

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.

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.

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.

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.

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

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.

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.

CodeStatusWhen
authentication.refused401A bearer token was presented and rejected: bad signature, wrong issuer or audience, expired, denied key, or a claim outside policy.
resource.not_found404No 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.unresolved404A lookup matched zero records or more than one.
request.invalid400The body, headers, or path did not match the compiled contract. fieldPath names the member when there is one.
query.invalid400A query option was malformed, over a bound, or not permitted by the grant.
query.cursor_invalid400A $skiptoken that is malformed, was issued for a different query, or has expired.
request.plan_refused400A 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_type415A body without the required Content-Type.
precondition.required428A route that requires If-Match was called without it.
precondition.failed412If-Match did not match the current revision.
mutation.conflict409A 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.conflict409The Idempotency-Key was reused with a different request.
request.timeout504The request exceeded operationalTimeouts.httpRequestMilliseconds.
source.unavailable503The database refused or timed out while the request held it.
service.unavailable503The runtime could not complete the mutation for a reason other than the request itself.
runtime.not_ready503The readiness probe before the package and database are usable.

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 pathTypeRequiredAccepted valuesConstraints
idStable event contract identifier, sent as ce-type. Use a new identifier for a breaking payload change.stringYesNot specifiedNone specified
projectionDeclared field identifiers to include in values. System event metadata is included separately.arrayYesNot specified
  • uniqueItems: true
projection[]Array itemstringNoNot specifiedNone specified
triggerCommitted record change that can produce this event.stringYes
  • created
  • patched
  • request_lifecycle
  • tombstoned
None specified
webhookLogical delivery destination. Production compilation requires a webhook.null | objectNoNot specifiedNone specified
webhook.destinationIdKey in runtime eventDestinations; the project carries no URL or secret.stringConditionalNot specifiedNone specified
whenOptional field tests, combined with AND. Omit to emit on every matching trigger.null | objectNoNot specifiedNone specified
when.afterEqualsRequired values after the change. Valid with created and patched triggers.objectConditionalNot 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 | stringConditionalNot specifiedNone specified
when.beforeEqualsRequired values before the change. Valid with patched and tombstoned triggers.objectConditionalNot 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 | stringConditionalNot specifiedNone specified
when.changedFields whose values must change. Only valid with the patched trigger.arrayConditionalNot specifiedSchema default, where declared: []
  • uniqueItems: true
when.changed[]Array itemstringConditionalNot specifiedNone specified
when.kindstringConditional
  • fields
  • request_lifecycle
None specified
when.stagesarrayConditionalNot specifiedSchema default, where declared: []
  • uniqueItems: true
when.stages[]Array itemstringConditionalNot specifiedNone specified
when.toStatesarrayConditionalNot specifiedSchema default, where declared: []
  • uniqueItems: true
when.toStates[]Array itemstringConditionalNot specifiedNone specified
when.transitionsarrayConditionalNot specifiedSchema default, where declared: []
  • uniqueItems: true
when.transitions[]Array itemstringConditionalNot specifiedNone 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 apiName spellings.
  • 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.

TriggerchangedbeforeEqualsafterEqualsProjected snapshot
createdNot allowedNot allowedAllowedAfter
patchedAllowedAllowedAllowedAfter
request_lifecycleNot allowedNot allowedNot allowedRequest at transition
tombstonedNot allowedAllowedNot allowedBefore

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.

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.

HeaderFormat or valueMeaning
Acceptapplication/jsonAccepted response media type.
Content-Typeapplication/jsonCanonical JSON event data.
ce-specversion1.0CloudEvents version.
ce-idUUIDCaptured event identity; unchanged by retry or replay.
ce-sourceurn:registrystack:registry:<registry-id>:instance:<instance-id>Registry instance identity.
ce-typeAuthored event IDExternal event contract, such as record-labelled-v1.
ce-timeRFC 3339 timestampMutation capture time; unchanged by retry or replay.
ce-dataschemaurn:breg:event-schema:<registry-id>:<entity-id>:<event-id>:sha256:<digest>Generated event payload schema identity and fingerprint.
Idempotency-Keysha256:<digest>Opaque key for deduplicating automatic retries.
X-Registry-Event-GenerationPositive integer, initially 1Increments on operator replay.
X-Registry-Delivery-AttemptPositive integer, initially 1Attempt number within the generation.
X-Registry-Delivery-TimeRFC 3339 timestampCurrent attempt time; used for receiver freshness checks.
X-Registry-Signaturev1=<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.

FieldTypeMeaning
entityStringAuthored entity ID.
recordIdUUID stringRecord identity.
revisionPositive integerRecord revision after the mutation.
triggercreated, patched, request_lifecycle, or tombstonedCaptured mutation or request-transition trigger.
packageRevisionsha256:<digest> stringPackage active at capture time.
valuesObjectExactly the declared projection, using authored field IDs; unset optional fields are null.
requestObject; required only for request_lifecycleProposal 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"}
}

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:

  1. ce-specversion
  2. ce-id
  3. ce-source
  4. ce-type
  5. ce-time
  6. ce-dataschema
  7. X-Registry-Event-Generation
  8. X-Registry-Delivery-Attempt
  9. X-Registry-Delivery-Time
  10. The method bytes, POST
  11. The configured request path bytes, without the origin
  12. The content type bytes, application/json
  13. Idempotency-Key
  14. 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.

PropertyContract
Delivery modeAsynchronous, after commit, at least once.
AcknowledgementAny 2xx response.
Failed attemptRedirect, other non-2xx response, timeout, or transport failure. Redirects are not followed.
Total attemptsCompiled ceiling: 5 per generation, including the first attempt. Runtime may lower it.
Attempt timeoutCompiled ceiling: 5,000 ms. Runtime may lower it to a minimum of 100 ms.
Retry delays1, 2, 4, then 8 seconds, stopping at the deployed attempt limit.
Payload sizeEvent-specific compiled bound, at most 1 MiB.
Payload retentioneventDelivery.payloadRetentionDays: default 7, range 1 to 30 days, measured from capture. Replay does not extend expiry.
Successful payloadErased when the successful terminal state commits.
OrderingNo 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.

PropertyAutomatic retryOperator replay
Event ID, source, type, time, and schemaUnchangedUnchanged
Body and captured package revisionUnchangedUnchanged
GenerationUnchangedIncremented
Idempotency keyUnchangedNew key
Attempt numberIncrementedFirst send is 1
Delivery time and signatureNew for the attemptNew for the attempt
Payload expiryUnchangedUnchanged

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.

StateMeaningShown by webhook listReplay eligible
pendingWaiting for an attempt or retry.YesNo
leasedAn attempt is in progress.NoNo
deliveredAcknowledged successfully; payload erased.NoNo
dead_letteredAttempt limit exhausted.YesOnly with unexpired retained payload and matching generation and destination binding.
expiredPayload retention ended.YesNo

An empty webhook list result is neither a delivery history nor proof of receiver acceptance, and an expired payload cannot be replayed.

Command prefix bregctl; add --format json for a machine-readable report.

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

FieldTypeMeaning
eventIdUUID stringCaptured event ID; matches ce-id.
deliveryIdStringCompiled delivery identifier used for replay.
generationPositive integerCurrent generation used as --expected-generation.
statepending, dead_lettered, or expiredReported delivery state.
attemptNonnegative integerAttempt count; 0 before the first send of a generation.
payloadAvailableBooleanPayload is retained and unexpired.
payloadExpiresAtRFC 3339 timestampCapture-based payload expiry.
replayEligibleBooleanDead letter with an available payload; replay also validates generation and destination binding.

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.

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.