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.
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.
- Author The program can't call an event by hand.
- Author An event takes no parameters, since nothing calls it and so nothing could pass them.
- Author An event is bound to a trigger and runs whenever the trigger happens: a field or variable changes, a bound value is read, a task or method is called, or one of the special occasions below comes.
-
Author Apart from that, events
follow the rules of tasks and methods. They can be declared inside a
task or inside a shape. In a shape they can be
shared(they belong to the shape and reach its shared members) or ordinary members (they happen on each value). -
Author Special events
(
oninit,ondrop,onchange,onstart,onend) cost nothing unless a program uses them. Vela has no default constructor that always runs.
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.
alters to change anything that is not its
own. What is its own:
- its own local variables;
-
for a
sharedevent inside a shape, that shape'sshared flexfields.
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
altersheaders.
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, andautoscopes. For decoupled, cross-component or cross-thread notifications, developers use asynchronous Actor channels (actor.dispatch(Msg)ordispatch(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 terminatingwhen <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:
-
sharedevents are globally active for the lifetime of the program. -
Non-shared member events inside a shape require explicit activation
per instance via
.listen()to avoid unwanted listener overhead on idle instances:-
player.listen()registers all non-shared member events on that instance. -
player.listen("on_damage", "on_heal")registers only the specified events.
-
-
The
autokeyword: When placed at the end of an in-shape member event declaration (e.g.event on_damage on .hp auto), the event is automatically active on every instance upon construction without requiring.listen(). Usingautoon asharedevent or outside a shape is a compile-time error.
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
ifon 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 atfi.
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
-
A variable goes away at the
fiof its block. The compiler knows the place, just as it knows where a region closes. -
A value in a region goes away when the region
closes. The region then has to walk through the values that have an
ondrop. That costs something, but only for shapes that have one. It is what already happens to aFile, which closes when its region ends. The existing mechanism for closing files could become the wayondropis built. -
An item in a slice goes away at
xs.del(β¦), or when the slice's region closes. - A value a task sends back leaves the task without going away. The copy in the task doesn't drop, and the caller now has the value.
-
A copy (
fixed b = a) is a second value. If it drops, but nothing ran for it when it was made, then a count kept withon initandon dropends up lower than the true number. Suggestion A copy also runson init, or has an event of its own,on copy. Otherwise the count is never exact, and exact counting was the reason for these events.
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
-
The syntax:
event name on triggeras proposed, the author'soninitwords, or something else. -
Whether every event must have a name, and how it is paused:
name.pause()or a keyword. -
Whether
altersmay name what it changes,alters(best), and whether that is required, optional, or not allowed. -
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. -
When
on dropruns for values in regions, items taken out of slices, and values a task sends back. -
Whether change events run after every change, or once when the
shape's
altersmethod returns. - Whether cycles between events are refused when compiling, or stopped by a guard while running.
- The order of several events on one trigger, within a file and across files.
-
How changes made through
&x,flex&references andaltersmethods reach a change event. - Whether read events may change the value read (lazy fields), and whether their own reads trigger them.
-
Call events: whether they read the arguments, whether "after" runs
on a failure, and whether a task from another module or from
stdcan be watched. -
Where
on startandon endare declared, their order across modules, and whetheron endruns when the program stops on an error. - Whether kinds have events (only call events on their shared methods), and what member and shared events mean in a shape that takes types.
- Who may pause a shape's events.
- 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?
-
Visibility: can another file watch a
privfield (it should not), and can it watch areadone (reading its changes seems fine)? -
Concurrency, once Vela has it: which thread runs an event, and
whether
altersbecomes required for tasks too, as the author expects it may.