Skip to content
Registry StackDocsv0.34.0

Declare change requests and actions

View as Markdown

You are authoring a Base Registry Engine (BReg) 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, read, 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:
authority: casework
policyId: asset-placement-correction
onApproved:
mode: manual
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 permissions, 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.
reviewThe logical external review authority and policy frozen into the submitted proposal. Casework owns the policy, review stages, reviewer independence, and terminal result. A request that intentionally needs no external review declares review: {mode: none}.
onApprovedWhat happens after BReg has reconciled an exact approved result. manual waits for a currently authorized caller to use apply_request. automatic requires a logical executor; runtime configuration binds that executor to a separate ordinary credential that uses the same source action and guards.
applicationOptional application preconditions over frozen request facts, current target facts, or signed Evidence. It does not choose review or application authority.
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.

Use caseworkctl source add from the paired Casework project instead of copying compiled metadata or writing integration glue. Preview first, then apply the exact reviewed changes:

Terminal window
caseworkctl source add ./my-registry \
--project ./casework --source-id asset-registry
caseworkctl source add ./my-registry \
--project ./casework --source-id asset-registry --apply

The source id must equal registry.id. The command verifies the Casework approval kind and producer admission, adds the lifecycle hook and least-privilege reader profile, and writes a candidate reviewAuthorities.<authority> runtime binding. Manual application adds no executor. For onApproved.mode: automatic, it also writes a candidate reviewExecutors.<executor> binding for the request’s single ordinary apply profile. Review the endpoints and secret references before merging the fragment into the launcher-owned runtime file. The command never activates a package or provisions a credential.

Result polling is the default. A Casework producer completion block explicitly enables completion delivery and causes the candidate authority binding to include completionTokenRef and completionRecipient. The BReg completion receiver acknowledges only an empty HTTP 204 No Content; other success statuses and a 204 with response bytes are retried. The notification is a wake-up. BReg still reads and correlates the retained result before it records approval.

accessProfiles:
- id: correction-submitter
default: true
principalClaim: registry_principal
requiredScopes: [registry:corrections:submit]
requiredPurposes: [asset-correction]
permissions:
- 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-reader
principalClaim: registry_principal
requiredScopes: [registry:corrections:read]
requiredPurposes: [asset-correction-review]
permissions:
- entity: placement-correction-request
rowBoundaries: []
operations: [get, list]
readableFields: [placement, proposed-site, reason]
readableRequestFields: [reason, review_state]
- id: correction-applier
principalClaim: registry_principal
requiredScopes: [registry:corrections:apply]
requiredPurposes: [asset-correction-apply]
permissions:
- 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. The reader can inspect BReg’s source-owned review projection because its readableRequestFields includes review_state; this is disclosure authority, not authority to decide the review. Casework policy asset-placement-correction defines the reviewers, stages, independence rules, and fields disclosed for the review task. The manual applier is a separate current source authorization check, not an additional review decision.

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 permission’s requestVisibility: owner restricts reads of the request entity to the requests the caller’s own principal created, which suits a submitter.

The source request record is stored as draft, submitted, cancelled, or applied, and bregctl explain lifecycle reports every one of them as reachable. The external result status is projected separately and never substitutes for the source lifecycle state. A settled result does not move the request, but it decides what its owner and applier are offered. After a rejection the owner can only cancel. After a send-back, or an approval whose availableUntil passed before anyone applied it, the owner can revise the request or cancel it, and apply_request is not offered; the revise_request action then carries rebase: false, so the new draft is recorded as a revision. BReg refuses what it does not offer, and bregctl explain lifecycle reports that check as its review_outcome layer. 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.

A change request that reaches into another record, a licence correction that names the licence it corrects, can require the submitter to still hold read authority over that record, not merely to have created the request. Add submitterTargets to the request entity’s grant, naming the entities its native reference fields target:

entities:
- id: professional-license
primaryDataset: directory
route: professional-licenses
mutationMode: mutable
fields:
- id: person-reference
type: string
required: true
classification: restricted
- id: licensed-activities
type: structured
required: true
classification: restricted
maxBytes: 512
schema:
type: array
items:
type: string
enum: [example-assessment, example-advisory-services, example-practical-services]
minItems: 1
maxItems: 3
uniqueItems: true
- id: scope-correction
primaryDataset: directory
route: scope-corrections
mutationMode: mutable
fields:
- id: record
type: reference
target: professional-license
required: true
classification: restricted
- id: licensed-activities
type: structured
required: true
classification: restricted
maxBytes: 512
schema:
type: array
items:
type: string
enum: [example-assessment, example-advisory-services, example-practical-services]
minItems: 1
maxItems: 3
uniqueItems: true
changeRequest:
effects:
- target:
fromField: record
operation: patch
set:
licensed-activities:
fromField: licensed-activities
review:
authority: casework
policyId: scope-correction
onApproved:
mode: manual
retention:
mode: operator_erase
accessProfiles:
- id: holder
principalClaim: registry_principal
requiredScopes: [starter:holder]
requiredPurposes: [starter-learning]
permissions:
- entity: professional-license
operations: [get, list]
readableFields: [person-reference, licensed-activities]
rowBoundaries:
- field: person-reference
claim: person_reference
operator: equals
- entity: scope-correction
operations: [create, get, patch, submit_request, revise_request, cancel_request, list]
readableFields: [record, licensed-activities]
writableFields: [record, licensed-activities]
requestVisibility: owner
rowBoundaries: []
submitterTargets: [professional-license]

submitterTargets must name exactly the request’s target entities: one entry for every entity its effects write, no more and no fewer. Each field the effects read a target from, record above, must be required on the request entity and readable through the permission, so admission can always read the target id it names, and writable through it wherever the profile holds create or patch on the request, so the submitter can supply the reference its own request needs. onApproved.mode must stay manual; an automatically applied request cannot declare submitter target grants. No target may itself be a change-request entity, since a request’s own read authority is the thing being checked. The profile itself must be non-anonymous and cannot hold batch on the request entity. And the same profile id must carry a permission on the target entity with get in its operations and no membershipBoundaries; a rowBoundaries restriction is fine, and the holder’s own row boundary on person-reference above is what actually limits which licences a holder may target. check refuses a permission that cannot honour all of this, with change_request.submitter_targets.invalid.

At runtime the engine reuses that same target permission’s current get authority, never the request entity’s own boundaries. Two checks carry it. The route itself refuses create, a draft patch, submit_request, and revise_request unless the caller’s claims resolve to target authority under that permission: a claim that carries no matching value, or one a boundary cannot reduce to the single value it needs, leaves the route unauthorized and answers 404, the same concealment an unrelated caller gets. Then submit_request and revise_request admit the named target rows inside the request transaction, under a share lock on each target table, and an exact idempotent replay admits them again before it replays: a target row the caller can no longer read through that permission answers 412 and precondition.failed. Reads are symmetric: once the request is retained, its target links appear only to a reader who currently holds that same get authority.

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 and which targets and fields it may write. 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
onApproved:
mode: manual
retention:
mode: operator_erase

The script at scripts/person-name-change.rhai builds the display name and returns the frozen effect:

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}
};
#{effects: [effect]}
}
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. The planner never selects review or application authority. review and onApproved remain explicit authored configuration, and a script that returns an application disposition is refused.

The server runs the script once, when the request is submitted, and freezes its effects and 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 an invalid planner, review binding, or approved-application binding 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
Ran the planner. 1 effect.
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>
effects 1
field mutations 1
dependencies 0
effect effect-1
target existing
operation patch
fields display-name
dependencies none

The report names the planner identity 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 permission 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]
permissions:
- 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.effects and planner are exclusive; review must be either {mode: none} or an authority and policy id; manual application must omit an executor; automatic application must name one. 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, attempted to return an obsolete disposition, or broke a bound. Read the message, fix the script or the ceiling, and rerun project planner-test with the same request file.
revise_request answers 409 mutation.conflict.The settled review result rules the shape out: a rejected request can only be cancelled, and a send-back or an expired approval is answered by a revision, not a rebase. Read the request again and follow the action it offers.
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.