Skip to content

Deterministic mutation hooks for ECS#87

Draft
caniko wants to merge 2 commits into
bevyengine:mainfrom
caniko:deterministic-mutation-rfc
Draft

Deterministic mutation hooks for ECS#87
caniko wants to merge 2 commits into
bevyengine:mainfrom
caniko:deterministic-mutation-rfc

Conversation

@caniko
Copy link
Copy Markdown

@caniko caniko commented May 31, 2026

RFC: Deterministic ECS mutation primitives

This RFC proposes two bevy_ecs primitives for deterministic mutation pipelines:

  • ObserverSet and ordered observer dispatch, matching the familiar .before / .after / .in_set / .chain() scheduling vocabulary.
  • restricted component access through #[component(restricted)] and RestrictedMut<T>, an opt-in safe write gate that prevents ordinary safe in-place mutable ECS access from bypassing a mediated mutation path.

Rendered RFC:

Related Bevy work:

Important provenance note for review:

  • bevy#24370 is currently closed, and its discussion includes Bevy AI-policy concerns. This RFC is intended to discuss the ECS design. It is not intended to bypass provenance requirements for any implementation PR. Any reopened or successor implementation PR must independently satisfy Bevy's contribution policy.

The RFC deliberately does not propose upstreaming MutationLog, SyncBarrier, networking, save/load, undo, replay, or audit policy. Those remain library concerns built on top of the two proposed engine-level enforcement hooks.

Specific design points where review would be especially useful:

  • whether the restricted spelling should be #[derive(Component)] #[component(restricted)] as recommended here, or the prototype's #[derive(RestrictedAccess)];
  • whether RestrictedMut<T>::get_mut should be included in v1 or whether the API should start with closure-style modify plus iteration;
  • whether the RFC's listed safe mutable access surface is complete enough for v1, especially the safe untyped ComponentId paths returning MutUntyped.

caniko added 2 commits May 31, 2026 12:34
Two bevy_ecs primitives for deterministic mutation pipelines:
ObserverSet (ordered observer dispatch) and restricted component
access (#[component(restricted)] + RestrictedMut<T>).
@amtep
Copy link
Copy Markdown

amtep commented Jun 1, 2026

Can you spell out what the use case is for restricted mutability when compared to making the component's fields private and providing mutation metbods?

@SpecificProtagonist
Copy link
Copy Markdown

SpecificProtagonist commented Jun 1, 2026

It is not clear to me how RestrictedMut gives the component author any control over what mutations happen/the ability to observe it? Any downstream crate can call modify() just the same.

Also, I assume that immutable components aren't sufficient because inserting components requires you to have ownership of a full component value, even if the data is expensive or impossible to clone? But the RFC needs to state why they can't be used.

Comment on lines +59 to +66
The two primitives are independent: neither depends on the other, and either
could land first. They are presented together because they share one motivation
(making deterministic mutation pipelines practical), are designed to compose,
and are each individually small. Because they are independent, bundling them
imposes no merge-order constraint between implementation PRs. An author who
preferred could split them into two cross-linked standalone RFCs without
changing their substance; they are kept together here because they are easier to
evaluate as two halves of the same problem.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

two halves of the same problem

You should explain more about why they are so deeply correlated. From what I see you never mention how one complements the other, or how a certain goal cannot be reached without both.


### AI assistance

AI was used to make holistic design decisions.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which decisions?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm kind of confused about that – shouldn't language models be even worse for large-scale design decisions than for generating language? And isn't the cost of a bad large-scale design decision higher than for a bad implementation too, making it even more important to actually think things through?

Observers for the same event can be ordered using the same vocabulary as
systems.

2. Cross-bucket observer ordering.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does "cross-bucket" mean?

Comment on lines +176 to +182
5. A safe write gate for restricted components.

Components that opt into restricted access cannot be mutated through ordinary
safe in-place mutable ECS access such as [`Query`][query]`<&mut T>`,
[`Query`][query]`<`[`Mut`][mut]`<T>>`,
typed world/entity mutable access, or safe untyped mutable access by
[`ComponentId`][component-id].
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only explains the limitations. What are the advantages that you gain by using restricted access that you cannot have otherwise? What do you gain by preventing the use of Query<&mut T>? I don't see this mentioned anywhere else in the RFC, and that's the most critical motivation for the feature.


7. A narrow engine surface that libraries can build on.

Bevy provides ordering and access control. Library policy remains downstream.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is a "library policy" here?

Comment on lines +329 to +354
A downstream library can wrap this further. In the example below `MutationLog` and
`HealthChanged` are illustrative application-owned types, not Bevy APIs:

```rust
fn apply_logged_damage(
damaged: Query<(Entity, &IncomingDamage)>,
mut health: RestrictedMut<Health>,
mut log: ResMut<MutationLog>,
) {
for (entity, damage) in &damaged {
let result = health.modify(entity, |health| {
let before = health.current;
health.current = health.current.saturating_sub(damage.amount);
before
});

if let Ok(before) = result {
log.push(HealthChanged {
entity,
before,
after_damage: damage.amount,
});
}
}
}
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the advantage of having to use modify here? If another downstream crate did the following instead, wouldn't the log get out of sync?

fn apply_logged_damage_bad(
    damaged: Query<(Entity, &IncomingDamage)>,
    mut health: RestrictedMut<Health>,
) {
    for (entity, damage) in &damaged {
        let result = health.modify(entity, |health| {
            let before = health.current;
            health.current = health.current.saturating_sub(damage.amount);
            before
        });
    }
}

Or, what is wrong with the current approach of doing this?

fn apply_logged_damage(
    mut query: Query<(Entity, &mut Health, &IncomingDamage)>,
    mut log: ResMut<MutationLog>,
) {
    for (entity, mut health, damage) in &mut query {
        let before = health.current;
        health.current = health.current.saturating_sub(damage.amount);
        
        log.push(HealthChanged {
            entity,
            before,
            after_damage: damage.amount,
        });
    }
}

Comment on lines +404 to +417
The apply observer mutates through `RestrictedMut<RoundState>`:

```rust
fn apply_command(
commands_to_apply: Query<(Entity, &ValidatedCommand)>,
mut round_state: RestrictedMut<RoundState>,
) {
for (entity, command) in &commands_to_apply {
let _ = round_state.modify(entity, |state| {
state.turn += command.turn_delta;
});
}
}
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is already possible with mutable queries though.

Comment on lines +1397 to +1398
Component hooks observe structural changes, but they do not cover every in-place
mutation through safe mutable ECS access.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sounds like you want to observer in-place mutations with observers, but the RFC doesn't actually do that.

be added later if reflection-driven tooling has a clear use case; see
[Reflection and scenes](#reflection-and-scenes) for the scene-loading implication.

[Unsafe APIs][unsafe-world-cell] remain unsafe escape hatches.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bevy_ecs' unsafe functions aren't escape hatches for component mutability. Trying to use them that way either fails or is unsound.

@laundmo
Copy link
Copy Markdown
Member

laundmo commented Jun 1, 2026

I would argue using LLMs for large-scale design decisions and writing RFC text is far beyond the simple autosuggest and other small-scale uses allowed by the AI Policy

Therefore, i would like to nominate this for closure. The label doesn't yet exist for this repo, so i have to state it like this.

Besides the AI Policy concerns, i've had a brief look at the contents and its proposing somewhat duplicated functionality without explaining the differences and besides that, i don't think this is the right way forward for how a solution for this problem should be designed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants