Released docs. You are viewing the documentation published with v0.34.0. Development docs are available at Latest.
You have a project that passes bregctl check --production
(Build a production candidate gets you there)
and want it serving. At the end of this page a breg process serves a signed package against
PostgreSQL, answers GET /ready, and exposes its metrics to your scraper.
This page covers the deploy phase only: from an empty PostgreSQL server to the first activated package. The sibling pages take over once the registry serves: Bind webhook receivers for the destinations the project declares, Change an active registry for successor packages, Retain, erase, and audit for history, retained Evidence uses, and the audit journal, and Move data in bulk for imports and exports.
Three roles take part. The author prepares a package from the project. A signer approves the exact
package bytes with a key the deployment trusts. The operator, holding the migration credential,
activates the signed package and runs the server. One person may hold all three roles on a pilot;
the commands stay the same. Every command reads --help. Paths given to --runtime-config,
--credentials, --output, and --package must be absolute.
Many operator paths are resolved through held directory descriptors rather than by name: audit
export output, data export output and checkpoint, data import state and checkpoint, test receipt
output and the credentials file bregctl test reads, package inputs and outputs, history request
files, the reviewed migrations tree, project migrate writes, and authoring sources, including each
module’s SQL assets and planner scripts, which are read through the module directory the listing
opened. bregctl resolves each component of these paths against the directory descriptor that
holds it, then opens, creates, renames, and publishes through those held descriptors, so a
directory replaced by a symbolic link after the path is validated cannot redirect the command.
--runtime-config and --package are resolved by pathname in registry-breg instead, as is
every bregctl dev path, and the local file apply --backup <binding>=<path> names, which
registry-breg opens with O_NOFOLLOW on its final component only.
Name the real directory rather than a symbolic link: on macOS, /tmp, /var, /etc, and /home,
among others, are reached through symbolic links at the root, so pass /private/tmp and
/private/var for the descriptor-resolved paths above. The descriptor-based refusal is
fail-closed: the Linux and macOS builds resolve every descriptor-based path this way, and a build
for a platform that offers no equivalent kernel-enforced resolution refuses each one instead of
falling back to resolution by pathname; the diagnostic code depends on the command, since each
surface reports the refusal under its own code rather than one shared across all of them.
Obtain the runtime
Section titled “Obtain the runtime”Install breg and bregctl from the release assets of the pinned tag, so the tool that packages
and the runtime that serves carry the same version, and so the binary a deployment tested is the
binary it runs:
curl -fsSL https://github.com/registrystack/registry-stack/releases/download/v0.26.1/breg-v0.26.1-install.sh | bashbregctl --versionbregctl 0.26.1The installer accepts the platforms in
platform support, and refuses any other
platform rather than guessing. It verifies every downloaded binary against the release SHA256SUMS
before anything reaches the install directory, and installs breg and bregctl together or not at all. It does not verify
release authenticity. The signed checksum chain that does, and the checks behind it, are recorded in
OpenSSF and release trust. Replace | bash with | less to read
the installer before you run it on a host you operate. For a higher-assurance installation, follow
release verification
for the pinned tag, then rerun the installer with BREG_ASSET_DIR pointing at the verified
directory. BREG_INSTALL_DIR selects the install directory; the default is ~/.local/bin.
Testing, packaging, activation, and serving must use one release: --version on each binary
prints it, and the installer keeps the set together, so reinstalling replaces all three. The release
also publishes breg-install.sh as a movable alias of the pinned installer.
The container image is ghcr.io/registrystack/breg:v0.26.1, built on distroless nonroot for
linux/amd64 only; see platform support
for the rest of the artifact matrix. Its
entrypoint is /usr/local/bin/breg, its default arguments are --config /etc/breg/runtime.yaml,
and it exposes port 8080. The image carries no shell, no writable directory, no healthcheck
subcommand, and no bregctl: the audit journal lives in PostgreSQL, the runtime configuration and
the files it names are mounted read-only under /etc/breg, and the orchestrator probes
GET /health or GET /healthz over HTTP for liveness while readiness stays with GET /ready, as
Activate and serve records. Because the image carries no bregctl,
activation always happens from a separate host, and the container’s own runtime document differs
from that host’s; see Serve the container image. The release
manifest published beside the binaries, registry-stack-v0.26.1-release-manifest.json, records
the promoted digest of each image; pin that digest instead of the movable tag.
Provision PostgreSQL
Section titled “Provision PostgreSQL”Base Registry Engine needs PostgreSQL 17 or newer, with TLS between the server and the database. A
project with a crs84-point field also needs PostGIS. Create two login
roles: a migration role that owns the schema and applies packages, and a runtime role the serving
process uses. Keep the migration credential off the serving host when no activation is in
progress.
CREATE ROLE registry_migration LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '<migration-password>';CREATE ROLE registry_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '<runtime-password>';Create two databases with the same shape: the serving database, and a schema-test database for
test. The five managed schemas in the schema-test database must be empty when test starts, and
test fills them without cleaning up after itself, so drop and recreate that database before every
run; a run against a database that still holds the previous run’s objects refuses with
schema-test database is not clean. In each database, as an administrator:
CREATE EXTENSION IF NOT EXISTS btree_gist;REVOKE ALL ON DATABASE registry FROM PUBLIC;GRANT CONNECT ON DATABASE registry TO registry_migration, registry_runtime;CREATE SCHEMA registry_internal AUTHORIZATION registry_migration;CREATE SCHEMA registry_data AUTHORIZATION registry_migration;CREATE SCHEMA registry_source AUTHORIZATION registry_migration;CREATE SCHEMA registry_derived AUTHORIZATION registry_migration;CREATE SCHEMA registry_context AUTHORIZATION registry_migration;REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC;Base Registry Engine creates every table, function, and policy inside those five schemas from the
package. It never creates a schema, an extension, or a role, so a missing prerequisite surfaces
at test or apply rather than at runtime.
For a spatial project, add a bounding-box role named after the runtime role with the suffix
__spatial_bbox, and install PostGIS in a dedicated schema that neither registry role owns:
CREATE ROLE registry_runtime__spatial_bbox NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS;GRANT registry_runtime__spatial_bbox TO registry_migration WITH INHERIT FALSE, SET TRUE, ADMIN FALSE;-- in each databaseCREATE SCHEMA registry_spatial_ext AUTHORIZATION postgres;CREATE EXTENSION IF NOT EXISTS postgis WITH SCHEMA registry_spatial_ext;REVOKE CREATE ON DATABASE registry FROM PUBLIC, registry_migration, registry_runtime;REVOKE ALL ON SCHEMA registry_spatial_ext FROM PUBLIC;GRANT USAGE ON SCHEMA registry_spatial_ext TO registry_migration, registry_runtime, registry_runtime__spatial_bbox;The maintained runtime validates these ownership boundaries when it prepares spatial storage.
Write the runtime configuration
Section titled “Write the runtime configuration”The runtime file binds one package to one database, one token issuer, and one listener, plus an
optional private metrics listener. It is a deployment artifact: keep it with the operator, outside
the authoring project, and never put a credential in it. Values such as secret:file/<name>
resolve to owner-only files under the file provider root; secret:env/<NAME> reads an environment
variable when secretProviders.environment is declared.
apiVersion: registry.registrystack.org/breg-runtime/v1alpha1kind: BRegRuntimeConfiglistener: bind: 127.0.0.1:8080 publicOrigin: https://registry.example.orgidentity: environment: production instanceId: civil-registry-1 databaseId: civil-registry-db-1 databaseInitializationEnvironment: productionsecretProviders: file: root: /etc/breg/secretsdatabase: runtimeUrlRef: secret:file/runtime-database-url migrationUrlRef: secret:file/migration-database-url pool: maxSize: 8 roles: migration: registry_migration runtime: registry_runtimepackage: root: /var/lib/breg/packages/build-1/package trustAnchorPath: /etc/breg/package-trust-anchor.json compilerSourceRevision: civil-registry-0.1.0 activeRevision: sha256:<package revision reported by package> activeSequence: 1authentication: oidc: issuer: https://issuer.example.org audience: breg allowedAlgorithm: ES256 accessTokenType: at+jwt scopeClaim: scope scopeSeparator: " " allowedClients: [registry-console] deniedKids: [] maxTokenLifetimeSeconds: 300 leewayMilliseconds: 30000 jwksSource: kind: discovery authorityClaims: principal: registry_principal purpose: registry_purposeaudit: hashKeyRef: secret:file/audit-keycursor: secretRef: secret:file/cursor-keyeventDestinations: {}publicOrigin may include a deployment path prefix, such as
https://registry.example.org/registry-a. Discovery, paging, and schema links preserve the
configured prefix and never derive their authority from request headers.
| Section | What it binds |
|---|---|
listener | The bind address and the optional public origin that generated documents cite. The runtime reads neither peer addresses nor forwarded headers, so client addresses and TLS termination are enforced at your proxy. |
identity | The environment, instance, and database this file may serve. environment and instanceId must equal the package block in registry.yaml; databaseId must equal the --database-id given to test and package. |
secretProviders | The file root (owner-only files) and, when declared, the environment provider. |
database | Secret references for the runtime and migration connection URLs, pool bounds, and the two role names the package’s policies are written for. |
package | The activated package directory, the trust anchor, the source revision the project declares as package.sourceRevision, and the active revision and sequence. |
authentication | The OpenID Connect verifier and the names of the claims that carry the caller’s principal and purpose. |
audit and cursor | The key that chains the audit journal and the secret that signs pagination cursors. Loss of either key invalidates existing chains or cursors. |
eventDestinations and eventDelivery | One binding per webhook destination the package declares, and payload retention; see Bind webhook receivers. |
evidenceProviders | One binding per Evidence provider the package declares, keyed by provider ID: baseUrl, trustBindingId, trustedJwksRef, revokedKeyIds, optional caBundleRef, and exactly one of tokenRef or a refreshing privateKeyJwt credential. The script cannot replace these bindings; see Governed registry actions. |
fieldEncryption | Key custody for encrypted fields; declare it when the project encrypts a field. The optional provider binding’s kind member selects the custodian. kind: transit requires unixSocketPath, mount, and keyName, with an optional timeoutMilliseconds (default 5000, at most 30000). kind: localFile requires dekRef, a secret:file/<name> reference to one base64 data-key file, and is for local assurance only. The model and its limits are in Field encryption for restricted fields. |
attachmentStorage | PostgreSQL by default, or an operator-bound S3-compatible backend for request attachments; see Attachment storage. |
attachmentVerification | Optional asynchronous HTTP verifier; content stays quarantined until approved. Configure it before the first attachment upload; see External attachment verification. |
operationalTimeouts | Optional HTTP request, record lock, migration lock, migration statement, and shutdown grace bounds. |
metricsListener | Optional second binding, private to the operator, that serves GET /metrics; see Scrape metrics. |
The fieldEncryption block is optional; its provider binding names the custodian:
fieldEncryption: provider: kind: transit unixSocketPath: /var/run/vault/transit.sock mount: transit keyName: breg-field-dek timeoutMilliseconds: 5000An encrypted field moves part of the registry’s availability into key custody, so plan the Transit binding’s custody before the first encrypted field is activated:
Fields the project declares encrypted are sealed under a data-encryption key that Transit wraps
and only Transit unwraps. If startup or restart cannot reach Transit or unwrap the key, Base
Registry Engine stops before serving any entity. After a successful start, an envelope that cannot
be opened fails the affected encrypted-field read or write with the HTTP 503 problem
runtime.field_encryption.unavailable; sibling entities without encrypted fields keep serving.
Base Registry Engine stores only the Transit-wrapped key or a nonsecret identifier for a local
key, and offers no recovery, so escrow the Transit key before enabling. The local file provider is
development custody: startup refuses it unless the
runtime file’s identity.databaseInitializationEnvironment is local, and bregctl doctor --runtime-config <absolute-file> reports the same refusal.
leewayMilliseconds must be a whole number of seconds and at most 300000. Static keys load at
startup; rotate them through a configuration change and restart:
jwksSource: kind: static documentRef: secret:file/<jwks-document>Tokens signed with a key absent from the pinned document are refused with the value-free
authentication.refused. Search the server’s JSON logs for unknown or disallowed key identifier
in fields.message, with logging set to warn or info. The server emits this warning
at most once per minute per authenticator. This warning
applies to both static and discovery sources. An arbitrary token or a deliberately denied key
can cause the same warning, so the warning alone does not establish provider key rotation.
If fresh logins fail and you independently confirm that the provider rotated its keys or regenerated them after state loss, recover a static pin with these steps:
The replacement document becomes the anchor for bearer verification. Confirm the provider’s current keys through its trusted configuration and operator before replacing the pin.
- Fetch the provider’s current JWKS document from its published
jwks_uriover TLS, the same way the initial pinning did. Confirm the keys with the provider operator if the rotation was not announced. - Prepare and review a compatible public-key subset of that document. Keep only signing keys
for your configured
allowedAlgorithm, with uniquekidvalues absent fromdeniedKids. Preserve their public key material and identifiers. Do not relabel algorithms or turn encryption keys into signing keys. The document must contain only akeysarray with 1 to 128 keys and fit within both the 65536-byte secret limit andjwksCache.maxDocumentBytes(65536 bytes by default). Each key must use the matching public-key shape and only the memberskty,kid,alg,use,key_ops,crv,x,y,n, andethat apply to that shape. Remove certificate metadata such asx5c; never include private key material. If present,usemust besigandkey_opsmust be exactly["verify"]. Review the resulting subset with the provider operator before use. - Replace the secret that
documentRefnames. Forsecret:file/<jwks-document>, replace the file while keeping the secret-file rules. Forsecret:env/<NAME>, whensecretProviders.environmentis enabled, replace the injected environment value in your service configuration with the document bytes. Keep the value out of command arguments and logs. - Restart
breg; the static document is read only at startup. If startup refuses the document, recheck its members, key shapes, algorithm, denied keys, and size before retrying. - Verify with a fresh provider login that authentication succeeds again, and that tokens signed by the removed keys are still refused.
If fresh logins are still refused, confirm that every serving instance restarted with the intended
documentRef, then check the issuer, audience, algorithm, token type, and key policy with the
provider operator. Keep verification enabled. Restore a previous pin only if its keys remain
trusted and the provider confirms they are still valid; never restore a revoked key to end an outage.
If a token signed by a removed key is accepted, stop routing protected traffic to the affected instance. Check for an old process or another serving instance using the previous configuration, and escalate the trust discrepancy to the deployment and identity-provider operators. Resume traffic only after the intended pin accepts current keys and refuses removed keys.
With kind: discovery, the runtime reads the issuer’s discovery document and caches its JWKS
under the optional jwksCache bounds.
Create the secret files
Section titled “Create the secret files”Create the files the example references as the user that runs breg and the operator commands,
because the resolver refuses a file owned by anyone else. Each must be a regular file with mode
0400 or 0600 and no other link or symbolic link to it, and its name starts with a lowercase
letter and uses lowercase letters, digits, ., _, and -. The bytes are used exactly as
written, neither trimmed nor decoded, so write them without a trailing newline:
install -d -m 0700 /etc/breg/secrets(umask 077; openssl rand -hex 32 | tr -d '\n' > /etc/breg/secrets/audit-key)(umask 077; openssl rand -hex 32 | tr -d '\n' > /etc/breg/secrets/cursor-key)(umask 077; printf '%s' 'postgresql://registry_runtime:<runtime-password>@db.example.org:5432/registry' \ > /etc/breg/secrets/runtime-database-url)(umask 077; printf '%s' 'postgresql://registry_migration:<migration-password>@db.example.org:5432/registry' \ > /etc/breg/secrets/migration-database-url)The audit key and the cursor secret each need at least 32 bytes; 64 hexadecimal characters
satisfy both. Percent-encode a password that carries reserved characters. The user in each URL
must equal the role named under database.roles, and the runtime requires TLS on both connections
itself, so the URL needs no sslmode parameter and a server without TLS refuses the connection.
What a token must carry
Section titled “What a token must carry”Base Registry Engine verifies a bearer access token before it reads any claim. Configure the issuer
so that every token for this deployment satisfies the table. Fixture claims from journeys.yaml
are never accepted by a running server.
| Token part | Requirement |
|---|---|
alg, typ, kid headers | alg equals allowedAlgorithm; typ matches accessTokenType case-insensitively, and when accessTokenType names the RFC 9068 access-token media type (at+jwt or application/at+jwt), either spelling of that one type is accepted; kid is present in the JWKS and absent from deniedKids. |
iss | Equals issuer. |
aud | A string equal to audience, or an array of 1 to 16 distinct nonempty strings containing that exact resource audience. |
exp, iat, nbf | The lifetime is at most maxTokenLifetimeSeconds; clock skew up to leewayMilliseconds is tolerated. |
azp or client_id | Listed in allowedClients when that list is not empty. |
scope | The single claim named by scopeClaim covers every required permission. It is a string split on scopeSeparator, or a JSON array of permission strings. Other shapes are refused; permissions from several claims are not merged. |
| principal claim | The claim named by authorityClaims.principal carries the identity recorded in audit and workflow decisions. It must match each authenticated profile’s principalClaim. |
| purpose claim | The claim named by authorityClaims.purpose is required when the profile lists requiredPurposes. |
| assignment claims | Each row boundary or claim-backed lookup names its own claim. An equals boundary needs a scalar; an in boundary needs a JSON array. An identity claim does not imply district or team assignments. |
Set maxTokenLifetimeSeconds to your deployment’s chosen limit, up to 7200 seconds.
The limit bounds the accepted difference between issuance and expiry; increasing it extends how long an already issued token may carry old permissions.
Match the issuer’s token settings and test both accepted and refused lifetimes.
Use a resource audience dedicated to this registry API, distinct from your application’s login client identifier.
Obtain access tokens for that resource; an ID token issued to the login client is not an API credential.
Choose identities that survive operations
Section titled “Choose identities that survive operations”Select the principal claim explicitly. sub is supported when selected in both the runtime and profiles;
the server never falls back to another identity claim if the selected claim is missing.
An issuer’s sub usually belongs to that issuer’s identity namespace.
For an issuer migration, prefer an institutional identifier carried in a custom claim by both issuers,
and document who guarantees that its values remain unique, stable, and never reassigned.
Base Registry Engine treats the selected value as an opaque identity and does not link accounts across issuers.
Changing it can change whether someone is recognized as a submitter or a previous reviewer.
Use the same principal claim in an ownership row boundary when the record’s owner field stores that identifier. Use separate assignment claims for district, tenant, or team membership. Configure the issuer to derive these claims from trusted assignments rather than caller-supplied values.
Configure a compatible OAuth issuer for registered machine clients. A client-credentials flow represents a service, not an individual signing in. Human login, account lifecycle, and multifactor authentication belong to your identity provider and application. When replacing an issuer, verify the same permitted and refused requests with real tokens before changing the serving configuration. Compare issuer, resource audience, token headers, permission source, principal identity, purpose, and assignment claim shapes.
Rotate signing keys and handle issuer outages
Section titled “Rotate signing keys and handle issuer outages”With discovery, jwksCache.cacheTtlSeconds defaults to 600 seconds.
An unfamiliar kid can trigger a refresh, subject to refreshCooldownSeconds (30 seconds by default) and the bounded negative cache.
Publish a new public key before issuing tokens with it, then verify a token using that key against the registry.
Keep the previous public key available while its legitimate tokens remain valid.
Removing a key from the issuer does not immediately remove an already cached key from the registry.
During a fetch failure, a known cached key may remain usable until its age reaches the cache TTL plus outageToleranceSeconds, which defaults to 900 seconds.
The default total allowance is therefore 1500 seconds from the last successful key-set fetch, not from the start of the outage.
Unknown keys cannot use this allowance, and the server cannot obtain keys for a cold start from an unavailable issuer.
After the allowance, verification requiring those stale keys fails until fetching succeeds.
Token expiry and every other verification rule continue to apply during the allowance.
Short token lifetimes bound how long issued permission and assignment claims remain usable.
Disabling an account or changing a group at the issuer does not update claims in an already issued token.
For a compromised signing key, add its identifier to deniedKids and restart every serving instance with that configuration.
That rejects all tokens signed with the denied key, including otherwise legitimate tokens.
Static JWKS documents also require a configuration change and restart to rotate.
Rotate credentials, keys, certificates, and trust covers related rotation boundaries for Relay and Evidence Gateway.
Change the issuer without losing request context
Section titled “Change the issuer without losing request context”The runtime trusts one configured issuer and reads its configuration at startup. Plan a coordinated restart and client token change; one process does not provide an overlap period accepting both issuers. Keep the selected principal values stable if existing ownership and review decisions should continue to recognize the same actors. Test new tokens before the serving cutover, including refused permissions and assignment shapes.
Resolve uncertain writes before changing issuer mappings, profiles, or packages.
After a timeout, retry the same request with its original idempotency key and the same effective principal, profile, purpose, and row context.
A replacement token can satisfy those conditions; changing identity or authority context can produce idempotency.conflict instead of a replay.
The binding also covers the request and package revision, so issuing a fresh key blindly can duplicate a write whose first response was lost.
Mutation retry rules explain the request contract.
Pagination cursors bind the principal, profile, purpose, row scope, query, projection, and compiled registry identity.
They also expire, with cursor.maxAgeSeconds defaulting to 300 seconds.
A token renewal alone does not require starting over when the effective context is unchanged, but changed mappings, policy, package, or cursor secret can invalidate an existing cursor.
Restart the query from its first page when that happens; do not treat a cursor as portable authority between deployments.
Test the candidate
Section titled “Test the candidate”test compiles the project, applies it to the empty schema-test database, measures the resulting
schema, runs every journey in tests/journeys.yaml over real HTTP with real tokens, and writes a
receipt that package later binds to. Prepare three inputs:
- A test runtime file: the same document as the serving one, with
databasepointing at the schema-test database,package.rootan empty directory, andpackage.activeRevisiona placeholder ofsha256:followed by 64 hexadecimal digits. - Real access tokens from your issuer for each journey principal, stored as owner-only files under the secret root.
- A credentials document binding every journey step to a token or to anonymous access:
apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1kind: SchemaTestCredentialsbindings: - journeyId: record-lifecycle stepId: create-record credential: type: bearer tokenRef: secret:file/schema-test-token - journeyId: record-lifecycle stepId: get-record credential: type: bearer tokenRef: secret:file/schema-test-token - journeyId: record-lifecycle stepId: list-records credential: type: anonymousThen run the test with the signature policy the package will carry:
bregctl --format json test ./my-registry \ --runtime-config /srv/registry/runtime-test.yaml \ --credentials /srv/registry/schema-test-credentials.yaml \ --database-id civil-registry-db-1 \ --signature-threshold 1 --signature-key-id registry-signer-2026 \ --output /srv/registry/schema-test-receipt.jsonThe report names the candidate packageRevision, the measured schemaFingerprint, and the
journeys that passed. Keep the receipt and the fingerprint with the candidate: package refuses a
receipt taken for different sources, a different baseline, or a different signature policy.
A journey failure reports the journey and step without record values.
Package and sign
Section titled “Package and sign”package writes the deployable package directory and stops at the signing boundary:
bregctl --format json package ./my-registry \ --database-id civil-registry-db-1 \ --test-receipt /srv/registry/schema-test-receipt.json \ --signature-threshold 1 --signature-key-id registry-signer-2026 \ --output /srv/registry/build-1The schema fingerprint comes from the test receipt; --schema-fingerprint states it explicitly
when you want the command to fail on any other value. The report’s state is
awaiting_signatures and build-1/signing-input.json holds the exact bytes to sign. The command
never accepts a private key. Your signer produces a detached Ed25519 signature over those bytes,
for example with OpenSSL, and returns a signature document:
openssl pkeyutl -sign -rawin -inkey registry-signer-2026.pem \ -in /srv/registry/build-1/signing-input.json -out signature.bin{"signatures": [{"keyId": "registry-signer-2026", "signatureHex": "<hex-encoded signature.bin>"}]}Rerun the same package command with --signatures <document> and the unchanged inputs and
output directory. The report’s state becomes published and packageRevision names the package.
The package directory is build-1/package.
The trust anchor the runtime file names lists the public keys and the threshold the deployment
accepts, bound to the same identity. The loader requires the file’s bytes to already be RFC 8785
canonical JSON: sorted object keys and no inserted whitespace. A pretty-printed copy of the same
values is refused as an integrity failure, so write it compact, for example with jq -cS:
{"apiVersion":"registry.registrystack.org/package-trust/v1","databaseId":"civil-registry-db-1","environment":"production","instanceId":"civil-registry-1","keys":[{"jwk":{"alg":"EdDSA","crv":"Ed25519","kid":"registry-signer-2026","kty":"OKP","x":"<base64url public key>"},"keyId":"registry-signer-2026"}],"threshold":1}--signature-threshold 0 builds an unsigned package. Production activation refuses it; the
quickstart uses it for local development only.
Activate and serve
Section titled “Activate and serve”Write the serving runtime file with package.root pointing at build-1/package,
activeRevision equal to the reported packageRevision, and activeSequence: 1. Then activate
with the migration credential:
bregctl --format json apply \ --runtime-config /etc/breg/runtime.yaml \ --package /srv/registry/build-1/package --initial--initial activates sequence one in an uninitialized database and requires the runtime file to
already name that package. The report carries the activated revision, sequence, and schema
fingerprint. Confirm the configuration before starting the process:
bregctl verify --runtime-config /etc/breg/runtime.yamlbregctl doctor --runtime-config /etc/breg/runtime.yamlbreg --config /etc/breg/runtime.yamlverify opens no runtime dependency. It proves that the configured package verifies against the
trust anchor and identity, and reports the registry id, version, and revision plus inventory
counts. It does not say whether the database is on that package; the apply report and readiness
do. doctor opens every startup dependency without binding the listener: the runtime file, the
package, the database connection and its readiness, the audit key, the cursor key, the OIDC
verifier’s key material, the event destination bindings, the retained review authority and
executor bindings, the authentication profile’s claim mapping, accepted algorithms, and audience
against the package this runtime serves, and the field-encryption provider and its stored key. It
names the first dependency that refuses and stops there; a run that reaches the end lists every
dependency it checked:
10 dependency checks passed. runtimeConfig pass package pass database pass audit pass cursor pass authentication.oidc pass eventDestinations pass reviewBindings pass authentication pass fieldEncryption pass{ "ok": true, "command": "doctor", "checked": [ "runtimeConfig", "package", "database", "audit", "cursor", "authentication.oidc", "eventDestinations", "reviewBindings", "authentication", "fieldEncryption" ]}Start the process and check
GET /ready before admitting traffic; GET /health reports liveness only.
Restarting this same version with the same runtime file, active package, and database resumes the durable review submission, result reconciliation, and automatic application jobs. A restart does not recreate a Casework review and does not bypass current Base Registry Engine application authority.
doctor and readiness report setup and dependency health. They are not a change-request workflow
console. To trace one review, read the request under a profile granted
readableRequestFields: [review_state] and inspect data.request.review. Its closed sections are
submission, result, delivery, application, and recovery; identifiers and timestamps are
omitted until known. Correlate its Casework request id with Casework’s request, task, result, and
result-feed resources. After durable submission, submission.recoveryDeadline reports the fixed
recovery deadline. It bounds submission and cancellation retries, not the review itself: once the
authority accepts a review, it may stay pending for as long as the authority holds it. Automatic
application jobs report application.attempts from 0 through 1000;
application.nextAttemptAt is present only while the job is queued or applying and is omitted once
it is blocked or applied. An applied job includes application.receiptRecovered: true when the
Base Registry Engine reconstructed the receipt by discovering the source request in its
already-applied state. Omission means the Base Registry Engine completed through the apply
response; an idempotent retry may have replayed that response, so omission does not prove that the
source applied the request for the first time. Use
application.state with recovery.code to choose the supported operator action, and do not query
the database directly.
recovery.code | Operator action |
|---|---|
remote-uncertain, cancellation-uncertain, result-lookup-uncertain, source-precondition-changed | Wait through application.nextAttemptAt where present and re-read the request. The durable worker retries automatically. A later pending answer or a reconciled result clears result-lookup-uncertain. |
token-unavailable | Restore the configured review-authority credential provider, then let the durable worker retry. |
submission-recovery-expired | Reconcile the Casework request by the recorded idempotency and digest bindings before starting a new proposal. |
remote-refused | Correct the producer, policy, or submitted contract reported by the review authority before submitting a new proposal. |
cancellation-recovery-expired, cancellation-attempts-exhausted | Reconcile the exact Casework request and cancellation idempotency key. If cancellation did not complete, correct the binding before starting a fresh proposal. |
result-expired | Start a new review; the authority no longer promises the result payload. |
result-poll-attempts-exhausted | The result lookup failed, or answered an empty 404, until the attempt budget ran out; a review the authority keeps answering as pending is never failed. Reconcile the accepted Casework request by its retained binding. If the authority still holds a result, repair the result endpoint and start a new proposal; the retained binding identifies the review to close. |
executor-unconfigured | Restore the named executor in runtime configuration, then use the advertised authorized manual apply action for the same approved proposal. |
executor-denied | Restore the executor’s current application grant, then use the advertised authorized manual apply action for the same approved proposal. |
source-action-unavailable | Restore the compiled source apply action and its access profile, then use the advertised authorized manual apply action for the same approved proposal. |
source-response-invalid | Repair the source deployment so its read and apply responses match the compiled contract, then use the advertised authorized manual apply action. |
application-attempts-exhausted | Inspect the source and executor logs using the request and application ids, then apply manually or create a new proposal. |
Retain the project sources, module locks, generated files, test receipt, signature documents, and runtime file for each activation. Store secrets separately.
Turn encryption on over existing plaintext
Section titled “Turn encryption on over existing plaintext”A successor package can turn encrypted: true on a field the registry already holds in
plaintext. The apply seals the stored values through the engine-executed backfill the package’s
reviewed migration declares, and the history choice the project authored in that migration
decides what happens to the plaintext the database retains. The two choices and their trade-offs
are in
Field encryption for restricted fields; this section
is the operator path around the apply, which Change an active registry
prepares and applies.
Run the preflight before you apply, while the runtime file still names the active package:
bregctl field-encryption preflight \ --runtime-config /etc/breg/runtime.yaml \ --package /srv/registry/build-2/packageThe preflight is read-only and value-free. It binds the same predecessor package an apply binds, refuses when the active revision is anything else, and reports counts per covered field: the live rows still carrying plaintext, the retained journal revisions that carry the field member, and the retained change-request snapshots, cached idempotency responses, and event payloads that mention the field. When a covered field declares a unique blind index, the preflight refuses when two of those plaintext values would normalize onto one index entry, naming the authored record identifiers, capped at 64, and never a value. Correct one of the named records and run the preflight again; the apply refuses the same collision before it seals anything.
When the authored history choice is erase-and-rebaseline, the retained history of every record
still carrying pre-flip plaintext is erased and snapshot coverage is restored with one
rebaseline. Erasure is whole-record, so retained history beyond the encrypted field is destroyed
with it, and nothing restores it. The erasure does not run as part of the apply: it waits until
the flip’s package is active, and you run it. Backups taken before the flip stay plaintext until
you retire or re-encrypt them yourself. When the choice is retain-plaintext-history, nothing is
erased and pre-flip revision snapshots keep serving as written, so the database-read guarantee
does not hold for them.
After the successor package is active, write an owner-only JSON request file (mode 0600)
carrying the operator reference and the reason alone:
{ "operatorReference": "approved-maintenance-003", "reason": "flip declared erase-and-rebaseline"}Then run the erase-history lifecycle:
bregctl field-encryption erase-history \ --runtime-config /etc/breg/runtime.yaml \ --request-file /srv/registry/private/erase-history.jsonThe request file names no record, entity, or field: the scope is the recorded
erase-and-rebaseline flips themselves, so a document that names records or fields is refused
rather than reinterpreted, and a request file that is not owner-only is refused before any
connection is opened. For every record still holding pre-flip plaintext, the lifecycle erases its
retained history through the same path
history erase uses and restores snapshot coverage
with one rebaseline, then writes one audit record that carries counts, not values.
bregctl field-encryption keygen --output <absolute-file> writes one fresh base64 data key for
the local file provider with owner-only permissions, never prints it, and refuses to overwrite an
existing file.
Serve the container image
Section titled “Serve the container image”The release image carries the breg binary alone, so the runtime document you activated with is
not the one the container serves: activation runs on the operator host that holds the migration
credential and bregctl, and the container only serves the package that host already activated.
Write a second runtime document for the container, changing exactly five things from the host’s:
listener.bind, secretProviders.file.root, package.root, package.trustAnchorPath, and the
database hostname the resolved connection URL names, because the container mounts its own paths
and reaches the database over its own network.
listener: bind: 0.0.0.0:8080secretProviders: file: root: /etc/breg/secretspackage: root: /var/lib/breg/package trustAnchorPath: /etc/breg/package-trust-anchor.jsonNothing restricts listener.bind to a loopback or private address the way metricsListener.bind
is restricted, so the container can bind 0.0.0.0 and accept traffic from outside its network
namespace; a host process that only ever serves 127.0.0.1 has no reason to. Mount the four
inputs the image needs, all read-only, and publish the listener port:
docker run --rm \ -p 8080:8080 \ -v /srv/registry/container/runtime.yaml:/etc/breg/runtime.yaml:ro \ -v /var/lib/breg/build-1/package:/var/lib/breg/package:ro \ -v /etc/breg/package-trust-anchor.json:/etc/breg/package-trust-anchor.json:ro \ -v /etc/breg/container-secrets:/etc/breg/secrets:ro \ ghcr.io/registrystack/breg:v0.26.1Run bregctl verify and bregctl doctor against this exact runtime document from a host that
reaches the same database and the same mounted package before you rely on the container’s
GET /ready.
Scrape metrics
Section titled “Scrape metrics”The listener that serves the registry API never serves metrics. To expose them, add a second listener that only the operator’s network can reach:
metricsListener: bind: 127.0.0.1:9464The address must be a loopback or private address (an IPv4 private range, or IPv6 unique-local)
with a non-zero port, and it must differ from listener.bind; the runtime file is otherwise
refused with runtime_config.invalid_metrics_listener. The listener answers GET /metrics in the
Prometheus text format and nothing else, and it carries no authentication, so keep it behind the
scraper’s network boundary rather than a public one.
| Series | Type | Labels |
|---|---|---|
breg_http_requests_total | counter | route, method, status |
breg_http_request_duration_seconds | histogram | route, method, status |
breg_anonymous_refusals_total | counter | route, method, reason |
breg_pool_connections | gauge | state: max_size, size, available, waiting |
route is the matched route template, such as /v1/records/placements/{record_id}, or
unmatched; it never carries a record identifier. status is success, client_error, or
server_error rather than the exact code. The label sets are closed, so a scrape cannot grow
without bound, and no label carries a principal, a token, or a record value. Pool states are read
from the connection pool at scrape time.
breg_anonymous_refusals_total counts requests that presented no credential and were refused
before admission: a profile the caller cannot hold, a route it cannot see, an absent scope or
purpose, or a query the route cannot parse. reason is one of nine fixed values,
read_request_invalid, read_concealed, read_refused, revision_request_invalid,
revision_concealed, revision_refused, mutation_concealed, mutation_refused, and
action_refused, derived from the refusal itself rather than from anything the request carried.
These refusals are counted rather than journaled, because a caller with no principal names nobody
the journal could hold accountable; refusals of an authenticated principal are journaled, and so
is every admitted request. Alert on a sharp rise in this counter the way you would on a rise in
client_error responses: it is the signal the journal no longer carries.
An admitted anonymous read writes the same pre-I/O audit envelope as any admitted request, and the runtime enforces no request-rate limit of its own, so put a registry that admits anonymous reads behind an upstream rate limit.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Next move |
|---|---|
test refuses the runtime file | Compare it with the serving file: same identity, schema-test database references, an empty package root, and a placeholder revision. |
| A secret reference is refused | The file under the secret root must be a regular file owned by the running user, with mode 0400 or 0600, one link, and no symbolic link, and its name must follow the naming rule in Create the secret files. |
package refuses the receipt | The receipt binds sources, baseline, fingerprint, and signature policy. Rerun test for the exact candidate. |
apply --initial reports a binding error | The runtime file must already name the target package’s revision and sequence one. |
doctor reports startup.oidc.refused | Check the issuer, discovery reachability or the static JWKS document, the algorithm, and the leeway bound. |
The runtime file is refused with runtime_config.invalid_metrics_listener | metricsListener.bind must be a loopback or private address with a non-zero port, distinct from listener.bind. |
| Every token is refused | Compare the token’s header and claims with the token table; a list-valued aud or a missing principal claim refuses the token. |
| An operator command cannot reach PostgreSQL | Check the migration URL secret, the role names, and SSL_CERT_FILE for a private authority. |
| A command refuses a path that exists | Some component of the path is a symbolic link. Name the real directory; on macOS pass /private/tmp rather than /tmp. |
- Bind webhook receivers when the project declares events, because the package refuses to activate until every destination is bound.
- Change an active registry for the successor package, its diff, and its migration evidence.
- Retain, erase, and audit for the maintenance commands that erase history, erase expired Evidence uses, and prove the audit journal.
- Harden a production deployment for the controls around the listener, the secret root, and the migration credential.
- Evidence deployment targets and the Evidence Compose adapter cover Evidence deployment shapes, not Base Registry Engine; read them when this deployment also runs Evidence alongside the registry.