Table of Contents

Runtime/Scheduler Declared-Access Validation

Opt-in InvalidAccessException when a system writes a component it never declared.

Status: โœ… Implemented ยท Visibility: Public ยท Level: ๐ŸŸฃ Advanced ยท Category: Errors

๐ŸŽฏ What it solves

Typhon's scheduler derives its execution DAG from what each system declares it writes (SystemBuilder.Writes<T>() / SideWrites<T>()), not from what the system body actually does. If a system's code drifts from its declaration โ€” someone adds a entity.Set(Comp, โ€ฆ) call and forgets the matching builder declaration โ€” the scheduler keeps building a DAG from stale information. Nothing about that failure is loud: the write still succeeds, but the parallelism/ordering guarantees the scheduler computed around the declared set are now silently wrong. This validator turns that drift into an immediate, specific exception when the check is switched on, before it ships as a hard-to-reproduce race.

โš™๏ธ How it works (in brief)

With the check enabled, every EntityRefMut.Set<T>() call is checked against the declared Writes/SideWrites set of the currently-executing system. A mismatch throws InvalidAccessException naming the system, the undeclared component type, and everything the system did declare. Systems that haven't declared any access yet (migration window) are exempt โ€” the check only activates once a system declares at least one Writes/SideWrites. It is a runtime strict-mode check, not a build flavour: enabled by Typhon:Checks:Enabled and Typhon:Checks:DeclaredAccess (both default false, in Debug and Release alike). Off, the gate is a static readonly bool the JIT folds away โ€” zero cost on the Write path.

๐Ÿ’ป Usage

class ClampSystem : QuerySystem
{
    protected override void Configure(SystemBuilder b) => b
        .Name("Clamp").Phase(Phase.Simulation).After("Movement")
        .Writes<Position>();       // declares the only component this system may mutate

    protected override void Execute(TickContext ctx)
    {
        foreach (var id in ctx.Entities)
        {
            var entity = ctx.Accessor.OpenMut(id);
            var pos = entity.Read(Unit.Position);   // OK: Position is declared
            pos.X = Math.Clamp(pos.X, 0, WorldWidth);
            entity.Set(Unit.Position, pos);

            var vel = entity.Read(Unit.Velocity);
            entity.Set(Unit.Velocity, vel);   // check on: throws InvalidAccessException
        }                                                    // (Velocity was never declared)
    }
}

try
{
    runtime.Tick();
}
catch (InvalidAccessException ex)
{
    // ex.SystemName == "Clamp", ex.UndeclaredType == typeof(Velocity)
    // Fix: add `.Writes<Velocity>()` (or `.SideWrites<Velocity>()`) to Configure โ€” never catch-and-continue.
}

โš ๏ธ Guarantees & limits

  • Opt-in (Typhon:Checks:Enabled + Typhon:Checks:DeclaredAccess, both off by default in every build): zero runtime cost when off, but also zero protection. It costs two HashSet lookups per write when on, so enable it in test runs (the engine test suite does) rather than in production.
  • Fires only on writes. Reads are not cross-checked at runtime โ€” Reads<T>()/ReadsFresh<T>()/ ReadsSnapshot<T>() correctness is enforced at Build() time (see Declarative Scheduling), not per-call.
  • Exempts systems with zero declarations entirely (not just the specific undeclared type) โ€” a not-yet-migrated system that declares nothing passes silently. Declaring even one Writes<T>() switches the system into fully-enforced mode.
  • IsTransient is false (inherited default) โ€” this is a code-declaration bug, not a condition that clears on retry. Fix the Configure() declaration; don't catch-and-retry.
  • Thread-local by construction: each worker thread tracks its own executing-system descriptor, so concurrent dispatch across workers cannot cross-contaminate which system a violation is attributed to.
  • Not a substitute for Build()-time conflict detection (Wร—W, ambiguous Rร—W) โ€” this check only catches divergence between a system's declared writes and its actual write calls; it says nothing about whether two systems' declarations conflict with each other.

๐Ÿงช Tests

  • SystemAccessValidatorTests โ€” an undeclared write throws InvalidAccessException (SystemName/UndeclaredType); a no-declarations system passes silently (migration-window exemption); a declared write passes.