Vela Events

Code that runs when something happens rather than when it is called: a field changes, a value is read, a task is called, a value is made or dropped, the program starts or ends.

This is a design document for an idea, not a guide to something Vela has. Nothing here is built, and nothing here is final: the rules came from the language's author and may still change, and the syntax is only a first proposal. The page keeps the idea in one place with what was decided, what was suggested, and what is still open.

How to read the labels. Author marks a rule the author set. Suggestion marks a proposal made while writing this page, which the author has not decided on yet. Open marks a question nobody has answered yet.

What an event is

An event is a body of code, much like a task or a method. The difference is that the program never calls it. Instead the event is bound to a trigger, and it runs each time the trigger happens. The closest ideas elsewhere are state and signals in React, Svelte and Solid, database triggers, and constructors and destructors. Vela's version is meant to be cheaper than all of them, because the compiler can see every place a trigger can happen.

One problem led to this idea. Shared fields can count how often X.new(…) is called, but not how many values of X exist. A literal skips new, a copy makes a second value without calling anything, and nothing runs when a value goes away. Events on a shape's own life, oninit and ondrop, answer that problem (below).

Where an event can be declared

Inside a task

It can watch the task's own variables, and anything else the task can see. It lives while the task runs, and stops at the end of the block it is declared in, the same way a variable does.

A member of a shape

It happens on each value, and reads that value through .x and .this the same way a method does. It takes alters to change the value's fields.

A shared event of a shape

It belongs to the shape rather than to a value, so it has no .this. It reaches shared fields and shared methods through the shape's name, X.count.

At the top of a file Open

onstart and onend belong to the program rather than to any task or shape, so they may need a place of their own: the top level of a file, next to tasks.

Open A kind has only shared methods and no fields, so it has nothing to watch for changes. Its events could only be call triggers on its shared methods. It is not settled whether that is worth allowing.

Changing things: the alters rule

This is the central rule of the design, and it is stricter than the rule for tasks on purpose.

The rule. Author An event needs alters to change anything that is not its own. What is its own:
  • its own local variables;
  • for a shared event inside a shape, that shape's shared flex fields.
Everything else needs alters. That includes the fields of the value a member event runs on (as for a method), a variable of the task the event is declared in, a shared field of another shape, and a shared field of the shape when the event is declared outside it.

Why events are stricter than tasks

Author A task runs only where the program calls it, so its author knows a call can change things there. An event can run where nobody expects it, in the middle of an unrelated assignment. Writing alters is the author's way of saying "this runs by surprise and changes things", and it tells a reader to be careful around it. Tasks may need the same strictness once Vela has concurrency, when a task can also run at an unexpected time.

Examples of the rule

shape Counter
    shared flex made: int = 0
    shared event counted on init        ; no alters: made is this shape's own shared field
        Counter.made = Counter.made + 1
    fi
fi

shape Audit
    shared flex seen: int = 0
    shared alters event noted on init   ; alters: seen is its own, but Counter.made is another shape's
        Audit.seen = Audit.seen + 1
        Counter.made = Counter.made + 1
    fi
fi

task main()
    flex changes = 0
    flex total = 0
    alters event tally on total         ; alters: changes belongs to main, not to the event
        changes = changes + 1
    fi
    total = 5                           ; tally runs here, and changes becomes 1
fi

Suggestion: the rule matches methods

Suggestion For events declared in a shape, the rule turns out to be the same as the rule for methods. A method takes alters to change .this, and needs no alters to change its shape's shared flex fields. So "an event follows the method rule for the place it is declared in, and needs alters for anything outside that place" is one sentence a reader can keep in mind. It is worth saying in the guide exactly like that.

The Indirect Alters Rule: Transitive Causality

Specification An event that runs by surprise is only trustworthy when it explicitly declares its complete mutation blast radius. An event must list not only the fields it directly modifies, but also any fields modified by other events that it triggers:

shape Player
    hp: int
    shield: int
    alive: bool = true

    ; Directly modifies .shield; indirectly alters .alive (via check_death)
    alters(.shield, .alive) event absorb on .hp when .hp < 50 auto
        .shield = .shield - 10
    fi

    ; Directly modifies .alive
    alters(.alive) event check_death on .shield when .shield <= 0 auto
        .alive = false
    fi
fi

Purpose & Rationale: In event-driven systems, the number one source of critical bugs is "action-at-a-distance" β€” where touching field X secretly causes a cascade of downstream events that corrupt field Y. The Indirect Alters Rule ensures complete transparency: a reader or auditor looking at absorb immediately knows that alive can be altered when absorb runs.

What it prevents:

  • Hidden mutation cascades: No field can ever change without being explicitly advertised in the root event's header.
  • Unseen side-effects in libraries: Downstream listeners cannot silently introduce unexpected mutations into upstream workflows.
  • Hidden recursion cycles: Circular mutation loops are exposed immediately in the complementary alters headers.

Pros & Cons:

  • Pros: Zero surprise mutations, compile-time deterministic verification, clear mental model, and robust architectural boundaries.
  • Cons: If a downstream event's mutations change, upstream event headers must be updated to acknowledge the new alters (which is intentional to prevent accidental side effects).

Smart Compiler Diagnostics: When an indirect alters target is omitted, the compiler traces the causality graph and provides the exact chain of triggers in its error message:

error: event 'absorb' indirectly alters 'Player.alive' (via event 'check_death' on '.shield');
add 'Player.alive' to its alters list: 'alters(.shield, .alive)'

Risks & Solutions:

  • Risk: Unbounded dynamic listeners across compilation units. If arbitrary external listeners could attach dynamically at runtime, static transitive reachability could become undecidable across separate compilation units.
    Solution: The Indirect Alters Rule is statically checked across all events declared within the shape, module, and auto scopes. For decoupled, cross-component or cross-thread notifications, developers use asynchronous Actor channels (actor.dispatch(Msg) or dispatch(actor, Msg)), which transfer immutable or owned values without cascading synchronous mutations.
  • Risk: Mutual recursion cycles (A triggers B, B triggers A).
    Solution: Event cycles are strictly prohibited by default. They are permitted only if every step in the cycle is guarded by a terminating when <condition> guard, and both events declare the shared set of altered targets (e.g., alters(X, Y)).

Triggers

These are the kinds of trigger the author named. The words used for each are in the next section.

1. A field or variable changes

Author The event runs whenever a particular field, or one of a set of fields, changes. This is the "state" of React, but with the compiler knowing every place that can change the field, so nothing has to be tracked while the program runs.

Suggestion It can watch more than one field at a time, on .x, .y. It could also have a condition, on .hp when .hp < 10, which is checked where the change happens, so the event runs only when the condition holds.

2. A value is read

Author A separate trigger runs when a bound value is read rather than changed. A word or modifier marks it, so it can't be mistaken for a change event.

Open Is a read done by the event itself a trigger? It must not be, or the event triggers itself. And may a read event change the value being read? If it may, it becomes a lazy or computed field, which is the property idea from another angle. That is powerful, but it hides work behind what looks like a plain read, and the property idea already had to guard against exactly that.

3. A task or method is called

Author The event runs whenever a task or method is called. It can run before the task starts, or after it finishes. An "after" event may even read what the task sent back.

Suggestion A call event could read the call's arguments by name, read-only, the same way a method reads .this. It takes no parameters, but it can see what was passed. A "before" event should not be able to cancel the call or change its arguments: that would make calls unpredictable, and a program that wants that has a wrapper task for it.

Open Can a program watch a task from another module, or from std? Can it watch a priv task from outside its file? It should not. And does an "after" event run when the task sends a failure (fail)? If it does, it needs a way to tell a result from a failure.

4. The special events of a shape

Event Runs when Notes
oninit a value of the shape is made Author It runs however the value was made: with a literal X { … }, with new X(…) at r, or inside a shared X.new(…). That makes counting exact.
ondrop a value of the shape goes away Author Used, for example, to lower a count. See when a value goes away for the hard part.
onchange any field of a value changes Author It differs from a change trigger, which names particular fields. This one runs for any field.
shared onchange any shared flex field of the shape changes Author The shared version of onchange.

This is how counting looks with them, the case that led to the idea:

shape Player
    name: text
    shared read alive: int = 0

    shared event born on init
        Player.alive = Player.alive + 1
    fi
    shared event gone on drop
        Player.alive = Player.alive - 1
    fi
fi

5. The program's own events

Event Runs when Notes
onstart before main starts Open If several modules each have one, which runs first? The order modules are included in is one answer, and it matches how people read a program.
onend when main finishes Open Does it also run when the program stops on an error, such as an overflow, a bad index or a plan B that stops the program? A last chance to write a log is useful, but code that runs after a failure has to be very careful, since the state it sees may be half-changed.

A proposed syntax Suggestion

The author has not chosen a syntax. What follows is one proposal, built from words and shapes Vela already uses. It needs one new word, event. The word on introduces the trigger. It is only a keyword in that position, so it could stay usable as a name elsewhere.

[priv] [shared] [alters(<targets>)] event <name> (on <trigger> | before <task> | after <task>) [when <condition>] [auto]
    …
fi
Trigger Written
a field or variable changes on total, on .hp, on .x, .y, on Player.alive
a value is read on read .hp
a task or method is called before save, after save, before .grow, after .grow
the shape's own life on init, on drop, on change
the program on start, on end

Event Scope, Registration & the auto keyword:

Spelling the special events as on init rather than oninit keeps them to one pattern: every event is event name on something. Only event becomes a reserved word, and init, drop, change, start and end stay free for programs to use as names. The author's words (oninit, ondrop, and so on) work just as well as reserved words, and are shorter to search for. It is a matter of taste.

Every event has a name. The name is needed to pause it, to name it in an error ("event tally changes changes without alters"), and for Studio to show it where it runs.

A larger example, in a task:

task main()
    flex score = 0
    flex best = 0
    alters(best) event record on score when score > best
        best = score
    fi
    score = 3        ; record runs: best is 3
    score = 1        ; the condition is false: record does not run
    score = 7        ; record runs: best is 7
    show(best)       ; 7
fi

Paying only for events that exist

Author No special event runs by default, the way a default constructor would. A trigger starts being watched only once an event for it is registered, in the program or in the shape. When every event for it is stopped, it stops being watched.

Suggestion: registration is mostly known when compiling

Suggestion Vela can do better than a list of listeners kept while the program runs, because an event is a declaration. The compiler sees every event in the program and every place that can trigger one: every assignment to Player.alive, every literal of Player, every call of save. So it adds the event's call at exactly those places and nowhere else:

  • A trigger that no event watches costs nothing: no flag, no table, no check. A program without events compiles to exactly the C it compiles to today.
  • A watched trigger costs one direct call at each place it can happen. That call is to a plain C function, which the C compiler can inline. It is not a callback through a pointer.
  • Only events that can be paused add a check at each such place: one if on a flag that almost never changes, so the processor predicts it almost every time.
  • An event declared in a task only watches while that task's block is running. For the task's own locals, the places are all inside that block, so the compiler adds the calls only there, with no flag at all. For something the rest of the program can change as well (a shared field), the event sets a flag when its block starts and clears it at fi, the same way a region is closed at fi.

This is what Svelte does for web pages ("reactivity compiled away"), done for a systems language. The dependency graph that React and Solid build while the program runs is known here before the program runs.

A sketch of the C this could produce for the record example above:

int64_t score = 0, best = 0;
score = 3;
if (score > best) { best = score; }   /* record, inlined where score changes */
score = 1;
if (score > best) { best = score; }

Pausing and resuming an event

Author A program needs a way to stop an event at some point, and perhaps to start it again. When every event on a trigger is stopped, the trigger stops being watched.

Suggestion Treat an event's name like a value with a few built-in members, the same way a slice has .len and .add:

record.pause()      ; stop running it
record.resume()     ; run it again
record.paused       ; true or false

That needs no new keyword. (stop is already one, for loops, and reusing it for events would read oddly inside a loop.) An event nobody ever pauses doesn't need the flag, as above. Only a program that pauses an event pays for it.

Suggestion A once event pauses itself after running the first time: event first on score once. This is common with web events and is cheap here.

Open Who may pause an event? Only the code that declared it (its task, or its shape's methods), or any code that can see it? A priv event is clear. For a public event of a shape, another file pausing the shape's ondrop could break the count the shape is keeping. It is safer to let only the shape's own code pause a shape's events.

The hard parts: when exactly an event runs

These are the questions that decide whether events feel predictable. Each has a suggested answer, and each still needs the author's decision.

When does a change event run: after each change, or once for several?

An alters method that sets three fields would run onchange three times, and the first two times the value is half-updated. Suggestion Changes made inside the shape's own alters methods are gathered, and the events run once when the method returns, the way React gathers updates. An event then only sees a value its methods left in a whole state. Outside the shape, an event runs after the statement that made the change.

Events that trigger themselves, or each other (Cycle Rejection Policy)

An event that changes what it watches, or triggers another event that cycles back, risks infinite recursion. Specification By default, the compiler strictly refuses any static event cycle at compile time, tracing the exact causal loop: "record changes best, which runs cap, which changes score, which runs record".

Cycle Exception Rule: Cycles are permitted only when every link in the cycle is explicitly guarded by a terminating when <condition> guard (such as when .hp < 50 or when .shield > 0), ensuring the loop terminates deterministically. Furthermore, in accordance with the Indirect Alters Rule, every event participating in the cycle must list the full set of altered fields in its alters(…) declaration (e.g. alters(X, Y) on both). Any unguarded or unproven cycle is rejected at compile time.

Several events on one trigger

Open In what order do they run? Within a file, declaration order is natural. Across files there is no obvious order, and a program that depends on one is fragile. One answer is that events on the same trigger may not depend on each other's changes, which the alters lists would let the compiler check.

Changes the compiler can't see at the place they happen

A watched local can be changed in ways that don't look like an assignment to it: given to a task as &total for a flex& parameter, changed through a flex& ref = &total reference, or changed by an alters method called on it. The task changing it doesn't know an event is watching. Suggestion Treat the call, or the statement that uses the reference, as the change: the event runs once, after it. That keeps every change site in the watching code, and costs nothing in the task that was called. The other choice, refusing &total while something watches total, is simpler but more restrictive.

When does a value go away?

This is the hardest question in the design, because Vela copies values instead of moving them. Open

Can an event fail?

Suggestion No. Nothing called the event, so there is nowhere to send a failure. An event sends nothing, so fail and <- with a value are refused in it. Something that would stop the program inside an event (an overflow, a bad index) still stops it, and the message says which event it was in.

Memory inside an event

Suggestion Each run of an event is its own block, so it gets a default region that is freed at its fi, like any block. An event can't keep anything it allocates unless it stores it in something that lives longer (a shared field in a static region, if Vela ever has one).

More ideas Idea

was: the value before the change

Inside a change event, was could name the value the watched field had just before it changed: when score < was. The compiler only keeps the old value when the event mentions was, so it costs nothing otherwise.

Studio shows where events run

The worry behind the alters rule is surprise. So Studio could put a small mark on every line that runs an event (for example "⚑ record") and list them when hovering. Nothing hidden stays hidden. The compiler knows every such place already.

Events that run later

event redraw on .x, .y later would run once, at the end of the current task or alters method, however many of the fields changed. That is the batching that interfaces need, stated in the header rather than assumed.

Kept values that update themselves

A read field kept up to date by a change event on the fields it depends on is a member that doesn't recompute. Nothing new is needed for it.

Events only in debug builds

debug event trace on call save could exist in a debug build and disappear entirely from a release build. That gives logging and profiling hooks that cost nothing when shipped.

Events in tests

With built-in testing, a test could watch a call to check that it happened ("save was called twice") without changing the code under test.

Compared with other languages

Idea elsewhere What Vela's events share with it What differs
React state, Solid and Preact signals code that runs when a value changes They track dependencies while the program runs. Vela knows every change site when compiling, so it keeps no graph, subscriptions or allocations.
Svelte reactivity worked out by the compiler Svelte does it for components in a web page. Vela would do it for any field or variable, in C.
C# events, observers handlers that run when something happens There, handlers are added and removed while the program runs, through delegates. In Vela an event is a declaration, and the compiler writes its calls in.
SQL triggers before and after, on a change or a call This is the closest match for call triggers, including reading what was returned.
C++ constructors and destructors, Rust Drop on init and on drop Nothing runs by default, and they are declared as events rather than special methods.
Python __setattr__, JavaScript proxies catching reads and writes of fields Those cost something on every access, watched or not. Vela only pays where an event watches.

Decisions still to make

For the author

  1. The syntax: event name on trigger as proposed, the author's oninit words, or something else.
  2. Whether every event must have a name, and how it is paused: name.pause() or a keyword.
  3. Whether alters may name what it changes, alters(best), and whether that is required, optional, or not allowed.
  4. Whether a copy counts as making a value (on init), has an event of its own (on copy), or doesn't count. Exact counting depends on this.
  5. When on drop runs for values in regions, items taken out of slices, and values a task sends back.
  6. Whether change events run after every change, or once when the shape's alters method returns.
  7. Whether cycles between events are refused when compiling, or stopped by a guard while running.
  8. The order of several events on one trigger, within a file and across files.
  9. How changes made through &x, flex& references and alters methods reach a change event.
  10. Whether read events may change the value read (lazy fields), and whether their own reads trigger them.
  11. Call events: whether they read the arguments, whether "after" runs on a failure, and whether a task from another module or from std can be watched.
  12. Where on start and on end are declared, their order across modules, and whether on end runs when the program stops on an error.
  13. Whether kinds have events (only call events on their shared methods), and what member and shared events mean in a shape that takes types.
  14. Who may pause a shape's events.
  15. An event declared inside a loop: is it declared again every round, or does it watch from its first round to the loop's end?
  16. Visibility: can another file watch a priv field (it should not), and can it watch a read one (reading its changes seems fine)?
  17. Concurrency, once Vela has it: which thread runs an event, and whether alters becomes required for tasks too, as the author expects it may.