Released docs. You are viewing the documentation published with v0.34.0. Development docs are available at Latest.
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, {action} under /v1/actions is an
immediate action identifier, {runId} is an ingestion-run identifier, and {chunkIndex} is a
chunk position. 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": [...]}. |
POST /v1/records/{route}/ingestion-runs | batch | Entity declares batch | 201 with the run document: the binding, the announced counts and bounds, and nextChunkIndex. |
GET /v1/records/{route}/ingestion-runs | batch | Entity declares batch | Run documents the caller created. limit (default 25, at most 100), keyset after, and optional status and inputDigest filters. |
GET /v1/records/{route}/ingestion-runs/{runId} | batch | Entity declares batch | The run document with nextChunkIndex. Another caller's run and an unknown run are the same 404. |
POST /v1/records/{route}/ingestion-runs/{runId}/chunks | batch | Entity declares batch | The run document with the attempt outcome. Takes no Idempotency-Key: the server derives the attempt key from run id, input digest, chunk index, and chunk digest. |
POST /v1/records/{route}/ingestion-runs/{runId}/cancel | batch | Entity declares batch | Cancels an open or blocked run. Counts and audit are preserved. |
GET /v1/records/{route}/ingestion-runs/{runId}/chunks/{chunkIndex}/receipt | batch | Entity declares batch | The stored batch response the chunk committed, or 410 ingestion.receipt_erased once its records are erased. |
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/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. |
POST /v1/review-completions | review completion receiver | A review authority configures a completion token and recipient binding | 204 after durable acceptance or an exact replay. Requires bearer authentication, Registry-Recipient-Binding, and an Idempotency-Key equal to the completion event ID. |
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.
Ingestion runs
Section titled “Ingestion runs”An ingestion run is the durable account of one bulk import that the client drives one bounded chunk at a time. The client keeps the source file; the run keeps the checkpoint. A run binds the active package revision, schema fingerprint, entity, selected access profile, create-or-patch operation, input digest, chunking algorithm, and the expected item and chunk counts. No server worker advances a run: only a chunk submission moves the checkpoint, and every operation rechecks the caller’s authority against the compiled batch route. A run identifier locates a run and grants nothing. Runs are visible to the caller that created them, and another caller’s run and an unknown run are the same concealed 404.
A run response carries operational metadata and bounded failure classifications only: never source rows, committed record values, or chunk bodies.
Create a run
Section titled “Create a run”POST /v1/records/{route}/ingestion-runs with the binding:
{ "operation": "create", "profileId": "operator", "packageRevision": "sha256:9f2c…", "schemaFingerprint": "sha256:1a7b…", "inputDigest": "5e8d…", "inputLength": 48211, "itemCount": 12000, "chunkCount": 13, "chunkAlgorithmVersion": "greedy-canonical-http-batch-v1"}The response is 201 Created with the run document. inputDigest is the SHA-256 of the whole
source as 64 lowercase hexadecimal characters. The chunking contract is
greedy-canonical-http-batch-v1; a run refuses any other algorithm, so its remaining source
bytes are never reinterpreted under a different chunking contract.
The run document
Section titled “The run document”Every run response is one document:
| Member | Meaning |
|---|---|
runId | The run identifier. |
status | open, complete, cancelled, or blocked. |
blockedReason | Present when the run is blocked: activePackageChanged. |
entityId, operation, profileId | The bound entity, its create-or-patch operation, and the selected profile. |
packageRevision, schemaFingerprint | The binding the run was created under. |
inputDigest, inputLength, itemCount, chunkCount | The announced source: digest, byte length, item count, and chunk count. |
chunkAlgorithmVersion | The chunking contract the chunks follow. |
maximumItems, maximumBytes | The compiled batch bounds every chunk stays inside. |
nextChunkIndex | The checkpoint: the chunk index the next submission must name. |
committedItems, committedPrefixDigest | The items committed so far and the digest of the source prefix they cover. |
lastAttempt | null before the first attempt, otherwise {"outcome", "chunkIndex"}. |
createdAt, updatedAt | RFC 3339 timestamps. |
complete | true when status is complete. |
An open run whose binding no longer matches the active package reports blocked with
blockedReason activePackageChanged. A blocked run is retained and inspectable, and it
never reinterprets its remaining bytes under a new binding: continue in a successor run created
under the new package.
lastAttempt.outcome classifies the last submission as committed, replayed, invalidItem,
refused, bindingChanged, chunkMismatch, runNotOpen, or unavailable. invalidItem and
refused leave the checkpoint where it was; the corrective move is a successor run, not a retry
with different bytes and not a skipped row.
Submit chunks
Section titled “Submit chunks”POST /v1/records/{route}/ingestion-runs/{runId}/chunks submits the chunk the run’s
nextChunkIndex names:
| Member | Meaning |
|---|---|
chunkIndex | The chunk position, which must equal nextChunkIndex. |
items | The chunk’s items, canonicalized into one batch body. |
digest | The SHA-256 of that canonical batch body, 64 lowercase hexadecimal characters. |
prefixDigest | The SHA-256 of every source byte through the end of the chunk, 64 lowercase hexadecimal characters. |
These members must match the run’s committed prefix exactly: a divergent chunk index, chunk
digest, prefix digest, or binding is refused, and nothing is written. The ingestion routes take
no Idempotency-Key header because the server derives the attempt key from the run id, the
input digest, the chunk index, and the chunk digest, so resubmitting the exact chunk is safe
whether the first response was lost or the transport dropped it. A chunk’s records, revisions,
audit, receipt, and the checkpoint advance commit in one transaction.
List and read runs
Section titled “List and read runs”GET /v1/records/{route}/ingestion-runs lists the caller’s runs: limit (default 25, at most
100), keyset after a run id, and optional status and inputDigest filters.
GET /v1/records/{route}/ingestion-runs/{runId} returns one run document with nextChunkIndex,
so a restarting client rebuilds its plan from API state alone and never resends a committed
chunk.
Cancel and recover
Section titled “Cancel and recover”POST /v1/records/{route}/ingestion-runs/{runId}/cancel cancels an open or blocked run. The
counts, the committed prefix, and the audit stay exactly as they were.
GET /v1/records/{route}/ingestion-runs/{runId}/chunks/{chunkIndex}/receipt returns the stored
batch response the named chunk’s commit produced, so a lost response is recoverable without
re-sending the chunk. A receipt is erased with the record history it describes; a later read
then answers 410 ingestion.receipt_erased, and the chunk’s counts stay in the run.
Recovery
Section titled “Recovery”| Failure | Recovery |
|---|---|
| Response lost after a chunk commit | Reread the run and submit the same chunk again. An exact replay returns the original receipt and writes nothing new. |
| Transport interruption | Resubmit the chunk nextChunkIndex names. |
| An invalid item or a business refusal | The checkpoint stays where it is. Start a successor run for the remainder. Row skipping is never a default recovery. |
| Authorization lost | Progress refuses: every operation rechecks the selected profile against the compiled batch route. |
| Package or schema binding changed | The run reports blocked with reason activePackageChanged and stays inspectable. Start a successor run under the new binding. |
| Operator stop | Cancel the open or blocked run. Counts and audit are preserved. |
A run drives the same compiled batch route one chunk at a time, so an existing direct batch client keeps its contract unchanged.
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
draft; a submitted proposal changes its targets only when it is applied. BReg owns four source
states:
draft ──submit──▶ submitted ──apply with exact approved evidence──▶ applied ▲ │ └────revise─────────┘draft or submitted ──cancel by owner──▶ cancelledsubmit, revise, and cancel belong to the principal that owns the request. Another principal
does not see those actions in actions, and calling one regardless is refused with
precondition.failed. Review decisions and reviewer stages belong to the configured external
authority, such as Registry Casework. BReg does not expose local approval routes.
With review: {mode: none}, the ordinary apply action needs no external result. Otherwise BReg
submits the exact frozen proposal binding to review.authority and accepts application only after
it has reconciled an approved result for the same subject, policy, and submission digest.
onApproved.mode: manual waits for a currently authorized caller. automatic names a logical
executor whose separately configured ordinary credential follows the same BReg apply action and
guards. A completion notification never carries application authority.
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": {"review": {"authority": "casework", "policyId": "asset-placement-correction"}}, "review": { "submission": {"state": "accepted", "authority": "casework", "requestId": "…", "submissionDigest": "sha256:…", "policy": {"id": "asset-placement-correction", "version": "1", "digest": "sha256:…"}}, "result": {"state": "approved", "resultId": "…", "completedAt": "2026-09-20T00:00:00.000000Z", "availableUntil": "2026-10-20T00:00:00.000000Z"}, "delivery": {"state": "reconciled", "eventId": "…", "receivedAt": "2026-09-20T00:00:01.000000Z"}, "application": {"mode": "manual", "state": "ready"}, "recovery": {"state": "none"} }, "editable": false, "actions": [ {"operation": "apply_request", "href": "/v1/records/placement-corrections/2d7e…/actions/apply?accessProfile=correction-applier", "ifMatch": "\"breg-hmac-sha256:…\"", "proposalVersion": 2, "effectDigest": "sha256:9f2c…"} ]}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 its frozen review binding.
review is separately disclosure-controlled by readableRequestFields: [review_state]. It reports
durable submission, terminal result, delivery, application, and recovery state. Optional binding,
result, delivery, and application identifiers are omitted until known. These observations do not
grant authority. editable is true only while the request is a draft, the caller owns it, and
the selected profile may patch it.
A settled result never moves the request out of submitted: its owner acts on it, and the result
decides which actions stay on offer.
| Result for the current proposal | Actions offered | Refused |
|---|---|---|
rejected | cancel_request | revise_request with 409 mutation.conflict; apply_request with 412 precondition.failed |
changesRequested | revise_request with rebase: false, cancel_request | a rebase with 409 mutation.conflict; apply_request with 412 precondition.failed |
approved, availableUntil not yet reached | apply_request for the applier; revise_request with rebase: true and cancel_request for the owner | nothing beyond the ordinary guards |
approved, availableUntil passed before application | revise_request with rebase: false, cancel_request | a rebase with 409 mutation.conflict; apply_request with 412 precondition.failed |
answered, cancelled, or superseded | revise_request with rebase: true, cancel_request | apply_request with 412 precondition.failed |
| none yet | unchanged | nothing beyond the ordinary guards |
Send the rebase value the revise_request action carries: false records the new draft as a
revision of the proposal, true as a rebase onto updated context. An approval that expired
unapplied reports review.application.state expired beside the result’s availableUntil, also
when its automatic application is still queued; an application already applying keeps that state
because it may still succeed. The other application states are awaitingReview, ready, queued,
applying, applied, and blocked.
The offered actions follow the result even when the selected profile does not disclose
review_state, so the owner and the applier can tell a coarse outcome from which actions remain,
for example that a request was rejected. This is deliberate: the same caller would learn as much
from the refusal of an action BReg no longer offers.
The read and apply decide expiry on different clocks. The read compares the cached result’s
availableUntil with the database clock, while apply fetches the result from the review authority
and checks it with the BReg service clock. Close to availableUntil the two can briefly disagree:
a read can still offer apply_request that then answers 412 precondition.failed, or report
expired while the authority’s result is still available. Apply is authoritative.
A request list whose selected profile discloses review_state may also filter on
reviewOutcome, with eq or in. Its values are pending, approved, approvedExpired,
rejected, changesRequested, answered, cancelled, and superseded; a request whose current
proposal was never submitted for review matches none of them. The filter describes the review
only, so combine it with bregState: an applied request keeps the outcome that authorized it, and
its approval can pass availableUntil afterwards. The approvals still waiting for an applier, and
those that expired before anyone applied them, are:
$filter=bregState eq 'submitted' and reviewOutcome eq 'approved'$filter=bregState eq 'submitted' and reviewOutcome eq 'approvedExpired'A profile that does not disclose review_state is refused the filter with 400 query.invalid, the
same answer as for any field its grant withholds.
| Action | Route suffix | Body | Accepted from |
|---|---|---|---|
submit | /actions/submit | {} | draft; freezes the effects, review requirement, and application guards, then queues external review submission when required |
revise | /actions/revise | {"rebase": false} | submitted; starts a new draft proposal version |
revise with rebase | /actions/revise | {"rebase": true} | submitted; starts a new draft after target changes |
cancel | /actions/cancel | {} | draft or submitted, only for the principal that owns the request; an accepted external review is cancelled asynchronously |
apply | /actions/apply | {"proposalVersion", "effectDigest"}, optional reason string | submitted with an exactly correlated approved external result, or explicit no-review; current source authority and guards are rechecked |
Every action requires If-Match and Idempotency-Key. An apply names the exact proposal through
proposalVersion and effectDigest; both come from the read that showed the action. BReg rejects
a stale or substituted binding before applying any effect.
An apply body may also include an optional reason string of up to 4096 Unicode characters.
The server preserves whitespace and Unicode exactly, accepts an empty string, and refuses null,
other types, NUL, or longer text with 400 request.invalid. Include the same reason when replaying
an action with its original idempotency key; changing it is a different request body. The applied
request’s data.request.application carries reasonPresent, and carries reason only when retained
and permitted by the selected profile. Request-detail erasure removes the text while retaining the
presence flag.
readableRequestFields accepts review_state, which discloses the closed review projection,
and actor_reference, which discloses submitterReference and
applierReference. Both require a non-anonymous profile. Every change-request action response
separately includes actorReference, the caller-supplied actor identity recorded with that
action, independent of readableRequestFields.
GET /v1/registry lists the same grant as readableRequestFields on each operation, beside
readableFields, so a caller can learn before a read which request fields its selected profile
receives. The list follows the rule request reads apply: an anonymous profile, or an entity
without a change request, lists no request fields.
History returns up to 50 whole proposals per page. The response byte budget can shorten a page
without splitting a proposal. To continue, send the returned
data.request.history.nextAfterProposalVersion as requestHistoryAfterProposalVersion on the
same request GET, keeping the same access profile. Follow each returned cursor until it is
null; a page with fewer than 50 proposals can still have a next page.
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.
The capability also lists the request’s effects: each effect’s id, operation, the target
entity with the request field (fromField) or earlier effect (fromEffect) that selects it, the
fields it sets with where each value comes from, and the fields it clears. A rhai planner
likewise lists its declared writes, each with its target, operation, and fields. Both are
filtered to what the caller may read: an effect or write on an entity the caller cannot read is
left out, a target field the caller cannot read is not named, and a request field the caller
cannot read is never given as a source, nor is an effect that is left out. possibleWriteCount still counts every declared write.
Fields in effects and writes are named by their authored field id, as in readableFields,
not by the apiName a record uses in data.domainData. To show one under its API name, look up
the id in the fields list of a capability on that entity, which gives each readable field’s id
and apiName. The description cannot grant write
authority: whether a request may be submitted or applied is still decided by the request routes.
The Rust, Node.js, and Python clients require effects, and writes on a rhai planner, so
use a client and runtime from the same release.
Request attachments
Section titled “Request attachments”A request entity can declare up to eight attachments[] slots. Each slot holds
one file and declares id, required, maximumBytes (1 to 16,777,216),
contentTypes (concrete lowercase media types), and classification. An
incomplete draft is allowed; submission refuses a missing required slot with
412 Precondition Failed.
| Method and route | Request | Authority and preconditions |
|---|---|---|
PATCH /v1/records/{route}/{record_id}/attachments/{slot_id} | Raw nonempty bytes with an allowed Content-Type | Draft owner, selected profile’s request patch grant and slot writableFields, row boundary, record If-Match, Idempotency-Key |
DELETE /v1/records/{route}/{record_id}/attachments/{slot_id} | Empty body | Same as upload |
GET /v1/records/{route}/{record_id}/attachments/{slot_id}?proposalVersion=N | No body | Authenticated current request GET authority for the exact proposal version and slot readableFields |
All three accept accessProfile. Discover concrete slot paths from caller-filtered
OpenAPI or the field schema’s x-registry-attachment extension in /v1/registry.
That extension repeats the compiled slot policy: requiredForSubmit,
maximumBytes, contentTypes, classification, and the verification policy.
Upload and removal return the ordinary record mutation response. Refetch the
request to obtain its updated attachment metadata and record ETag. A slot is
engine-owned and cannot be set by ordinary create, JSON Patch, or request effects.
A selected slot appears under domainData using its slot ID verbatim. It is null
when empty. Filled metadata contains slotId, proposalVersion, filled,
erased, byteSize, sha256, contentType, uploadedAt, uploadedBy, and
verificationStatus.
BReg computes size and SHA-256 from the received bytes. requiredForSubmit in
discovery is independent of the ordinary create-field required flag. Current
upload limits and allowed media types do not rewrite retained metadata: older
proposals can describe content accepted under an earlier slot policy.
verificationStatus is notRequired when external verification is disabled.
When configured, an upload is quarantined as pending until the asynchronous
verifier returns approved or rejected. Pending and rejected content cannot be
downloaded (404 Not Found) and blocks submission with 412 Precondition Failed, including a
filled optional slot. Refetch the request to observe the result. External service
failure leaves the status pending for retry. Replacing content requires approval
for the exact hash, media type, and policy; an existing matching approval can be
reused. With verification configured, submission binds the verification policy
digest into the frozen manifest.
Upload, replacement, and removal advance request concurrency state. Submission
binds the slot manifest into proposal integrity. Revision and rebase carry slot
references into the next draft; editing that draft preserves earlier submitted
manifests. Downloads use the explicit proposal version, never a hash or storage
URL. Owner and applier access use their current compiled request read authority.
Casework reviewers read source context through a separately configured Casework source profile;
the caller’s delegated credential is checked against that ordinary BReg read grant. applyTargets
continues to constrain the applier. Field projection also applies: a slot reaches a caller only
through the profile’s readableFields. The authored classification is served
so a caller can handle the bytes accordingly; it grants and withholds nothing.
Downloads return complete, integrity-verified bytes with attachment disposition,
Cache-Control: no-store, and X-Content-Type-Options: nosniff. Hidden requests,
slots, versions, and erased content do not become discoverable through a download.
Erasure retains slot ID, proposal version, filled/erased state, size, and hash;
it removes content type, verification status, and uploading actor/time from the
projected metadata.
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. |
action.refused | 422 | An immediate-action handler intentionally refused the operation. refusalCode identifies a declared reason, detail contains its static label, and optional fieldPath names an input. No effects commit. |
action.handler_failed | 500 | An activated immediate-action handler failed to produce an accepted result. Operators use the trace identifier and safe server diagnostics to repair the package. A handler deadline returns 503 service.unavailable instead. |
action.evidence_failed | 503 | An immediate-action handler declared an Evidence dependency that could not be acquired or accepted: the provider did not answer, or the assertion was refused, failed verification, or exceeded the retention bound. fieldPath names the declared Evidence alias when the failure is bound to one. No effects commit. |
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 storage-pattern failure includes the declared entityId and fieldId; correct the input before retrying. A 409 does not imply that retrying an unchanged request can succeed. |
idempotency.conflict | 409 | The Idempotency-Key was reused with a different request. |
ingestion.profile_mismatch | 403 | The selected profile is not the one the run bound, or does not satisfy the compiled batch route for the bound operation. A run id grants nothing on its own. |
ingestion.run_not_open | 409 | The run completed or was cancelled, so it accepts no chunk and no cancel. The run stays readable. |
ingestion.run_blocked | 409 | The active package revision or schema fingerprint no longer matches the run binding. The run stays blocked and inspectable; continue in a successor run. |
ingestion.chunk_mismatch | 409 | The chunk index, chunk digest, or rolling prefix digest does not match the run's expected next chunk. Nothing was written; reread the run and resubmit the exact chunk. |
ingestion.receipt_erased | 410 | The chunk receipt was erased with the record history it describes. The chunk and its counts stay visible in the run. |
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. |
runtime.field_encryption.unavailable | 503 | The route reaches an entity with encrypted fields while the field-encryption key state is not usable. Entities without encrypted fields keep serving. |
Events and webhooks
Section titled “Events and webhooks”An event is declared as a hook on an entity or a module extension. A url handler delivers to a
webhook destination bound in the runtime configuration; a rhai or wasm handler runs its
reviewed program in the post-commit worker. Field paths in the next table are relative to one
hook under entities[].hooks[]; types, defaults, and descriptions come from the generated module
schema, and compiler rules follow the table.
| Key path | Type | Required | Accepted values | Constraints |
|---|---|---|---|---|
handlerGoverned, destination-neutral delivery. destinationId is a key in
runtime eventDestinations; the project carries no URL or secret, and
deployment configuration may tighten the bounds it binds but cannot
supply or widen this authority. Production compilation requires it. | null | object | No | Not specified | None specified |
handler.abiFor rhai: The handler ABI the script speaks. For wasm: The handler ABI the module speaks. | null | string | Conditional | Not specified | None specified |
handler.destinationIdKey in the runtime destination binding. | string | Conditional | Not specified | None specified |
handler.kind | string | Conditional |
| None specified |
handler.moduleModule path in the project. | string | Conditional | Not specified | None specified |
handler.scriptScript path in the project. | string | Conditional | Not specified | None specified |
idStable hook contract identifier, sent as ce-type. Use a new identifier for a breaking payload change. | string | Yes | Not specified | None specified |
phaseWhen the hook runs relative to the triggering transaction. | string | Yes |
| None specified |
principalThe access profile a proposal from this hook is applied under.
Optional: a hook that declares none is a non-proposing hook, and a
proposal from one is refused and dead-lettered at delivery time.
Declaring none is not an authoring error. | null | string | No | 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 hook. | string | Yes |
| None specified |
whenOptional field tests, combined with AND. Omit to run 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.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:
- Hook IDs are unique across the registry.
- A projection names at least one declared field.
- An entity hook declares
phase: after; the compiler refuses any other phase.handler.kindisurl,rhai, orwasm; a local handler names its reviewedscriptormodulepath. - Production compilation requires every hook to declare a handler, either a bound destination or a reviewed local program.
- 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
handler.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 body is one shared hook envelope, the same envelope every Registry Stack product delivers. Its eight members are required and nothing else appears beside them. The envelope restates the event identity the CloudEvents headers carry, so a receiver that reads only the body still knows which event it holds; a body and its headers can never describe two different events. Delivery attributes stay on the transport: attempt, generation, delivery time, idempotency key, and signature are headers, never envelope members.
| Field | Type | Meaning |
|---|---|---|
id | UUID string | Captured event identity; equals ce-id. |
type | Authored event ID | External event contract; equals ce-type. |
source | urn:registrystack:registry:<registry-id>:instance:<instance-id> | Registry instance identity; equals ce-source. |
time | RFC 3339 timestamp, UTC, whole milliseconds | Mutation capture time; equals ce-time. |
subject | Object with recordReference and recordRevision | Hashed record reference and the revision the change produced. The reference is hmac-sha256:<64-hex>, never the raw record id. |
dataschema | urn:breg:event-schema:<registry-id>:<entity-id>:<event-id>:sha256:<digest> | Generated event data schema identity; equals ce-dataschema. |
data | Object | The captured event data, described below. |
causation | Object with root, hop, and optional parent | Chain position. A captured registry mutation is a root event: root equals id, hop is 0, and parent is absent. |
data carries the captured event data. The six common members are required, and a lifecycle
event also requires request. Other 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, stable deduplication key, and reasonPresent. Rejection and request-revision may include the reviewer explanation as reason. |
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.
{ "causation": {"hop": 0, "root": "<event-uuid>"}, "data": { "entity": "record", "packageRevision": "sha256:<package-digest>", "recordId": "<record-uuid>", "revision": 2, "trigger": "patched", "values": {"code": "EX-003", "label": "Updated label"} }, "dataschema": "urn:breg:event-schema:<registry-id>:record:record-patched-v1:sha256:<digest>", "id": "<event-uuid>", "source": "urn:registrystack:registry:<registry-id>:instance:<instance-id>", "subject": {"recordReference": "hmac-sha256:<64-hex>", "recordRevision": 2}, "time": "2026-01-01T00:00:00.123Z", "type": "record-patched-v1"}Members appear in canonical JSON order, the order the bytes on the wire use.
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.
Node receivers can use the registry client’s
webhook delivery verifier instead of constructing this
signing input themselves. The receiver still owns clock-skew, expected-event, and deduplication
policy.
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.