Registry stack documentation: machine-readable Markdown.
Index of all pages: https://docs.registrystack.org/dev/llms.txt
Full corpus: https://docs.registrystack.org/dev/llms-full.txt

# Declare change requests and actions

> Declare change-request entities with review stages, application policy, and retention, plan their effects with a Rhai script, and declare immediate actions that write several records in one transaction.

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](../../explanation/configuration-defined-registry/) 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

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.

```yaml
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](../../operate/breg-retention/) covers what an operator can erase later.

### Split the roles across profiles

```yaml
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](../../reference/breg-api/#change-requests) 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:

```sh
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.

{/* Evidence: products/breg/acceptance/asset-site-placement-change-requests/registry.yaml; crates/registry-breg/src/contract.rs, ChangeControlSource and ChangeRequestSource and RequestVisibilitySource; crates/registry-breg/src/request_workflow.rs; crates/registry-breg/src/access.rs. */}

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

```yaml
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:

```rust
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:

```json
{
  "person": "11111111-1111-4111-8111-111111111111",
  "given-name": "  Ada  ",
  "family-name": "  Lovelace  ",
  "handling": "routine"
}
```

```sh
bregctl project planner-test ./my-registry \
  --entity person-name-change-request --request ./routine-request.json
```

```text
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.

{/* Evidence: products/breg/acceptance/person-name-change-rhai/registry.yaml;
    products/breg/acceptance/person-name-change-rhai/scripts/person-name-change.rhai;
    products/breg/acceptance/person-name-change-rhai/examples/routine-request.json;
    crates/registry-breg/src/rhai_planner.rs;
    crates/registry-breg/src/change_request.rs;
    crates/registry-bregctl/src/lib.rs, planner_test() and MAX_PLANNER_TEST_REQUEST_BYTES. */}

## Immediate actions

An action writes several records in one transaction from one typed input, without a review step:

```yaml
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:

```yaml
  - 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.

```sh
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](../../reference/breg-api/#immediate-actions) documents the invoke route, the target-conditions request, and the result envelope.

{/* Evidence: products/breg/fixtures/asset-registration-actions/registry.yaml; products/breg/fixtures/asset-registration-actions/modules/asset-registration-actions-core/module.yaml; crates/registry-breg/src/contract.rs, ActionInputSource and ActionEffectSource; crates/registry-breg/src/mutation/action.rs. */}

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

## Next

- [Review changes before updating a registry](../../tutorials/review-registry-changes/): submit, review, and apply a request against a running registry.
- [Test with journeys](../breg-journeys/): write the submit, approve, apply, and refusal steps that prove the workflow.
- [Control access per profile](../breg-access/): the grant members every role on this page depends on.
- [Base Registry Engine API reference](../../reference/breg-api/#change-requests): each request action, its route, and the states it accepts.
- [Retain, erase, and audit](../../operate/breg-retention/): what `operator_erase` lets an operator remove later.