Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.26.1.
You are authoring a registry project whose profiles already check, and some writes need more than one caller holding the right grant. A registry has three write paths. A direct mutation is a create, patch, or tombstone that a profile performs on a record and that commits at once. A change request is a record of its own that proposes a change to another entity’s record, waits for review, and is applied later by a profile that holds apply authority. An immediate action writes several records in one transaction from one typed input, with no review step. How a configured registry works explains why all three are declared in the project rather than coded.
Choose a change request when someone other than the caller must agree before the target changes, when the proposal itself must remain as a record with its own state, or when the write must wait for a later application. Choose an immediate action when the caller already holds the authority to write every target and the point is that several records change together or not at all. At the end of this page you have declared either or both, split the roles across profiles, and evaluated a planner script offline.
Change requests
Section titled “Change requests”A change request is an entity of its own whose records propose a change to another entity.
Three declarations connect them: the request entity’s changeRequest block, the target entity’s changeControl block, and the profiles that submit, review, and apply.
entities: - id: asset-placement primaryDataset: asset-registry route: placements mutationMode: mutable changeControl: requiredFor: [patch] fields: - id: asset type: reference target: asset-item required: true classification: internal - id: site type: reference target: asset-site required: true classification: internal - id: placement-correction-request primaryDataset: asset-registry route: placement-corrections mutationMode: mutable fields: - id: placement type: reference target: asset-placement required: true classification: internal - id: proposed-site type: reference target: asset-site required: true classification: internal - id: reason type: text required: true maxLength: 2000 classification: internal changeRequest: effects: - target: fromField: placement operation: patch set: site: fromField: proposed-site review: stages: - id: review approvals: 1 excludeSubmitter: true - id: final-approval approvals: 1 excludeSubmitter: true excludePreviousReviewers: true retention: mode: operator_erase| Member | Effect |
|---|---|
changeControl.requiredFor | The operations on the target that must go through a request. With [patch], a direct patch of a placement is refused even for a profile that holds patch: the operation is absent from ordinary grants, and only applying a request performs it. |
changeRequest.effects | What applying the request writes. target names the record from a reference field, operation is create or patch, set maps target fields to request fields, and clear lists target fields to null. A request entity declares either effects or a planner, never both. |
review.stages | Stages run in order. Each needs approvals distinct approvers. excludeSubmitter keeps the submitter out of that stage; excludePreviousReviewers keeps actors who decided an earlier stage of the current proposal out of this one. A request that needs no review declares review.mode: none instead of stages; submitting it moves it straight to approved. |
application.mode | Who applies. The default, manual, waits for an applier. automatic applies inside the submit, or inside the final approval when stages exist; one profile must then hold that action together with apply_request and applyTargets for every target. planner leaves the choice to a script: allowedDispositions lists which of apply and queue it may return, and a queued request names one of queueReasons. |
retention.mode | retain or operator_erase. With operator_erase, an operator can erase the proposal detail of an applied or canceled request while the record and its state remain. |
Decide retention.mode before the first request is submitted, because a proposal stored under retain keeps its detail for as long as the record exists; retain, erase, and audit covers what an operator can erase later.
Split the roles across profiles
Section titled “Split the roles across profiles”accessProfiles: - id: correction-submitter default: true principalClaim: registry_principal requiredScopes: [registry:corrections:submit] requiredPurposes: [asset-correction] grants: - entity: placement-correction-request rowBoundaries: [] operations: [create, get, patch, submit_request, revise_request, cancel_request] readableFields: [placement, proposed-site, reason] writableFields: [placement, proposed-site, reason] requestVisibility: owner - id: correction-reviewer principalClaim: registry_principal requiredScopes: [registry:corrections:review] requiredPurposes: [asset-correction-review] grants: - entity: placement-correction-request rowBoundaries: [] operations: [get, list, approve_request, reject_request, request_revision] readableFields: [placement, proposed-site, reason] reviewStages: - stage: review targets: - entity: asset-placement readableFields: [site] rowBoundaries: [] - id: correction-final-approver principalClaim: registry_principal requiredScopes: [registry:corrections:final-approve] requiredPurposes: [asset-correction-review] grants: - entity: placement-correction-request rowBoundaries: [] operations: [get, approve_request, reject_request, request_revision] readableFields: [placement, proposed-site, reason] reviewStages: - stage: final-approval targets: - entity: asset-placement readableFields: [site] rowBoundaries: [] - id: correction-applier principalClaim: registry_principal requiredScopes: [registry:corrections:apply] requiredPurposes: [asset-correction-apply] grants: - entity: placement-correction-request rowBoundaries: [] operations: [get, apply_request] readableFields: [placement, proposed-site, reason] applyTargets: - entity: asset-placement rowBoundaries: []The submitter is the default for shared request reads. Each review stage has its own route and one eligible profile in this example, so that profile is selected automatically. An explicit profile is required only when the particular route has several eligible profiles and no default.
This example requires three distinct identities: the submitter, a reviewer, and a final approver.
Separate profile names alone do not establish that separation; one person might hold both review permissions.
The stage exclusions enforce it using the verified principal identity.
Both exclusions default to false, so set them explicitly where independence is required.
The manual applier may also have participated in review; applying is a separately granted operation, not an additional independent approval.
A reviewer’s reviewStages names the stages the profile may decide and the target fields it may see while deciding.
An applier’s applyTargets names the entities the profile may write when it applies, with row boundaries of its own.
Give the profiles that read the target entity a requestPresence entry so they can see that a correction is pending.
A grant’s requestVisibility: owner restricts reads of the request entity to the requests the caller’s own principal created, which suits a submitter.
The request record moves through draft, submitted, needs_changes, rejected, approved, applied, and canceled.
Applying writes the target the way a direct mutation would, so the target gains a revision and the request moves to applied.
Base Registry Engine API reference describes each action, its route, and the states it accepts.
To see everything the compiler derived, including the routes each profile receives and which request types may write each controlled entity:
bregctl explain change-requests ./my-registryThe JSON report carries a controlledWrites list, one entry per target entity with its requiredFor operations and eligibleRequestTypes, and a requests list with the actions and routes of each request entity.
Plan effects with a Rhai script
Section titled “Plan effects with a Rhai script”When the effects depend on the request’s content, a request entity declares a planner instead of effects.
The YAML still owns the ceiling: which request fields the script may read, which targets and fields it may write, and which outcomes it may choose.
The script only fills in values within that ceiling.
entities: - id: person-name-change-request primaryDataset: person-registry route: person-name-change-requests mutationMode: mutable fields: - id: person type: reference target: person required: true classification: internal - id: given-name type: string required: true maxLength: 80 classification: internal - id: family-name type: string required: true maxLength: 80 classification: internal - id: handling type: vocabulary-code vocabulary: name-change-handling required: true classification: internal changeRequest: planner: kind: rhai script: scripts/person-name-change.rhai abi: registry.change-request-plan/v1 requestFields: [person, given-name, family-name, handling] writes: - target: fromField: person operation: patch fields: [display-name] review: mode: none application: mode: planner allowedDispositions: [apply, queue] queueReasons: assisted-review: Assisted review requested by the submitter. retention: mode: operator_eraseThe script at scripts/person-name-change.rhai builds the display name and picks the outcome:
fn plan(ctx) { let given = ctx.request["given-name"]; given.trim(); let family = ctx.request["family-name"]; family.trim(); let effect = #{ target: #{fromField: "person"}, operation: "patch", set: #{"display-name": given + " " + family} }; if ctx.request["handling"] == "routine" { return #{effects: [effect], disposition: "apply"}; } #{effects: [effect], disposition: "queue", reasonCode: "assisted-review"}}| Member | Meaning |
|---|---|
requestFields | The request fields the script receives as ctx.request, keyed by their authored ids. Nothing else reaches the script: no target record, no caller identity, no other request. |
writes | Every target the script may touch, with the operation and the fields it may set or clear. target is fromField: <reference field> for an existing record or entity: <id> for a record the script creates. |
script | A path relative to the document that declares it, the project root or a module directory, ending in .rhai. The compiler reads it at check and package time, and its digest is part of the compiled contract, so editing the script changes the contract fingerprint. |
abi | The planner contract, which must be registry.change-request-plan/v1. |
The script defines fn plan(ctx) and returns a map whose effects is a non-empty list within the declared writes.
Each effect carries target, operation, and set, plus clear for a patch; a create effect also needs an id, and a later effect refers to the created record with fromEffect: <id>.
A reference field in set takes fromField or fromEffect, never a literal identifier; other values are checked against the target field’s type.
Under application.mode: planner the map also carries disposition, apply or queue, and a queued plan names its reasonCode.
Under manual or automatic the YAML decides, and a script that returns a disposition is refused.
The server runs the script once, when the request is submitted, and freezes its effects, its disposition, and the script digest in the proposal.
Review, retry, and apply never rerun it, so a later package with an edited script leaves an already frozen proposal untouched.
The engine is closed: the script cannot import modules, evaluate strings, print, or reach a file, the database, or the network, and it runs within fixed bounds (64 KiB of source, 100,000 operations, and bounded call depth, strings, arrays, and maps).
A script that fails, leaves the ceiling, or breaks a bound refuses the submit with request.invalid.
check reports a planner block, review mode, or application policy that disagree with each other as change_request.* errors; it does not run the script.
Evaluate the script offline, with no database and no credentials, from a JSON file of request field values keyed by their authored ids:
{ "person": "11111111-1111-4111-8111-111111111111", "given-name": " Ada ", "family-name": " Lovelace ", "handling": "routine"}bregctl project planner-test ./my-registry \ --entity person-name-change-request --request ./routine-request.jsonproject planner-test succeededcompiled revision: sha256:<digest>request entity: person-name-change-requestplanner kind: rhaiplanner ABI: registry.change-request-plan/v1planner script SHA-256: sha256:<digest>disposition: applyeffect effect-1: target=existing, operation=patch, fields=display-name, dependencies=counts: effects=1, field mutations=1, dependencies=0The report names the planner identity, the disposition, and each effect’s target kind, operation, and fields; it never repeats the request values. The request file is limited to 64 KiB. A journey against a database remains the proof for authorization, freezing, and application.
Immediate actions
Section titled “Immediate actions”An action writes several records in one transaction from one typed input, without a review step:
actions: - id: register-asset-with-inspection inputs: - id: asset-code apiName: assetCode type: string required: true maxLength: 64 classification: internal - id: label type: string required: true maxLength: 200 classification: internal - id: jurisdiction type: string required: true maxLength: 80 classification: internal - id: observed-at apiName: observedAt type: timestamp required: true classification: internal - id: inspection-result apiName: initialResult type: vocabulary-code vocabulary: inspection-result required: true classification: internal effects: - id: asset target: entity: asset operation: create set: asset-code: fromField: asset-code label: fromField: label jurisdiction: fromField: jurisdiction - id: initial-inspection target: entity: asset-inspection operation: create set: asset: fromEffect: asset observed-at: fromField: observed-at result: fromField: inspection-result jurisdiction: fromField: jurisdictioninputs use the same types and members as fields, except that an input cannot declare validTimeRole.
Each effect has an id, a target that is either a fixed entity or a record named by an input reference through fromField, set values that come fromField or fromEffect, so a later effect can reference a record an earlier effect created, and an optional clear list for a patch.
The grant names the action, the targets it may write, and the effect results the caller gets back:
- id: asset-registrar principalClaim: registry_principal requiredScopes: [registry:asset:register] requiredPurposes: [asset-registration] grants: - action: register-asset-with-inspection operations: [invoke] targets: - entity: asset rowBoundaries: - field: jurisdiction claim: jurisdiction operator: equals - entity: asset-inspection rowBoundaries: - field: jurisdiction claim: jurisdiction operator: equals results: [asset]Row boundaries on action targets apply to the records the action writes, so a registrar bound to one jurisdiction cannot register an asset in another.
Actions that patch existing records take preconditions: the caller first asks for target conditions, then invokes with the ETags it received, and the action fails with precondition.failed if a target moved in between.
An invocation either commits every effect or none, so a refused effect leaves no partial record behind.
bregctl explain actions ./my-registryThe report lists every compiled action with its inputs, effects, grants, contract fingerprint, and the bounds an invocation stays within. Base Registry Engine API reference documents the invoke route, the target-conditions request, and the result envelope.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and next move |
|---|---|
check reports a change_request.* error. | The planner block, the review mode, and the application policy must agree: effects and planner are exclusive, review.mode: none excludes stages, dispositions and queue reasons belong to application.mode: planner only, and a policy that applies on submit or final approval needs one profile holding both that action and complete apply authority. check does not run the script; evaluate it with project planner-test. |
A direct patch of a controlled entity is refused although the profile holds patch. | The entity’s changeControl.requiredFor includes patch, so the write must arrive as an applied request. Submit a request of one of the eligibleRequestTypes that explain change-requests lists. |
project planner-test fails, or a submit is refused with request.invalid. | The script failed, returned an effect outside writes, returned a disposition the mode does not allow, or broke a bound. Read the message, fix the script or the ceiling, and rerun project planner-test with the same request file. |
An invoke fails with precondition.failed. | A target changed between the target-conditions request and the invoke. Ask for target conditions again and invoke with the new ETags. |
- Review changes before updating a registry: submit, review, and apply a request against a running registry.
- Test with journeys: write the submit, approve, apply, and refusal steps that prove the workflow.
- Control access per profile: the grant members every role on this page depends on.
- Base Registry Engine API reference: each request action, its route, and the states it accepts.
- Retain, erase, and audit: what
operator_eraselets an operator remove later.