Skip to content
Registry StackDocsDevelopment (unreleased)

Test with journeys

View as Markdown

You have a model and its profiles, and you want proof that the project behaves before it reaches a database. This page covers the offline checks first: check compiles the project and reports findings, explain prints what the compiler derived, and generate writes the artifacts a client will see. It then covers journeys, the scenarios in tests/journeys.yaml that bregctl test replays over HTTP against a throwaway database, because a row boundary, a query bound, or a review workflow is only proven by a request that runs. At the end you know what each command proves, what only a journey can prove, and what a failure of each looks like.

Terminal window
bregctl check ./my-registry

check compiles the project without a database and prints the compiled revision, a content digest of everything the compiler read:

check succeeded
revision: sha256:<digest>
finding access.profile.unrestricted_collection at entities[id=record].accessProfiles[id=operator].rowBoundaries: this profile can list all rows, subject only to query bounds; caller filters are not authorization. Add a claim-bound row restriction or review this registry-wide access

Two people holding the same revision hold the same project. An error is a declaration the compiler cannot compile, printed as error <code> at <path>: <message>, and the command exits with status 1. A finding is an advisory attached to a document path: the project compiles, but a reviewer must read it. The project init wrote reports the access.profile.unrestricted_collection finding on purpose, because its operator profile lists every record.

Two flags change what counts as a failure, and a review gate wants both:

Terminal window
bregctl check ./my-registry --deny-findings
bregctl check ./my-registry --production

--deny-findings turns every finding into an error, so the first command prints error access.profile.unrestricted_collection at ... and exits with status 1 until the finding is resolved or the profile is changed. --production applies the production closure rules: package identity, a lock entry with a pinned digest for every module, a loaded source for every lock, and a webhook for every event must all be present, and a project missing one fails with package.identity.required, module.lock.missing, module.lock.digest_required, module.source.required, or event.delivery.required. The project init wrote already carries all of them, so check --production succeeds on it. explain and generate accept --production as well; --deny-findings belongs to check alone.

Terminal window
bregctl explain model ./my-registry
bregctl explain routes ./my-registry
bregctl explain queries ./my-registry

explain takes one subject, prints the revision and findings the way check does, then the report: JSON for every subject except access, whose report is written for reading. model prints every entity, field, and profile as compiled. routes prints every HTTP route with its method, path, operation, and the profiles that may select it, including the default. queries prints, per entity and operation, the fields a response may carry, the filterable fields with example $filter expressions, and the query bounds a request stays within. access prints the compiled grants per profile in a human-readable form and, with --scenario, previews admission; control access per profile covers it. change-requests and actions are covered on declare change requests and actions, and events on author a registry project.

Read explain queries before you write a client or a journey: a request that filters on a field the profile cannot filter is refused, not empty.

Terminal window
mkdir -p ./generated
bregctl generate openapi ./my-registry --output ./generated/openapi
bregctl generate schemas ./my-registry --output ./generated/schemas

generate writes one artifact family into a new directory: openapi, schemas, actions, manifest, metadata, or sql. The output directory’s parent must exist, and the directory itself must not: a missing parent fails with output.parent.invalid, and an existing directory, or a path containing .., fails with output.destination.invalid, so a run can never overwrite artifacts you already reviewed. The command creates the directory and writes under a generated/ subdirectory inside it, so the two commands produce ./generated/openapi/generated/openapi.json and ./generated/schemas/generated/schemas/<entity>.schema.json.

Output is deterministic: the same project produces the same bytes, so commit the generated documents and, in review, generate into a fresh directory and diff it against the committed copy. The running server publishes the same OpenAPI document at /openapi.json and the same schemas under /v1/schemas/.

These are all the findings the compiler can report, each attached to the document path it applies to:

FindingMeaningWhat to do
access.profile.anonymous_collectionlist is granted to unauthenticated callers, so every row the profile can read is public.Confirm the whole collection is meant to be public.
access.profile.data_exportBulk export is enabled, and disabling it later cannot recall downloaded data.Review the readable fields and row boundaries first.
access.profile.higher_classificationA readable field is more sensitive than its entity’s classification.Verify the profile’s scope and purpose before disclosing it.
access.profile.no_required_scopeNo scope restricts who may select the profile.Add a required scope unless that is intended.
access.profile.no_writable_fieldsThe profile grants create or patch and names no writable field.List the writable fields, or remove the write operations.
access.profile.related_disclosureA read path discloses related records under the root profile rather than the target entity’s own profiles.Review its fields and the target entity’s access requirements.
access.profile.revision_historyHistory can disclose values removed from the current record.Review historical disclosure separately.
access.profile.snapshot_historySnapshot reads reproduce retained historical rows under current authorization.Review stored-field projection, filters, and row boundaries separately.
access.profile.unrestricted_collectionThe profile can list every row; caller filters are not authorization.Add a claim-bound row boundary, or accept a registry-wide role knowingly.
access.profile.writable_row_boundarypatch can change a field a row boundary depends on, within the caller’s allowed values.Remove it from the writable fields unless moving records is intended.
manifest_projection.missingNo Registry Manifest projection is declared.Declare one to clear the finding. check --production passes without it, and package builds a package without one.
module.lock.digest_missingA module lock entry has no production digest.Run project lock before a production package.
module.lock.missingA module source has no lock entry.Run project lock.
module.source.missingA lock entry names a module whose source is not loaded.Restore the module file or remove the lock entry.
package.identity.missingNo package identity is declared.Declare one before a production package.

Under --production, the module.* and package.* findings become errors; manifest_projection.missing is reported only in authoring mode, so resolve it before you leave this page.

A journey is a list of steps; each step names an entity, a profile, the claims the synthetic token carries, one request, and the outcome it expects. bregctl test replays them over real HTTP against an empty PostgreSQL database, binding one credential per step from its own credentials file, so the journey file never holds a credential. Every entity, profile, field, and claim in a journey is resolved against the compiled project first, so a journey can never reach past what a profile already allows.

The journey init wrote proves the record-reader row boundary by moving a record out of the reader’s rows and counting; these steps add a refusal:

apiVersion: registry.registrystack.org/breg-journeys/v1
journeys:
- id: record-lifecycle
steps:
- id: create-record-group
entity: record-group
accessProfile: operator
claims: &operator_claims
principal: generic-registry-operator
scopes: [registry:generic:operate]
purpose: registry-operations
request:
operation: create
data:
code: group-a
label: Example group
expect:
outcome: success
status: 201
fields:
code: group-a
capture: example-group
- id: create-record
entity: record
accessProfile: operator
claims: *operator_claims
request:
operation: create
data:
code: example
label: Example record
group:
recordRef: example-group
status: active
expect:
outcome: success
status: 201
capture: example-record
- id: retire-record
entity: record
accessProfile: operator
claims: *operator_claims
request:
operation: patch
recordRef: example-record
etagRef: example-record
changes:
- field: status
value: retired
expect:
outcome: success
status: 200
fields:
status: retired
- id: reader-outside-the-claim-sees-nothing
entity: record
accessProfile: record-reader
claims:
principal: generic-registry-reader
scopes: [registry:generic:read]
purpose: registry-reporting
directClaims:
registry_record_status: active
request:
operation: get
recordRef: example-record
expect:
outcome: refusal
status: 404
problemCode: resource.not_found
Step memberMeaning
claimsprincipal, scopes, purpose, and the directClaims row boundaries and claim lookups read. Values are synthetic.
request.operationcreate, get, list, query, lookup, read_path, patch, batch, target_conditions, invoke, or a change-request action: submit_request, approve_request, reject_request, and request_revision with a stage, revise_request, cancel_request, or apply_request.
recordRef, etagRefA record and ETag captured by an earlier step. etagRef sends the ETag as If-Match, so a patch fails rather than overwrite a concurrent change.
data, changes, items, selector and values, path, select, top, count, bbox, input, preconditionsThe request body or query for the operation.
expectoutcome is success or refusal; status is the HTTP status; fields asserts returned values; count asserts the item count; problemCode asserts the problem code of a refusal.
capture, captureResultsStore the returned record and ETag, or an action’s named results, under a name for later steps.

A read outside the caller’s boundary returns 404 with resource.not_found, because the server conceals what the caller may not know exists. Write a refusal step for every boundary you rely on: the compiler cannot prove a row boundary works, only a journey can.

Running the journeys needs more than the project: an empty PostgreSQL database, a runtime configuration, a credentials file binding one credential per step, and a path for the receipt bregctl test writes. Build a production candidate runs them step by step, and deploy a registry covers the runtime configuration they read.

SymptomCause and next move
check passes but check --deny-findings fails.A finding is present. Read it and either change the project or accept the finding knowingly.
generate fails with output.parent.invalid.The output directory’s parent does not exist. Create it, or give a destination inside a directory that exists.
generate fails with output.destination.invalid.The output directory already exists, or the path contains ... Give a new path; remove the old directory first if you meant to regenerate.
A journey step fails with 404 where you expected data.The profile’s row boundary or purpose excluded the caller. Compare the step’s claims with the grant, or preview them with explain access --scenario (control access per profile).
A journey filters or sorts on a field and is refused.The profile’s filterableFields or sortableFields do not list it. Check explain queries for the forms the profile may use.