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

# Declare change requests and actions

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

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](../../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, read, 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:
        authority: casework
        policyId: asset-placement-correction
      onApproved:
        mode: manual
      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 permissions, 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` | The 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}`. |
| `onApproved` | What 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. |
| `application` | Optional application preconditions over frozen request facts, current target facts, or signed Evidence. It does not choose review or application authority. |
| `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.

### Connect the Casework authority

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:

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

### Split the roles across profiles

```yaml
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](../../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/lifecycle.rs, request_lifecycle and every_state_is_reachable_and_only_applied_and_cancelled_are_terminal; crates/registry-breg/src/access.rs. */}

### Require submitter authority over targets

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:

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

{/* Evidence: products/breg/starters/professional-licences/core/registry.yaml; crates/registry-breg/src/contract.rs, AccessPermissionSource; crates/registry-breg/src/change_request.rs; crates/registry-breg/src/mutation/request.rs, admit_submitter_targets(); crates/registry-breg/src/api/mod.rs, authorize_direct_route_base(); crates/registry-breg/src/idempotency.rs; crates/registry-breg/src/postgres/request_read.rs, target_get_is_authorized(); crates/registry-breg/src/problem.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 and which targets and fields it may write.
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
      onApproved:
        mode: manual
      retention:
        mode: operator_erase
```

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

```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}
    };
    #{effects: [effect]}
}
```

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

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

{/* 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 permission 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]
    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.

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

## 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, external-result reconciliation, apply, and refusal steps that prove the workflow.
- [Control access per profile](../breg-access/): the permission 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.