Skip to content
Registry StackDocsDevelopment (unreleased)

Declare change requests and actions

View as Markdown

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.

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
MemberEffect
changeControl.requiredForThe 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.effectsWhat 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.stagesStages 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.modeWho 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.moderetain 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.

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:

Terminal window
bregctl explain change-requests ./my-registry

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

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_erase

The 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"}
}
MemberMeaning
requestFieldsThe 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.
writesEvery 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.
scriptA 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.
abiThe 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"
}
Terminal window
bregctl project planner-test ./my-registry \
--entity person-name-change-request --request ./routine-request.json
project planner-test succeeded
compiled revision: sha256:<digest>
request entity: person-name-change-request
planner kind: rhai
planner ABI: registry.change-request-plan/v1
planner script SHA-256: sha256:<digest>
disposition: apply
effect effect-1: target=existing, operation=patch, fields=display-name, dependencies=
counts: effects=1, field mutations=1, dependencies=0

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

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: jurisdiction

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

Terminal window
bregctl explain actions ./my-registry

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

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